# frozen_string_literal: true

#
# Copyright (C) 2013 - 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/>.
#

module Outcomes
  module ResultAnalytics

    Rollup = Struct.new(:context, :scores)
    Result = Struct.new(:learning_outcome, :score, :count, :hide_points)

    # Public: Queries learning_outcome_results for rollup.
    #
    # user - User requesting results.
    # opts - The options for the query. In a later version of ruby, these would
    #        be named parameters.
    #        :users    - The users to lookup results for (required)
    #        :context  - The context to lookup results for (required)
    #        :outcomes - The outcomes to lookup results for (required)
    #
    # Returns a relation of the results, suitably ordered.
    def find_outcome_results(user, opts)
      required_opts = [:users, :context, :outcomes]
      required_opts.each { |p| raise "#{p} option is required" unless opts[p] }
      users, context, outcomes = opts.values_at(*required_opts)
      results = LearningOutcomeResult.active.where(
        context_code:        context.asset_string,
        user_id:             users.map(&:id),
        learning_outcome_id: outcomes.map(&:id),
      )
      unless context.grants_any_right?(user, :manage_grades, :view_all_grades)
        results = results.exclude_muted_associations
      end
      unless opts[:include_hidden]
        results = results.where(hidden: false)
      end
      order_results_for_rollup results
    end

    # Internal: Add an order clause to a relation so results are returned in an
    # order suitable for rollup calculations.
    #
    # relation - The relation to add an order clause to.
    #
    # Returns the resulting relation
    def order_results_for_rollup(relation)
      relation.joins(:user).order(User.sortable_name_order_by_clause).order('learning_outcome_results.learning_outcome_id ASC, learning_outcome_results.id ASC')
    end

    # Public: Generates a rollup of each outcome result for each user.
    #
    # results - An Enumeration of properly sorted LearningOutcomeResult objects.
    #           The results should be sorted by user id and then by outcome id.
    #
    # users - (Optional) Ensure rollups are included for users in this list.
    #         A listed user with no results will have an empty score array.
    #
    # excludes - (Optional) Specify additional values to exclude. "missing_user_rollups" excludes
    #            rollups for users without results.
    #
    # context - (Optional) The current context making the function call which will be used in
    #            determining the current_method chosen for calculating rollups.
    #
    # Returns an Array of Rollup objects.
    def outcome_results_rollups(results: , users: [], excludes: [], context: nil)
      ActiveRecord::Associations::Preloader.new.preload(results, :learning_outcome)
      rollups = results.chunk(&:user_id).map do |_, user_results|
        Rollup.new(user_results.first.user, rollup_user_results(user_results, context))
      end
      if excludes.include? 'missing_user_rollups'
        rollups
      else
        add_missing_user_rollups(rollups, users)
      end
    end

    # Public: Calculates an average rollup for the specified results
    #
    # results - An Enumeration of properly sorted LearningOutcomeResult objects.
    # context - The context to use for the resulting rollup.
    #
    # Returns a Rollup.
    def aggregate_outcome_results_rollup(results, context, stat = 'mean')
      rollups = outcome_results_rollups(results: results, context: context)
      rollup_scores = rollups.map(&:scores).flatten
      outcome_results = rollup_scores.group_by(&:outcome).values
      aggregate_results = outcome_results.map do |scores|
        scores.map{|score| Result.new(score.outcome, score.score, score.count, score.hide_points)}
      end
      opts = {aggregate_score: true, aggregate_stat: stat, **mastery_scale_opts(context)}
      aggregate_rollups = aggregate_results.map do |result|
        RollupScore.new(outcome_results: result, opts: opts)
      end
      Rollup.new(context, aggregate_rollups)
    end

    # Internal: Generates a rollup of the outcome results, Assuming all the
    # results are for the same user.
    #
    # user_results - An Enumeration of LearningOutcomeResult objects for a user
    #                sorted by outcome id.
    #
    # Returns an Array of RollupScore objects
    def rollup_user_results(user_results, context = nil)
      filtered_results = user_results.select{|r| !r.score.nil?}
      opts = mastery_scale_opts(context)
      filtered_results.group_by(&:learning_outcome_id).map do |_, outcome_results|
        RollupScore.new(outcome_results:outcome_results, opts: opts)
      end
    end

    def mastery_scale_opts(context)
      return {} unless context.is_a?(Course) && context.root_account.feature_enabled?(:account_level_mastery_scales)
      @mastery_scale_opts ||= {}
      @mastery_scale_opts[context.asset_string] ||= begin
        method = context.resolved_outcome_calculation_method
        mastery_scale = context.resolved_outcome_proficiency
        {
          calculation_method: method&.calculation_method,
          calculation_int: method&.calculation_int,
          points_possible: mastery_scale&.points_possible,
          mastery_points: mastery_scale&.mastery_points,
          ratings: mastery_scale&.ratings_hash
        }
      end
    end

    # Internal: Adds rollups rows for users that did not have any results
    #
    # rollups - The list of rollup objects based on existing results.
    # users   - The list of User objects that should have results.
    #
    # Returns the modified rollups list. Users without rollups will have a
    #   rollup row with an empty scores array.
    def add_missing_user_rollups(rollups, users)
      missing_users = users - rollups.map(&:context)
      rollups + missing_users.map { |u| Rollup.new(u, []) }
    end

    # Public: Gets rating percents for outcomes based on rollup
    #
    # Returns a hash of outcome id to array of rating percents
    def rating_percents(rollups, context: nil)
      counts = {}
      outcome_proficiency_ratings = if context&.root_account&.feature_enabled?(:account_level_mastery_scales)
        context.resolved_outcome_proficiency.ratings_hash
      end
      rollups.each do |rollup|
        rollup.scores.each do |score|
          next unless score.score

          outcome = score.outcome
          next unless outcome

          ratings = outcome_proficiency_ratings || outcome.rubric_criterion[:ratings]
          next unless ratings

          counts[outcome.id] = Array.new(ratings.length, 0) unless counts[outcome.id]
          idx = ratings.find_index { |rating| rating[:points] <= score.score }
          counts[outcome.id][idx] = counts[outcome.id][idx] + 1 if idx
        end
      end
      counts.each {|k, v| counts[k] = to_percents(v)}
      counts
    end

    def to_percents(count_arr)
      total = count_arr.sum
      return count_arr if total.zero?
      count_arr.map {|v| (100.0 * v / total).round}
    end

    class << self
      include ResultAnalytics
    end
  end
end
