defmodule WhoNeedHelp.Pagination do
  @moduledoc """
  Small keyset-pagination primitives shared by database-backed lists.

  Cursors contain only the final row's UTC timestamp and UUID. They are
  validated before being used as query parameters and carry no authorization
  decision.
  """

  @default_limit 24
  @maximum_limit 100

  defmodule Page do
    @moduledoc false
    defstruct entries: [], next_cursor: nil
  end

  def limit(options, default \\ @default_limit) do
    options
    |> Keyword.get(:limit, default)
    |> normalize_limit(default)
  end

  def cursor(options) do
    case decode(Keyword.get(options, :after)) do
      {:ok, cursor} -> cursor
      :error -> nil
    end
  end

  def page(rows, limit, cursor_fields) when is_function(cursor_fields, 1) do
    entries = Enum.take(rows, limit)

    next_cursor =
      if length(rows) > limit do
        entries
        |> List.last()
        |> cursor_fields.()
        |> then(fn {timestamp, id} -> encode(timestamp, id) end)
      end

    %Page{entries: entries, next_cursor: next_cursor}
  end

  def encode(%DateTime{} = timestamp, id) do
    %{"at" => DateTime.to_iso8601(timestamp), "id" => to_string(id)}
    |> Jason.encode!()
    |> Base.url_encode64(padding: false)
  end

  def decode(nil), do: {:ok, nil}
  def decode(""), do: {:ok, nil}

  def decode(encoded) when is_binary(encoded) do
    with {:ok, json} <- Base.url_decode64(encoded, padding: false),
         {:ok, %{"at" => timestamp, "id" => id}} <- Jason.decode(json),
         {:ok, timestamp, 0} <- DateTime.from_iso8601(timestamp),
         {:ok, id} <- Ecto.UUID.cast(id) do
      {:ok, {timestamp, id}}
    else
      _ -> :error
    end
  end

  def decode(_encoded), do: :error

  defp normalize_limit(limit, _default)
       when is_integer(limit) and limit > 0 and limit <= @maximum_limit,
       do: limit

  defp normalize_limit(_limit, default), do: min(default, @maximum_limit)
end
