# frozen_string_literal: true

#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#

class Collaboration < ActiveRecord::Base
  include Workflow
  include SendToStream

  DEEP_LINKING_EXTENSION = "https://canvas.instructure.com/lti/collaboration"

  attr_readonly :collaboration_type

  belongs_to :context, polymorphic: [:course, :group]
  belongs_to :user
  has_many :collaborators, dependent: :destroy
  has_many :users, through: :collaborators

  before_destroy { |record| Collaborator.where(collaboration_id: record).destroy_all }

  before_save :assign_uuid
  before_save :set_context_code
  before_save :set_root_account_id

  after_save :include_author_as_collaborator
  after_save :touch_context
  after_commit :generate_document, on: :create

  TITLE_MAX_LENGTH = 255
  validates :title, :workflow_state, :context_id, :context_type, presence: true
  validates :title, length: { maximum: TITLE_MAX_LENGTH }
  validates :description, length: { maximum: maximum_text_length, allow_blank: true }

  serialize :data

  alias_method :destroy_permanently!, :destroy

  workflow do
    state :active
    state :deleted
  end

  on_create_send_to_streams do
    [user_id] + collaborators.map(&:user_id)
  end

  set_policy do
    given do |user|
      user &&
        !new_record? &&
        (user_id == user.id ||
         users.include?(user) ||
         Collaborator
             .joins("INNER JOIN #{GroupMembership.quoted_table_name} ON collaborators.group_id = group_memberships.group_id AND group_memberships.workflow_state <> 'deleted'")
             .where('collaborators.group_id IS NOT NULL AND
                            group_memberships.user_id = ? AND
                            collaborators.collaboration_id = ?',
                    user,
                    self).exists?)
    end
    can :read

    given { |user, session| context.grants_right?(user, session, :create_collaborations) }
    can :create

    given do |user, session|
      user && context.grants_right?(user, session, :manage_course_content_edit)
    end
    can :read and can :update

    given do |user, session|
      user && context.grants_right?(user, session, :manage_course_content_delete)
    end
    can :read and can :delete

    given do |user, session|
      user && user_id == user.id &&
        context.grants_right?(user, session, :create_collaborations)
    end
    can :read and can :update and can :delete
  end

  scope :active, -> { where("collaborations.workflow_state<>'deleted'") }

  scope :after, ->(date) { where("collaborations.updated_at>?", date) }

  scope :for_context_codes, ->(context_codes) { where(context_code: context_codes) }
  scope :for_context, ->(context) { where(context_type: context.class.reflection_type_name, context_id: context) }

  # These methods should be implemented in child classes.

  def service_name
    "Collaboration"
  end

  def delete_document; end

  def initialize_document; end

  def user_can_access_document_type?(_user)
    true
  end

  def authorize_user(user); end

  # def remove_users_from_document(users_to_remove); end

  # def add_users_to_document(users_to_add); end

  def config
    raise NotImplementedError
  end

  def parse_data
    nil
  end

  # Public: Find the class of for the given type.
  #
  # type - The string name of the collaboration type (e.g. 'GoogleDocs' or 'EtherPad').
  #
  # Returns a class or nil.
  def self.collaboration_class(type)
    if (klass = "#{type}Collaboration".constantize)
      (klass.ancestors.include?(Collaboration) && klass.config) ? klass : nil
    end
  rescue NameError
    nil
  end

  # Public: Create a new collaboration of the given type.
  #
  # name - The string name of the collaboration type.
  #
  # Returns a collaboration instance or raises an exception if type unknown.
  def self.typed_collaboration_instance(name)
    class_config = Collaboration.collaboration_types.find { |c| c["name"] == name }
    raise InvalidCollaborationType unless class_config

    klass = collaboration_class(class_config["type"].titleize.gsub(/\s/, ""))

    if klass
      collaboration = klass.new
      collaboration.collaboration_type = class_config["name"]
    else
      raise "Unrecognized collaboration type #{type}."
    end

    collaboration
  end

  # Public: Find the available collaboration types.
  #
  # Returns an array of type hashes w/ 'name' and 'type' keys.
  def self.collaboration_types
    Canvas::Plugin.all_for_tag(:collaborations).select(&:enabled?).map do |plugin|
      # google_drive is really a google_docs_collaboration
      # eventually this will go away. baby steps...
      if plugin.id == "google_drive"
        type = "google_docs"
        name = "Google Docs"
      else
        type = plugin.id
        name = plugin.name
      end

      ActiveSupport::HashWithIndifferentAccess.new({ "name" => name, "type" => type })
    end
  end

  # Public: Determine if any collaborations plugin is enabled.
  #
  # Returns true/false.
  def self.any_collaborations_configured?(context)
    plugin_collabs = collaboration_types.any? do |type|
      collaboration_class(type["type"].titleize.gsub(/\s/, "")).present?
    end
    external_tool_collabs =
      Lti::ContextToolFinder.all_tools_scope_union(context, placements: :collaboration).exists?
    plugin_collabs || external_tool_collabs
  end

  # Public: Declare excluded serialization fields.
  #
  # Returns an array.
  def self.serialization_excludes
    [:uuid]
  end

  # Public: Soft-delete this collaboration.
  #
  # Returns true.
  def destroy
    self.workflow_state = "deleted"
    self.deleted_at     = Time.zone.now

    save!
  end

  # Public: Un-delete this collaboration.
  #
  # Returns a success boolean.
  def restore
    update_attribute(:workflow_state, "active")
  end

  # Internal: Add the author of the collaboration to its collaborators.
  #
  # Returns nothing.
  def include_author_as_collaborator
    return unless user.present?

    author = collaborators.where(user_id:).first

    unless author
      collaborator = Collaborator.new(collaboration: self)
      collaborator.user_id = user_id
      collaborator.authorized_service_user_id = authorized_service_user_id_for(user)
      collaborator.save
    end
  end

  # Public: Create a CSS style string.
  #
  # NOTE: I assume this is for compatibility w/ something in Canvas' bowels,
  # but it may not be needed anymore.
  #
  # Returns nil.
  def style_class
    nil
  end

  # Public: Create a list of collaborator IDs.
  #
  # Returns a comma-seperated list of collaborator user IDs.
  def collaborator_ids
    collaborators.pluck(:user_id).join(",")
  end

  # Internal: Create the collaboration document in the remote service.
  #
  # Returns nothing.
  def generate_document
    return if @generated

    @generated = true
    assign_uuid
    initialize_document
    save!
  end

  # Public: Determine if a given user can access this collaboration.
  #
  # user - The user to test.
  #
  # Returns a boolean.
  def valid_user?(user)
    if grants_right?(user, nil, :read)
      user_can_access_document_type?(user)
    else
      false
    end
  end

  # Public: Update user and group collaborators for this collaboration.
  #
  # Any current collaborators not passed to this method will be destroyed.
  #
  # users     - An array of users to include as collaborators.
  # groups    - An array of groups or group ids to include as collaborators.
  #
  # Returns nothing.
  def update_members(users = [], groups = [])
    group_ids = groups.map { |g| g.try(:id) || g }
    save! if new_record?
    generate_document
    users << user if user.present? && !users.include?(user)
    update_user_collaborators(users)
    update_group_collaborators(group_ids)
    if respond_to?(:add_users_to_document)
      group_users_to_add = User
                           .distinct
                           .joins(:group_memberships)
                           .where("group_memberships.group_id" => group_ids).to_a
      add_users_to_document((users + group_users_to_add).uniq)
    end
  end

  # Internal: Create a new UUID for this collaboration if one does not exist.
  #
  # Returns a UUID string.
  def assign_uuid
    self.uuid ||= CanvasSlug.generate_securish_uuid
  end
  protected :assign_uuid

  # Internal: Set the context code for this collaboration.
  #
  # Returns a context code.
  def set_context_code
    self.context_code = "#{context_type.underscore}_#{context_id}"
  rescue NoMethodError
    nil
  end
  protected :set_context_code

  def set_root_account_id
    self.root_account_id = context.root_account_id
  end

  # Internal: Delete existing collaborating users and add new ones.
  #
  # users - The array of users to add. Any duplicates with current users
  #         will not be removed or re-added.
  #
  # Returns nothing.
  def update_user_collaborators(users)
    if respond_to?(:remove_users_from_document)
      # need to get everyone added to the document, cause we're going to re-add them all
      users_to_remove = collaborators.where.not(user_id: nil).pluck(:user_id)
      group_ids = collaborators.where.not(group_id: nil).pluck(:group_id)
      unless group_ids.empty?
        users_to_remove += GroupMembership.where(group_id: group_ids).distinct.pluck(:user_id)
        users_to_remove.uniq!
      end
      # make real user objects, instead of just ids, cause that's what this code expects
      users_to_remove.reject! { |id| id == user.id }
      users_to_remove = users_to_remove.map { |id| User.send(:instantiate, "id" => id) }
      remove_users_from_document(users_to_remove)
    end
    remove_users_from_collaborators(users)
    add_users_to_collaborators(users)
  end
  protected :update_user_collaborators

  # Internal: Remove old group collaborators and add new ones.
  #
  # group_ids - An array of IDs for groups to be added. Any duplicates w/
  #            existing groups will not be deleted/added.
  def update_group_collaborators(group_ids)
    remove_groups_from_collaborators(group_ids)
    add_groups_to_collaborators(group_ids)
  end
  protected :update_group_collaborators

  # Internal: Delete groups no longer being collaborated with.
  #
  # group_ids - An array of group IDs that will be used as collaborators.
  #
  # Returns nothing.
  def remove_groups_from_collaborators(group_ids)
    collaborators.where.not(group_id: group_ids.presence).delete_all
  end
  protected :remove_groups_from_collaborators

  # Internal: Delete users no longer being collaborated with.
  #
  # users - An array of users that will be used as collaborators.
  #
  # Returns nothing.
  def remove_users_from_collaborators(users)
    collaborators.where.not(user_id: users.presence).delete_all
  end
  protected :remove_users_from_collaborators

  # Internal: Update collaborators with the given groups.
  #
  # group_ids - An array of group IDs to add as collaborators.
  #
  # Returns nothing.
  def add_groups_to_collaborators(group_ids)
    return unless context.respond_to?(:groups)

    unless group_ids.empty?
      existing_groups = collaborators.where(group_id: group_ids).select(:group_id)
      context.groups.where(id: group_ids).where.not(id: existing_groups).each do |g|
        collaborator = collaborators.build
        collaborator.group_id = g
        collaborator.save
      end
    end
  end
  private :add_groups_to_collaborators

  # Internal: Update collaborators with the given groups.
  #
  # users - An array of users to add as collaborators.
  #
  # Returns nothing.
  def add_users_to_collaborators(users)
    unless users.empty?
      existing_users = collaborators.where(user_id: users).select(:user_id)
      context.potential_collaborators.where(id: users).where.not(id: existing_users).each do |u|
        collaborators.create(user: u, authorized_service_user_id: authorized_service_user_id_for(u))
      end
    end
  end
  private :add_users_to_collaborators

  class InvalidCollaborationType < StandardError; end
  protected

  # Internal: Get the authorized_service_user_id for a user.
  # May be overridden by other collaboration types.
  def authorized_service_user_id_for(user)
    user.gmail
  end
end
