defmodule Mv.MembershipFees.CycleGenerator do
  @moduledoc """
  Module for generating membership fee cycles for members.

  This module provides functions to automatically generate membership fee cycles
  based on a member's fee type, start date, and exit date.

  ## Algorithm

  1. Load member with relationships (membership_fee_type, membership_fee_cycles)
  2. Determine the generation start point:
     - If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
     - If cycles exist: Start from the cycle AFTER the last existing one
  3. Generate all cycle starts from the determined start point to today (or `exit_date`)
  4. Create new cycles with the current amount from `membership_fee_type`

  ## Important: Gap Handling

  **Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted
  but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle.
  It always continues from the LAST existing cycle, regardless of any gaps.

  This behavior ensures that manually deleted cycles remain deleted and prevents
  unwanted automatic recreation of intentionally removed cycles.

  ## Concurrency

  Uses PostgreSQL advisory locks to prevent race conditions when generating
  cycles for the same member concurrently.

  ## Authorization

  This module runs systemically and uses the system actor for all operations.
  This ensures that cycle generation always works, regardless of user permissions.

  All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
  user permission checks, as cycle generation is a mandatory side effect.

  ## Examples

      # Generate cycles for a single member
      {:ok, cycles} = CycleGenerator.generate_cycles_for_member(member)

      # Generate cycles for all active members
      {:ok, results} = CycleGenerator.generate_cycles_for_all_members()

  """

  alias Mv.Helpers
  alias Mv.Helpers.SystemActor
  alias Mv.Membership.Member
  alias Mv.MembershipFees.CalendarCycles
  alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
  alias Mv.MembershipFees.MembershipFeeCycle
  alias Mv.Repo

  require Ash.Query
  require Logger

  @type generate_result ::
          {:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}

  @doc """
  Generates membership fee cycles for a single member.

  Uses an advisory lock to prevent concurrent generation for the same member.

  ## Parameters

    - `member` - The member struct or member ID
    - `opts` - Options:
      - `:today` - Override today's date (useful for testing)

  ## Returns

    - `{:ok, cycles, notifications}` - List of newly created cycles and notifications
    - `{:error, reason}` - Error with reason

  ## Examples

      {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member)
      {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member_id)
      {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31])

  """
  @spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result()
  def generate_cycles_for_member(member_or_id, opts \\ [])

  def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
    case load_member(member_id, opts) do
      {:ok, member} -> generate_cycles_for_member(member, opts)
      {:error, reason} -> {:error, reason}
    end
  end

  def generate_cycles_for_member(%Member{} = member, opts) do
    today = Keyword.get(opts, :today, Date.utc_today())
    skip_lock? = Keyword.get(opts, :skip_lock?, false)

    do_generate_cycles_with_lock(member, today, skip_lock?, opts)
  end

  # Generate cycles with lock handling
  # Returns {:ok, cycles, notifications} - notifications are never sent here,
  # they should be returned to the caller (e.g., via after_action hook)
  defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
    # Lock already set by caller (e.g., regenerate_cycles_on_type_change or seeds)
    # Just generate cycles without additional locking
    do_generate_cycles(member, today, opts)
  end

  defp do_generate_cycles_with_lock(member, today, false, opts) do
    lock_key = :erlang.phash2(member.id)

    Repo.transaction(fn ->
      Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])

      case do_generate_cycles(member, today, opts) do
        {:ok, cycles, notifications} ->
          # Return cycles and notifications - do NOT send notifications here
          # They will be sent by the caller (e.g., via after_action hook)
          {cycles, notifications}

        {:error, reason} ->
          Repo.rollback(reason)
      end
    end)
    |> case do
      {:ok, {cycles, notifications}} -> {:ok, cycles, notifications}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Generates membership fee cycles for all members with a fee type assigned.

  This includes both active and inactive (left) members. Inactive members
  will have cycles generated up to their exit_date if they don't have cycles
  for that period yet. This allows for catch-up generation of missing cycles.

  Members processed are those who:
  - Have a membership_fee_type assigned
  - Have a join_date set

  The exit_date boundary is respected during generation (not in the query),
  so inactive members will get cycles up to their exit date.

  ## Parameters

    - `opts` - Options:
      - `:today` - Override today's date (useful for testing)
      - `:batch_size` - Number of members to process in parallel (default: 10)

  ## Returns

    - `{:ok, results}` - Map with :success and :failed counts
    - `{:error, reason}` - Error with reason

  """
  @spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
  def generate_cycles_for_all_members(opts \\ []) do
    today = Keyword.get(opts, :today, Date.utc_today())
    batch_size = Keyword.get(opts, :batch_size, 10)

    # Query ALL members with fee type assigned (including inactive/left members)
    # The exit_date boundary is applied during cycle generation, not here.
    # This allows catch-up generation for members who left but are missing cycles.
    system_actor = SystemActor.get_system_actor()
    opts = Helpers.ash_actor_opts(system_actor)

    query =
      Member
      |> Ash.Query.filter(not is_nil(membership_fee_type_id))
      |> Ash.Query.filter(not is_nil(join_date))

    case Ash.read(query, opts) do
      {:ok, members} ->
        results = process_members_in_batches(members, batch_size, today)
        {:ok, build_results_summary(results)}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp process_members_in_batches(members, batch_size, today) do
    members
    |> Enum.chunk_every(batch_size)
    |> Enum.flat_map(&process_batch(&1, today))
  end

  defp process_batch(batch, today) do
    batch
    |> Task.async_stream(fn member ->
      process_member_cycle_generation(member, today)
    end)
    |> Enum.map(fn
      {:ok, result} ->
        result

      {:exit, reason} ->
        # Task crashed - log and return error tuple
        Logger.error("Task crashed during cycle generation: #{inspect(reason)}")
        {nil, {:error, {:task_exit, reason}}}
    end)
  end

  # Process cycle generation for a single member in batch job
  # Returns {member_id, result} tuple where result is {:ok, cycles, notifications} or {:error, reason}
  defp process_member_cycle_generation(member, today) do
    case generate_cycles_for_member(member, today: today) do
      {:ok, _cycles, notifications} = ok ->
        send_notifications_for_batch_job(notifications)
        {member.id, ok}

      {:error, _reason} = err ->
        {member.id, err}
    end
  end

  # Send notifications for batch job
  # This is a top-level job, so we need to send notifications explicitly
  defp send_notifications_for_batch_job(notifications) do
    if Enum.any?(notifications) do
      Ash.Notifier.notify(notifications)
    end
  end

  defp build_results_summary(results) do
    success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _, _}, result) end)
    failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end)

    %{success: success_count, failed: failed_count, total: length(results)}
  end

  # Private functions

  # Use actor from opts when provided (e.g. seeds pass admin); otherwise system actor
  defp get_actor(opts) do
    case Keyword.get(opts, :actor) do
      nil -> SystemActor.get_system_actor()
      actor -> actor
    end
  end

  defp load_member(member_id, opts) do
    actor = get_actor(opts)
    read_opts = Helpers.ash_actor_opts(actor)

    query =
      Member
      |> Ash.Query.filter(id == ^member_id)
      |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])

    case Ash.read_one(query, read_opts) do
      {:ok, nil} -> {:error, :member_not_found}
      {:ok, member} -> {:ok, member}
      {:error, reason} -> {:error, reason}
    end
  end

  defp do_generate_cycles(member, today, opts) do
    # Reload member with relationships to ensure fresh data
    case load_member(member.id, opts) do
      {:ok, member} ->
        cond do
          is_nil(member.membership_fee_type_id) ->
            {:error, :no_membership_fee_type}

          is_nil(member.join_date) ->
            {:error, :no_join_date}

          true ->
            generate_missing_cycles(member, today, opts)
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp generate_missing_cycles(member, today, opts) do
    fee_type = member.membership_fee_type
    interval = fee_type.interval
    amount = fee_type.amount
    existing_cycles = member.membership_fee_cycles || []

    # Determine start point based on existing cycles
    # Note: We do NOT fill gaps - only generate from the last existing cycle onwards
    start_date = determine_generation_start(member, existing_cycles, interval)

    # Determine end date (today or exit_date, whichever is earlier)
    end_date = determine_end_date(member, today)

    # Only generate if start_date <= end_date
    if start_date && Date.compare(start_date, end_date) != :gt do
      cycle_starts = generate_cycle_starts(start_date, end_date, interval)
      create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
    else
      {:ok, [], []}
    end
  end

  # No existing cycles: start from membership_fee_start_date
  defp determine_generation_start(member, [], interval) do
    determine_start_date(member, interval)
  end

  # Has existing cycles: start from the cycle AFTER the last one
  # This ensures gaps (deleted cycles) are NOT filled
  defp determine_generation_start(_member, existing_cycles, interval) do
    last_cycle_start =
      existing_cycles
      |> Enum.map(& &1.cycle_start)
      |> Enum.max(Date)

    CalendarCycles.next_cycle_start(last_cycle_start, interval)
  end

  defp determine_start_date(member, interval) do
    if member.membership_fee_start_date do
      member.membership_fee_start_date
    else
      # Calculate from join_date using global settings
      include_joining_cycle = get_include_joining_cycle()

      SetMembershipFeeStartDate.calculate_start_date(
        member.join_date,
        interval,
        include_joining_cycle
      )
    end
  end

  defp determine_end_date(member, today) do
    if member.exit_date && Date.compare(member.exit_date, today) == :lt do
      # Member has left - use the exit date as boundary
      # Note: If exit_date == cycle_start, the cycle IS still generated.
      # This means the member is considered a member on the first day of that cycle.
      # Example: exit_date = 2025-01-01, yearly interval
      #   -> The 2025 cycle (starting 2025-01-01) WILL be generated
      member.exit_date
    else
      today
    end
  end

  defp get_include_joining_cycle do
    case Mv.Membership.get_settings() do
      {:ok, %{include_joining_cycle: include}} -> include
      {:error, _} -> true
    end
  end

  @doc """
  Generates all cycle start dates from a start date to an end date.

  ## Parameters

    - `start_date` - The first cycle start date
    - `end_date` - The date up to which cycles should be generated
    - `interval` - The billing interval

  ## Returns

    List of cycle start dates.

  ## Examples

      iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly)
      [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]]

  """
  @spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()]
  def generate_cycle_starts(start_date, end_date, interval) do
    # Ensure start_date is aligned to cycle boundary
    aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval)

    generate_cycle_starts_acc(aligned_start, end_date, interval, [])
  end

  defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do
    if Date.compare(current_start, end_date) == :gt do
      # Current cycle start is after end date - stop
      Enum.reverse(acc)
    else
      # Include this cycle and continue to next
      next_start = CalendarCycles.next_cycle_start(current_start, interval)
      generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc])
    end
  end

  defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
    actor = get_actor(opts)
    create_opts = Helpers.ash_actor_opts(actor)

    # Always use return_notifications?: true to collect notifications
    # Notifications will be returned to the caller, who is responsible for
    # sending them (e.g., via after_action hook returning {:ok, result, notifications})
    results =
      Enum.map(cycle_starts, fn cycle_start ->
        attrs = %{
          cycle_start: cycle_start,
          member_id: member_id,
          membership_fee_type_id: fee_type_id,
          amount: amount,
          status: :unpaid
        }

        handle_cycle_creation_result(
          Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ create_opts),
          cycle_start
        )
      end)

    {successes, skips, errors} =
      Enum.reduce(results, {[], [], []}, fn
        {:ok, cycle, notifications}, {successes, skips, errors} ->
          {[{:ok, cycle, notifications} | successes], skips, errors}

        {:skip, cycle_start}, {successes, skips, errors} ->
          {successes, [cycle_start | skips], errors}

        {:error, error}, {successes, skips, errors} ->
          {successes, skips, [error | errors]}
      end)

    all_notifications =
      Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)

    if Enum.empty?(errors) do
      successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)

      if Enum.any?(skips) do
        Logger.debug("Skipped #{length(skips)} cycles that already exist for member #{member_id}")
      end

      {:ok, successful_cycles, all_notifications}
    else
      Logger.warning("Some cycles failed to create: #{inspect(errors)}")
      # Return partial failure with errors
      # Note: When this error occurs, the transaction will be rolled back,
      # so no cycles were actually persisted in the database
      {:error, {:partial_failure, errors}}
    end
  end

  defp handle_cycle_creation_result({:ok, cycle, notifications}, _cycle_start)
       when is_list(notifications) do
    {:ok, cycle, notifications}
  end

  defp handle_cycle_creation_result({:ok, cycle}, _cycle_start) do
    {:ok, cycle, []}
  end

  defp handle_cycle_creation_result(
         {:error,
          %Ash.Error.Invalid{
            errors: [
              %Ash.Error.Changes.InvalidAttribute{
                private_vars: %{constraint: constraint, constraint_type: :unique}
              }
            ]
          }} = error,
         cycle_start
       ) do
    # Cycle already exists (unique constraint violation) - skip it silently
    # This makes the function idempotent and prevents errors on server restart
    handle_unique_constraint_violation(constraint, cycle_start, error)
  end

  defp handle_cycle_creation_result({:error, reason}, cycle_start) do
    {:error, {cycle_start, reason}}
  end

  defp handle_unique_constraint_violation(
         "membership_fee_cycles_unique_cycle_per_member_index",
         cycle_start,
         _error
       ) do
    {:skip, cycle_start}
  end

  defp handle_unique_constraint_violation(_constraint, cycle_start, error) do
    {:error, {cycle_start, error}}
  end
end
