defmodule Mv.Statistics do
  @moduledoc """
  Aggregated statistics for members and membership fee cycles.

  Used by the statistics LiveView to display counts and sums. All functions
  accept an `opts` keyword list and pass `:actor` (and `:domain` where needed)
  to Ash reads so that policies are enforced.
  """

  require Ash.Query
  import Ash.Expr

  require Logger

  alias Mv.Membership.Member
  alias Mv.MembershipFees
  alias Mv.MembershipFees.MembershipFeeCycle

  @doc """
  Returns the earliest year in which any member has a join_date.

  Used to determine the start of the "relevant" year range for statistics
  (from first membership to current year). Returns `nil` if no member has
  a join_date.
  """
  @spec first_join_year(keyword()) :: non_neg_integer() | nil
  def first_join_year(opts) do
    query =
      Member
      |> Ash.Query.filter(expr(not is_nil(join_date)))
      |> Ash.Query.sort(join_date: :asc)
      |> Ash.Query.limit(1)

    case Ash.read_one(query, opts) do
      {:ok, nil} ->
        nil

      {:ok, member} ->
        member.join_date.year

      {:error, reason} ->
        Logger.warning("Statistics.first_join_year failed: #{inspect(reason)}")
        nil
    end
  end

  @doc """
  Returns the count of active members (exit_date is nil).
  """
  @spec active_member_count(keyword()) :: non_neg_integer()
  def active_member_count(opts) do
    query =
      Member
      |> Ash.Query.filter(expr(is_nil(exit_date)))

    case Ash.count(query, opts) do
      {:ok, count} ->
        count

      {:error, reason} ->
        Logger.warning("Statistics.active_member_count failed: #{inspect(reason)}")
        0
    end
  end

  @doc """
  Returns the count of inactive members (exit_date is not nil).
  """
  @spec inactive_member_count(keyword()) :: non_neg_integer()
  def inactive_member_count(opts) do
    query =
      Member
      |> Ash.Query.filter(expr(not is_nil(exit_date)))

    case Ash.count(query, opts) do
      {:ok, count} ->
        count

      {:error, reason} ->
        Logger.warning("Statistics.inactive_member_count failed: #{inspect(reason)}")
        0
    end
  end

  @doc """
  Returns the count of members who joined in the given year (join_date in that year).
  """
  @spec joins_by_year(integer(), keyword()) :: non_neg_integer()
  def joins_by_year(year, opts) do
    first_day = Date.new!(year, 1, 1)
    last_day = Date.new!(year, 12, 31)

    query =
      Member
      |> Ash.Query.filter(expr(join_date >= ^first_day and join_date <= ^last_day))

    case Ash.count(query, opts) do
      {:ok, count} ->
        count

      {:error, reason} ->
        Logger.warning("Statistics.joins_by_year failed: #{inspect(reason)}")
        0
    end
  end

  @doc """
  Returns the count of members who exited in the given year (exit_date in that year).
  """
  @spec exits_by_year(integer(), keyword()) :: non_neg_integer()
  def exits_by_year(year, opts) do
    first_day = Date.new!(year, 1, 1)
    last_day = Date.new!(year, 12, 31)

    query =
      Member
      |> Ash.Query.filter(expr(exit_date >= ^first_day and exit_date <= ^last_day))

    case Ash.count(query, opts) do
      {:ok, count} ->
        count

      {:error, reason} ->
        Logger.warning("Statistics.exits_by_year failed: #{inspect(reason)}")
        0
    end
  end

  @doc """
  Returns totals for membership fee cycles whose cycle_start falls in the given year.

  Returns a map with keys: `:total`, `:paid`, `:unpaid`, `:suspended` (each a Decimal sum).
  """
  @spec cycle_totals_by_year(integer(), keyword()) :: %{
          total: Decimal.t(),
          paid: Decimal.t(),
          unpaid: Decimal.t(),
          suspended: Decimal.t()
        }
  def cycle_totals_by_year(year, opts) do
    first_day = Date.new!(year, 1, 1)
    last_day = Date.new!(year, 12, 31)

    query =
      MembershipFeeCycle
      |> Ash.Query.filter(expr(cycle_start >= ^first_day and cycle_start <= ^last_day))

    query = maybe_filter_by_fee_type(query, opts)
    # Only pass actor and domain to Ash.read; fee_type_id is only for our filter above
    opts_for_read =
      opts
      |> Keyword.drop([:fee_type_id])
      |> Keyword.put(:domain, MembershipFees)

    case Ash.read(query, opts_for_read) do
      {:ok, cycles} ->
        cycle_totals_from_cycles(cycles)

      {:error, reason} ->
        Logger.warning("Statistics.cycle_totals_by_year failed: #{inspect(reason)}")
        zero_cycle_totals()
    end
  end

  defp cycle_totals_from_cycles(cycles) do
    by_status = Enum.group_by(cycles, & &1.status)
    sum = fn status -> sum_amounts(by_status[status] || []) end

    total =
      [:paid, :unpaid, :suspended]
      |> Enum.map(&sum.(&1))
      |> Enum.reduce(Decimal.new(0), &Decimal.add/2)

    %{
      total: total,
      paid: sum.(:paid),
      unpaid: sum.(:unpaid),
      suspended: sum.(:suspended)
    }
  end

  defp sum_amounts(cycles),
    do: Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)

  defp zero_cycle_totals do
    %{
      total: Decimal.new(0),
      paid: Decimal.new(0),
      unpaid: Decimal.new(0),
      suspended: Decimal.new(0)
    }
  end

  defp maybe_filter_by_fee_type(query, opts) do
    case Keyword.get(opts, :fee_type_id) do
      nil ->
        query

      id when is_binary(id) ->
        # Only apply filter for valid UUID strings (e.g. from form/URL)
        if Ecto.UUID.cast(id) != :error do
          Ash.Query.filter(query, expr(membership_fee_type_id == ^id))
        else
          query
        end

      id ->
        Ash.Query.filter(query, expr(membership_fee_type_id == ^id))
    end
  end

  @doc """
  Returns the sum of amount for all cycles with status :unpaid.
  """
  @spec open_amount_total(keyword()) :: Decimal.t()
  def open_amount_total(opts) do
    query =
      MembershipFeeCycle
      |> Ash.Query.filter(expr(status == :unpaid))

    query = maybe_filter_by_fee_type(query, opts)

    opts_for_read =
      opts
      |> Keyword.drop([:fee_type_id])
      |> Keyword.put(:domain, MembershipFees)

    case Ash.read(query, opts_for_read) do
      {:ok, cycles} ->
        Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)

      {:error, reason} ->
        Logger.warning("Statistics.open_amount_total failed: #{inspect(reason)}")
        Decimal.new(0)
    end
  end
end
