defmodule Tarakan.Market do
  @moduledoc """
  The bounty marketplace ("contracts" in user-facing copy).

  A sponsor funds a bounty on a public target - a listed repository, an
  infestation pattern, or a public canonical finding - either with fiat
  (Stripe Checkout escrow) or with platform credits (escrowed by debiting the
  sponsor at creation).

  Lifecycle:

      pending_funding ──(webhook: checkout.session.completed)──▶ open
      open ──(claim_bounty)──▶ claimed ──(settle)──▶ payout_pending ──(mark_paid)──▶ paid   [fiat]
      open ──(claim_bounty)──▶ claimed ──(settle)──▶ paid                                     [credits]
      pending_funding/open ──(cancel / expiry sweep)──▶ cancelled / expired

  Claims are backed by real Work queue tasks: claiming creates a review task
  (`Work.create_task/3`), auto-publishes it on behalf of the platform (the
  queue's publish path requires steward/moderator auth, which does not apply
  to system-initiated bounty tasks), and claims it for the hunter
  (`Work.claim_task/2`, standard 2h lease). Submissions and acceptance flow
  through the ordinary Work review path; settlement follows task acceptance -
  either by a moderator explicitly (`settle_bounty/2`) or automatically by the
  hourly sweep (`Tarakan.Market.SweepWorker`).
  """

  import Ecto.Query, warn: false

  alias Tarakan.Accounts.{Account, Scope}
  alias Tarakan.Audit
  alias Tarakan.Billing.Stripe
  alias Tarakan.Credits
  alias Tarakan.Infestations
  alias Tarakan.Market.{Bounty, BountyClaim}
  alias Tarakan.Policy
  alias Tarakan.Repo
  alias Tarakan.Repositories
  alias Tarakan.Repositories.Repository
  alias Tarakan.Scans.{CanonicalFinding, Finding, Scan}
  alias Tarakan.Work

  @default_expiry_days 30
  # Stripe Checkout sessions expire after 24h; an unfunded bounty past that
  # window can never be paid and is cancelled by the sweep.
  @pending_funding_ttl_seconds 24 * 60 * 60

  ## Queries

  @doc "Open bounties, newest first, optionally filtered by target type."
  def list_open(opts \\ []) do
    target_type = Keyword.get(opts, :target_type)
    limit = Keyword.get(opts, :limit, 100)

    Bounty
    |> where([bounty], bounty.status == "open")
    |> maybe_target_type(target_type)
    |> order_by([bounty], desc: bounty.inserted_at, desc: bounty.id)
    |> limit(^limit)
    |> preload([:sponsor_account, :repository, :canonical_finding])
    |> Repo.all()
  end

  defp maybe_target_type(query, nil), do: query

  defp maybe_target_type(query, target_type)
       when target_type in ["repository", "infestation", "finding"],
       do: where(query, [bounty], bounty.target_type == ^target_type)

  defp maybe_target_type(query, _), do: query

  @doc "Fetches one bounty by public id with display preloads."
  def get_bounty!(public_id) do
    Bounty
    |> Repo.get_by!(public_id: public_id)
    |> Repo.preload([
      :sponsor_account,
      :repository,
      :winner_account,
      canonical_finding: :repository,
      claims: [:account, :review_task]
    ])
  end

  @doc "Open bounties attached to one target, for the contract banners."
  def open_bounties_for_target(target_type, ref) do
    target_clause(target_type, ref)
    |> case do
      nil ->
        []

      {field, value} ->
        Bounty
        |> where([bounty], bounty.status == "open")
        |> where([bounty], field(bounty, ^field) == ^value)
        |> order_by([bounty], desc: bounty.amount_cents, desc: bounty.credit_amount)
        |> preload([:sponsor_account])
        |> Repo.all()
    end
  end

  @doc """
  Open contract value per repository, for a batch of repository ids.

  Returns `%{repository_id => %{cents: n, credits: n, count: n}}`. Batched
  because the job queue needs it for a whole page at once: a worker deciding
  where to spend its tokens should be able to see what is actually on offer
  without the listing turning into a query per row.
  """
  def open_value_by_repository(repository_ids) when is_list(repository_ids) do
    ids = repository_ids |> Enum.reject(&is_nil/1) |> Enum.uniq()

    if ids == [] do
      %{}
    else
      Repo.all(
        from bounty in Bounty,
          where: bounty.status == "open" and bounty.repository_id in ^ids,
          group_by: bounty.repository_id,
          select: %{
            repository_id: bounty.repository_id,
            cents: coalesce(sum(bounty.amount_cents), 0),
            credits: coalesce(sum(bounty.credit_amount), 0),
            count: count(bounty.id)
          }
      )
      |> Map.new(fn row -> {row.repository_id, Map.delete(row, :repository_id)} end)
    end
  end

  defp target_clause(:repository, id) when is_integer(id), do: {:repository_id, id}
  defp target_clause(:infestation, key) when is_binary(key), do: {:pattern_key, key}
  defp target_clause(:finding, id) when is_integer(id), do: {:canonical_finding_id, id}
  defp target_clause(_, _), do: nil

  @doc "Claims made by one account, newest first."
  def list_claims_for_account(%Account{id: account_id}) do
    BountyClaim
    |> where([claim], claim.account_id == ^account_id)
    |> order_by([claim], desc: claim.id)
    |> preload(bounty: [:repository, :canonical_finding])
    |> Repo.all()
  end

  ## Creation

  @doc """
  Creates a bounty sponsored by the scope's account.

  Fiat bounties create a Stripe Checkout Session and return
  `{:ok, bounty, checkout_url}` with status `pending_funding`; the webhook
  flips them to `open`. Credit bounties escrow the amount immediately
  (debiting the sponsor) and return `{:ok, bounty}` with status `open`.
  """
  def create_bounty(%Scope{account: %Account{} = account} = scope, attrs) when is_map(attrs) do
    attrs = stringify_keys(attrs)

    with :ok <- active_standing(scope),
         {:ok, target} <- resolve_target(attrs) do
      case present_or(attrs["funding"], "credits") do
        "fiat" -> create_fiat_bounty(scope, account, attrs, target)
        "credits" -> create_credit_bounty(scope, account, attrs, target)
        other -> {:error, {:invalid_funding, other}}
      end
    end
  end

  def create_bounty(_scope, _attrs), do: {:error, :unauthorized}

  defp create_fiat_bounty(scope, account, attrs, target) do
    amount_cents = to_integer(attrs["amount_cents"])

    changeset =
      build_changeset(account, attrs, target,
        funding: "fiat",
        amount_cents: amount_cents,
        status: "pending_funding"
      )

    Repo.transaction(fn ->
      bounty = insert_bounty!(scope, changeset)

      case create_checkout_session(bounty, amount_cents) do
        {:ok, %{"id" => session_id, "url" => checkout_url}} ->
          bounty =
            bounty
            |> Ecto.Changeset.change(stripe_checkout_session_id: session_id)
            |> Repo.update!()

          {bounty, checkout_url}

        {:ok, response} ->
          Repo.rollback({:stripe_error, response})

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

  defp create_credit_bounty(scope, account, attrs, target) do
    credit_amount = to_integer(attrs["credit_amount"])

    changeset =
      build_changeset(account, attrs, target,
        funding: "credits",
        credit_amount: credit_amount,
        status: "open"
      )

    Repo.transaction(fn ->
      bounty = insert_bounty!(scope, changeset)

      case Credits.debit(account, :spend_bounty_escrow, {"bounty", bounty.id}, credit_amount) do
        {:ok, _entry} -> bounty
        {:error, reason} -> Repo.rollback(reason)
      end
    end)
  end

  defp build_changeset(account, attrs, target, extra) do
    expires_at =
      case to_integer(attrs["expires_days"]) do
        nil -> DateTime.add(DateTime.utc_now(), @default_expiry_days * 24 * 60 * 60, :second)
        days -> DateTime.add(DateTime.utc_now(), days * 24 * 60 * 60, :second)
      end

    %Bounty{}
    |> Bounty.changeset(%{
      "target_type" => target.type,
      "repository_id" => target.repository_id,
      "pattern_key" => target.pattern_key,
      "canonical_finding_id" => target.canonical_finding_id,
      "title" => attrs["title"],
      "description" => attrs["description"],
      "funding" => Keyword.fetch!(extra, :funding),
      "amount_cents" => Keyword.get(extra, :amount_cents),
      "credit_amount" => Keyword.get(extra, :credit_amount),
      "take_rate" => take_rate(),
      "status" => Keyword.fetch!(extra, :status),
      "expires_at" => DateTime.truncate(expires_at, :second)
    })
    |> Ecto.Changeset.put_change(:sponsor_account_id, account.id)
  end

  defp insert_bounty!(scope, changeset) do
    case Repo.insert(changeset) do
      {:ok, bounty} ->
        case Audit.record(scope, :bounty_created, bounty, %{
               metadata: %{"funding" => bounty.funding}
             }) do
          {:ok, _event} -> bounty
          {:error, reason} -> Repo.rollback({:audit_failed, reason})
        end

      {:error, changeset} ->
        Repo.rollback(changeset)
    end
  end

  defp create_checkout_session(bounty, amount_cents) do
    bounty_url = TarakanWeb.Endpoint.url() <> "/bounties/#{bounty.public_id}"

    Stripe.request(:post, "/v1/checkout/sessions", [
      {"mode", "payment"},
      {"success_url", bounty_url},
      {"cancel_url", bounty_url},
      {"client_reference_id", bounty.public_id},
      {"metadata[bounty_public_id]", bounty.public_id},
      {"line_items[0][quantity]", "1"},
      {"line_items[0][price_data][currency]", "usd"},
      {"line_items[0][price_data][unit_amount]", Integer.to_string(amount_cents)},
      {"line_items[0][price_data][product_data][name]", "Contract: #{bounty.title}"}
    ])
  end

  ## Funding webhook

  @doc """
  Marks a fiat bounty funded after its Checkout Session was paid.

  `payment_status` must be Stripe's own `payment_status` for the session.
  Completion is not payment: for any delayed-notification method (bank
  transfer, SEPA debit, Klarna, OXXO) Stripe fires
  `checkout.session.completed` with `payment_status: "unpaid"` and only
  confirms later with `checkout.session.async_payment_succeeded`. Opening the
  bounty on completion alone would let a hunter be assigned, do the work, and
  be paid out against money that never arrived. Card-only accounts never see
  this, which is exactly why it must not depend on a dashboard setting.

  Idempotent: an already-`open` bounty returns `{:ok, bounty}` unchanged.
  """
  def mark_funded(session_id, payment_status \\ nil)

  def mark_funded(session_id, payment_status)
      when is_binary(session_id) and payment_status not in [nil, "paid"] do
    _ = session_id
    {:error, :payment_not_completed}
  end

  def mark_funded(session_id, _payment_status) when is_binary(session_id) do
    # Stripe retries deliveries, so the state is decided under a lock: two
    # concurrent deliveries must produce one funding event, not two.
    Repo.transaction(fn ->
      locked =
        Repo.one(
          from row in Bounty,
            where: row.stripe_checkout_session_id == ^session_id,
            lock: "FOR UPDATE"
        )

      fund_locked(locked, session_id)
    end)
    |> case do
      {:ok, bounty} -> {:ok, bounty}
      {:error, reason} -> {:error, reason}
    end
  end

  defp fund_locked(locked, session_id) do
    case locked do
      nil ->
        Repo.rollback(:not_found)

      %Bounty{status: "open"} = bounty ->
        bounty

      %Bounty{status: "pending_funding"} = bounty ->
        fund_pending!(bounty, session_id)

      %Bounty{} ->
        Repo.rollback(:invalid_state)
    end
  end

  defp fund_pending!(bounty, session_id) do
    bounty =
      bounty
      |> Ecto.Changeset.change(status: "open")
      |> Repo.update!()

    record_audit!(Scope.for_system(), :bounty_funded, bounty, %{
      from_state: "pending_funding",
      to_state: "open",
      metadata: %{"stripe_checkout_session_id" => session_id}
    })

    bounty
  end

  ## Claims

  @doc """
  Claims an open bounty for the scope's account.

  Creates the backing Work task, auto-publishes it, and claims it for the
  hunter - all in one transaction with the `bounty_claims` row and the bounty
  status flip.
  """
  def claim_bounty(%Scope{account: %Account{} = account} = scope, %Bounty{} = bounty) do
    with :ok <- active_standing(scope) do
      Repo.transaction(fn ->
        locked =
          Repo.one!(from row in Bounty, where: row.id == ^bounty.id, lock: "FOR UPDATE")

        cond do
          locked.status != "open" ->
            Repo.rollback(:not_open)

          locked.sponsor_account_id == account.id ->
            Repo.rollback(:own_bounty)

          true ->
            case work_target(locked) do
              {:ok, repository, task_attrs} ->
                task = create_and_claim_task!(repository, scope, account, task_attrs)

                claim =
                  %BountyClaim{}
                  |> BountyClaim.changeset(%{
                    bounty_id: locked.id,
                    account_id: account.id,
                    review_task_id: task.id,
                    status: "claimed"
                  })
                  |> Repo.insert!()

                locked
                |> Ecto.Changeset.change(status: "claimed")
                |> Repo.update!()

                record_audit!(scope, :bounty_claimed, locked, %{
                  from_state: "open",
                  to_state: "claimed",
                  metadata: %{"claim_id" => claim.id, "review_task_id" => task.id}
                })

                claim

              {:error, reason} ->
                Repo.rollback(reason)
            end
        end
      end)
    end
  end

  def claim_bounty(_scope, _bounty), do: {:error, :unauthorized}

  defp create_and_claim_task!(repository, scope, account, task_attrs) do
    case Work.create_task(repository, account, task_attrs) do
      {:ok, task} ->
        now = DateTime.utc_now()

        # Platform auto-publish: bounty tasks skip the steward/moderator
        # publish gate and land straight on the public queue, already claimed.
        task =
          task
          |> Ecto.Changeset.change(
            status: "open",
            visibility: "public",
            published_at: now,
            disclosed_at: now
          )
          |> Repo.update!()

        case Work.claim_task(task, scope) do
          {:ok, claimed} -> claimed
          {:error, reason} -> Repo.rollback(reason)
        end

      {:error, reason} ->
        Repo.rollback(reason)
    end
  end

  # Resolves the repository + Work task attributes backing a bounty target.
  defp work_target(%Bounty{target_type: "repository"} = bounty) do
    repository = Repo.get!(Repository, bounty.repository_id)

    with {:ok, commit_sha} <- head_commit_sha(repository) do
      {:ok, repository,
       %{
         "kind" => "code_review",
         "capability" => "human",
         "commit_sha" => commit_sha,
         "title" => task_title(bounty),
         "description" => bounty.description
       }}
    end
  end

  defp work_target(%Bounty{target_type: "finding"} = bounty) do
    finding =
      CanonicalFinding
      |> Repo.get!(bounty.canonical_finding_id)
      |> Repo.preload(:repository)

    scan_id =
      Repo.one(
        from occurrence in Finding,
          where: occurrence.canonical_finding_id == ^finding.id,
          order_by: [desc: occurrence.id],
          limit: 1,
          select: occurrence.scan_id
      )

    attrs = %{
      "capability" => "human",
      "commit_sha" => finding.last_seen_commit_sha,
      "title" => task_title(bounty),
      "description" => bounty.description
    }

    if scan_id do
      {:ok, finding.repository,
       attrs |> Map.put("kind", "verify_findings") |> Map.put("target_review_id", scan_id)}
    else
      {:ok, finding.repository, Map.put(attrs, "kind", "code_review")}
    end
  end

  defp work_target(%Bounty{target_type: "infestation"} = bounty) do
    %{entries: entries} = Infestations.list_pattern_repos_page(bounty.pattern_key, limit: 20)

    repository =
      entries
      |> Enum.map(& &1.repository_id)
      |> Enum.find_value(fn repository_id ->
        case Repo.get(Repository, repository_id) do
          %Repository{listing_status: "listed"} = repository -> repository
          _other -> nil
        end
      end)

    with %Repository{} <- repository,
         {:ok, commit_sha} <- head_commit_sha(repository) do
      {:ok, repository,
       %{
         "kind" => "code_review",
         "capability" => "human",
         "commit_sha" => commit_sha,
         "title" => task_title(bounty),
         "description" => bounty.description
       }}
    else
      _other -> {:error, :target_unavailable}
    end
  end

  defp task_title(bounty), do: String.slice("Contract: #{bounty.title}", 0, 160)

  defp head_commit_sha(%Repository{} = repository) do
    if Repository.hosted?(repository) do
      case Repo.one(
             from scan in Scan,
               where: scan.repository_id == ^repository.id,
               order_by: [desc: scan.id],
               limit: 1,
               select: scan.commit_sha
           ) do
        nil -> {:error, :target_unavailable}
        commit_sha -> {:ok, commit_sha}
      end
    else
      case Tarakan.GitHub.fetch_branch_head(
             repository.owner,
             repository.name,
             repository.default_branch || "main"
           ) do
        {:ok, %{sha: commit_sha}} -> {:ok, commit_sha}
        {:error, _reason} -> {:error, :target_unavailable}
      end
    end
  end

  @doc """
  Submits work for the scope account's claim.

  Delegates to `Work.submit_task/3`; on success the claim moves to
  `submitted` and waits for task acceptance / settlement.
  """
  def submit_claim(
        %Scope{account: %Account{id: account_id}} = scope,
        %BountyClaim{} = claim,
        attrs
      ) do
    claim = Repo.preload(claim, [:review_task, :bounty])

    cond do
      claim.account_id != account_id ->
        {:error, :unauthorized}

      claim.status != "claimed" ->
        {:error, :invalid_state}

      true ->
        case Work.submit_task(claim.review_task, scope, attrs) do
          {:ok, _task} ->
            claim
            |> Ecto.Changeset.change(status: "submitted")
            |> Repo.update()

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

  def submit_claim(_scope, _claim, _attrs), do: {:error, :unauthorized}

  ## Settlement

  @doc """
  Settles a claimed bounty, naming the winning hunter.

  The winner is the hunter whose backing Work task was accepted. A human
  moderator may also settle on a `submitted` claim (discretionary payout);
  the automatic sweep (`Scope.for_system/0`) only settles accepted tasks.

  Fiat bounties move to `payout_pending` (manual payout this iteration);
  credit bounties pay the net amount (gross × (1 - take rate), rounded) to
  the winner immediately and move to `paid`.
  """
  def settle_bounty(%Scope{} = scope, %Bounty{} = bounty) do
    with :ok <- Policy.authorize(scope, :moderate, bounty) do
      Repo.transaction(fn ->
        # Locked, like every other bounty mutation. Two settlements racing (a
        # moderator and the hourly sweep, or a Stripe retry) could each read
        # "claimed" and each pick a winner. The credit ledger's unique index is
        # per (kind, subject, *account*), so two settlements that chose
        # different claims would both succeed and the bounty would pay out
        # twice against one escrow.
        locked =
          from(row in Bounty, where: row.id == ^bounty.id, lock: "FOR UPDATE")
          |> Repo.one!()
          |> Repo.preload(claims: :review_task)

        if locked.status != "claimed", do: Repo.rollback(:invalid_state)

        case find_winning_claim(scope, locked.claims) do
          {:ok, winner_claim} ->
            finalize_settlement!(scope, locked, winner_claim)

          {:error, reason} ->
            Repo.rollback(reason)
        end
      end)
    end
  end

  def settle_bounty(_scope, _bounty), do: {:error, :unauthorized}

  defp find_winning_claim(scope, claims) do
    accepted =
      Enum.find(claims, fn claim ->
        claim.review_task && claim.review_task.status == "accepted"
      end)

    submitted =
      claims
      |> Enum.filter(&(&1.status == "submitted"))
      |> Enum.sort_by(& &1.id, :desc)
      |> List.first()

    cond do
      accepted -> {:ok, accepted}
      scope.authentication_method == :system -> {:error, :no_winning_claim}
      submitted -> {:ok, submitted}
      true -> {:error, :no_winning_claim}
    end
  end

  defp finalize_settlement!(scope, bounty, winner_claim) do
    final_status = if bounty.funding == "fiat", do: "payout_pending", else: "paid"

    bounty =
      bounty
      |> Ecto.Changeset.change(winner_account_id: winner_claim.account_id, status: final_status)
      |> Repo.update!()

    winner_claim
    |> Ecto.Changeset.change(status: "accepted")
    |> Repo.update!()

    # Losing active claims are rejected; the lease on their tasks expires on
    # its own via the ordinary Work claim window.
    Repo.update_all(
      from(claim in BountyClaim,
        where:
          claim.bounty_id == ^bounty.id and claim.id != ^winner_claim.id and
            claim.status in ["claimed", "submitted"]
      ),
      set: [status: "rejected"]
    )

    if bounty.funding == "credits" do
      winner = Repo.get!(Account, winner_claim.account_id)
      net = net_amount(bounty.credit_amount, bounty.take_rate)

      case Credits.credit(winner, :receive_bounty, {"bounty", bounty.id}, net) do
        {:ok, _entry} -> :ok
        {:error, :already_minted} -> :ok
        {:error, reason} -> Repo.rollback(reason)
      end
    end

    record_audit!(scope, :bounty_settled, bounty, %{
      to_state: final_status,
      metadata: %{
        "winner_account_id" => winner_claim.account_id,
        "claim_id" => winner_claim.id,
        "funding" => bounty.funding
      }
    })

    bounty
  end

  @doc "Marks a fiat bounty paid out (manual payout), `payout_pending` → `paid`."
  def mark_paid(%Scope{} = scope, %Bounty{} = bounty) do
    with :ok <- Policy.authorize(scope, :administer, bounty) do
      Repo.transaction(fn ->
        # The state check reads a locked row rather than the caller's struct:
        # this marks an out-of-band payout done, and two admins acting on the
        # same stale page would otherwise both write a `bounty_paid` event for
        # one payment.
        locked = Repo.one!(from row in Bounty, where: row.id == ^bounty.id, lock: "FOR UPDATE")

        if locked.status != "payout_pending", do: Repo.rollback(:invalid_state)

        paid =
          locked
          |> Ecto.Changeset.change(status: "paid")
          |> Repo.update!()

        record_audit!(scope, :bounty_paid, paid, %{
          from_state: "payout_pending",
          to_state: "paid"
        })

        paid
      end)
    end
  end

  def mark_paid(_scope, _bounty), do: {:error, :unauthorized}

  ## Cancellation & expiry

  @doc """
  Cancels a bounty before any claim.

  The sponsor may cancel while no claims exist; moderators may cancel any
  unclaimed bounty. Funded fiat bounties are refunded via Stripe; credit
  escrows are returned to the sponsor in full.
  """
  def cancel_bounty(%Scope{account: %Account{}} = scope, %Bounty{} = bounty) do
    bounty = Repo.preload(bounty, :claims)

    cond do
      scope.account_id == bounty.sponsor_account_id and bounty.claims != [] ->
        {:error, :has_claims}

      scope.account_id != bounty.sponsor_account_id and
          Policy.authorize(scope, :moderate, bounty) != :ok ->
        {:error, :unauthorized}

      bounty.status not in ["pending_funding", "open"] ->
        {:error, :invalid_state}

      true ->
        do_cancel(scope, bounty, "cancelled")
    end
  end

  def cancel_bounty(_scope, _bounty), do: {:error, :unauthorized}

  defp do_cancel(scope, bounty, final_status) do
    Repo.transaction(fn ->
      locked = Repo.one!(from row in Bounty, where: row.id == ^bounty.id, lock: "FOR UPDATE")

      if locked.status not in ["pending_funding", "open"], do: Repo.rollback(:invalid_state)

      case refund_funding(locked) do
        :ok ->
          event = if final_status == "cancelled", do: :bounty_cancelled, else: :bounty_expired

          bounty =
            locked
            |> Ecto.Changeset.change(status: final_status)
            |> Repo.update!()

          record_audit!(scope, event, bounty, %{
            from_state: locked.status,
            to_state: final_status
          })

          bounty

        {:error, reason} ->
          Repo.rollback(reason)
      end
    end)
  end

  # Returns escrowed funds for an unclaimed bounty. Unfunded fiat bounties
  # (pending_funding) have nothing to refund.
  defp refund_funding(%Bounty{funding: "credits", status: "open"} = bounty) do
    sponsor = Repo.get!(Account, bounty.sponsor_account_id)

    case Credits.credit(sponsor, :receive_bounty, {"bounty", bounty.id}, bounty.credit_amount) do
      {:ok, _entry} -> :ok
      {:error, :already_minted} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  defp refund_funding(%Bounty{funding: "fiat", status: "open"} = bounty) do
    with {:ok, %{"payment_intent" => payment_intent}} when is_binary(payment_intent) <-
           Stripe.request(:get, "/v1/checkout/sessions/#{bounty.stripe_checkout_session_id}"),
         {:ok, _refund} <-
           Stripe.request(:post, "/v1/refunds", payment_intent: payment_intent) do
      :ok
    else
      {:ok, response} -> {:error, {:stripe_error, response}}
      {:error, reason} -> {:error, reason}
    end
  end

  defp refund_funding(%Bounty{}), do: :ok

  ## Sweeps (hourly, via Tarakan.Market.SweepWorker)

  @doc """
  Settles claimed bounties whose backing Work task was accepted.

  Returns the number of bounties settled.
  """
  def settle_accepted do
    scope = Scope.for_system()

    Bounty
    |> where([bounty], bounty.status == "claimed")
    |> Repo.all()
    |> Enum.count(fn bounty ->
      match?({:ok, _}, settle_bounty(scope, bounty))
    end)
  end

  @doc """
  Expires stale bounties: unfunded fiat bounties past the Checkout window are
  cancelled; open bounties past `expires_at` with no claims are expired and
  refunded. Returns `{cancelled, expired}` counts.
  """
  def expire_stale(now \\ DateTime.utc_now()) do
    scope = Scope.for_system()
    pending_cutoff = DateTime.add(now, -@pending_funding_ttl_seconds, :second)

    stale_pending =
      Repo.all(
        from bounty in Bounty,
          where: bounty.status == "pending_funding" and bounty.inserted_at < ^pending_cutoff
      )

    expired_open =
      Repo.all(
        from bounty in Bounty,
          where:
            bounty.status == "open" and not is_nil(bounty.expires_at) and
              bounty.expires_at < ^now,
          preload: [:claims]
      )
      |> Enum.reject(fn bounty -> bounty.claims != [] end)

    cancelled =
      Enum.count(stale_pending, fn bounty ->
        match?({:ok, _}, do_cancel(scope, bounty, "cancelled"))
      end)

    expired =
      Enum.count(expired_open, fn bounty ->
        match?({:ok, _}, do_cancel(scope, bounty, "expired"))
      end)

    {cancelled, expired}
  end

  ## Helpers

  @doc "The platform take rate configured for new bounties."
  def take_rate do
    :tarakan
    |> Application.get_env(:market_take_rate, 0.10)
    |> Decimal.from_float()
  end

  @doc "Net payout for a gross amount after the take rate, rounded."
  def net_amount(gross, %Decimal{} = rate) when is_integer(gross) do
    Decimal.sub(Decimal.new(1), rate)
    |> Decimal.mult(gross)
    |> Decimal.round(0)
    |> Decimal.to_integer()
  end

  defp active_standing(%Scope{account_state: "active"}), do: :ok
  defp active_standing(_scope), do: {:error, :unauthorized}

  defp resolve_target(attrs) do
    case attrs["target_type"] do
      "repository" -> resolve_repository_target(attrs["target_ref"])
      "infestation" -> resolve_infestation_target(attrs["target_ref"])
      "finding" -> resolve_finding_target(attrs["target_ref"])
      other -> {:error, {:invalid_target_type, other}}
    end
  end

  defp resolve_repository_target(ref) when is_binary(ref) do
    case normalize_repository_ref(ref) do
      # A bare slug is host-ambiguous: hosted repos live at `owner/name` too.
      [owner, name] ->
        fetch_repository_target(Repositories.get_repository_by_slug(owner, name))

      [host, owner, name] ->
        fetch_repository_target(Repositories.get_repository(host, owner, name))

      _other ->
        {:error, :target_not_found}
    end
  end

  defp resolve_repository_target(_ref), do: {:error, :target_not_found}

  # Accepts `owner/name`, `host/owner/name`, and anything pasted from a browser:
  # a Tarakan path, a source URL, with or without a `.git` suffix.
  defp normalize_repository_ref(ref) do
    ref
    |> String.trim()
    |> String.replace(~r{^[a-z][a-z0-9+.-]*://}i, "")
    |> String.split(["?", "#"], parts: 2)
    |> hd()
    |> String.replace_suffix(".git", "")
    |> String.split("/", trim: true)
    |> case do
      [host, owner, name | _rest] -> [host, owner, name]
      segments -> segments
    end
  end

  defp fetch_repository_target(repository) do
    case repository do
      %Repository{listing_status: "listed"} = repository ->
        {:ok,
         %{
           type: "repository",
           repository_id: repository.id,
           pattern_key: nil,
           canonical_finding_id: nil
         }}

      %Repository{} ->
        {:error, :target_not_public}

      nil ->
        {:error, :target_not_found}
    end
  end

  defp resolve_infestation_target(pattern_key) when is_binary(pattern_key) do
    case Infestations.get_infestation(pattern_key) do
      nil ->
        {:error, :target_not_found}

      _infestation ->
        {:ok,
         %{
           type: "infestation",
           repository_id: nil,
           pattern_key: pattern_key,
           canonical_finding_id: nil
         }}
    end
  end

  defp resolve_infestation_target(_ref), do: {:error, :target_not_found}

  defp resolve_finding_target(public_id) when is_binary(public_id) do
    case Repo.get_by(CanonicalFinding, public_id: public_id) do
      nil ->
        {:error, :target_not_found}

      finding ->
        finding = Repo.preload(finding, :repository)

        if finding.repository.listing_status == "listed" do
          {:ok,
           %{
             type: "finding",
             repository_id: nil,
             pattern_key: nil,
             canonical_finding_id: finding.id
           }}
        else
          {:error, :target_not_public}
        end
    end
  end

  defp resolve_finding_target(_ref), do: {:error, :target_not_found}

  defp record_audit!(scope, event, subject, attrs) do
    case Audit.record(scope, event, subject, attrs) do
      {:ok, _event} -> :ok
      {:error, reason} -> Repo.rollback({:audit_failed, reason})
    end
  end

  defp stringify_keys(attrs) when is_map(attrs) do
    Map.new(attrs, fn
      {key, value} when is_atom(key) -> {Atom.to_string(key), value}
      {key, value} when is_binary(key) -> {key, value}
    end)
  end

  defp present_or(nil, default), do: default
  defp present_or("", default), do: default
  defp present_or(value, _default), do: value

  defp to_integer(nil), do: nil
  defp to_integer(value) when is_integer(value), do: value

  defp to_integer(value) when is_binary(value) do
    case Integer.parse(String.trim(value)) do
      {integer, ""} -> integer
      _other -> nil
    end
  end
end
