defmodule TarakanWeb.Webhooks.StripeController do
  @moduledoc """
  Stripe webhook receiver.

  Verifies the `Stripe-Signature` header against the raw request body (cached
  by `TarakanWeb.Endpoint.read_body/2`) and processes:

    * `checkout.session.completed` - mode `payment` funds the matching
      bounty; mode `subscription` activates the account's plan subscription
    * `customer.subscription.updated` / `customer.subscription.deleted` and
      `invoice.payment_failed` - sync the subscription lifecycle
      (`Tarakan.Billing`)

  Unknown events are acknowledged and ignored so Stripe does not retry them
  forever.
  """

  use TarakanWeb, :controller

  alias Tarakan.Billing
  alias Tarakan.Market

  require Logger

  @signature_tolerance_seconds 300

  def handle(conn, _params) do
    case verify_signature(conn) do
      :ok ->
        process_event(conn.params)
        json(conn, %{received: true})

      {:error, reason} ->
        conn
        |> put_status(:bad_request)
        |> json(%{error: to_string(reason)})
    end
  end

  # Checkout completion is shared by bounty funding (mode "payment") and
  # plan subscriptions (mode "subscription").
  defp process_event(%{
         "type" => "checkout.session.completed",
         "data" => %{"object" => %{"mode" => "subscription"} = session}
       }) do
    case Billing.handle_checkout_completed(session) do
      {:ok, _subscription} ->
        :ok

      {:error, reason} ->
        Logger.warning("stripe webhook: subscription checkout not applied: #{inspect(reason)}")
    end
  end

  # Completion is not payment. Delayed-notification methods complete the
  # session with `payment_status: "unpaid"` and settle later through
  # `checkout.session.async_payment_succeeded`, so the status is passed through
  # and the bounty stays pending until Stripe says the money arrived.
  defp process_event(%{
         "type" => "checkout.session.completed",
         "data" => %{"object" => %{"id" => session_id} = session}
       }) do
    fund_bounty(session_id, Map.get(session, "payment_status"))
  end

  defp process_event(%{
         "type" => "checkout.session.async_payment_succeeded",
         "data" => %{"object" => %{"id" => session_id}}
       }) do
    fund_bounty(session_id, "paid")
  end

  defp process_event(%{
         "type" => "checkout.session.async_payment_failed",
         "data" => %{"object" => %{"id" => session_id}}
       }) do
    Logger.warning("stripe webhook: async payment failed for #{session_id}; bounty stays pending")
    :ok
  end

  defp process_event(%{
         "type" => "customer.subscription.updated",
         "data" => %{"object" => object}
       }) do
    Billing.handle_subscription_updated(object)
    :ok
  end

  defp process_event(%{
         "type" => "customer.subscription.deleted",
         "data" => %{"object" => object}
       }) do
    Billing.handle_subscription_deleted(object)
    :ok
  end

  defp process_event(%{
         "type" => "invoice.payment_failed",
         "data" => %{"object" => object}
       }) do
    Billing.handle_invoice_payment_failed(object)
    :ok
  end

  defp process_event(_event), do: :ignored

  defp fund_bounty(session_id, payment_status) do
    case Market.mark_funded(session_id, payment_status) do
      {:ok, _bounty} ->
        :ok

      {:error, :payment_not_completed} ->
        Logger.info(
          "stripe webhook: session #{session_id} completed unpaid (#{inspect(payment_status)}); " <>
            "waiting for async payment confirmation"
        )

      {:error, reason} ->
        Logger.warning(
          "stripe webhook: could not fund bounty for #{session_id}: #{inspect(reason)}"
        )
    end
  end

  defp verify_signature(conn) do
    with {:ok, secret} <- webhook_secret(),
         {:ok, header} <- signature_header(conn),
         {:ok, raw_body} <- raw_body(conn),
         {:ok, timestamp, signatures} <- parse_signature_header(header),
         :ok <- check_timestamp(timestamp) do
      expected = sign(secret, timestamp, raw_body)

      if Enum.any?(signatures, &Plug.Crypto.secure_compare(expected, &1)) do
        :ok
      else
        {:error, :invalid_signature}
      end
    end
  end

  defp webhook_secret do
    case Application.get_env(:tarakan, :stripe_webhook_secret) do
      secret when is_binary(secret) and secret != "" -> {:ok, secret}
      _missing -> {:error, :webhook_not_configured}
    end
  end

  defp signature_header(conn) do
    case Plug.Conn.get_req_header(conn, "stripe-signature") do
      [header | _rest] -> {:ok, header}
      [] -> {:error, :missing_signature}
    end
  end

  defp raw_body(conn) do
    case conn.assigns[:raw_body] do
      body when is_binary(body) -> {:ok, body}
      _missing -> {:error, :missing_raw_body}
    end
  end

  # Header shape: t=1492774577,v1=5257a869e7ec...,v1=6ffbb59b...
  defp parse_signature_header(header) do
    pairs =
      for pair <- String.split(header, ","),
          [key, value] <- [String.split(pair, "=", parts: 2)],
          do: {key, value}

    timestamp = pairs |> Enum.find_value(fn {key, value} -> key == "t" && value end)

    signatures = for {"v1", value} <- pairs, do: value

    with timestamp when is_binary(timestamp) <- timestamp,
         {unix, ""} <- Integer.parse(timestamp),
         true <- signatures != [] do
      {:ok, unix, signatures}
    else
      _invalid -> {:error, :malformed_signature}
    end
  end

  defp check_timestamp(timestamp) do
    if abs(System.system_time(:second) - timestamp) <= @signature_tolerance_seconds do
      :ok
    else
      {:error, :timestamp_outside_tolerance}
    end
  end

  defp sign(secret, timestamp, raw_body) do
    :crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{raw_body}")
    |> Base.encode16(case: :lower)
  end
end
