defmodule Oli.Delivery.Attempts.ActivityLifecycle.RollUp do
  @moduledoc """
  Handles **rollup operations** after part-level evaluations to ensure proper propagation of scores
  from part attempts up through activity attempts, resource attempts, and resource accesses.

  This module is responsible for aggregating and updating scores and lifecycle states after individual
  part attempts have been evaluated, completing the activity lifecycle and ensuring consistency across related records.

  ### Key Responsibilities:
  - Aggregate scores from individual part attempts and roll them up to the activity attempt.
  - For graded pages with score-as-you-go strategies, further roll up the results to the resource attempt and resource access levels.
  - Determine the appropriate rollup function based on evaluation context (evaluated, submitted, or no-op).
  - Update lifecycle states (e.g., marking activity attempts as `:submitted` or `:evaluated`).
  - Support multiple scoring application types, including "batch" and "score-as-you-go."

  ### Public API Functions:

  #### `determine_activity_rollup_fn/3`
  Determines and returns a function that performs the appropriate rollup action depending on the evaluation state.

  - **Params**:
    - `activity_attempt_guid`: The GUID of the activity attempt.
    - `part_inputs`: The collection of part inputs submitted.
    - `part_attempts`: The current part attempts.
  - **Behavior**: Based on the inputs and attempt states, selects one of:
    - `evaluated_fn`: Rolls up to activity and possibly resource level.
    - `submitted_fn`: Marks activity as submitted without scoring rollup.
    - `no_op_fn`: Does nothing (noop).
  - **Returns**: A zero-arity function to be executed later.

  ---

  #### `rollup_evaluated/2`
  Performs a full rollup of evaluated part attempts to their corresponding activity attempt and,
  if applicable, to the resource attempt and resource access records.

  - **Params**:
    - `activity_attempt_guid`: The GUID of the evaluated activity attempt.
    - `part_attempts`: List of part attempts used for score aggregation.
  - **Behavior**:
    - Calculates aggregate scores based on the configured scoring strategy.
    - Updates `activity_attempt`, `resource_attempt`, and `resource_access` records.
    - Supports both "batch scoring" and "score-as-you-go" workflows.
  - **Returns**: `:ok` or `:error`.

  ---

  #### `rollup_submitted/1`
  Marks an activity attempt as `:submitted` without performing score aggregation.
  """

  require Logger

  import Ecto.Query, warn: false
  import Oli.Delivery.Attempts.Core
  import Oli.Delivery.Attempts.ActivityLifecycle.Persistence
  import Oli.Delivery.Attempts.ActivityLifecycle.Utils

  alias Oli.Delivery.Attempts.Core.{ActivityAttempt, ResourceAttempt, ResourceAccess}
  alias Oli.Delivery.Evaluation.{Result}
  alias Oli.Delivery.Attempts.Core.StudentInput
  alias Oli.Repo
  alias Oli.Delivery.Attempts.Scoring
  alias Oli.Resources.Revision
  alias Oli.Delivery.Sections.Section
  alias Oli.Delivery.Sections.SectionResource

  @doc """
  Returns a function that when executed, will properly roll up the evaluated
  part attempts to (at least) the activity attempt. Depending on the context,
  this function may also roll up the results to the resource attempt and resource access.
  """
  def determine_activity_rollup_fn(
        activity_attempt_guid,
        part_inputs,
        part_attempts
      ) do
    evaluated_fn = fn result ->
      rollup_evaluated(activity_attempt_guid)
      result
    end

    submitted_fn = fn result ->
      rollup_submitted(activity_attempt_guid)
      result
    end

    no_op_fn = fn result -> result end

    case determine_activity_rollup_state(part_inputs, part_attempts) do
      :evaluated ->
        evaluated_fn

      :submitted ->
        submitted_fn

      :no_op ->
        no_op_fn
    end
  end

  @doc """
  From the update to a collection of part attempts, return a function that will properly roll
  up the score to the activity attempt, and possibly the resource attempt and resource access.

  The returned function takes zero arguments, and returns either :ok or :error.

  This function makes at most three read queries to the database.
  """
  def rollup_evaluated(activity_attempt_guid) do
    # First query: retrieve the latest part attempts
    part_attempts = get_latest_part_attempts(activity_attempt_guid)

    # Second query: retrieve both the activity and resource attempt info, plus key
    # details about the page they are on
    %{
      activity_attempt_id: activity_attempt_id,
      activity_id: activity_id,
      resource_attempt_id: resource_attempt_id,
      resource_access_id: resource_access_id,
      activity_scoring_strategy_id: activity_scoring_strategy_id,
      page_scoring_strategy_id: page_scoring_strategy_id,
      graded: graded,
      batch_scoring: batch_scoring,
      grade_passback_enabled: grade_passback_enabled,
      section_id: section_id
    } = get_attempts_and_page_details(activity_attempt_guid)

    score_as_you_go? = graded and !batch_scoring

    # Record the time, so we apply it consistently to all records
    now = DateTime.utc_now()

    %Result{score: score, out_of: out_of} =
      Scoring.calculate_score(activity_scoring_strategy_id, part_attempts)

    case score_as_you_go? do
      # Third query: retrieve portions of all of the activity attempts for this
      # activity on this page - and the latest attempts for other
      # activities on this page
      true ->
        relevant_activity_attempts =
          get_relevant_activity_attempts(resource_attempt_id)

        # Here we calculate the "aggregate" score for this activity across all of its attempts
        other_attempts_for_this_activity =
          Enum.filter(relevant_activity_attempts, fn a ->
            a.resource_id == activity_id and a.attempt_guid != activity_attempt_guid
          end)

        other_attempts_for_this_activity = fix_bad_records(other_attempts_for_this_activity)

        all_attempts = [
          %{score: score, out_of: out_of, date_evaluated: now} | other_attempts_for_this_activity
        ]

        %Result{score: aggregate_score, out_of: aggregate_out_of} =
          Scoring.calculate_score(page_scoring_strategy_id, all_attempts)

        # Now we calculate page attempt score - across the aggregate scores of all other
        # activities on this page and the one that we just calculated

        attempts_for_other_activities =
          Enum.filter(relevant_activity_attempts, fn a ->
            a.resource_id != activity_id
          end)
          |> Enum.group_by(fn a -> a.resource_id end)
          |> Enum.map(fn {_, attempts} ->
            attempts = fix_bad_records(attempts)
            Enum.max_by(attempts, fn a -> a.attempt_number end)
          end)
          |> List.flatten()

        all_aggregate_attempts =
          [
            %{
              aggregate_score: aggregate_score,
              aggregate_out_of: aggregate_out_of,
              date_evaluated: now
            }
            | attempts_for_other_activities
          ]
          |> Enum.map(fn a ->
            # If the aggregate out of is nil, this signifies that the activity has not been evaluated yet
            # so we need to use the original score and out_of values, because the original out_of will
            # at least have the "preset" out_of value for the activity
            case a.aggregate_out_of do
              nil ->
                %{score: a.score, out_of: a.out_of, date_evaluated: a.date_evaluated}

              _ ->
                %{
                  score: a.aggregate_score,
                  out_of: a.aggregate_out_of,
                  date_evaluated: a.date_evaluated
                }
            end
          end)

        total_scoring_strategy_id = Oli.Resources.ScoringStrategy.get_id_by_type("total")

        %Result{score: page_score, out_of: page_out_of} =
          Scoring.calculate_score(total_scoring_strategy_id, all_aggregate_attempts)

        with :ok <-
               update_resource_access(
                 resource_access_id,
                 %{score: page_score, out_of: page_out_of},
                 now
               ),
             {1, _} <-
               update_resource_attempt(
                 resource_attempt_id,
                 %{score: page_score, out_of: page_out_of},
                 now
               ),
             {1, _} <-
               update_activity_attempt(
                 activity_attempt_id,
                 %{
                   score: score,
                   out_of: out_of,
                   aggregate_score: aggregate_score,
                   aggregate_out_of: aggregate_out_of
                 },
                 now
               ) do
          if grade_passback_enabled and graded do
            initiate_grade_passback(section_id, resource_access_id)
          end

          # Notify the client of the score change
          Oli.Delivery.ScoreAsYouGoNotifications.question_answered(
            resource_attempt_id,
            activity_attempt_guid,
            {page_score, page_out_of}
          )

          :ok
        else
          _ ->
            :error
        end

      false ->
        case update_activity_attempt(activity_attempt_id, %{score: score, out_of: out_of}, now) do
          {1, _} -> :ok
          _ -> :error
        end
    end
  end

  @doc """
  Rollup the submitted state of an activity attempt.
  """
  def rollup_submitted(activity_attempt_guid) do
    get_activity_attempt_by(attempt_guid: activity_attempt_guid)
    |> update_activity_attempt(%{
      lifecycle_state: :submitted,
      date_submitted: DateTime.utc_now()
    })
  end

  @doc """
  For a given resource attempt, roll up the scores from all part attempts
  to all "active" activity attempts.
  """
  def rollup_all(
        resource_attempt,
        datashop_session_id,
        effective_settings
      ) do
    activity_attempts =
      case resource_attempt.revision do
        %{content: %{"advancedDelivery" => true}} ->
          get_latest_non_active_activity_attempts(resource_attempt.id)

        _ ->
          get_latest_activity_attempts(resource_attempt.id)
      end

    Enum.reduce_while(
      activity_attempts,
      {0, [], [], []},
      fn activity_attempt,
         {
           i,
           activity_attempt_values,
           activity_attempt_params,
           part_attempt_guids
         } = acc ->
        if activity_attempt.lifecycle_state != :evaluated and activity_attempt.scoreable do
          activity_attempt = Map.put(activity_attempt, :resource_attempt, resource_attempt)

          case update_part_attempts_for_activity(
                 activity_attempt,
                 datashop_session_id,
                 effective_settings
               ) do
            {:ok, part_inputs} ->
              part_attempts = get_latest_part_attempts(activity_attempt.attempt_guid)

              %{
                score: score,
                out_of: out_of,
                lifecycle_state: lifecycle_state,
                date_evaluated: date_evaluated,
                date_submitted: date_submitted
              } = determine_activity_rollup_attrs(part_inputs, part_attempts, activity_attempt)

              {:cont,
               {
                 i + 6,
                 activity_attempt_values ++
                   [
                     "($#{i + 1}, $#{i + 2}::double precision, $#{i + 3}::double precision, $#{i + 4}, $#{i + 5}::timestamp, $#{i + 6}::timestamp)"
                   ],
                 activity_attempt_params ++
                   [
                     activity_attempt.attempt_guid,
                     score,
                     out_of,
                     Atom.to_string(lifecycle_state),
                     date_evaluated,
                     date_submitted
                   ],
                 part_attempt_guids ++
                   Enum.map(part_attempts, fn part_attempt -> part_attempt.attempt_guid end)
               }}

            error ->
              {:halt, error}
          end
        else
          {:cont, acc}
        end
      end
    )
  end

  @doc """
  Account for bad records by filtering out multiple records for the same attempt number.
  """
  def fix_bad_records(attempts) do
    # Group the records by activity id
    by_activity_id = Enum.group_by(attempts, & &1.resource_id)

    # Then disambiguate the records for each activity id
    Enum.reduce(by_activity_id, [], fn {_activity_id, records}, acc ->
      acc ++ fix_bad_records_for_activity_id(records)
    end)
  end

  defp fix_bad_records_for_activity_id(attempts) do
    grouped = Enum.group_by(attempts, & &1.attempt_number)

    # For all groups where there is more than one entry, that
    # means that we somehow ended up with multiple records for
    # the same attempt number
    Enum.reduce(grouped, [], fn {_attempt_number, records}, acc ->
      if length(records) > 1 do
        # Filter down to only those that are evaluated
        evaluated = Enum.filter(records, &(&1.lifecycle_state == :evaluated))

        case Enum.count(evaluated) do
          # None were evaluated, so it is safe to use any one of them
          0 -> acc ++ [records |> hd()]
          # Sort by date evaluated descending, return the most recently evaluated
          _n -> acc ++ [Enum.sort_by(evaluated, & &1.date_evaluated, :desc) |> List.first()]
        end
      else
        acc ++ records
      end
    end)
  end

  defp initiate_grade_passback(section_id, resource_access_id) do
    Oli.Delivery.Attempts.PageLifecycle.GradeUpdateWorker.create(
      section_id,
      resource_access_id,
      :inline
    )
  end

  defp update_part_attempts_for_activity(
         activity_attempt,
         datashop_session_id,
         effective_settings
       ) do
    part_attempts = get_latest_part_attempts(activity_attempt.attempt_guid)

    part_inputs =
      part_attempts
      |> Enum.map(fn pa ->
        {input, files} =
          if pa.response,
            do: {Map.get(pa.response, "input"), Map.get(pa.response, "files", [])},
            else: {nil, nil}

        %{
          attempt_guid: pa.attempt_guid,
          input: %StudentInput{input: input, files: files}
        }
      end)
      |> filter_already_evaluated(part_attempts)

    case activity_attempt
         |> do_evaluate_submissions(part_inputs, part_attempts, effective_settings)
         |> persist_evaluations(part_inputs, fn result -> result end, datashop_session_id) do
      {:ok, _} -> {:ok, part_inputs}
      e -> e
    end
  end

  defp update_resource_access(resource_access_id, attrs, now) do
    attrs = Map.merge(attrs, %{updated_at: now})

    Oli.Repo.get(ResourceAccess, resource_access_id)
    |> Oli.CertificationEligibility.update_resource_access_and_verify_qualification(attrs)

    :ok
  end

  defp update_resource_attempt(resource_attempt_id, attrs, now) do
    attrs = Map.merge(attrs, %{updated_at: now})
    keyword_list = attrs |> Map.to_list()

    from(a in ResourceAttempt,
      where: a.id == ^resource_attempt_id,
      update: [set: ^keyword_list]
    )
    |> Repo.update_all([])
  end

  defp update_activity_attempt(activity_attempt_id, attrs, now) do
    attrs =
      Map.merge(attrs, %{
        lifecycle_state: :evaluated,
        date_evaluated: now,
        date_submitted: now,
        updated_at: now
      })

    keyword_list = attrs |> Map.to_list()

    from(a in ActivityAttempt,
      where: a.id == ^activity_attempt_id,
      update: [set: ^keyword_list]
    )
    |> Repo.update_all([])
  end

  defp get_attempts_and_page_details(activity_attempt_guid) do
    from(
      a in ActivityAttempt,
      join: ra in ResourceAttempt,
      on: a.resource_attempt_id == ra.id,
      join: rev in Revision,
      on: rev.id == a.revision_id,
      join: rev2 in Revision,
      on: rev2.id == ra.revision_id,
      join: r in ResourceAccess,
      on: r.id == ra.resource_access_id,
      join: s in Section,
      on: s.id == r.section_id,
      join: sr in SectionResource,
      on: sr.resource_id == rev2.resource_id and sr.section_id == s.id,
      where: a.attempt_guid == ^activity_attempt_guid,
      select: %{
        activity_attempt_id: a.id,
        activity_out_of: a.out_of,
        resource_attempt_id: ra.id,
        activity_id: a.resource_id,
        resource_access_id: ra.resource_access_id,
        activity_scoring_strategy_id: rev.scoring_strategy_id,
        graded: rev2.graded,
        page_scoring_strategy_id: sr.scoring_strategy_id,
        batch_scoring: sr.batch_scoring,
        grade_passback_enabled: s.grade_passback_enabled,
        section_id: s.id
      }
    )
    |> Repo.one()
  end

  defp get_relevant_activity_attempts(resource_attempt_id) do
    Repo.all(
      from(aa in ActivityAttempt,
        where: aa.resource_attempt_id == ^resource_attempt_id,
        select: %{
          attempt_guid: aa.attempt_guid,
          resource_id: aa.resource_id,
          score: aa.score,
          out_of: aa.out_of,
          aggregate_score: aa.aggregate_score,
          aggregate_out_of: aa.aggregate_out_of,
          date_evaluated: aa.date_evaluated,
          attempt_number: aa.attempt_number
        }
      )
    )
  end

  defp determine_activity_rollup_attrs(part_inputs, part_attempts, activity_attempt) do
    case determine_activity_rollup_state(part_inputs, part_attempts) do
      :evaluated ->
        %Result{score: score, out_of: out_of} =
          Scoring.calculate_score(activity_attempt.revision.scoring_strategy_id, part_attempts)

        %{
          score: score,
          out_of: out_of,
          lifecycle_state: :evaluated,
          date_evaluated: DateTime.utc_now(),
          date_submitted: DateTime.utc_now()
        }

      :submitted ->
        %{
          score: nil,
          out_of: nil,
          lifecycle_state: :submitted,
          date_evaluated: nil,
          date_submitted: DateTime.utc_now()
        }

      :no_op ->
        activity_attempt
    end
  end

  defp determine_activity_rollup_state(part_inputs, part_attempts) do
    count_if = fn attempts, type ->
      Enum.reduce(attempts, 0, fn a, c ->
        if a.lifecycle_state == type do
          c + 1
        else
          c
        end
      end)
    end

    part_attempts_map =
      Enum.reduce(part_attempts, %{}, fn pa, m -> Map.put(m, pa.attempt_guid, pa) end)

    part_attempts =
      Enum.reduce(part_inputs, part_attempts_map, fn part_input, map ->
        pa = Map.get(map, part_input.attempt_guid)

        unless is_nil(pa) do
          if (pa.lifecycle_state == :submitted or pa.lifecycle_state == :active) and
               pa.grading_approach == :automatic do
            Map.put(map, pa.attempt_guid, %{pa | lifecycle_state: :evaluated})
          else
            if (pa.lifecycle_state == :submitted or pa.lifecycle_state == :active) and
                 pa.grading_approach == :manual do
              Map.put(map, pa.attempt_guid, %{pa | lifecycle_state: :submitted})
            else
              map
            end
          end
        else
          Logger.info(
            "Part attempt with GUID #{part_input.attempt_guid} was not found in part attempts map"
          )

          map
        end
      end)
      |> Map.values()

    case {count_if.(part_attempts, :evaluated), count_if.(part_attempts, :submitted),
          count_if.(part_attempts, :active)} do
      {_, 0, 0} -> :evaluated
      {_, _, 0} -> :submitted
      {_, _, _} -> :no_op
    end
  end
end
