defmodule WhoNeedHelp.Trust.RateLimiter do
  @moduledoc """
  Shared PostgreSQL-backed action limits.

  Policies are opt-in and supplied as a map whose values contain positive
  `limit` and `window_seconds` integers. No product thresholds are assumed.
  """

  import Ecto.Query

  alias WhoNeedHelp.Repo
  alias WhoNeedHelp.Trust.RateLimitBucket

  def check(action, scope) when is_atom(action), do: check(Atom.to_string(action), scope)

  def check(action, scope) when is_binary(action) do
    case policy(action) do
      nil ->
        {:ok, :not_configured}

      %{limit: limit, window_seconds: window_seconds} ->
        increment(action, scope, limit, window_seconds)
    end
  end

  def prune_expired do
    now = DateTime.utc_now(:second)
    Repo.delete_all(from bucket in RateLimitBucket, where: bucket.expires_at <= ^now)
  end

  defp policy(action) do
    policies = Application.get_env(:who_need_help, :rate_limit_policies, %{})

    raw =
      if is_map(policies) do
        Map.get(policies, action) || Map.get(policies, String.to_existing_atom(action))
      end

    case raw do
      %{limit: limit, window_seconds: window}
      when is_integer(limit) and limit > 0 and is_integer(window) and window > 0 ->
        %{limit: limit, window_seconds: window}

      %{"limit" => limit, "window_seconds" => window}
      when is_integer(limit) and limit > 0 and is_integer(window) and window > 0 ->
        %{limit: limit, window_seconds: window}

      _ ->
        nil
    end
  rescue
    ArgumentError -> nil
  end

  defp increment(action, scope, limit, window_seconds) do
    now = DateTime.utc_now(:second)
    unix = DateTime.to_unix(now)
    window_start = (div(unix, window_seconds) * window_seconds) |> DateTime.from_unix!()
    expires_at = DateTime.add(window_start, window_seconds, :second)
    scope_hash = :crypto.hash(:sha256, to_string(scope))

    {_count, [bucket]} =
      Repo.insert_all(
        RateLimitBucket,
        [
          %{
            id: Ecto.UUID.generate(),
            action: action,
            scope_hash: scope_hash,
            window_started_at: window_start,
            count: 1,
            expires_at: expires_at,
            inserted_at: now,
            updated_at: now
          }
        ],
        on_conflict: [inc: [count: 1], set: [expires_at: expires_at, updated_at: now]],
        conflict_target: [:action, :scope_hash, :window_started_at],
        returning: [:count]
      )

    if bucket.count <= limit do
      {:ok, %{count: bucket.count, limit: limit, resets_at: expires_at}}
    else
      {:error, :rate_limited}
    end
  end
end
