defmodule Tarakan.Billing do
  @moduledoc """
  Managed-disclosure subscriptions and watchlists.

  There are no feature gates. The `enterprise` plan is a service retainer and
  unlocks nothing; watchlists, alerts, and the API are free for every account.
  Platform revenue is the `Tarakan.Market` take rate on settled bounties.

  ## Stripe lifecycle

  `ensure_checkout/2` creates a Checkout Session (mode `subscription`) with
  `client_reference_id` set to the account id and the chosen plan mirrored in
  session metadata. Webhook handlers below are called from
  `TarakanWeb.Webhooks.StripeController` and are all idempotent - they upsert
  by `stripe_subscription_id` (or `account_id`, which is unique) so Stripe
  retries converge:

      checkout.session.completed (mode=subscription) → active subscription
      customer.subscription.updated                   → sync status/plan/period end
      customer.subscription.deleted                   → canceled
      invoice.payment_failed                          → past_due
  """

  import Ecto.Query, warn: false

  alias Tarakan.Accounts.{Account, Scope}
  alias Tarakan.Billing.{Stripe, Subscription, Watchlist}
  alias Tarakan.Repo

  require Logger

  @plans ~w(enterprise)

  ## Plans & checkout

  @doc "Plans available for checkout. Only the managed-disclosure retainer."
  def plans, do: @plans

  @doc """
  Creates a Stripe Checkout Session for the scope's account on `plan`
  (`"enterprise"`) and returns `{:ok, checkout_url}`.
  """
  def ensure_checkout(%Scope{account: %Account{} = account}, plan) when plan in @plans do
    billing_url = TarakanWeb.Endpoint.url() <> "/accounts/billing"

    case Stripe.request(:post, "/v1/checkout/sessions", [
           {"mode", "subscription"},
           {"success_url", billing_url <> "?checkout=success"},
           {"cancel_url", billing_url <> "?checkout=cancelled"},
           # The webhook keys the upsert on this; never trust client input.
           {"client_reference_id", Integer.to_string(account.id)},
           {"metadata[plan]", plan},
           {"line_items[0][quantity]", "1"},
           {"line_items[0][price]", price_id_for(plan)}
         ]) do
      {:ok, %{"url" => checkout_url}} -> {:ok, checkout_url}
      {:ok, response} -> {:error, {:stripe_error, response}}
      {:error, reason} -> {:error, reason}
    end
  end

  def ensure_checkout(_scope, _plan), do: {:error, :invalid_plan}

  @doc "Configured Stripe price id for a plan."
  def price_id_for(plan) when plan in @plans do
    Application.get_env(:tarakan, String.to_existing_atom("stripe_price_#{plan}"))
  end

  @doc """
  Creates a Stripe Billing Portal session for the account's customer and
  returns `{:ok, portal_url}`.
  """
  def portal_session(%Account{} = account) do
    case subscription(account) do
      %Subscription{stripe_customer_id: customer_id} when is_binary(customer_id) ->
        return_url = TarakanWeb.Endpoint.url() <> "/accounts/billing"

        case Stripe.request(:post, "/v1/billing_portal/sessions", [
               {"customer", customer_id},
               {"return_url", return_url}
             ]) do
          {:ok, %{"url" => portal_url}} -> {:ok, portal_url}
          {:ok, response} -> {:error, {:stripe_error, response}}
          {:error, reason} -> {:error, reason}
        end

      _no_customer ->
        {:error, :no_subscription}
    end
  end

  ## Subscriptions

  @doc "The account's subscription, if any."
  def subscription(%Account{id: account_id}) do
    Repo.get_by(Subscription, account_id: account_id)
  end

  ## Webhook handlers (idempotent; called by Webhooks.StripeController)

  @doc """
  Upserts a subscription from a completed `mode=subscription` Checkout
  Session. The account comes from `client_reference_id` (set by
  `ensure_checkout/2`); the plan comes from the session metadata.
  """
  def handle_checkout_completed(%{"mode" => "subscription"} = session) do
    with account_id when is_integer(account_id) <- parse_account_id(session),
         %Account{} <- Repo.get(Account, account_id) do
      attrs = %{
        account_id: account_id,
        plan: present_or(get_in(session, ["metadata", "plan"]), "enterprise"),
        status: "active",
        stripe_customer_id: session["customer"],
        stripe_subscription_id: session["subscription"]
      }

      upsert_subscription(attrs)
    else
      _other ->
        Logger.warning("billing webhook: checkout session without a known account")
        {:error, :account_not_found}
    end
  end

  def handle_checkout_completed(_session), do: :ignored

  @doc "Syncs status/plan/period end from `customer.subscription.updated`."
  def handle_subscription_updated(%{"id" => stripe_subscription_id} = object) do
    case Repo.get_by(Subscription, stripe_subscription_id: stripe_subscription_id) do
      nil ->
        # Events can arrive before checkout.session.completed; acknowledge
        # without creating a row we cannot attribute to an account.
        Logger.info("billing webhook: unknown subscription #{stripe_subscription_id}")
        {:error, :not_found}

      %Subscription{} = subscription ->
        subscription
        |> Subscription.changeset(%{
          status: normalize_status(object["status"]),
          plan: plan_from_items(object) || subscription.plan,
          stripe_customer_id: object["customer"] || subscription.stripe_customer_id,
          current_period_end: period_end(object)
        })
        |> Repo.update()
    end
  end

  @doc "Marks the subscription `canceled` on `customer.subscription.deleted`."
  def handle_subscription_deleted(%{"id" => stripe_subscription_id}) do
    update_status_by_stripe_id(stripe_subscription_id, "canceled")
  end

  @doc "Marks the subscription `past_due` on `invoice.payment_failed`."
  def handle_invoice_payment_failed(%{"subscription" => stripe_subscription_id})
      when is_binary(stripe_subscription_id) do
    update_status_by_stripe_id(stripe_subscription_id, "past_due")
  end

  def handle_invoice_payment_failed(_invoice), do: :ignored

  # Idempotent upsert: keyed by stripe_subscription_id when known, else by
  # the (unique) account_id, so webhook replays and re-checkouts converge.
  defp upsert_subscription(attrs) do
    existing =
      case Map.get(attrs, :stripe_subscription_id) do
        stripe_id when is_binary(stripe_id) ->
          Repo.get_by(Subscription, stripe_subscription_id: stripe_id) ||
            Repo.get_by(Subscription, account_id: attrs.account_id)

        _missing ->
          Repo.get_by(Subscription, account_id: attrs.account_id)
      end

    case existing do
      nil ->
        %Subscription{}
        |> Subscription.changeset(attrs)
        |> Repo.insert()

      %Subscription{} = subscription ->
        subscription
        |> Subscription.changeset(
          Map.take(attrs, [
            :plan,
            :status,
            :stripe_customer_id,
            :stripe_subscription_id,
            :current_period_end
          ])
        )
        |> Repo.update()
    end
  end

  defp update_status_by_stripe_id(stripe_subscription_id, status) do
    case Repo.get_by(Subscription, stripe_subscription_id: stripe_subscription_id) do
      nil ->
        Logger.info("billing webhook: unknown subscription #{stripe_subscription_id}")
        {:error, :not_found}

      %Subscription{status: ^status} = subscription ->
        {:ok, subscription}

      %Subscription{} = subscription ->
        subscription
        |> Subscription.changeset(%{status: status})
        |> Repo.update()
    end
  end

  defp parse_account_id(%{"client_reference_id" => ref}) when is_binary(ref) do
    case Integer.parse(ref) do
      {account_id, ""} -> account_id
      _other -> nil
    end
  end

  defp parse_account_id(_session), do: nil

  # Stripe statuses collapse onto our four; anything unrecognized stays a
  # non-entitling "incomplete".
  defp normalize_status(status) when status in ["active", "trialing"], do: "active"
  defp normalize_status(status) when status in ["past_due", "unpaid"], do: "past_due"
  defp normalize_status("canceled"), do: "canceled"
  defp normalize_status(_other), do: "incomplete"

  # Plan follows the subscribed price; unknown prices keep the stored plan.
  defp plan_from_items(object) do
    price_id =
      get_in(object, ["items", "data", Access.at(0), "price", "id"]) ||
        get_in(object, ["plan", "id"])

    Enum.find(@plans, fn plan -> price_id != nil and price_id == price_id_for(plan) end)
  end

  defp period_end(object) do
    unix =
      object["current_period_end"] ||
        get_in(object, ["items", "data", Access.at(0), "current_period_end"])

    case unix do
      seconds when is_integer(seconds) -> DateTime.from_unix!(seconds)
      _other -> nil
    end
  end

  ## Watchlists

  @doc "The account's watchlists, newest first."
  def list_watchlists(%Account{id: account_id}) do
    Watchlist
    |> where([watchlist], watchlist.account_id == ^account_id)
    |> order_by([watchlist], desc: watchlist.id)
    |> Repo.all()
  end

  @doc "Creates a watchlist for the scope's account."
  def create_watchlist(%Scope{account: %Account{} = account}, attrs) do
    %Watchlist{account_id: account.id}
    |> Watchlist.changeset(attrs)
    |> Repo.insert()
  end

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

  @doc "Updates one of the scope account's own watchlists."
  def update_watchlist(%Scope{account: %Account{} = account}, %Watchlist{} = watchlist, attrs) do
    if watchlist.account_id == account.id do
      watchlist
      |> Watchlist.changeset(attrs)
      |> Repo.update()
    else
      {:error, :unauthorized}
    end
  end

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

  @doc "Deletes one of the scope account's own watchlists."
  def delete_watchlist(%Scope{account: %Account{} = account}, %Watchlist{} = watchlist) do
    if watchlist.account_id == account.id do
      Repo.delete(watchlist)
    else
      {:error, :unauthorized}
    end
  end

  def delete_watchlist(_scope, _watchlist), do: {:error, :unauthorized}

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