defmodule WhoNeedHelp.Push.HTTPAdapter do
  @moduledoc """
  HTTP boundary used by the local external-service drill.

  It is not an FCM or APNs implementation. Every retry reuses the caller's
  idempotency key so a compatible provider can deduplicate an ambiguous result.
  """

  @behaviour WhoNeedHelp.Push

  @retryable_statuses [408, 425, 429, 500, 502, 503, 504]

  @impl true
  def deliver(notification, opts) do
    with {:ok, endpoint} <- fetch_binary(opts, :endpoint),
         {:ok, bearer_token} <- fetch_binary(opts, :bearer_token),
         {:ok, max_attempts} <- fetch_positive_integer(opts, :max_attempts, 1),
         {:ok, receive_timeout} <- fetch_positive_integer(opts, :receive_timeout),
         {:ok, connect_timeout} <- fetch_positive_integer(opts, :connect_timeout),
         {:ok, retry_delay_ms} <- fetch_non_negative_integer(opts, :retry_delay_ms, 0) do
      request_opts = [
        url: endpoint,
        json: notification,
        headers: [
          {"authorization", "Bearer #{bearer_token}"},
          {"idempotency-key", notification.idempotency_key}
        ],
        retry: false,
        receive_timeout: receive_timeout,
        connect_options: [timeout: connect_timeout]
      ]

      attempt(request_opts, max_attempts, retry_delay_ms, 1)
    end
  end

  defp attempt(request_opts, max_attempts, retry_delay_ms, attempt_number) do
    case Req.post(request_opts) do
      {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
        normalize_receipt(body)

      {:ok, %Req.Response{status: status, body: _body}}
      when status in @retryable_statuses and attempt_number < max_attempts ->
        wait(retry_delay_ms)
        attempt(request_opts, max_attempts, retry_delay_ms, attempt_number + 1)

      {:ok, %Req.Response{status: status, body: body}} when status in @retryable_statuses ->
        {:error, {:retry_exhausted, status, sanitize_body(body)}}

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, {:rejected, status, sanitize_body(body)}}

      {:error, _error} when attempt_number < max_attempts ->
        wait(retry_delay_ms)
        attempt(request_opts, max_attempts, retry_delay_ms, attempt_number + 1)

      {:error, error} ->
        {:error, {:transport, transport_reason(error)}}
    end
  end

  defp normalize_receipt(%{"id" => id} = body) when is_binary(id) and id != "" do
    {:ok, %{id: id, duplicate: Map.get(body, "duplicate", false) == true}}
  end

  defp normalize_receipt(_body), do: {:error, :invalid_receipt}

  defp sanitize_body(body) when is_map(body),
    do: Map.take(body, ["error", "message"])

  defp sanitize_body(_body), do: nil

  defp transport_reason(%Req.TransportError{reason: reason}), do: reason
  defp transport_reason(%{__struct__: module}), do: module

  defp fetch_binary(opts, key) do
    case Keyword.get(opts, key) do
      value when is_binary(value) and value != "" -> {:ok, value}
      _other -> {:error, {:invalid_option, key}}
    end
  end

  defp fetch_positive_integer(opts, key, default \\ nil) do
    case Keyword.get(opts, key, default) do
      value when is_integer(value) and value > 0 -> {:ok, value}
      _other -> {:error, {:invalid_option, key}}
    end
  end

  defp fetch_non_negative_integer(opts, key, default) do
    case Keyword.get(opts, key, default) do
      value when is_integer(value) and value >= 0 -> {:ok, value}
      _other -> {:error, {:invalid_option, key}}
    end
  end

  defp wait(0), do: :ok
  defp wait(milliseconds), do: Process.sleep(milliseconds)
end
