defmodule Tarakan.CreditsTest do
  use Tarakan.DataCase, async: true

  alias Tarakan.Accounts.{Account, Scope}
  alias Tarakan.Audit.Event
  alias Tarakan.Credits

  describe "credit/4" do
    test "mints the fixed amount and tracks balance_after" do
      account = account_fixture()

      assert {:ok, entry} =
               Credits.credit(account, :mint_finding_verified, {"canonical_finding", 1})

      assert entry.kind == "mint_finding_verified"
      assert entry.amount == 50
      assert entry.balance_after == 50
      assert Credits.balance(account) == 50
      assert Repo.get!(Account, account.id).credit_balance == 50
    end

    test "a repeated mint for the same subject returns already_minted" do
      account = account_fixture()
      subject = {"canonical_finding", 42}

      assert {:ok, _entry} = Credits.credit(account, :mint_finding_verified, subject)
      assert {:error, :already_minted} = Credits.credit(account, :mint_finding_verified, subject)

      assert Credits.balance(account) == 50
      assert [_only] = Credits.ledger(account)
    end

    test "the same subject can mint different kinds" do
      account = account_fixture()
      subject = {"finding_check", 7}

      assert {:ok, _entry} = Credits.credit(account, :mint_check_correct, subject)
      assert {:ok, _entry} = Credits.credit(account, :mint_fix_settled, subject)

      assert Credits.balance(account) == 35
    end

    test "credits an explicit amount for non-mint kinds" do
      account = account_fixture()

      assert {:ok, entry} = Credits.credit(account, :receive_bounty, {"bounty", 7}, 200)

      assert entry.amount == 200
      assert entry.balance_after == 200
      assert Credits.balance(account) == 200
    end

    test "rejects unknown kinds" do
      account = account_fixture()

      assert {:error, changeset} = Credits.credit(account, :bogus_kind, nil, 10)
      assert "is invalid" in errors_on(changeset).kind
      assert Credits.balance(account) == 0
    end
  end

  describe "debit/4" do
    test "spends credits and keeps the balance_after chain intact" do
      account = account_fixture()

      {:ok, _entry} = Credits.credit(account, :mint_finding_verified, {"canonical_finding", 1})
      {:ok, _entry} = Credits.credit(account, :mint_check_correct, {"finding_check", 2})

      assert {:ok, debit_entry} = Credits.debit(account, :spend_priority_boost, nil, 20)
      assert debit_entry.amount == -20
      assert debit_entry.balance_after == 40
      assert Credits.balance(account) == 40

      assert [latest, second, first] = Credits.ledger(account)
      assert {first.amount, first.balance_after} == {50, 50}
      assert {second.amount, second.balance_after} == {10, 60}
      assert {latest.amount, latest.balance_after} == {-20, 40}
    end

    test "rejects debits that exceed the balance" do
      account = account_fixture()
      {:ok, _entry} = Credits.credit(account, :mint_check_correct, {"finding_check", 1})

      assert {:error, :insufficient_credits} =
               Credits.debit(account, :spend_bounty_escrow, nil, 25)

      assert Credits.balance(account) == 10
      assert [_only] = Credits.ledger(account)
    end
  end

  describe "refund/2" do
    test "returns a spend to the account that made it, once" do
      account = active_account_fixture()
      {:ok, _entry} = Credits.credit(account, :adjustment, nil, 100)
      {:ok, _spend} = Credits.debit(account, :spend_review_job, {"review_task", 7}, 40)

      assert Credits.balance(account) == 60

      assert {:ok, _refund} = Credits.refund(:spend_review_job, {"review_task", 7})
      assert Credits.balance(account) == 100

      # The refund carries its own subject, so the mint index makes a repeat
      # call a no-op rather than a second payout.
      assert {:ok, :already_refunded} = Credits.refund(:spend_review_job, {"review_task", 7})
      assert Credits.balance(account) == 100
    end

    test "reports a spend that never happened" do
      assert {:error, :no_such_spend} = Credits.refund(:spend_review_job, {"review_task", 999})
    end

    test "refuses to reverse something that was not a spend" do
      account = active_account_fixture()
      {:ok, _entry} = Credits.credit(account, :receive_bounty, {"bounty", 3}, 50)

      assert {:error, :not_a_spend} = Credits.refund(:receive_bounty, {"bounty", 3})
      assert Credits.balance(account) == 50
    end
  end

  describe "adjust/3" do
    test "moderators can adjust and the adjustment is audited" do
      moderator = moderator_account_fixture()
      account = account_fixture()
      scope = Scope.for_account(moderator)

      assert {:ok, entry} = Credits.adjust(scope, account, 15)
      assert entry.kind == "adjustment"
      assert entry.amount == 15
      assert entry.balance_after == 15
      assert Credits.balance(account) == 15

      assert {:ok, correction} = Credits.adjust(scope, account, -15)
      assert correction.balance_after == 0

      events =
        Repo.all(
          from event in Event,
            where:
              event.action == "adjust_credits" and
                event.subject_id == ^account.id and
                event.actor_id == ^moderator.id
        )

      assert length(events) == 2
      assert Enum.map(events, & &1.metadata["amount"]) |> Enum.sort() == [-15, 15]
    end

    test "members are denied and nothing is written" do
      member = account_fixture()
      account = account_fixture()

      assert {:error, :unauthorized} = Credits.adjust(Scope.for_account(member), account, 100)

      assert Credits.balance(account) == 0
      assert Credits.ledger(account) == []

      assert Repo.all(
               from event in Event,
                 where: event.action == "adjust_credits" and event.subject_id == ^account.id
             ) == []
    end

    test "negative adjustments cannot push the balance below zero" do
      moderator = moderator_account_fixture()
      account = account_fixture()

      assert {:error, :insufficient_credits} =
               Credits.adjust(Scope.for_account(moderator), account, -5)

      assert Credits.balance(account) == 0
    end
  end
end
