# frozen_string_literal: true

#
# Copyright (C) 2012 - 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 AppointmentGroup < ActiveRecord::Base
  include Workflow
  include TextHelper

  has_many :appointments, -> { order(:start_at).preload(:child_events).where("calendar_events.workflow_state <> 'deleted'") }, **(opts = { class_name: 'CalendarEvent', as: :context, inverse_of: :context })
  # has_many :through on the same table does not alias columns in condition
  # strings, just hashes. we create this helper association to ensure
  # appointments_participants conditions have the correct table alias
  has_many :_appointments, -> { order(:start_at).preload(:child_events).where("_appointments_appointments_participants.workflow_state <> 'deleted'") }, **opts
  has_many :appointments_participants, -> { where("calendar_events.workflow_state <> 'deleted'").order(:start_at) }, through: :_appointments, source: :child_events
  has_many :appointment_group_contexts
  has_many :appointment_group_sub_contexts, -> { preload(:sub_context) }

  def context
    appointment_group_contexts.first&.context
  end

  def contexts
    appointment_group_contexts.map &:context
  end

  def active_contexts
    contexts.reject { |context| context.workflow_state == 'deleted' }
  end

  def sub_contexts
    # I wonder how rails is adding multiples of the same sub_contexts
    appointment_group_sub_contexts.map &:sub_context
  end

  validates_presence_of :workflow_state
  before_validation :default_values
  before_validation :update_contexts_and_sub_contexts
  before_save :update_cached_values
  after_save :update_appointments

  validates_length_of :title, :maximum => maximum_string_length
  validates_length_of :location_name, :maximum => maximum_string_length
  validates_length_of :description, :maximum => maximum_long_text_length, :allow_nil => true, :allow_blank => true
  validates_inclusion_of :participant_visibility, :in => ['private', 'protected'] # presumably we might add public if we decide to show appointments on the public calendar feed
  validates_each :appointments do |record, attr, value|
    next unless record.new_appointments.present? || record.validation_event_override
    appointments = value
    if record.validation_event_override
      appointments = appointments.select{ |a| a.new_record? || a.id != record.validation_event_override.id} << record.validation_event_override
    end
    appointments.sort_by(&:start_at).inject(nil) do |prev, appointment|
      record.errors.add(attr, t('errors.invalid_end_at', "Appointment end time precedes start time")) if appointment.end_at < appointment.start_at
      record.errors.add(attr, t('errors.overlapping_appointments', "Appointments overlap")) if prev && appointment.start_at < prev.end_at
      appointment
    end
  end

  def validate
    if appointment_group_contexts.empty?
      errors.add :appointment_group_contexts,
                 t('errors.needs_contexts', 'Must have at least one context')
    end
  end

  # when creating/updating an appointment, you can give it a list of (new)
  # appointment times. these will be added to the existing appointment times
  # format is [[start, end], [start, end], ...]
  attr_reader :new_appointments
  def new_appointments=(appointments)
    appointments = appointments.values if appointments.is_a?(Hash)
    @new_appointments = appointments.map { |start_at, end_at|
      next unless start_at && end_at
      a = self.appointments.build(:start_at => start_at, :end_at => end_at)
      a.context = self
      a
    }
  end
  attr_accessor :validation_event_override
  attr_accessor :cancel_reason

  def reload
    remove_instance_variable :@new_appointments if @new_appointments
    super
  end

  # TODO: someday this should become context_codes= for consistency (in
  # conjunction with checking permissions in update_contexts_and_sub_contexts)
  def contexts=(new_contexts)
    @new_contexts ||= []
    @new_contexts += new_contexts.compact
  end

  def sub_context_codes=(codes)
    @new_sub_context_codes ||= []
    @new_sub_context_codes += codes.compact
  end

  def update_contexts_and_sub_contexts
    # TODO: validate the updating user has manage rights for all contexts /
    # sub_contexts. currently this is done in the controller level, since
    # we validate contexts beforehand
    @new_sub_context_codes -= sub_context_codes if @new_sub_context_codes
    new_sub_contexts = []
    if @new_sub_context_codes.present?
      if new_record? &&
          @new_contexts.size == 1 &&
          @new_sub_context_codes.size == 1 &&
          @new_sub_context_codes.first =~ /\Agroup_category_(.*)/
        # a group category can only be assigned at creation time to
        # appointment groups with one course
        gc = GroupCategory.where(id: $1).first
        code = @new_sub_context_codes.first
        self.appointment_group_sub_contexts = [
          AppointmentGroupSubContext.new(:appointment_group => self,
                                         :sub_context => gc,
                                         :sub_context_code => code)
        ]
      else
        # if new record and we have a course without sections, add all the sections
        if new_record?
          @new_contexts.each do |context|
            context_subs = context.course_sections.map(&:asset_string)
            # if context has no selected subcontexts, add all subcontexts
            if (@new_sub_context_codes & context_subs).count == 0
              @new_sub_context_codes += context_subs
            end
          end
        end

        # right now we don't support changing the sub contexts for a context
        # on an appointment group after it has been saved
        disallowed_sub_context_codes = contexts.map(&:course_sections).
          flatten.map(&:asset_string)
        @new_sub_context_codes -= disallowed_sub_context_codes

        new_sub_contexts = @new_sub_context_codes.map { |code|
          next unless code =~ /\Acourse_section_(.*)/
          cs = CourseSection.where(id: $1).first
          AppointmentGroupSubContext.new(:appointment_group => self,
                                         :sub_context => cs,
                                         :sub_context_code => code)
        }.compact
      end
    end

    # contexts
    @new_contexts -= contexts if @new_contexts
    if @new_contexts.present?
      unless (appointment_group_sub_contexts + new_sub_contexts).size == 1 &&
          (appointment_group_sub_contexts + new_sub_contexts).first.sub_context_type == 'GroupCategory' &&
          !new_record?
        self.appointment_group_contexts += @new_contexts.map { |c|
          AppointmentGroupContext.new :context => c, :appointment_group => self
        }
        @contexts_changed = true
      end
    end

    if new_sub_contexts.present?
      # the sub_contexts get validated as soon as we add them in Rails 3,
      # so we need to add them after we have updated the contexts
      self.appointment_group_sub_contexts += new_sub_contexts
    end
  end

  def sub_context_codes
    appointment_group_sub_contexts.map &:sub_context_code
  end

  # complements :reserve permission
  scope :reservable_by, lambda { |*options|
    user = options.shift
    restrict_to_codes = options.shift

    codes = user.appointment_context_codes.dup
    if restrict_to_codes
      codes[:primary] &= restrict_to_codes
    end
    distinct.
        joins("JOIN #{AppointmentGroupContext.quoted_table_name} agc " \
              "ON appointment_groups.id = agc.appointment_group_id " \
              "LEFT JOIN #{AppointmentGroupSubContext.quoted_table_name} sc " \
              "ON appointment_groups.id = sc.appointment_group_id").
        where(<<~COND, codes[:primary], codes[:secondary])
        workflow_state = 'active'
        AND agc.context_code IN (?)
        AND (
          sc.sub_context_code IS NULL
          OR sc.sub_context_code IN (?)
        )
        COND
  }
  # complements :manage permission
  scope :manageable_by, lambda { |*options|
    user = options.shift
    restrict_to_codes = options.shift

    codes = user.manageable_appointment_context_codes.dup
    if restrict_to_codes
      codes[:full] &= restrict_to_codes
      codes[:limited] &= restrict_to_codes
    end
    distinct.
        joins("JOIN #{AppointmentGroupContext.quoted_table_name} agc " \
              "ON appointment_groups.id = agc.appointment_group_id " \
              "LEFT JOIN #{AppointmentGroupSubContext.quoted_table_name} sc " \
              "ON appointment_groups.id = sc.appointment_group_id").
        where(<<~COND, codes[:full] + codes[:limited], codes[:full], codes[:secondary])
        workflow_state <> 'deleted'
        AND agc.context_code IN (?)
        AND (
          agc.context_code IN (?)
          OR sc.sub_context_code IN (?)
        )
        COND
  }
  scope :current, -> { where("end_at>=?", Time.zone.now) }
  scope :current_or_undated, -> { where("end_at>=? OR end_at IS NULL", Time.zone.now) }
  scope :intersecting, lambda { |start_date, end_date| where("start_at<? AND end_at>?", end_date, start_date) }

  set_policy do
    given { |user|
      active? && participant_for(user)
    }
    can :reserve and can :read

    given { |user|
      next false if deleted?
      next false unless active_contexts.any? { |c| c.grants_right? user, :manage_calendar }
      if appointment_group_sub_contexts.present? && appointment_group_sub_contexts.first.sub_context_type == 'CourseSection'
        sub_context_ids = appointment_group_sub_contexts.map(&:sub_context_id)
        user_visible_section_ids = contexts.map { |c|
          c.section_visibilities_for(user).map { |v| v[:course_section_id] }
        }.flatten
        next true if (sub_context_ids - user_visible_section_ids).empty?
      end
      contexts.any? { |c| c.enrollment_visibility_level_for(user) == :full }
    }
    can :manage and can :manage_calendar and can :read and can :read_appointment_participants and
    can :create and can :update and can :delete

    given { |user|
      participant_visibility == 'protected' && grants_right?(user, :reserve)
    }
    can :read_appointment_participants
  end

  def broadcast_data
    data = {}
    data[:root_account_id] = context.root_account_id if context.root_account_id
    data[:course_id] = context.id if context.is_a?(Course)
    data
  end

  has_a_broadcast_policy

  set_broadcast_policy do
    dispatch :appointment_group_published
    to       { possible_users }
    whenever { contexts.any?(&:available?) && active? && saved_change_to_workflow_state? }
    data     { broadcast_data}

    dispatch :appointment_group_updated
    to       { possible_users }
    whenever { contexts.any?(&:available?) && active? && new_appointments && !saved_change_to_workflow_state? }
    data     { broadcast_data}

    dispatch :appointment_group_deleted
    to       { possible_users }
    whenever { contexts.any?(&:available?) && changed_state(:deleted, :active) }
    data     { broadcast_data.merge({:cancel_reason => @cancel_reason}) }
  end

  def possible_users
    participant_type == 'User' ?
      possible_participants(include_observers: true).uniq :
      possible_participants.flatten.map(&:participants).flatten.uniq
  end

  def instructors
    if participant_type == 'User' && sub_contexts.present?
      contexts.map { |c| c.participating_instructors.restrict_to_sections(sub_contexts) }.flatten.uniq
    else
      contexts.map(&:participating_instructors).flatten.uniq
    end
  end

  def possible_participants(registration_status: nil, include_observers: false)
    participants = if participant_type == 'User'
                     participant_func = if include_observers
                                          ->(c) {c.participating_students_by_date + c.participating_observers_by_date}
                                        else
                                          ->(c) {c.participating_students_by_date}
                                        end
                     sub_contexts.empty? ?
                       contexts.map(&participant_func).flatten :
                       sub_contexts.map(&participant_func).flatten
                   else
                     # FIXME?
                     sub_contexts.map(&:groups).flatten
                   end
    participant_ids = self.participant_ids
    registered = participants.select { |p| participant_ids.include?(p.id) }

    participants = case registration_status
      when 'registered';     registered
      when 'unregistered';   participants - registered
      else                   participants
    end

    if participant_type == 'User'
      participants.sort_by { |p| [Canvas::ICU.collation_key(p.sortable_name), p.id] }
    else
      participants.sort_by { |p| [Canvas::ICU.collation_key(p.name), p.id] }
    end
  end

  def participant_ids
    appointments_participants.
        except(:order).
        where(:context_type => participant_type).
        pluck(:context_id)
  end

  def participant_table
    Kernel.const_get(participant_type).table_name
  end

  def eligible_participant?(participant)
    return false unless participant && participant.class.base_class.name == participant_type
    codes = participant.appointment_context_codes
    return false unless (codes[:primary] & appointment_group_contexts.map(&:context_code)).present?
    return false unless sub_context_codes.empty? || (codes[:secondary] & sub_context_codes).present?
    true
  end

  # TODO: create a scope that does this
  # students would generally call this with the user as the argument
  # instructors would call it with the user or group (depending on participant_type)
  def requiring_action?(user_or_participant)
    participant = user_or_participant
    participant = participant_for(user_or_participant) if participant_type == 'Group' && participant.is_a?(User)
    return false unless eligible_participant?(participant)
    return false unless min_appointments_per_participant
    return false if all_appointments_filled?
    return reservations_for(participant).size < min_appointments_per_participant
  end

  def all_appointments_filled?
    return false unless participants_per_appointment
    appointments_participants.count >= appointments.sum(
      sanitize_sql(['COALESCE(participants_per_appointment, ?)', self.participants_per_appointment]))
  end

  def participant_for(user)
    @participant_for ||= {}
    return @participant_for[user.global_id] if @participant_for.has_key?(user.global_id)
    @participant_for[user.global_id] = begin
      participant = if participant_type == 'User'
          user
        else
          # can't have more than one group_category
          group_categories = sub_contexts.find_all{|sc| sc.instance_of? GroupCategory }
          raise %Q{inconsistent appointment group: #{self.id} #{group_categories}} if group_categories.length > 1
          group_category_id = group_categories.first.id
          user.current_groups.detect{ |g| g.group_category_id == group_category_id }
        end
      participant if participant && eligible_participant?(participant)
    end
  end

  def reservations_for(participant)
    appointments_participants.for_context_codes(participant.asset_string)
  end

  def update_cached_values
    self.start_at = appointments.map(&:start_at).min
    self.end_at = appointments.map(&:end_at).max
    clear_cached_available_slots! if participants_per_appointment_changed?
    true
  end

  EVENT_ATTRIBUTES = [
    :title,
    :description,
    :location_name,
    :location_address
  ]

  def update_appointments
    changed = Hash[
      EVENT_ATTRIBUTES.select{ |attr| saved_change_to_attribute?(attr) }.
      map{ |attr| [attr, send(attr)] }
    ]

    if @contexts_changed
      changed[:root_account_id] = self.context&.root_account_id
      changed[:effective_context_code] = contexts.map(&:asset_string).join(",")
    end

    return if changed.blank?

    if changed.present?
      appointments.update_all(changed)
      changed.delete(:effective_context_code)
    end

    if changed.present?
      CalendarEvent.joins(:parent_event).where(
          workflow_state: ['active', 'locked'],
          parent_events_calendar_events: { context_id: self, context_type: 'AppointmentGroup' }
      ).update_all(changed)
    end

    @new_appointments.each(&:reload) if @new_appointments.present?
  end

  def participant_type
    types = appointment_group_sub_contexts.map(&:participant_type).uniq
    raise "inconsistent participant types in appointment group" if types.size > 1
    types.first || 'User'
  end

  def available_slots(current_only: false)
    return nil unless participants_per_appointment
    Rails.cache.fetch([self, 'available_slots'].cache_key) do
      filtered_appointments = current_only ? appointments.current : appointments
      # participants_per_appointment can change after the fact, so a given
      # could exceed it and we can't just say:
      #   appointments.size * participants_per_appointment
      filtered_appointments.inject(0){ |total, appointment|
        total + [participants_per_appointment - appointment.child_events.size, 0].max
      }
    end
  end

  def clear_cached_available_slots!
    Rails.cache.delete([self, 'available_slots'].cache_key)
  end

  def default_values
    self.participant_visibility ||= 'private'
  end

  workflow do
    state :pending do
      event :publish, :transitions_to => :active
    end
    state :active
    state :deleted
  end

  alias_method :destroy_permanently!, :destroy
  def destroy(updating_user)
    transaction do
      self.workflow_state = 'deleted'
      save!
      self.appointments.map do |a|
        a.updating_user = updating_user
        a.destroy(false)
      end
    end
  end

  def contexts_for_user(user)
    @contexts_for_user ||= {}
    return @contexts_for_user[user.global_id] if @contexts_for_user.has_key?(user.global_id)
    @contexts_for_user[user.global_id] = begin
      context_codes = context_codes_for_user(user)
      course_ids = appointment_group_contexts.select{|agc| context_codes.include? agc.context_code }.map(&:context_id)
      Course.where(:id => course_ids).to_a
    end
  end

  def context_codes_for_user(user)
    @context_codes_for_user ||= {}
    return @context_codes_for_user[user.global_id] if @context_codes_for_user.has_key?(user.global_id)
    @context_codes_for_user[user.global_id] = begin
      manageable_codes = user.manageable_appointment_context_codes
      user_codes = user.appointment_context_codes(include_observers: true)[:primary] |
        manageable_codes[:full] | manageable_codes[:limited]
      context_codes & user_codes
    end
  end

  def context_codes
    appointment_group_contexts.map(&:context_code)
  end

  def users_with_reservations_through_group
    appointments_participants
      .joins("INNER JOIN #{GroupMembership.quoted_table_name} " \
             "ON group_memberships.group_id = calendar_events.context_id " \
             "and calendar_events.context_type = 'Group'")
      .where("group_memberships.workflow_state <> 'deleted'")
      .pluck("group_memberships.user_id")
  end

end
