# frozen_string_literal: true

#
# Copyright (C) 2014 - 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 GradingPeriod < ActiveRecord::Base
  include Canvas::SoftDeletable

  belongs_to :grading_period_group, inverse_of: :grading_periods
  has_many :scores, -> { active }
  has_many :submissions, -> { active }, inverse_of: :grading_period
  has_many :auditor_grade_change_records,
           class_name: "Auditors::ActiveRecord::GradeChangeRecord",
           inverse_of: :grading_period

  validates :title, :start_date, :end_date, :close_date, :grading_period_group_id, presence: true
  validates :weight, numericality: true, allow_nil: true
  validate :start_date_is_before_end_date
  validate :close_date_is_on_or_after_end_date
  validate :not_overlapping, unless: :skip_not_overlapping_validator?

  before_validation :adjust_close_date_for_course_period
  before_validation :ensure_close_date

  before_save :set_root_account_id
  after_save :recompute_scores, if: :dates_or_weight_or_workflow_state_changed?
  after_destroy :destroy_grading_period_set, if: :last_remaining_legacy_period?
  after_destroy :destroy_scores
  scope :current, lambda {
    now = Time.zone.now.change(sec: 0)
    where(
      "date_trunc('minute', grading_periods.end_date) >= ? AND date_trunc('minute', grading_periods.start_date) < ?",
      now,
      now
    )
  }

  scope :closed, -> { where(grading_periods: { close_date: ...Time.zone.now }) }
  scope :open, -> { where("grading_periods.close_date IS NULL OR grading_periods.close_date >= ?", Time.zone.now) }

  scope :grading_periods_by, lambda { |context_with_ids|
    joins(:grading_period_group).where(grading_period_groups: context_with_ids).readonly(false)
  }

  set_policy do
    %i[read create update delete].each do |permission|
      given do |user|
        grading_period_group.present? &&
          grading_period_group.grants_right?(user, permission)
      end
      can permission
    end
  end

  def self.date_in_closed_grading_period?(course:, date:, periods: nil)
    period = for_date_in_course(date:, course:, periods:)
    period.present? && period.closed?
  end

  def self.for_date_in_course(date:, course:, periods: nil)
    periods ||= self.for(course)

    if date.nil?
      periods.max_by(&:end_date)
    else
      periods.detect { |p| p.in_date_range?(date) }
    end
  end

  def self.for(context, inherit: true)
    grading_periods = context.grading_periods.active
    if context.is_a?(Course) && inherit && grading_periods.empty?
      context.enrollment_term.grading_periods.active
    else
      grading_periods
    end
  end

  def self.current_period_for(context)
    self.for(context).find(&:current?)
  end

  def account_group?
    grading_period_group.course_id.nil?
  end

  def course_group?
    grading_period_group.course_id.present?
  end

  def assignments_for_student(course, assignments, student, includes: [])
    assignment_ids = GradebookGradingPeriodAssignments.new(course, student:, includes:).to_h.fetch(id, [])
    if assignment_ids.empty?
      []
    else
      assignments.select { |assignment| assignment_ids.include?(assignment.id.to_s) }
    end
  end

  def assignments(course, assignments)
    assignment_ids = GradebookGradingPeriodAssignments.new(course).to_h.fetch(id, [])
    if assignment_ids.empty?
      []
    else
      assignments.select { |assignment| assignment_ids.include?(assignment.id.to_s) }
    end
  end

  def current?
    in_date_range?(Time.zone.now)
  end

  def in_date_range?(date)
    comparison_date = date_for_comparison(date)
    date_for_comparison(start_date) < comparison_date && comparison_date <= date_for_comparison(end_date)
  end

  def last?
    # should never be nil, because self is part of the potential set
    @last_period ||= grading_period_group
                     .grading_periods
                     .active
                     .order(end_date: :desc)
                     .first
    @last_period == self
  end
  alias_method :is_last, :last?

  def closed?
    Time.zone.now > close_date
  end
  alias_method :is_closed, :closed?

  def overlapping?
    overlaps.active.exists?
  end

  def skip_not_overlapping_validator
    @_skip_not_overlapping_validator = true
  end

  def self.json_for(context, user)
    periods = self.for(context).sort_by(&:start_date)
    periods_json(periods, user)
  end

  def self.periods_json(periods, user)
    periods.map do |period|
      period.as_json_with_user_permissions(user)
    end
  end

  def as_json_with_user_permissions(user)
    as_json(
      only: %i[id title start_date end_date close_date weight],
      permissions: { user: },
      methods: [:is_last, :is_closed]
    ).fetch(:grading_period)
  end

  def disable_post_to_sis
    raise(RangeError, "The grading period is not yet closed.") if Time.zone.now < close_date

    # This method is called from a job, to know if it is already processed we
    # cache that the job has processed.
    # If the look_back in the job is changed, the amount of time we cache needs
    # to also follow, so using the same setting.
    look_back = Setting.get("disable_post_to_sis_on_grading_period", "60").to_i + 10
    Rails.cache.fetch(["disable_post_to_sis_in_completed", self].cache_key, expires_in: look_back.minutes) do
      submissions.in_batches do |submissions_batch|
        assignment_ids = submissions_batch.pluck(:assignment_id).uniq
        Assignment.active.where(id: assignment_ids, post_to_sis: true).update_all(post_to_sis: false, updated_at: Time.zone.now)
      end
      # caching that it has completed, so if this gets called again, it can skip.
      true
    end
  end

  private

  def set_root_account_id
    self.root_account_id ||= grading_period_group&.root_account_id
  end

  def date_for_comparison(date)
    comparison_date = date.is_a?(String) ? Time.zone.parse(date) : date
    comparison_date&.change(sec: 0)
  end

  def destroy_scores
    scores.find_ids_in_ranges do |min_id, max_id|
      scores.where(id: min_id..max_id).update_all(workflow_state: :deleted)
    end
  end

  def destroy_grading_period_set
    grading_period_group.destroy
  end

  def last_remaining_legacy_period?
    course_group? && grading_period_group.active? && siblings.active.empty?
  end

  def skip_not_overlapping_validator?
    @_skip_not_overlapping_validator
  end

  scope :overlaps, lambda { |from, to|
    # sourced: http://c2.com/cgi/wiki?TestIfDateRangesOverlap
    where(
      "((date_trunc('minute', end_date) > ?) and (date_trunc('minute', start_date) < ?))",
      from&.change(sec: 0),
      to&.change(sec: 0)
    )
  }

  def not_overlapping
    if overlapping?
      errors.add(:base, t("errors.overlap_message",
                          "Grading period cannot overlap with existing grading periods in group"))
    end
  end

  def overlaps
    siblings.overlaps(start_date, end_date)
  end

  def siblings
    grading_periods = self.class.where(
      grading_period_group_id:
    )

    if new_record?
      grading_periods
    else
      grading_periods.where.not(id:)
    end
  end

  def start_date_is_before_end_date
    if start_date && end_date && end_date < start_date
      errors.add(:end_date, t("must be after start date"))
    end
  end

  def adjust_close_date_for_course_period
    self.close_date = end_date if grading_period_group.present? && course_group?
  end

  def ensure_close_date
    self.close_date ||= end_date
  end

  def close_date_is_on_or_after_end_date
    if close_date.present? && end_date.present? && close_date < end_date
      errors.add(:close_date, t("must be on or after end date"))
    end
  end

  def recompute_scores
    dates_or_workflow_state_changed = time_boundaries_changed? || saved_change_to_workflow_state?

    if course_group?
      course = grading_period_group.course
      course.recompute_student_scores(update_all_grading_period_scores: dates_or_workflow_state_changed)
      SubmissionLifecycleManager.recompute_course(course) if dates_or_workflow_state_changed
    else
      grading_period_group.recompute_scores_for_each_term(dates_or_workflow_state_changed)
    end
  end

  def weight_actually_changed?
    grading_period_group.weighted && saved_change_to_weight?
  end

  def time_boundaries_changed?
    saved_change_to_start_date? || saved_change_to_end_date?
  end

  def dates_or_weight_or_workflow_state_changed?
    time_boundaries_changed? || weight_actually_changed? || saved_change_to_workflow_state?
  end
end
