defmodule Tarakan.Credits do
  @moduledoc """
  The credits economy.

  Credits are earned only on verification events - never purchasable and
  non-transferable. The ledger is append-only and public: every mutation of
  `accounts.credit_balance` writes a `credit_entries` row carrying the
  resulting `balance_after`, so the balance can always be replayed.

  Mint entries carry a subject and ride a partial unique index, which makes
  every mint idempotent: a repeated mint returns `{:error, :already_minted}`
  and callers treat that as success.
  """

  import Ecto.Query, warn: false

  alias Tarakan.Accounts.{Account, Scope}
  alias Tarakan.Audit
  alias Tarakan.Credits.Entry
  alias Tarakan.Policy
  alias Tarakan.Repo

  @mint_finding_verified 50
  @mint_check_correct 10
  @mint_fix_settled 25

  @mint_amounts %{
    "mint_finding_verified" => @mint_finding_verified,
    "mint_check_correct" => @mint_check_correct,
    "mint_fix_settled" => @mint_fix_settled
  }

  @doc "Fixed mint amounts, keyed by kind."
  def mint_amounts, do: @mint_amounts

  @doc """
  Credits an account, defaulting to the fixed amount for mint kinds.

  `subject` is `{subject_type, subject_id}` or nil. A repeated mint for the
  same subject returns `{:error, :already_minted}` without touching the
  balance.
  """
  def credit(%Account{} = account, kind, subject \\ nil, amount \\ nil) do
    amount = amount || Map.fetch!(@mint_amounts, to_string(kind))

    Repo.transaction(fn ->
      append_entry!(account.id, kind, subject, amount)
    end)
  end

  @doc """
  Debits an account by a positive `amount`.

  Returns `{:error, :insufficient_credits}` when the balance cannot cover
  the debit; the balance is left untouched.
  """
  def debit(%Account{} = account, kind, subject \\ nil, amount)
      when is_integer(amount) and amount > 0 do
    Repo.transaction(fn ->
      append_entry!(account.id, kind, subject, -amount)
    end)
  end

  @doc """
  Reverses a spend, returning it to the account that made it.

  Takes the kind and subject of the original debit rather than an amount, so a
  caller never has to know what was charged - or how the ledger stores it. The
  refund carries its own subject, which rides the mint index, so calling this
  twice returns `{:ok, :already_refunded}` instead of paying out again.
  """
  def refund(kind, {subject_type, subject_id} = _subject) do
    spend =
      Repo.one(
        from entry in Entry,
          where:
            entry.kind == ^to_string(kind) and entry.subject_type == ^to_string(subject_type) and
              entry.subject_id == ^subject_id
      )

    case spend do
      nil ->
        {:error, :no_such_spend}

      %Entry{amount: amount} when amount >= 0 ->
        {:error, :not_a_spend}

      %Entry{} = entry ->
        account = Repo.get!(Account, entry.account_id)
        refund_subject = {"#{subject_type}_refund", subject_id}

        case credit(account, :adjustment, refund_subject, -entry.amount) do
          {:ok, refunded} -> {:ok, refunded}
          {:error, :already_minted} -> {:ok, :already_refunded}
          {:error, reason} -> {:error, reason}
        end
    end
  end

  @doc "Current credit balance, read fresh from the accounts row."
  def balance(%Account{} = account) do
    Repo.one!(from item in Account, where: item.id == ^account.id, select: item.credit_balance)
  end

  @doc "Recent ledger entries for an account, newest first."
  def ledger(%Account{} = account, opts \\ []) do
    limit = Keyword.get(opts, :limit, 20)

    Repo.all(
      from entry in Entry,
        where: entry.account_id == ^account.id,
        order_by: [desc: entry.id],
        limit: ^limit
    )
  end

  @doc """
  Moderator/admin balance correction.

  Writes an `adjustment` entry and an audit event in one transaction.
  """
  def adjust(%Scope{} = scope, %Account{} = account, amount) when is_integer(amount) do
    case Policy.authorize(scope, :moderate, account) do
      :ok ->
        Repo.transaction(fn ->
          entry = append_entry!(account.id, :adjustment, nil, amount)

          case Audit.record(scope, :adjust_credits, account, %{
                 metadata: %{"amount" => amount, "entry_id" => entry.id}
               }) do
            {:ok, _event} -> entry
            {:error, reason} -> Repo.rollback(reason)
          end
        end)

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

  def adjust(_scope, _account, _amount), do: {:error, :unauthorized}

  # Runs inside the caller's transaction: locks the account row, moves the
  # balance, and appends the ledger entry. Rolls back on insufficient funds
  # or a duplicate mint.
  defp append_entry!(account_id, kind, subject, delta) do
    account =
      Repo.one!(from item in Account, where: item.id == ^account_id, lock: "FOR UPDATE")

    balance_after = account.credit_balance + delta

    if balance_after < 0 do
      Repo.rollback(:insufficient_credits)
    end

    {:ok, _account} =
      account
      |> Ecto.Changeset.change(credit_balance: balance_after)
      |> Repo.update()

    {subject_type, subject_id} = subject_pair(subject)

    %Entry{}
    |> Entry.changeset(%{
      account_id: account.id,
      amount: delta,
      kind: to_string(kind),
      subject_type: subject_type,
      subject_id: subject_id,
      balance_after: balance_after
    })
    |> Repo.insert()
    |> case do
      {:ok, entry} ->
        entry

      {:error, changeset} ->
        if unique_violation?(changeset) do
          Repo.rollback(:already_minted)
        else
          Repo.rollback(changeset)
        end
    end
  end

  defp subject_pair(nil), do: {nil, nil}
  defp subject_pair({type, id}), do: {to_string(type), id}

  defp unique_violation?(changeset) do
    Enum.any?(changeset.errors, fn {_field, {_message, meta}} ->
      meta[:constraint] == :unique
    end)
  end
end
