defmodule TarakanWeb.Plugs.ApiRateLimit do
  @moduledoc """
  Applies independent IP, account, token, and mutation limits to API traffic.

  Every request counts against the per-IP bucket, including Bearer traffic:
  a request carrying a bogus `Authorization: Bearer …` token must not dodge
  throttling by failing authentication before the actor buckets run. Valid
  Bearer requests additionally count against the actor buckets after auth
  (the double count is deliberate; both buckets share the request limit).

  IPv6 clients bucket by their /64 prefix. Platform admins and moderators
  are not rate-limited (mass registration / operational tooling).

  Authenticated actors get `@actor_multiplier` × the configured limits. The
  IP bucket stays at the base limit, so the actor ceiling rises without
  raising the network one.
  """

  import Plug.Conn
  import Phoenix.Controller, only: [json: 2]

  alias Tarakan.RateLimiter
  alias TarakanWeb.Plugs.ClientIp

  @defaults [request_limit: 120, mutation_limit: 20, window_seconds: 60]
  @actor_multiplier 4
  @unlimited_roles ~w(admin moderator)

  def init(opts) do
    configured = Application.get_env(:tarakan, __MODULE__, [])
    @defaults |> Keyword.merge(configured) |> Keyword.merge(opts)
  end

  def call(conn, opts) do
    mode = Keyword.fetch!(opts, :mode)

    # Admins/moderators (after auth) are unlimited for operational mass import.
    if unlimited_actor?(conn) do
      conn
    else
      request_limit = Keyword.fetch!(opts, :request_limit)
      mutation_limit = Keyword.fetch!(opts, :mutation_limit)
      window_seconds = Keyword.fetch!(opts, :window_seconds)

      # Pipelines that serve different traffic get separate namespaces, so a
      # hotlinked public asset cannot spend the budget a contributor's API
      # client needs from the same network.
      bucket = Keyword.get(opts, :bucket, :api_ip)

      checks =
        case mode do
          :ip -> [{{bucket, remote_ip_bucket(conn)}, request_limit}]
          :actor -> actor_checks(conn, request_limit, mutation_limit)
        end

      case Enum.find_value(checks, &limited?(&1, window_seconds)) do
        nil -> conn
        retry_after -> reject(conn, retry_after)
      end
    end
  end

  defp unlimited_actor?(conn) do
    case conn.assigns[:current_scope] do
      %{platform_role: role} when role in @unlimited_roles -> true
      %{account: %{platform_role: role}} when role in @unlimited_roles -> true
      _ -> false
    end
  end

  defp actor_checks(conn, request_limit, mutation_limit) do
    scope = conn.assigns[:current_scope]
    account_id = scope && scope.account_id
    token_id = scope && scope.token_id

    {request_limit, mutation_limit} =
      {request_limit * @actor_multiplier, mutation_limit * @actor_multiplier}

    base = [
      {{:api_account, account_id || :anonymous}, request_limit},
      {{:api_token, token_id || :session}, request_limit}
    ]

    if conn.method in ~w(POST PUT PATCH DELETE) do
      [
        {{:api_account_mutation, account_id || :anonymous}, mutation_limit},
        {{:api_token_mutation, token_id || :session}, mutation_limit}
        | base
      ]
    else
      base
    end
  end

  defp limited?({key, limit}, window_seconds) do
    case RateLimiter.check(key, limit, window_seconds) do
      :ok -> nil
      {:error, _reason, retry_after} -> retry_after
    end
  end

  defp reject(conn, retry_after) do
    conn
    |> put_resp_header("retry-after", Integer.to_string(retry_after))
    |> put_status(:too_many_requests)
    |> json(%{error: "rate limit exceeded", retry_after: retry_after})
    |> halt()
  end

  defp remote_ip_bucket(conn), do: ClientIp.remote_ip_bucket(conn)
end
