# 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 GradeCalculator
  attr_reader :gradable_assignments
  attr_accessor :assignments, :groups

  def initialize(user_ids, course, **opts)
    opts = opts.reverse_merge(
      emit_live_event: true,
      ignore_muted: true,
      update_all_grading_period_scores: true,
      update_course_score: true,
      only_update_course_gp_metadata: false,
      only_update_points: false,
      use_what_if_scores: false,
      include_discussion_checkpoints: false
    )

    @course = course.is_a?(Course) ? course : Course.find(course)

    @groups = opts[:groups] || @course.assignment_groups.active.to_a
    @grading_period = opts[:grading_period]
    # if we're updating an overall course score (no grading period specified), we
    # want to first update all grading period scores for the users
    @update_all_grading_period_scores = @grading_period.nil? && opts[:update_all_grading_period_scores]
    # if we're updating a grading period score, we also need to update the
    # overall course score
    @update_course_score = @grading_period.present? && opts[:update_course_score]
    @ignore_unposted_anonymous = opts.fetch(
      :ignore_unposted_anonymous,
      @course.root_account.feature_enabled?(:grade_calc_ignore_unposted_anonymous)
    )
    @gradable_assignments = (opts[:assignments] || @course.assignments.published.gradeable).to_a

    @assignments = if @ignore_unposted_anonymous
                     Assignment.preload_unposted_anonymous_submissions(@gradable_assignments)

                     # Ignore anonymous assignments with unposted submissions in the grade calculation
                     # so that we don't break anonymity prior to the assignment being posted
                     # (which is when identities are revealed)
                     @gradable_assignments.reject(&:unposted_anonymous_submissions?)
                   else
                     @gradable_assignments
                   end

    @user_ids = Array(user_ids).map { |id| Shard.relative_id_for(id, Shard.current, @course.shard) }
    @current_updates = {}
    @final_updates = {}
    @dropped_updates = {}
    @current_groups = {}
    @final_groups = {}
    # The developers of the future, I leave you this gift:
    #   If you add a new options here, make sure you also update the
    #   opts in the compute_branch method
    @emit_live_event = opts[:emit_live_event]
    @ignore_muted = opts[:ignore_muted]
    @effective_due_dates = opts[:effective_due_dates]
    @enrollments = opts[:enrollments]
    @periods = opts[:periods]
    @submissions = opts[:submissions]
    @only_update_course_gp_metadata = opts[:only_update_course_gp_metadata]
    @only_update_points = opts[:only_update_points]
    @use_what_if_scores = opts[:use_what_if_scores]
    @include_discussion_checkpoints = opts[:include_discussion_checkpoints]
  end

  # recomputes the scores and saves them to each user's Enrollment
  def self.recompute_final_score(user_ids, course_id, **compute_score_opts)
    user_ids = Array(user_ids).uniq.map(&:to_i)
    return if user_ids.empty?

    course = course_id.is_a?(Course) ? course_id : Course.active.find_by(id: course_id)
    return unless course

    assignments = compute_score_opts[:assignments] || course.assignments.published.gradeable.to_a
    groups = compute_score_opts[:groups] || course.assignment_groups.active.to_a
    periods = compute_score_opts[:periods] || GradingPeriod.for(course)
    grading_period_id = compute_score_opts.delete(:grading_period_id)
    grading_period = periods.find_by(id: grading_period_id) if grading_period_id
    opts = compute_score_opts.reverse_merge(
      grading_period:,
      assignments:,
      groups:,
      periods:
    )
    user_ids.sort.in_groups_of(100, false) do |user_ids_group|
      GradeCalculator.new(user_ids_group, course, **opts).compute_and_save_scores
    end
  end

  def submissions
    assignments = if @include_discussion_checkpoints
                    checkpoint_assignments = @assignments.select(&:has_sub_assignments?).map(&:sub_assignments).flatten
                    @assignments += checkpoint_assignments
                  else
                    @assignments
                  end
    @submissions ||= begin
      submissions = @course.submissions
                           .except(:order, :select)
                           .for_user(@user_ids)
                           .where(assignment_id: assignments)
                           .select("submissions.id, user_id, assignment_id, score, excused, submissions.workflow_state, submissions.posted_at, student_entered_score")
                           .preload(:assignment)

      submissions
    end
  end

  def compute_scores
    scores_and_group_sums = []
    @user_ids.each_slice(100) do |batched_ids|
      scores_and_group_sums_batch = compute_scores_and_group_sums_for_batch(batched_ids)
      scores_and_group_sums.concat(scores_and_group_sums_batch)
    end
    scores_and_group_sums
  end

  def compute_and_save_scores
    calculate_grading_period_scores if @update_all_grading_period_scores
    compute_scores
    scores_prior_to_compute = Score.where(enrollment: @enrollments.map(&:id), assignment_group_id: nil, course_score: true).to_a
    save_scores
    update_score_statistics
    # The next line looks weird, but it is intended behaviour.  Its
    # saying "if we're on the branch not calculating hidden scores, run
    # the branch that does."
    calculate_hidden_scores if @ignore_muted

    # Since we @emit_live_event only in the outer call when @ignore_muted is true, this has to be
    # done after calculate_hidden_scores -- so changes in that inner call are also captured. But
    # it must be done before calculate_course_score so if @update_course_score is true (we are
    # scoring a grading period, not a course) we don't trigger an additional alert/live event here.
    create_course_grade_alerts_and_live_events(scores_prior_to_compute)

    calculate_course_score if @update_course_score
  end

  private

  def effective_due_dates
    @effective_due_dates ||= EffectiveDueDates.for_course(@course, @assignments).filter_students_to(@user_ids)
  end

  def observer_ids
    @observer_ids ||= ObserverEnrollment.where.not(workflow_state: [:rejected, :deleted])
                                        .where(course: @course)
                                        .pluck(:user_id)
                                        .uniq
  end

  def create_course_grade_alerts_and_live_events(scores)
    @course.shard.activate do
      ActiveRecord::Associations.preload(scores, :enrollment)
      # Make only one alert per user even if they have multiple enrollments (sections in same course)
      scores = scores.uniq { |s| s.enrollment.user_id }

      scores.each_slice(100) do |scores_batch|
        scores_info = scores_batch.each_with_object({ student_ids: [], ids: [] }) do |score, memo|
          memo[:student_ids] << score.enrollment.user_id
          memo[:ids] << score.id
        end

        preloaded_thresholds = ObserverAlertThreshold.active
                                                     .where(user_id: scores_info[:student_ids], alert_type: ["course_grade_high", "course_grade_low"])
                                                     .group_by(&:user_id)

        reloaded_scores = Score.where(id: scores_info[:ids]).index_by(&:id)
        scores_batch.each do |score|
          reloaded_score = reloaded_scores[score.id]
          # NOTE: only the old score has enrollment pre-loaded
          create_course_grade_live_event(score, reloaded_score) if @emit_live_event

          thresholds = preloaded_thresholds.fetch(score.enrollment.user_id, [])
          create_course_grade_alert(score, reloaded_score, thresholds)
        end
      end
    end
  end

  LIVE_EVENT_FIELDS = %i[current_score final_score unposted_current_score unposted_final_score].freeze
  private_constant :LIVE_EVENT_FIELDS

  def create_course_grade_live_event(old_score, score)
    return if LIVE_EVENT_FIELDS.all? { |f| old_score.send(f) == score.send(f) }

    old_score_values = LIVE_EVENT_FIELDS.index_with { |f| old_score.send(f) }
    Canvas::LiveEvents.course_grade_change(score, old_score_values, old_score.enrollment)
  end

  def create_course_grade_alert(old_score, score, thresholds)
    thresholds.each do |threshold|
      next unless threshold.did_pass_threshold(old_score.current_score, score.current_score)
      next unless observer_ids.include?(threshold.observer_id)

      ObserverAlert.create(observer_id: threshold.observer_id,
                           user_id: threshold.user_id,
                           observer_alert_threshold: threshold,
                           context: @course,
                           action_date: score.updated_at,
                           alert_type: threshold.alert_type,
                           title: I18n.t("Course grade: %{grade}% in %{course_code}", {
                                           grade: score.current_score,
                                           course_code: @course.course_code
                                         }))
    end
  end

  def compute_scores_and_group_sums_for_batch(user_ids)
    user_ids.filter_map do |user_id|
      next unless enrollments_by_user[user_id].first

      group_sums = compute_group_sums_for_user(user_id)
      scores = compute_scores_for_user(user_id, group_sums)
      update_changes_hash_for_user(user_id, scores, group_sums)
      {
        current: scores[:current],
        current_groups: group_sums[:current].index_by { |group| group[:id] },
        final: scores[:final],
        final_groups: group_sums[:final].index_by { |group| group[:id] }
      }
    end
  end

  def assignment_visible_to_student?(assignment_id, user_id)
    effective_due_dates.find_effective_due_date(user_id, assignment_id).key?(:due_at)
  end

  def compute_group_sums_for_user(user_id)
    user_submissions = submissions_by_user.fetch(user_id, []).select do |submission|
      assignment_visible_to_student?(submission.assignment_id, user_id)
    end

    {
      current: create_group_sums(user_submissions, user_id, ignore_ungraded: true),
      final: create_group_sums(user_submissions, user_id, ignore_ungraded: false)
    }
  end

  def compute_scores_for_user(user_id, group_sums)
    if compute_course_scores_from_weighted_grading_periods?
      calculate_total_from_weighted_grading_periods(user_id)
    else
      {
        current: calculate_total_from_group_scores(group_sums[:current]),
        final: calculate_total_from_group_scores(group_sums[:final])
      }
    end
  end

  def update_changes_hash_for_user(user_id, scores, group_sums)
    @current_updates[user_id] = scores[:current]
    @final_updates[user_id] = scores[:final]
    @current_groups[user_id] = group_sums[:current]
    @final_groups[user_id] = group_sums[:final]
    @dropped_updates[user_id] = {
      current: { dropped: scores[:current][:dropped] },
      final: { dropped: scores[:final][:dropped] }
    }
  end

  def grading_period_scores(enrollment_id)
    @grading_period_scores ||= Score.active.where(
      enrollment: enrollments.map(&:id),
      grading_period: grading_periods_for_course.map(&:id)
    ).group_by(&:enrollment_id)
    @grading_period_scores[enrollment_id] || []
  end

  def calculate_total_from_weighted_grading_periods(user_id)
    enrollment = enrollments_by_user[user_id].first
    grading_period_scores = grading_period_scores(enrollment.id)
    scores = apply_grading_period_weights_to_scores(grading_period_scores)
    scale_and_round_scores(scores, grading_period_scores)
  end

  def apply_grading_period_weights_to_scores(grading_period_scores)
    grading_period_scores.each_with_object(
      { current: { full_weight: 0.0, grade: 0.0 }, final: { full_weight: 0.0, grade: 0.0 } }
    ) do |score, scores|
      weight = grading_period_weights[score.grading_period_id] || 0.0
      scores[:final][:full_weight] += weight
      scores[:current][:full_weight] += weight if score.current_score
      scores[:current][:grade] += (score.current_score || 0.0) * (weight / 100.0)
      scores[:final][:grade] += (score.final_score || 0.0) * (weight / 100.0)
    end
  end

  def scale_and_round_scores(scores, grading_period_scores)
    [:current, :final].each_with_object({ current: {}, final: {} }) do |score_type, adjusted_scores|
      score = scores[score_type][:grade]
      full_weight = scores[score_type][:full_weight]
      score = scale_score_up(score, full_weight) if full_weight < 100
      if score.abs < Float::EPSILON && score_type == :current && grading_period_scores.none?(&:current_score)
        score = nil
      end
      adjusted_scores[score_type][:grade] = score ? score.round(2) : score
      adjusted_scores[score_type][:total] = adjusted_scores[score_type][:grade]
    end
  end

  def scale_score_up(score, weight)
    return 0.0 if weight.zero?

    (score * 100.0) / weight
  end

  def compute_course_scores_from_weighted_grading_periods?
    return @compute_from_weighted_periods if @compute_from_weighted_periods.present?

    @compute_from_weighted_periods = if @grading_period || grading_periods_for_course.empty?
                                       false
                                     else
                                       grading_periods_for_course.first.grading_period_group.weighted?
                                     end
  end

  def grading_periods_for_course
    @periods ||= GradingPeriod.for(@course)
  end

  def grading_period_weights
    @grading_period_weights ||= grading_periods_for_course.each_with_object({}) do |period, weights|
      weights[period.id] = period.weight
    end
  end

  def submissions_by_user
    @submissions_by_user ||= submissions.group_by { |s| Shard.relative_id_for(s.user_id, Shard.current, @course.shard) }
  end

  def compute_branch(**opts)
    opts = opts.reverse_merge(
      groups: @groups,
      grading_period: @grading_period,
      update_all_grading_period_scores: false,
      update_course_score: false,
      assignments: @assignments,
      emit_live_event: @emit_live_event,
      ignore_muted: @ignore_muted,
      ignore_unposted_anonymous: @ignore_unposted_anonymous,
      periods: grading_periods_for_course,
      effective_due_dates:,
      enrollments:,
      submissions:,
      only_update_course_gp_metadata: @only_update_course_gp_metadata,
      only_update_points: @only_update_points
    )
    GradeCalculator.new(@user_ids, @course, **opts).compute_and_save_scores
  end

  def calculate_hidden_scores
    # re-run this calculator, except include muted assignments/unposted submissions
    compute_branch(ignore_muted: false, emit_live_event: false)
  end

  def calculate_grading_period_scores
    grading_periods_for_course.each do |grading_period|
      # update this grading period score
      compute_branch(grading_period:)
    end

    # delete any grading period scores that are no longer relevant
    grading_period_ids = grading_periods_for_course.empty? ? nil : grading_periods_for_course.map(&:id)
    @course.shard.activate do
      Score.active.joins(:enrollment)
           .where(enrollments: { user_id: @user_ids, course_id: @course.id })
           .where.not(grading_period_id: grading_period_ids)
           .update_all(workflow_state: :deleted)
    end
  end

  def calculate_course_score
    # update the overall course score now that we've finished
    # updating the grading period score
    compute_branch(grading_period: nil)
  end

  def enrollments
    @enrollments ||= Enrollment.shard(@course.shard).active
                               .where(user_id: @user_ids, course_id: @course.id)
                               .select(:id, :user_id, :workflow_state)
  end

  def joined_enrollment_ids
    # use local_id because we'll exec the query on the enrollment's shard
    @joined_enrollment_ids ||= enrollments.map(&:local_id).join(",")
  end

  def enrollments_by_user
    @enrollments_by_user ||= begin
      hsh = enrollments.group_by { |e| Shard.relative_id_for(e.user_id, Shard.current, @course.shard) }
      hsh.default = []
      hsh
    end
  end

  def group_score_rows
    enrollments_by_user.keys.flat_map do |user_id|
      current_group_scores = @current_groups[user_id].index_by { |group| group[:global_id] }
      final_group_scores = @final_groups[user_id].index_by { |group| group[:global_id] }
      @groups.flat_map do |group|
        agid = group.global_id
        current = current_group_scores[agid]
        final = final_group_scores[agid]
        enrollments_by_user[user_id].map do |enrollment|
          fields = [enrollment.id, group.id]
          fields.push(current[:grade], final[:grade]) unless @only_update_points
          fields.push(current[:score], final[:score])
        end
      end
    end
  end

  # if use_batches_score_metadata_updates is OFF, we use this method
  def group_dropped_rows_old
    enrollments_by_user.keys.map do |user_id|
      current = @current_groups[user_id].pluck(:global_id, :dropped).to_h
      final = @final_groups[user_id].pluck(:global_id, :dropped).to_h
      @groups.map do |group|
        agid = group.global_id
        hsh = {
          current: { dropped: current[agid] },
          final: { dropped: final[agid] }
        }
        enrollments_by_user[user_id].map do |enrollment|
          "(#{enrollment.id}, #{group.id}, '#{hsh.to_json}')"
        end
      end
    end.flatten
  end

  # if use_batches_score_metadata_updates is ON, we use this method
  def group_dropped_rows
    enrollments_by_user.keys.flat_map do |user_id|
      current = @current_groups[user_id].pluck(:global_id, :dropped).to_h
      final = @final_groups[user_id].pluck(:global_id, :dropped).to_h
      @groups.flat_map do |group|
        agid = group.global_id
        hsh = {
          current: { dropped: current[agid] },
          final: { dropped: final[agid] }
        }
        enrollments_by_user[user_id].map do |enrollment|
          [enrollment.id, group.id, hsh]
        end
      end
    end
  end

  def updated_at
    @updated_at ||= Score.connection.quote(Time.now.utc)
  end

  def column_prefix
    @ignore_muted ? "" : "unposted_"
  end

  def current_score_column
    "#{column_prefix}current_score"
  end

  def final_score_column
    "#{column_prefix}final_score"
  end

  def points_column(type)
    "#{column_prefix}#{type}_points"
  end

  def update_score_statistics
    return if @grading_period   # only update score statistics when calculating course scores
    return unless @ignore_muted # only update when calculating final scores

    ScoreStatisticsGenerator.update_score_statistics_in_singleton(@course)
  end

  def save_scores
    raise "What if score can not be saved as scores" if @use_what_if_scores
    return if @current_updates.empty? && @final_updates.empty?
    return if joined_enrollment_ids.blank?
    return if @grading_period&.deleted?

    save_scores_in_transaction
  end

  def save_scores_in_transaction
    Score.transaction do
      @course.shard.activate do
        save_course_and_grading_period_scores
        save_course_and_grading_period_metadata
        score_rows = group_score_rows
        if @grading_period.nil? && score_rows.any?
          if Account.site_admin.feature_enabled?(:use_batches_score_metadata_updates)
            save_assignment_group_scores(score_rows, group_dropped_rows)
          else
            save_assignment_group_scores_old(score_rows, group_dropped_rows_old)
          end
        end
      end
    end
  end

  def data_to_insert_or_update
    @data_to_insert_or_update ||= begin
      data = { columns: [points_column(:current), points_column(:final)] }
      data[:columns].unshift(current_score_column, final_score_column) unless @only_update_points

      data[:values] = (@current_updates.keys + @final_updates.keys).uniq.flat_map do |user_id|
        enrollments_by_user[user_id].map do |enrollment|
          datum = [enrollment.id]
          datum.push(@current_updates[user_id][:grade], @final_updates[user_id][:grade]) unless @only_update_points
          datum.push(@current_updates[user_id][:total], @final_updates[user_id][:total])
        end
      end

      data
    end
  end

  def save_course_and_grading_period_scores
    return if @only_update_course_gp_metadata

    # Depending on whether we're updating course scores or grading period
    # scores, we need to check our inserted values against different uniqueness
    # constraints
    conflict_target = if @grading_period.present?
                        "(enrollment_id, grading_period_id) WHERE grading_period_id IS NOT NULL"
                      else
                        "(enrollment_id) WHERE course_score"
                      end

    table = Score.quoted_table_name

    query_parts = data_to_insert_or_update[:columns]
                  .each_with_object(Hash.new { |h, k| h[k] = +"" }).with_index do |(c, h), i|
      join = i > 0
      h[:insert_columns] << %(#{", " if join}#{c})
      h[:inner_selects] << %(#{", " if join}(x->>#{i + 1})::FLOAT8 AS #{c})
      h[:update_columns] << %(#{", " if join}#{c} = excluded.#{c})
      h[:update_conditions] << %(#{" OR " if join}#{table}.#{c} IS DISTINCT FROM excluded.#{c})
    end

    # Update existing course and grading period Scores or create them if needed.
    Score.connection.with_max_update_limit(enrollments.length) do
      Score.connection.execute(<<~SQL.squish)
        INSERT INTO #{table}
            (
              enrollment_id, grading_period_id,
              #{data_to_insert_or_update[:columns].join(", ")},
              course_score, root_account_id, created_at, updated_at
            )
            SELECT
              enrollments.id as enrollment_id,
              #{@grading_period.try(:id) || "NULL"} AS grading_period_id,
              #{query_parts[:insert_columns]},
              #{@grading_period ? "FALSE" : "TRUE"} AS course_score,
              #{@course.root_account_id} AS root_account_id,
              #{updated_at} AS created_at,
              #{updated_at} AS updated_at
            FROM
                (
                  SELECT
                    (x->>0)::INT8 AS enrollment_id,
                    #{query_parts[:inner_selects]}
                  FROM
                    jsonb_array_elements('#{data_to_insert_or_update[:values].to_json}') x
                ) j
            JOIN #{Enrollment.quoted_table_name} AS enrollments ON j.enrollment_id = enrollments.id
        ORDER BY enrollment_id
        ON CONFLICT #{conflict_target}
        DO UPDATE SET
            #{query_parts[:update_columns]},
            updated_at = excluded.updated_at,
            root_account_id = excluded.root_account_id,
            /* if workflow_state was previously deleted for some reason, update it to active */
            workflow_state = COALESCE(NULLIF(excluded.workflow_state, 'deleted'), 'active')
        WHERE
          #{query_parts[:update_conditions]}
          OR #{table}.root_account_id IS DISTINCT FROM excluded.root_account_id
          OR #{table}.workflow_state IS DISTINCT FROM COALESCE(NULLIF(excluded.workflow_state, 'deleted'), 'active')
      SQL
    end
  rescue ActiveRecord::Deadlocked => e
    Canvas::Errors.capture_exception(:grade_calcuator, e, :warn)
    raise Delayed::RetriableError, "Deadlock in upserting course or grading period scores"
  end

  def save_course_and_grading_period_metadata
    # We only save score metadata for posted grades. This means, if we're
    # calculating unposted grades (which means @ignore_muted is false),
    # we don't want to update the score metadata. TODO: start storing the
    # score metadata for unposted grades.
    return unless @ignore_muted

    table = ScoreMetadata.quoted_table_name
    ScoreMetadata.connection.execute(<<~SQL.squish)
      INSERT INTO #{table}
        (score_id, calculation_details, created_at, updated_at)
        SELECT
          scores.id AS score_id,
          CASE enrollments.user_id
            #{@dropped_updates.map do |user_id, dropped|
              "WHEN #{user_id} THEN cast('#{dropped.to_json}' as json)"
            end.join(" ")}
            ELSE NULL
          END AS calculation_details,
          #{updated_at} AS created_at,
          #{updated_at} AS updated_at
        FROM #{Score.quoted_table_name} scores
        INNER JOIN #{Enrollment.quoted_table_name} enrollments ON
          enrollments.id = scores.enrollment_id
        LEFT OUTER JOIN #{ScoreMetadata.quoted_table_name} metadata ON
          metadata.score_id = scores.id
        WHERE
          scores.enrollment_id IN (#{joined_enrollment_ids}) AND
          scores.assignment_group_id IS NULL AND
          #{@grading_period ? "scores.grading_period_id = #{@grading_period.id}" : "scores.course_score IS TRUE"}
        ORDER BY enrollment_id
      ON CONFLICT (score_id)
      DO UPDATE SET
        calculation_details = excluded.calculation_details,
        updated_at = excluded.updated_at
      WHERE
        #{table}.calculation_details #>>'{current,dropped}' IS DISTINCT FROM excluded.calculation_details #>>'{current,dropped}' OR
        #{table}.calculation_details #>>'{final,dropped}' IS DISTINCT FROM excluded.calculation_details #>>'{final,dropped}'
    SQL
  end

  def assignment_group_columns_to_insert_or_update
    @assignment_group_columns_to_insert_or_update ||= begin
      column_list = {
        insert_columns: [],
        update_columns: [],
        value_names: []
      }

      unless @only_update_points
        column_list[:value_names].push("current_score", "final_score")
        column_list[:update_columns].push(
          { column: current_score_column, target: "excluded.current_score" },
          { column: final_score_column, target: "excluded.final_score" }
        )
        column_list[:insert_columns].push(
          "val.current_score AS #{current_score_column}",
          "val.final_score AS #{final_score_column}"
        )
      end

      column_list[:value_names].push("current_points", "final_points")
      column_list[:update_columns].push(
        { column: points_column(:current), target: "excluded.current_points" },
        { column: points_column(:final), target: "excluded.final_points" }
      )
      column_list[:insert_columns].push(
        "val.current_points AS #{points_column(:current)}",
        "val.final_points AS #{points_column(:final)}"
      )

      column_list
    end
  end

  # if use_batches_score_metadata_updates is OFF, we use this method
  def save_assignment_group_scores_old(score_values, dropped_values)
    table = Score.quoted_table_name

    update_columns, update_conditions = assignment_group_columns_to_insert_or_update[:update_columns]
                                        .each_with_object([+"", +""]) do |uc, (cols, conds)|
      cols << %(#{", " unless cols.empty?}#{uc[:column]} = #{uc[:target]})
      conds << %(#{" OR " unless conds.empty?}#{table}.#{uc[:column]} IS DISTINCT FROM #{uc[:target]})
    end

    value_names = assignment_group_columns_to_insert_or_update[:value_names]
                  .map.with_index { |name, i| %((x->>#{i + 2})::FLOAT8 AS #{name}) }.join(", ")

    Score.connection.with_max_update_limit(score_values.length) do
      # Update existing assignment group Scores or create them if needed.
      Score.connection.execute(<<~SQL.squish)
        INSERT INTO #{table} (
          enrollment_id, assignment_group_id,
          #{assignment_group_columns_to_insert_or_update[:value_names].join(", ")},
          course_score, root_account_id, created_at, updated_at
        )
          SELECT
            val.enrollment_id AS enrollment_id,
            val.assignment_group_id as assignment_group_id,
            #{assignment_group_columns_to_insert_or_update[:insert_columns].join(", ")},
            FALSE AS course_score,
            #{@course.root_account_id} AS root_account_id,
            #{updated_at} AS created_at,
            #{updated_at} AS updated_at
          FROM
            (
              SELECT
                (x->>0)::INT8 AS enrollment_id,
                (x->>1)::INT8 AS assignment_group_id,
                #{value_names}
              FROM
                jsonb_array_elements('#{score_values.to_json}') x
            ) val
          ORDER BY assignment_group_id, enrollment_id
        ON CONFLICT (enrollment_id, assignment_group_id) WHERE assignment_group_id IS NOT NULL
        DO UPDATE SET
          #{update_columns},
          updated_at = excluded.updated_at,
          root_account_id = excluded.root_account_id,
          workflow_state = COALESCE(NULLIF(excluded.workflow_state, 'deleted'), 'active')
        WHERE
          #{update_conditions}
          OR #{table}.root_account_id IS DISTINCT FROM excluded.root_account_id
          OR #{table}.workflow_state IS DISTINCT FROM COALESCE(NULLIF(excluded.workflow_state, 'deleted'), 'active')
      SQL
    end

    # We only save score metadata for posted grades. This means, if we're
    # calculating unposted grades (which means @ignore_muted is false),
    # we don't want to update the score metadata. TODO: start storing the
    # score metadata for unposted grades.
    if @ignore_muted
      table = ScoreMetadata.quoted_table_name
      Score.connection.with_max_update_limit(dropped_values.length) do
        ScoreMetadata.connection.execute(<<~SQL.squish)
          INSERT INTO #{table}
            (score_id, calculation_details, created_at, updated_at)
            SELECT
              scores.id AS score_id,
              CAST(val.calculation_details as json) AS calculation_details,
              #{updated_at} AS created_at,
              #{updated_at} AS updated_at
            FROM (VALUES #{dropped_values.join(",")}) val
              (enrollment_id, assignment_group_id, calculation_details)
            LEFT OUTER JOIN #{Score.quoted_table_name} scores ON
              scores.enrollment_id = val.enrollment_id AND
              scores.assignment_group_id = val.assignment_group_id
            ORDER BY score_id
          ON CONFLICT (score_id)
          DO UPDATE SET
            calculation_details = excluded.calculation_details,
            updated_at = excluded.updated_at
          WHERE
          #{table}.calculation_details #>>'{current,dropped}' IS DISTINCT FROM excluded.calculation_details #>>'{current,dropped}' OR
          #{table}.calculation_details #>>'{final,dropped}' IS DISTINCT FROM excluded.calculation_details #>>'{final,dropped}'
        SQL
      end
    end
  rescue ActiveRecord::Deadlocked => e
    Canvas::Errors.capture_exception(:grade_calculator, e, :warn)
    raise Delayed::RetriableError, "Deadlock in upserting assignment group scores"
  end

  # if use_batches_score_metadata_updates is ON, we use this method
  def save_assignment_group_scores(score_values, dropped_values)
    table = Score.quoted_table_name

    update_columns, update_conditions = assignment_group_columns_to_insert_or_update[:update_columns]
                                        .each_with_object([+"", +""]) do |uc, (cols, conds)|
      cols << %(#{", " unless cols.empty?}#{uc[:column]} = #{uc[:target]})
      conds << %(#{" OR " unless conds.empty?}#{table}.#{uc[:column]} IS DISTINCT FROM #{uc[:target]})
    end

    value_names = assignment_group_columns_to_insert_or_update[:value_names]
                  .map.with_index { |name, i| %((x->>#{i + 2})::FLOAT8 AS #{name}) }.join(", ")

    Score.connection.with_max_update_limit(score_values.length) do
      # Update existing assignment group Scores or create them if needed.
      Score.connection.execute(<<~SQL.squish)
        INSERT INTO #{table} (
          enrollment_id, assignment_group_id,
          #{assignment_group_columns_to_insert_or_update[:value_names].join(", ")},
          course_score, root_account_id, created_at, updated_at
        )
          SELECT
            val.enrollment_id AS enrollment_id,
            val.assignment_group_id as assignment_group_id,
            #{assignment_group_columns_to_insert_or_update[:insert_columns].join(", ")},
            FALSE AS course_score,
            #{@course.root_account_id} AS root_account_id,
            #{updated_at} AS created_at,
            #{updated_at} AS updated_at
          FROM
            (
              SELECT
                (x->>0)::INT8 AS enrollment_id,
                (x->>1)::INT8 AS assignment_group_id,
                #{value_names}
              FROM
                jsonb_array_elements('#{score_values.to_json}') x
            ) val
          ORDER BY assignment_group_id, enrollment_id
        ON CONFLICT (enrollment_id, assignment_group_id) WHERE assignment_group_id IS NOT NULL
        DO UPDATE SET
          #{update_columns},
          updated_at = excluded.updated_at,
          root_account_id = excluded.root_account_id,
          workflow_state = COALESCE(NULLIF(excluded.workflow_state, 'deleted'), 'active')
        WHERE
          #{update_conditions}
          OR #{table}.root_account_id IS DISTINCT FROM excluded.root_account_id
          OR #{table}.workflow_state IS DISTINCT FROM COALESCE(NULLIF(excluded.workflow_state, 'deleted'), 'active')
      SQL
    end

    # We only save score metadata for posted grades. This means, if we're
    # calculating unposted grades (which means @ignore_muted is false),
    # we don't want to update the score metadata. TODO: start storing the
    # score metadata for unposted grades.
    if @ignore_muted
      table = ScoreMetadata.quoted_table_name
      batch_size = 1000

      # Separate data into empty drops and non-empty drops
      empty_drop_hsh = { current: { dropped: [] }, final: { dropped: [] } }
      empty_drop_json = "'#{empty_drop_hsh.to_json}'::json"

      empty_drop_rows = []
      non_empty_drop_rows = []

      dropped_values.each do |enrollment_id, group_id, hsh|
        if hsh == empty_drop_hsh
          empty_drop_rows << "(#{enrollment_id}, #{group_id})"
        else
          non_empty_drop_rows << "(#{enrollment_id}, #{group_id}, '#{hsh.to_json}')"
        end
      end

      execute_score_metadata_upsert = lambda do |max_update_limit, from_clause|
        Score.connection.with_max_update_limit(max_update_limit) do
          ScoreMetadata.connection.execute(<<~SQL.squish)
            INSERT INTO #{table}
              (score_id, calculation_details, created_at, updated_at)
              SELECT
                scores.id AS score_id,
                CAST(val.calculation_details as json) AS calculation_details,
                #{updated_at} AS created_at,
                #{updated_at} AS updated_at
              #{from_clause}
              LEFT OUTER JOIN #{Score.quoted_table_name} scores ON
                scores.enrollment_id = val.enrollment_id AND
                scores.assignment_group_id = val.assignment_group_id
              ORDER BY score_id
            ON CONFLICT (score_id)
            DO UPDATE SET
              calculation_details = excluded.calculation_details,
              updated_at = excluded.updated_at
            WHERE
            #{table}.calculation_details #>>'{current,dropped}' IS DISTINCT FROM excluded.calculation_details #>>'{current,dropped}' OR
            #{table}.calculation_details #>>'{final,dropped}' IS DISTINCT FROM excluded.calculation_details #>>'{final,dropped}'
          SQL
        end
      end

      # CROSS JOIN optimization: reference identical JSON once instead of repeating in massive VALUES
      empty_drop_rows.each_slice(batch_size) do |batch_values|
        from_clause = <<~SQL.squish
          FROM (
            SELECT pairs.enrollment_id, pairs.assignment_group_id, json_data.calculation_details
            FROM (VALUES #{batch_values.join(",")}) pairs(enrollment_id, assignment_group_id)
            CROSS JOIN (SELECT #{empty_drop_json} as calculation_details) json_data
          ) val
        SQL

        execute_score_metadata_upsert.call(batch_values.length, from_clause)
      end

      non_empty_drop_rows.each_slice(batch_size) do |batch_values|
        from_clause = "FROM (VALUES #{batch_values.join(",")}) val (enrollment_id, assignment_group_id, calculation_details)"

        execute_score_metadata_upsert.call(batch_values.length, from_clause)
      end
    end
  rescue ActiveRecord::Deadlocked => e
    Canvas::Errors.capture_exception(:grade_calculator, e, :warn)
    raise Delayed::RetriableError, "Deadlock in upserting assignment group scores"
  end

  # returns information about assignments groups in the form:
  # [
  #   {
  #    :id       => 1
  #    :score    => 5,
  #    :possible => 7,
  #    :grade    => 71.42,
  #    :weight   => 50},
  #   ...]
  # each group
  def create_group_sums(submissions, user_id, ignore_ungraded: true)
    visible_assignments = @assignments.select { |assignment| assignment_visible_to_student?(assignment.id, user_id) }

    if @grading_period
      visible_assignments.select! do |assignment|
        effective_due_dates.grading_period_id_for(
          student_id: user_id,
          assignment_id: assignment.id
        ) == Shard.relative_id_for(@grading_period.id, Shard.current, @course.shard)
      end
    end

    assignments_by_group_id = visible_assignments.group_by(&:assignment_group_id)
    submissions_by_assignment_id =
      submissions.index_by(&:assignment_id)

    @groups.map do |group|
      assignments = assignments_by_group_id[group.id] || []

      group_submissions = assignments.map do |a|
        s = submissions_by_assignment_id[a.id]

        # ignore unposted submissions and all submissions for muted assignments
        s = nil if ignore_submission?(submission: s, assignment: a)

        # ignore pending_review quiz submissions
        s = nil if ignore_ungraded && s.try(:pending_review?)

        {
          assignment: a,
          submission: s,
          score: use_what_if_score?(s) ? s.student_entered_score : s&.score,
          total: BigDecimal(a.points_possible || 0, 15),
          excused: use_what_if_score?(s) ? false : s&.excused?
        }
      end

      if enrollments_by_user[user_id].all? { |e| e.workflow_state == "completed" }
        group_submissions.reject! { |s| s[:submission].nil? }
      end

      group_submissions.reject! { |s| s[:score].nil? } if ignore_ungraded
      group_submissions.reject! { |s| s[:excused] }
      group_submissions.reject! { |s| s[:assignment].omit_from_final_grade? }
      group_submissions.each { |s| s[:score] ||= 0 }

      kept = drop_assignments(group_submissions, group.rules_hash)
      dropped_submissions = (group_submissions - kept).filter_map { |s| s[:submission]&.id }

      score, possible = kept.reduce([0.0, 0.0]) do |(s_sum, p_sum), s|
        [s_sum.to_d + s[:score].to_d, p_sum.to_d + s[:total].to_d]
      end

      {
        id: group.id,
        global_id: group.global_id,
        score:,
        possible:,
        weight: group.group_weight,
        grade: ((score.to_f / possible * 100).round(2).to_f if possible > 0),
        dropped: dropped_submissions
      }
    end
  end

  def use_what_if_score?(submission)
    @use_what_if_scores && submission&.student_entered_score.present?
  end

  # see comments for dropAssignments in grade_calculator.js
  def drop_assignments(submissions, rules)
    drop_lowest    = rules[:drop_lowest] || 0
    drop_highest   = rules[:drop_highest] || 0
    never_drop_ids = rules[:never_drop] || []
    return submissions if drop_lowest.zero? && drop_highest.zero?

    cant_drop = []
    if never_drop_ids.present?
      cant_drop, submissions = submissions.partition { |submission| never_drop_ids.include?(submission[:assignment].id) }
    end

    # fudge the drop rules if there aren't enough submissions
    return cant_drop if submissions.empty?

    drop_lowest = submissions.size - 1 if drop_lowest >= submissions.size
    drop_highest = 0 if drop_lowest + drop_highest >= submissions.size

    keep_highest = submissions.size - drop_lowest
    keep_lowest  = keep_highest - drop_highest

    submissions.sort! { |a, b| a[:assignment].id - b[:assignment].id }

    # assignment groups that have no points possible have to be dropped
    # differently (it's a simpler case, but not one that fits in with our
    # usual bisection approach)
    kept = if (cant_drop + submissions).any? { |s| s[:total] > 0 }
             drop_pointed(submissions, cant_drop, keep_highest, keep_lowest)
           else
             drop_unpointed(submissions, keep_highest, keep_lowest)
           end

    kept + cant_drop
  end

  def drop_unpointed(submissions, keep_highest, keep_lowest)
    sorted_submissions = submissions.sort_by { |s| s[:score] }
    sorted_submissions.last(keep_highest).first(keep_lowest)
  end

  def drop_pointed(submissions, cant_drop, n_highest, n_lowest)
    max_total = (submissions + cant_drop).pluck(:total).max

    kept = keep_highest(submissions, cant_drop, n_highest, max_total)
    keep_lowest(kept, cant_drop, n_lowest, max_total)
  end

  def keep_highest(submissions, cant_drop, keep, max_total)
    keep_helper(submissions, cant_drop, keep, max_total, keep_mode: :highest) { |*args| big_f_best(*args) }
  end

  def keep_lowest(submissions, cant_drop, keep, max_total)
    keep_helper(submissions, cant_drop, keep, max_total, keep_mode: :lowest) { |*args| big_f_worst(*args) }
  end

  # @param submissions [Array<Submission>] set of droppable submissions
  # @param cant_drop [Array<Submission>] submissions that are not eligible for dropping
  # @param keep [Integer] number of submissions to keep from +submissions+
  # @param max_total [Float] the highest number of points possible
  # @yield sorting block for the big_f function
  # returns +keep+ +submissions+
  def keep_helper(submissions, cant_drop, keep, max_total, keep_mode: nil)
    return submissions if submissions.size <= keep

    unpointed, pointed = (submissions + cant_drop).partition do |s|
      s[:total].zero?
    end

    kept = nil
    if pointed.empty? && keep_mode == :lowest
      # this is a really dumb situation that we saw in the wild.  17
      # assignments, 2 of them have points possible, the rest have 0
      # points possible with drop rules of drop 8 lowest and drop 7
      # highest.
      #
      # In drop_pointed above, the call to keep_highest that
      # goes here ends up eliminating the pointed assignments, so when
      # keep_lowest is called, we end up here with unpointed
      # assignments which completely breaks math. estimate_q_high
      # comes back as NaN and q_low is nil.  "(nil + NaN)/2" means
      # you're gonna have a bad time.
      #
      # What we'll do instead is just sort by score like
      # drop_unpointed above, and drop the unpointed
      # ones up to keep.
      kept = unpointed.sort_by { |s| s[:score].to_f }[-keep, keep]
    else
      grades = pointed.map { |s| s[:score].to_f / s[:total] }.sort

      q_high = estimate_q_high(pointed, unpointed, grades)
      q_low  = grades.first
      q_mid  = (q_low + q_high) / 2

      x, kept = yield(q_mid, submissions, cant_drop, keep)
      threshold = 1 / (2 * keep * (max_total**2))
      until q_high - q_low < threshold
        if x < 0
          q_high = q_mid
        else
          q_low = q_mid
        end
        q_mid = (q_low + q_high) / 2

        # bail if we can't can't ever satisfy the threshold (floats!)
        break if q_mid == q_high || q_mid == q_low

        x, kept = yield(q_mid, submissions, cant_drop, keep)
      end
    end

    kept
  end

  def big_f(q, submissions, cant_drop, keep, &)
    kept = submissions.map do |s|
      rated_score = s[:score] - (q * s[:total])
      [rated_score, s]
    end.sort(&).first(keep)

    q_kept = kept.reduce(0) { |sum, (rated_score, _)| sum + rated_score }
    q_cant_drop = cant_drop.reduce(0) { |sum, s| sum + (s[:score] - (q * s[:total])) }

    [q_kept + q_cant_drop, kept.map(&:last)]
  end

  # we can't use the student's highest grade as an upper-bound for bisection
  # when 0-points-possible assignments are present, so guess the best possible
  # grade the student could have earned in that case
  def estimate_q_high(pointed, unpointed, grades)
    if unpointed.present?
      points_possible = pointed.reduce(0) { |sum, s| sum + s[:total] }
      best_pointed_score = [
        points_possible, # 100%
        pointed.reduce(0) { |sum, s| sum + s[:score] } # ... or extra credit
      ].max
      unpointed_score = unpointed.reduce(0) { |sum, s| sum + s[:score] }
      max_score = best_pointed_score + unpointed_score
      max_score.to_f / points_possible
    else
      grades.last
    end
  end

  # determines the best +keep+ assignments from submissions for the given q
  # (suitable for use with drop_lowest)
  def big_f_best(q, submissions, cant_drop, keep)
    big_f(q, submissions, cant_drop, keep) { |(a, _), (b, _)| b <=> a }
  end

  # determines the worst +keep+ assignments from submissions for the given q
  # (suitable for use with drop_highest)
  def big_f_worst(q, submissions, cant_drop, keep)
    big_f(q, submissions, cant_drop, keep) { |(a, _), (b, _)| a <=> b }
  end

  def gather_dropped_from_group_scores(group_sums)
    dropped = group_sums.pluck(:dropped)
    dropped.flatten!
    dropped.uniq!
    dropped
  end

  # returns grade information from all the assignment groups
  def calculate_total_from_group_scores(group_sums)
    dropped = gather_dropped_from_group_scores(group_sums)

    if @course.group_weighting_scheme == "percent"
      relevant_group_sums = group_sums.reject do |gs|
        gs[:possible].zero? || gs[:possible].nil?
      end
      final_grade = relevant_group_sums.reduce(0) do |grade, gs|
        grade + ((gs[:score].to_d / gs[:possible]) * gs[:weight].to_d)
      end

      # scale the grade up if total weights don't add up to 100%
      full_weight = relevant_group_sums.reduce(0) { |w, gs| w + gs[:weight] }
      if full_weight.zero?
        final_grade = nil
      elsif full_weight < 100
        final_grade = BigDecimal(final_grade.to_s) / BigDecimal(full_weight.to_s) * BigDecimal(100)
      end

      rounded_grade = final_grade&.to_f.try(:round, 2)
      {
        grade: rounded_grade,
        total: rounded_grade,
        dropped:
      }
    else
      total, possible = group_sums.reduce([0, 0]) { |(m, n), gs| [m + gs[:score], n + gs[:possible]] }
      if possible > 0
        final_grade = (total.to_f / possible) * 100
        {
          grade: final_grade.round(2).to_f,
          total: total.to_f,
          possible: possible.to_f,
          dropped:
        }
      else
        {
          grade: nil,
          total: total.to_f,
          dropped:
        }
      end
    end
  end

  def ignore_submission?(submission:, assignment:)
    # If a student is testing a score, we don't want to ignore this submission
    # even if the grade is unposted
    return false if use_what_if_score?(submission)

    return false unless @ignore_muted

    # If we decided to ignore this submission earlier in this run (see
    # create_group_sums), it will be nil, in which case keep ignoring it
    submission.blank? || !submission.posted?
  end
end
