# 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 EnrollmentTerm < ActiveRecord::Base
  DEFAULT_TERM_NAME = "Default Term"
  include Workflow

  belongs_to :root_account, class_name: "Account"
  belongs_to :grading_period_group, inverse_of: :enrollment_terms
  has_many :grading_periods, through: :grading_period_group
  has_many :enrollment_dates_overrides
  has_many :courses
  has_many :enrollments, through: :courses
  has_many :course_sections

  validates :root_account_id, :workflow_state, presence: true
  validate :prevent_default_term_name_change
  validate :check_if_deletable
  validate :consistent_account_associations

  before_validation :verify_unique_sis_source_id
  after_save :update_courses_later_if_necessary
  after_save :recompute_course_scores_later, if: :grading_period_group_id_has_changed?

  include StickySisFields

  are_sis_sticky :name, :start_at, :end_at

  def self.ensure_dummy_enrollment_term
    Account.ensure_dummy_root_account
    create_with(root_account_id: 0, workflow_state: "deleted").find_or_create_by!(id: 0)
  end

  def prevent_default_term_name_change
    if name_changed? && name_was == DEFAULT_TERM_NAME && self == root_account.default_enrollment_term
      errors.add(:name, t("Cannot change the default term name"))
    end
  end

  def check_if_deletable
    if workflow_state_changed? && workflow_state == "deleted"
      if default_term?
        errors.add(:workflow_state, t("errors.delete_default_term", "Cannot delete the default term"))
      elsif courses.active.exists?
        errors.add(:workflow_state, t("errors.delete_term_with_courses", "Cannot delete a term with active courses"))
      end
    end
  end

  def update_courses_later_if_necessary
    if !new_record? && (saved_change_to_start_at? || saved_change_to_end_at?)
      update_courses_and_states_later
    end
  end

  # specifically for use in specs
  def reset_touched_courses_flag
    @touched_courses = false
  end

  def touch_all_courses
    courses.in_batches(of: 10_000).touch_all
  end

  def update_courses_and_states_later(enrollment_type = nil)
    return if new_record?

    delay_if_production(singleton: "EnrollmentTerm#touch_all_courses_#{global_id}").touch_all_courses unless @touched_courses
    @touched_courses = true

    EnrollmentState.delay_if_production(singleton: "EnrollmentState.invalidate_states_for_term_#{global_id}_#{enrollment_type}")
                   .invalidate_states_for_term(self, enrollment_type)
  end

  def self.i18n_default_term_name
    t "#account.default_term_name", "Default Term"
  end

  def recompute_course_scores_later(update_all_grading_period_scores: true, strand_identifier: "EnrollmentTerm:#{global_id}")
    courses_to_recompute.find_ids_in_ranges(batch_size: 1000) do |min_id, max_id|
      delay_if_production(n_strand: "EnrollmentTerm#recompute_scores_for_batch:#{strand_identifier}",
                          priority: Delayed::LOW_PRIORITY)
        .recompute_scores_for_batch(min_id, max_id, update_all_grading_period_scores)
    end
  end

  def default_term?
    self["name"] == EnrollmentTerm::DEFAULT_TERM_NAME
  end

  def name
    if default_term?
      EnrollmentTerm.i18n_default_term_name
    else
      super
    end
  end

  def name=(new_name)
    if new_name == EnrollmentTerm.i18n_default_term_name
      super(DEFAULT_TERM_NAME)
    else
      super
    end
  end

  def set_overrides(context, params)
    return unless params && context

    params.map do |type, values|
      type = type.classify
      enrollment_type = Enrollment.typed_enrollment(type).to_s
      override = enrollment_dates_overrides.where(enrollment_type:).first_or_initialize
      # preload the reverse association - VERY IMPORTANT so that @touched_enrollments is shared
      override.enrollment_term = self
      override.start_at = values[:start_at]
      override.end_at = values[:end_at]
      override.context = context
      override.save
      override
    end
  end

  def verify_unique_sis_source_id
    return true unless sis_source_id
    return true if !root_account_id_changed? && !sis_source_id_changed?

    scope = root_account.enrollment_terms.where(sis_source_id:)
    scope = scope.where("id<>?", self) unless new_record?

    return true unless scope.exists?

    errors.add(:sis_source_id, t("errors.not_unique", "SIS ID \"%{sis_source_id}\" is already in use", sis_source_id:))
    throw :abort
  end

  def self.user_counts(root_account, terms)
    # Warning: returns keys as strings, I think because of the join
    Enrollment.active.joins(:course)
              .where(root_account_id: root_account, courses: { enrollment_term_id: terms })
              .group(:enrollment_term_id)
              .distinct
              .count(:user_id)
  end

  def self.course_counts(terms)
    Course.active
          .where(enrollment_term_id: terms)
          .group(:enrollment_term_id)
          .count
  end

  workflow do
    state :active
    state :deleted
  end

  def enrollment_dates_for(enrollment)
    # detect will cause the whole collection to load; that's fine, it's a small collection, and
    # we'll probably call enrollment_dates_for multiple times in a single request, so we want
    # it cached, rather than using .scoped which would force a re-query every time
    override = enrollment_dates_overrides.detect { |ov| ov.enrollment_type == enrollment.type.to_s }

    # ignore the start dates as admin
    [override.try(:start_at) || (enrollment.admin? ? nil : start_at), override.try(:end_at) || end_at]
  end

  # return the term dates applicable to the given enrollment(s)
  def overridden_term_dates(enrollments)
    dates = enrollments.uniq(&:type).map { |enrollment| enrollment_dates_for(enrollment) }
    start_dates = dates.map(&:first)
    end_dates = dates.map(&:last)
    [start_dates.include?(nil) ? nil : start_dates.min, end_dates.include?(nil) ? nil : end_dates.max]
  end

  alias_method :destroy_permanently!, :destroy
  def destroy
    self.workflow_state = "deleted"
    save!
  end

  def consistent_account_associations
    if grading_period_group_id && (root_account_id != grading_period_group.account_id)
      errors.add(:grading_period_group, t("cannot be associated with a different account"))
    end
  end

  def filter_courses_by_term
    "/accounts/#{root_account_id}?enrollment_term_id=#{id}"
  end

  scope :active, -> { where("enrollment_terms.workflow_state<>'deleted'") }
  scope :ended, -> { where(enrollment_terms: { end_at: ...Time.now.utc }) }
  scope :started, -> { where("enrollment_terms.start_at IS NULL OR enrollment_terms.start_at < ?", Time.now.utc) }
  scope :not_ended, -> { where("enrollment_terms.end_at IS NULL OR enrollment_terms.end_at >= ?", Time.now.utc) }
  scope :not_started, -> { where("enrollment_terms.start_at IS NOT NULL AND enrollment_terms.start_at > ?", Time.now.utc) }
  scope :not_default, -> { where.not(name: EnrollmentTerm::DEFAULT_TERM_NAME) }
  scope :by_name, -> { order(best_unicode_collation_key("name")) }

  private

  def recompute_scores_for_batch(min_id, max_id, update_all_grading_period_scores)
    courses_to_recompute.where(id: min_id..max_id).find_each do |course|
      # we don't need to recompute scores for each grading period if only weight has changed.
      # run_immediately: true because we're already in a delayed job
      course.recompute_student_scores(
        update_all_grading_period_scores:,
        run_immediately: true
      )
      # SubmissionLifecycleManager handles updating the cached grading_period_id on submissions.
      # run_immediately: true because we're already in a delayed job
      SubmissionLifecycleManager.recompute_course(course, run_immediately: true) if update_all_grading_period_scores
    end
  end

  def courses_to_recompute
    courses.active
  end

  def grading_period_group_id_has_changed?
    # migration 20111111214313_add_trust_link_for_default_account
    # will throw an error without this check
    respond_to?(:saved_change_to_grading_period_group_id?) && saved_change_to_grading_period_group_id?
  end
end
