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

  alias Tarakan.Accounts.{Account, Scope}
  alias Tarakan.Audit.Event
  alias Tarakan.Billing.StripeStub
  alias Tarakan.Credits
  alias Tarakan.Market
  alias Tarakan.Market.{Bounty, BountyClaim}
  alias Tarakan.Work

  defp sponsor_fixture(credits \\ 1_000) do
    sponsor = active_account_fixture() |> fund_credits(credits)
    {sponsor, Scope.for_account(sponsor)}
  end

  defp admin_account_fixture do
    account = active_account_fixture()

    account
    |> Account.authorization_changeset(%{
      state: "active",
      platform_role: "admin",
      trust_tier: "reviewer"
    })
    |> Repo.update!()
  end

  describe "create_bounty/2 with credits" do
    test "escrows the amount and opens the bounty" do
      repository = listed_github_repository_fixture()
      {sponsor, scope} = sponsor_fixture()

      assert {:ok, %Bounty{} = bounty} =
               Market.create_bounty(scope, valid_bounty_attributes(repository))

      assert bounty.status == "open"
      assert bounty.funding == "credits"
      assert bounty.credit_amount == 500
      assert Decimal.equal?(bounty.take_rate, Decimal.new("0.10"))
      assert bounty.target_type == "repository"
      assert bounty.repository_id == repository.id
      assert bounty.sponsor_account_id == sponsor.id
      assert bounty.expires_at

      assert Credits.balance(sponsor) == 500

      assert [entry] =
               Repo.all(
                 from entry in Credits.Entry,
                   where: entry.kind == "spend_bounty_escrow",
                   where: entry.subject_type == "bounty",
                   where: entry.subject_id == ^bounty.id
               )

      assert entry.amount == -500

      assert Repo.one(
               from event in Event,
                 where: event.action == "bounty_created" and event.subject_id == ^bounty.id,
                 select: count()
             ) == 1
    end

    test "rejects sponsors without enough credits and writes nothing" do
      repository = listed_github_repository_fixture()
      {sponsor, scope} = sponsor_fixture(100)

      assert {:error, :insufficient_credits} =
               Market.create_bounty(scope, valid_bounty_attributes(repository))

      assert Credits.balance(sponsor) == 100
      assert Repo.aggregate(Bounty, :count) == 0
    end

    test "rejects targets that are not publicly listed" do
      repository =
        github_repository_fixture()
        |> Tarakan.Repositories.Repository.listing_changeset(%{listing_status: "quarantined"})
        |> Repo.update!()

      {_sponsor, scope} = sponsor_fixture()

      assert {:error, :target_not_public} =
               Market.create_bounty(scope, valid_bounty_attributes(repository))
    end

    test "rejects unknown targets" do
      {_sponsor, scope} = sponsor_fixture()

      assert {:error, :target_not_found} =
               Market.create_bounty(
                 scope,
                 valid_bounty_attributes(%{owner: "missing", name: "repository"})
               )
    end

    test "resolves a bare slug to a hosted repository" do
      {_sponsor, scope} = sponsor_fixture()
      repository = listed_hosted_repository_fixture()

      assert {:ok, %Bounty{} = bounty} =
               Market.create_bounty(scope, valid_bounty_attributes(repository))

      assert bounty.repository_id == repository.id
    end

    test "resolves a pasted repository URL" do
      {_sponsor, scope} = sponsor_fixture()
      repository = listed_github_repository_fixture()

      attrs =
        valid_bounty_attributes(repository, %{
          "target_ref" => "https://github.com/#{repository.owner}/#{repository.name}.git"
        })

      assert {:ok, %Bounty{} = bounty} = Market.create_bounty(scope, attrs)
      assert bounty.repository_id == repository.id
    end

    test "rejects sponsors without active standing" do
      repository = listed_github_repository_fixture()
      probation = account_fixture()

      assert {:error, :unauthorized} =
               Market.create_bounty(
                 Scope.for_account(probation),
                 valid_bounty_attributes(repository)
               )
    end
  end

  describe "create_bounty/2 with fiat" do
    test "creates a checkout session and stays pending until funded" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()

      assert {bounty, checkout_url} = fiat_bounty_fixture(scope, repository)

      assert bounty.status == "pending_funding"
      assert bounty.funding == "fiat"
      assert bounty.amount_cents == 25_000
      assert bounty.stripe_checkout_session_id =~ "cs_test_"
      assert checkout_url =~ "https://checkout.stripe.com/"

      assert Repo.get_by(Bounty, public_id: bounty.public_id).stripe_checkout_session_id ==
               bounty.stripe_checkout_session_id
    end

    test "rolls the bounty back when Stripe session creation fails" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()

      StripeStub.respond_with({:error, :stripe_unavailable})

      assert {:error, :stripe_unavailable} =
               Market.create_bounty(
                 scope,
                 valid_bounty_attributes(repository, %{
                   "funding" => "fiat",
                   "amount_cents" => 25_000
                 })
               )

      assert Repo.aggregate(Bounty, :count) == 0
    after
      StripeStub.reset()
    end
  end

  describe "open_value_by_repository/1" do
    test "sums open contracts per repository in one pass" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture(2_000)

      credits_bounty_fixture(scope, repository, %{"credit_amount" => 300})
      credits_bounty_fixture(scope, repository, %{"credit_amount" => 200})

      value = Market.open_value_by_repository([repository.id])

      assert value[repository.id].credits == 500
      assert value[repository.id].count == 2
      assert value[repository.id].cents == 0
    end

    test "repositories with nothing on offer are simply absent" do
      repository = listed_github_repository_fixture()

      assert Market.open_value_by_repository([repository.id]) == %{}
      assert Market.open_value_by_repository([]) == %{}
      assert Market.open_value_by_repository([nil]) == %{}
    end

    test "settled contracts stop counting" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture(2_000)
      bounty = credits_bounty_fixture(scope, repository, %{"credit_amount" => 300})

      assert Market.open_value_by_repository([repository.id])[repository.id].credits == 300

      bounty
      |> Ecto.Changeset.change(status: "cancelled")
      |> Repo.update!()

      assert Market.open_value_by_repository([repository.id]) == %{}
    end
  end

  describe "mark_funded/1" do
    test "flips pending_funding to open and is idempotent" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      {bounty, _url} = fiat_bounty_fixture(scope, repository)

      assert {:ok, funded} = Market.mark_funded(bounty.stripe_checkout_session_id)
      assert funded.status == "open"

      assert {:ok, again} = Market.mark_funded(bounty.stripe_checkout_session_id)
      assert again.status == "open"

      assert {:error, :not_found} = Market.mark_funded("cs_test_missing")
    end
  end

  describe "claim_bounty/2" do
    test "creates and claims a Work task and links the claim" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      hunter = active_account_fixture()

      assert {:ok, %BountyClaim{} = claim} =
               Market.claim_bounty(Scope.for_account(hunter), bounty)

      bounty = Market.get_bounty!(bounty.public_id)
      assert bounty.status == "claimed"

      assert claim.bounty_id == bounty.id
      assert claim.account_id == hunter.id
      assert claim.status == "claimed"
      assert claim.review_task_id

      task = Repo.get!(Work.ReviewTask, claim.review_task_id)
      assert task.status == "claimed"
      assert task.claimed_by_id == hunter.id
      assert task.created_by_id == hunter.id
      assert task.repository_id == repository.id
      assert task.kind == "code_review"
      assert task.visibility == "public"
      assert Work.ReviewTask.claim_active?(task)
    end

    test "the sponsor cannot claim their own bounty" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      assert {:error, :own_bounty} = Market.claim_bounty(scope, bounty)
    end

    test "a claimed bounty cannot be claimed again" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      hunter = active_account_fixture()
      assert {:ok, _claim} = Market.claim_bounty(Scope.for_account(hunter), bounty)

      other = active_account_fixture()

      assert {:error, :not_open} = Market.claim_bounty(Scope.for_account(other), bounty)
    end
  end

  describe "credit lifecycle: claim → submit → settle → paid" do
    test "pays the winner the net amount after the take rate" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository, %{"credit_amount" => 500})

      hunter = active_account_fixture()
      hunter_scope = Scope.for_account(hunter)
      {:ok, claim} = Market.claim_bounty(hunter_scope, bounty)

      # A stranger cannot submit the hunter's claim.
      stranger = active_account_fixture()

      assert {:error, :unauthorized} =
               Market.submit_claim(Scope.for_account(stranger), claim, submission_attributes())

      assert {:ok, submitted} = Market.submit_claim(hunter_scope, claim, submission_attributes())
      assert submitted.status == "submitted"

      task = Repo.get!(Work.ReviewTask, claim.review_task_id)
      assert task.status == "submitted"

      moderator = moderator_account_fixture()
      moderator_scope = Scope.for_account(moderator)

      assert {:ok, settled} =
               Market.settle_bounty(moderator_scope, Market.get_bounty!(bounty.public_id))

      assert settled.status == "paid"
      assert settled.winner_account_id == hunter.id

      # 500 gross x (1 - 0.10) = 450 net.
      assert Credits.balance(hunter) == 450

      claim = Repo.get!(BountyClaim, claim.id)
      assert claim.status == "accepted"

      assert Repo.one(
               from event in Event,
                 where: event.action == "bounty_settled" and event.subject_id == ^bounty.id,
                 select: count()
             ) == 1

      # Members cannot settle.
      member = active_account_fixture()

      assert {:error, :unauthorized} =
               Market.settle_bounty(
                 Scope.for_account(member),
                 Market.get_bounty!(bounty.public_id)
               )
    end

    test "settle_accepted/0 settles automatically once the task is accepted" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      hunter = active_account_fixture()
      hunter_scope = Scope.for_account(hunter)
      {:ok, claim} = Market.claim_bounty(hunter_scope, bounty)
      {:ok, _submitted} = Market.submit_claim(hunter_scope, claim, submission_attributes())

      # The sweep ignores bounties whose task is not yet accepted.
      assert Market.settle_accepted() == 0

      moderator = moderator_account_fixture()
      task = Repo.get!(Work.ReviewTask, claim.review_task_id)

      assert {:ok, _accepted} =
               Work.accept_task(task, moderator, %{
                 "reason" => "Fix verified against the pinned commit.",
                 "evidence" => "Followed the reproduction notes; patch holds under review."
               })

      assert Market.settle_accepted() == 1

      bounty = Market.get_bounty!(bounty.public_id)
      assert bounty.status == "paid"
      assert bounty.winner_account_id == hunter.id
      assert Credits.balance(hunter) == 450
    end
  end

  describe "fiat lifecycle: fund → claim → submit → settle → mark paid" do
    test "settles to payout_pending and admins mark it paid" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      {bounty, _url} = fiat_bounty_fixture(scope, repository)
      {:ok, bounty} = Market.mark_funded(bounty.stripe_checkout_session_id)

      hunter = active_account_fixture()
      hunter_scope = Scope.for_account(hunter)
      {:ok, claim} = Market.claim_bounty(hunter_scope, bounty)
      {:ok, _submitted} = Market.submit_claim(hunter_scope, claim, submission_attributes())

      moderator = moderator_account_fixture()
      moderator_scope = Scope.for_account(moderator)

      assert {:ok, settled} =
               Market.settle_bounty(moderator_scope, Market.get_bounty!(bounty.public_id))

      assert settled.status == "payout_pending"

      # Moderators cannot mark paid; admins can.
      assert {:error, :unauthorized} = Market.mark_paid(moderator_scope, settled)

      admin = admin_account_fixture()

      assert {:ok, paid} = Market.mark_paid(Scope.for_account(admin), settled)
      assert paid.status == "paid"

      assert {:error, :invalid_state} = Market.mark_paid(Scope.for_account(admin), paid)
    end
  end

  describe "cancel_bounty/2" do
    test "the sponsor cancels an open credits bounty and gets the escrow back" do
      repository = listed_github_repository_fixture()
      {sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)
      assert Credits.balance(sponsor) == 500

      assert {:ok, cancelled} = Market.cancel_bounty(scope, bounty)
      assert cancelled.status == "cancelled"
      assert Credits.balance(sponsor) == 1_000
    end

    test "the sponsor cancels a funded fiat bounty and Stripe refunds it" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      {bounty, _url} = fiat_bounty_fixture(scope, repository)
      {:ok, bounty} = Market.mark_funded(bounty.stripe_checkout_session_id)

      test_pid = self()

      StripeStub.respond_with(fn
        :get, "/v1/checkout/sessions/" <> _id, _params ->
          {:ok, %{"payment_intent" => "pi_refund_me"}}

        :post, "/v1/refunds", body ->
          send(test_pid, {:refund_requested, body})
          {:ok, %{"id" => "re_test_1", "status" => "succeeded"}}
      end)

      assert {:ok, cancelled} = Market.cancel_bounty(scope, bounty)
      assert cancelled.status == "cancelled"
      assert_received {:refund_requested, body}
      assert body[:payment_intent] == "pi_refund_me"
    after
      StripeStub.reset()
    end

    test "an unfunded fiat bounty cancels without a refund call" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      {bounty, _url} = fiat_bounty_fixture(scope, repository)

      StripeStub.respond_with(fn _method, _path, _body ->
        raise "no Stripe call expected"
      end)

      assert {:ok, cancelled} = Market.cancel_bounty(scope, bounty)
      assert cancelled.status == "cancelled"
    after
      StripeStub.reset()
    end

    test "a sponsor cannot cancel once claimed; a moderator can cancel unclaimed" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      hunter = active_account_fixture()
      {:ok, _claim} = Market.claim_bounty(Scope.for_account(hunter), bounty)
      bounty = Market.get_bounty!(bounty.public_id)

      assert {:error, :has_claims} = Market.cancel_bounty(scope, bounty)

      stranger = active_account_fixture()
      assert {:error, :unauthorized} = Market.cancel_bounty(Scope.for_account(stranger), bounty)

      moderator = moderator_account_fixture()
      assert {:error, :invalid_state} = Market.cancel_bounty(Scope.for_account(moderator), bounty)
    end
  end

  describe "expire_stale/1" do
    test "cancels unfunded fiat bounties past the checkout window" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      {bounty, _url} = fiat_bounty_fixture(scope, repository)

      backdate(bounty, days: -2)

      assert {1, 0} = Market.expire_stale()
      assert Repo.get!(Bounty, bounty.id).status == "cancelled"
    end

    test "expires unclaimed open bounties and refunds the escrow" do
      repository = listed_github_repository_fixture()
      {sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      bounty
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -3600, :second))
      |> Repo.update!()

      assert {0, 1} = Market.expire_stale()
      assert Repo.get!(Bounty, bounty.id).status == "expired"
      assert Credits.balance(sponsor) == 1_000
    end

    test "leaves claimed bounties alone even past expiry" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      hunter = active_account_fixture()
      {:ok, _claim} = Market.claim_bounty(Scope.for_account(hunter), bounty)

      bounty
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -3600, :second))
      |> Repo.update!()

      assert {0, 0} = Market.expire_stale()
      assert Repo.get!(Bounty, bounty.id).status == "claimed"
    end
  end

  describe "listing queries" do
    test "list_open/1 filters by target type and get_bounty!/1 preloads" do
      repository = listed_github_repository_fixture()
      {sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      assert [%Bounty{public_id: public_id}] = Market.list_open()
      assert public_id == bounty.public_id
      assert [%Bounty{}] = Market.list_open(target_type: "repository")
      assert [] = Market.list_open(target_type: "finding")

      fetched = Market.get_bounty!(bounty.public_id)
      assert fetched.sponsor_account.handle == sponsor.handle
      assert fetched.repository.id == repository.id
    end

    test "open_bounties_for_target/2 finds bounties for banners" do
      repository = listed_github_repository_fixture()
      {_sponsor, scope} = sponsor_fixture()
      bounty = credits_bounty_fixture(scope, repository)

      assert [%Bounty{public_id: public_id}] =
               Market.open_bounties_for_target(:repository, repository.id)

      assert public_id == bounty.public_id
      assert [] = Market.open_bounties_for_target(:infestation, "no-such-pattern")
      assert [] = Market.open_bounties_for_target(:finding, -1)
    end
  end

  defp backdate(bounty, days: days) do
    Repo.update_all(
      from(row in Bounty, where: row.id == ^bounty.id),
      set: [inserted_at: DateTime.add(DateTime.utc_now(), days * 24 * 60 * 60, :second)]
    )
  end
end
