# 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/>.
#

# Contains a hash of arrays with hashes in them. This is so that
# we can get all the submissions for a course grouped by date and
# ordered by date, person, then assignment.  Since working with this is
# a loop in a loop in a loop, it gets a little awkward for controllers
# and views, so I contain it in a class with some helper methods.  The
# dictionary comes from facets, a stable and mature library that's been
# around for years.  A dictionary is just an ordered hash.
#
# To use this:
#
# s = SubmissionList.new(course)
# s.each {|e| # lists each submission hash in the right order }
# s.each_day {|e| # lists each day with an array of submission hashes }
#
# The submission hash has some very useful meta data in there:
#
# :grader => printable name of the grader, or Graded on submission if unknown
# :grader_id => user_id of the grader
# :previous_grade => the grade previous to this one, or nil
# :current_grade => the most current grade, the last submission for this assignment and student
# :new_grade => the new grade this submission received
# :assignment_name => a printable name of the assignment
# :student_user_id => the user_id of the student
# :course_id => the course id
# :assignment_id => the assignment id
# :student_name => a printable name of the student
# :graded_on => the day (not the time) the submission was made
# :score_before_regrade => the score prior to regrading
#
# The version data is actually pulled from some yaml storage through
# simply_versioned.
class SubmissionList
  VALID_KEYS = %i[
    assignment_id
    assignment_name
    attachment_id
    attachment_ids
    body
    course_id
    created_at
    current_grade
    current_graded_at
    current_grader
    grade_matches_current_submission
    graded_at
    graded_on
    grader
    grader_id
    group_id
    id
    new_grade
    new_graded_at
    new_grader
    previous_grade
    previous_graded_at
    previous_grader
    processed
    published_grade
    published_score
    safe_grader_id
    score
    student_entered_score
    student_user_id
    submission_id
    student_name
    submission_type
    updated_at
    url
    user_id
    workflow_state
    score_before_regrade
  ].freeze

  class << self
    # Shortcut for SubmissionList.each(course) { ... }
    def each(course, &)
      sl = new(course)
      sl.each(&)
    end

    def each_day(course, &)
      sl = new(course)
      sl.each_day(&)
    end

    def days(course)
      new(course).days
    end

    def submission_entries(course)
      new(course).submission_entries
    end

    def list(course)
      new(course).list
    end
  end

  # The course
  attr_reader :course

  # The dictionary of submissions
  attr_reader :list

  def initialize(course)
    raise ArgumentError, "Must provide a course." unless course.is_a?(Course)

    @course = course
    process
  end

  # An iterator on a sorted and filtered list of submission versions.
  def each(&)
    submission_entries.each(&)
  end

  # An iterator on the day only, not each submission
  def each_day(&)
    list.each(&)
  end

  DateAndGraders = Struct.new(:date, :graders)
  # An array of days with an array of grader open structs for that day and course.
  def days
    list.map do |day, _value|
      DateAndGraders.new(day, graders_for_day(day))
    end
  end

  SubmissionEntry = Struct.new(*VALID_KEYS, keyword_init: true)
  # A filtered list of hashes of all submission versions that change the
  # grade with all the meta data finally included. This list can be sorted
  # and displayed.
  def submission_entries
    return @submission_entries if @submission_entries

    @submission_entries = filtered_submissions.map do |s|
      entry = current_grade_map[s[:id]]
      SubmissionEntry.new(**s.slice(*VALID_KEYS), current_grade: entry.grade, current_graded_at: entry.graded_at, current_grader: entry.grader)
    end
  end

  # A cleaner look at a SubmissionList
  def inspect
    "SubmissionList: course: #{course.name} submissions used: #{submission_entries.size} days used: #{list.keys.inspect} graders: #{graders.map(&:name).inspect}"
  end

  protected

  AssignmentsForGrader = Struct.new(:assignments, :name, :grader_id)
  # Returns an array of graders with an array of assignment open structs
  def graders_for_day(day)
    hsh = list[day].each_with_object({}) do |submission, h|
      grader = submission[:grader]
      h[grader] ||= AssignmentsForGrader.new(
        assignments_for_grader_and_day(grader, day),
        grader,
        submission[:grader_id]
      )
    end
    hsh.values
  end

  AssignmentsForGraderAndDay = Struct.new(:name, :assignment_id, :submissions, :submission_count)
  # Returns an array of assignments with an array of submission open structs.
  def assignments_for_grader_and_day(grader, day)
    hsh = submission_entries.find_all { |e| e[:grader] == grader and e[:graded_on] == day }.each_with_object({}) do |submission, h|
      assignment = submission[:assignment_name]
      h[assignment] ||= AssignmentsForGraderAndDay.new(
        assignment,
        submission[:assignment_id],
        []
      )

      h[assignment].submissions << submission
    end

    hsh.each_value do |v|
      v.submissions = Canvas::ICU.collate_by(v.submissions, &:student_name)
      v.submission_count = v.submissions.size
    end

    hsh.values
  end

  # Produce @list, wich is a sorted, filtered, list of submissions with
  # all the meta data we need and no banned keys included.
  def process
    @list = submission_entries.sort_by { |a| [a[:graded_at] ? -a[:graded_at].to_f : CanvasSort::Last, a[:safe_grader_id], a[:assignment_id]] }
                              .each_with_object({}) do |se, d|
      d[se[:graded_on]] ||= []
      d[se[:graded_on]] << se
    end
  end

  CurrentGrade = Struct.new(:grade, :graded_at, :grader)
  # A hash of the current grades of each submission, keyed by submission.id
  def current_grade_map
    @current_grade_map ||= course.submissions.not_placeholder.each_with_object({}) do |submission, hash|
      grader = if submission.grader_id.present?
                 grader_map[submission.grader_id].try(:name)
               end
      grader ||= I18n.t("gradebooks.history.graded_on_submission", "Graded on submission")

      hash[submission.id] = CurrentGrade.new(translate_grade(submission),
                                             submission.graded_at,
                                             grader)
    end
  end

  # Creates a list of any submissions that change the grade. Adds:
  # * previous_grade
  # * previous_graded_at
  # * previous_grader
  # * new_grade
  # * new_graded_at
  # * new_grader
  # * current_grade
  # * current_graded_at
  # * current_grader
  def filtered_submissions
    return @filtered_submissions if @filtered_submissions

    # Sorts by submission then updated at in ascending order.  So:
    # submission 1 1/1/2009, submission 1 1/15/2009, submission 2 1/1/2009
    full_hash_list.sort_by! { |a| [a[:id], a[:updated_at]] }
    prior_submission_id, prior_grade, prior_score, prior_graded_at, prior_grader = nil

    @filtered_submissions = full_hash_list.each_with_object([]) do |h, l|
      # If the submission is different (not null for the first one, or just
      # different than the last one), set the previous_grade to nil (this is
      # the first version that changes a grade), set the new_grade to this
      # version's grade, and add this to the list.
      if prior_submission_id != h[:submission_id]
        h[:previous_grade] = nil
        h[:previous_graded_at] = nil
        h[:previous_grader] = nil
        h[:new_grade] = translate_grade(h)
        h[:new_score] = translate_score(h)
        h[:new_graded_at] = h[:graded_at]
        h[:new_grader] = h[:grader]
        l << h
      # If the prior_grade is different than the grade for this version, the
      # grade for this submission has been changed.  That's because we know
      # that this submission must be the same as the prior submission.
      # Set the prevous grade and the new grade and add this to the list.
      # Remove the old submission so that it doesn't show up twice in the
      # grade history.
      elsif prior_score != h[:score]
        l.pop if prior_graded_at.try(:to_date) == h[:graded_at].try(:to_date) && prior_grader == h[:grader]
        h[:previous_grade] = prior_grade
        h[:previous_graded_at] = prior_graded_at
        h[:previous_grader] = prior_grader
        h[:new_grade] = translate_grade(h)
        h[:new_score] = translate_score(h)
        h[:new_graded_at] = h[:graded_at]
        h[:new_grader] = h[:grader]
        l << h
      end

      # At this point, we are only working with versions that have changed a
      # grade.  Go ahead and save that grade and save this version as the
      # prior version and iterate.
      prior_grade = translate_grade(h)
      prior_score = translate_score(h)
      prior_graded_at = h[:graded_at]
      prior_grader = h[:grader]
      prior_submission_id = h[:submission_id]
    end
  end

  def translate_grade(submission)
    submission[:excused] ? "EX" : submission[:grade]
  end

  def translate_score(submission)
    submission[:excused] ? "EX" : submission[:score]
  end

  # A list of all versions in YAML format
  def yaml_list
    @yaml_list ||= course.submissions.not_placeholder.preload(:versions).flat_map do |s|
      s.versions.map(&:yaml)
    end
  end

  # A list of hashes.  All the versions of all the submissions for a
  # course, unfiltered and unsorted.
  def raw_hash_list
    @hash_list ||= begin
      hash_list = yaml_list.map { |y| YAML.load(y).symbolize_keys }
      add_regrade_info(hash_list)
    end
  end

  # This method will add regrade details to the existing raw_hash_list
  def add_regrade_info(hash_list)
    quiz_submission_ids = hash_list.filter_map { |y| y[:quiz_submission_id] }
    return hash_list if quiz_submission_ids.blank?

    quiz_submissions = Quizzes::QuizSubmission.where("id IN (?) AND score_before_regrade IS NOT NULL", quiz_submission_ids)
    quiz_submissions.each do |qs|
      matches = hash_list.select { |a| a[:id] == qs.submission_id }
      matches.each do |h|
        h[:score_before_regrade] = qs.score_before_regrade
      end
    end

    hash_list
  end

  # Still a list of unsorted, unfiltered hashes, but the meta data is inserted at this point
  def full_hash_list
    @full_hash_list ||= raw_hash_list.map do |h|
      h[:grader] = if h.key? :score_before_regrade
                     I18n.t("gradebooks.history.regraded", "Regraded")
                   elsif h[:grader_id] && grader_map[h[:grader_id]]
                     grader_map[h[:grader_id]].name
                   else
                     I18n.t("gradebooks.history.graded_on_submission", "Graded on submission")
                   end
      h[:safe_grader_id] = h[:grader_id] || 0
      h[:assignment_name] = assignment_map[h[:assignment_id]].title
      h[:student_user_id] = h[:user_id]
      h[:student_name] = student_map[h[:user_id]].name
      h[:course_id] = course.id
      h[:submission_id] = h[:id]
      h[:graded_on] = h[:graded_at].in_time_zone.to_date if h[:graded_at]

      h
    end
  end

  # A unique list of all grader ids
  def all_grader_ids
    @all_grader_ids ||= raw_hash_list.pluck(:grader_id).uniq.compact
  end

  # A complete list of all graders that have graded submissions for this
  # course as User models
  def graders
    @graders ||= User.where(id: all_grader_ids).to_a
  end

  # A hash of graders by their ids, for easy lookup in full_hash_list
  def grader_map
    @grader_map ||= graders.index_by(&:id)
  end

  # A unique list of all student ids
  def all_student_ids
    @all_student_ids ||= raw_hash_list.pluck(:user_id).uniq.compact
  end

  # A complete list of all students that have submissions for this course
  # as User models
  def students
    @students ||= User.where(id: all_student_ids).to_a
  end

  # A hash of students by their ids, for easy lookup in full_hash_list
  def student_map
    @student_map ||= students.index_by(&:id)
  end

  # A unique list of all assignment ids
  def all_assignment_ids
    @all_assignment_ids ||= raw_hash_list.pluck(:assignment_id).uniq.compact
  end

  # A complete list of assignments that have submissions for this course
  def assignments
    @assignments ||= Assignment.where(id: all_assignment_ids).to_a
  end

  # A hash of assignments by their ids, for easy lookup in full_hash_list
  def assignment_map
    @assignment_map ||= assignments.index_by(&:id)
  end
end
