defmodule BDS.AI.HttpClient do
  @moduledoc """
  Req-based HTTP client for AI endpoints.

  Replaces the previous `:httpc` wrapper with explicit connect/receive
  timeouts, TLS verification via Req's defaults, and transient retries for
  idempotent GETs only — POSTs (chat completions) are never retried.

  The response contract is unchanged: `{:ok, %{status, headers, body}}` with
  downcased single-valued header names and the body as a raw binary (callers
  decode JSON themselves), or `{:error, reason}` where transport failures
  surface as plain reason atoms such as `:timeout` or `:econnrefused`.

  Config (`config :bds, BDS.AI.HttpClient`):

    * `:connect_timeout_ms` — TCP/TLS connect budget (default 5_000)
    * `:receive_timeout_ms` — response budget (default 120_000; generous
      because local LLM completions are slow)
    * `:get_max_retries` — transient retries for GETs (default 2)
    * `:retry_delay_ms` — constant delay between retries (default 500)
  """

  @default_connect_timeout_ms 5_000
  @default_receive_timeout_ms 120_000
  @default_get_max_retries 2
  @default_retry_delay_ms 500

  @spec request_timeout_ms() :: pos_integer()
  def request_timeout_ms do
    max(
      config(:connect_timeout_ms, @default_connect_timeout_ms),
      config(:receive_timeout_ms, @default_receive_timeout_ms)
    )
  end

  @spec get(String.t(), %{String.t() => String.t()}) ::
          {:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
  def get(url, headers) when is_binary(url) and is_map(headers) do
    [
      method: :get,
      url: url,
      headers: headers,
      retry: :transient,
      max_retries: config(:get_max_retries, @default_get_max_retries),
      retry_delay: fn _retry_count -> config(:retry_delay_ms, @default_retry_delay_ms) end,
      retry_log_level: false
    ]
    |> Keyword.merge(base_options())
    |> Req.request()
    |> normalize_result()
  end

  @spec post(String.t(), %{String.t() => String.t()}, binary()) ::
          {:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
  def post(url, headers, body)
      when is_binary(url) and is_map(headers) and is_binary(body) do
    [
      method: :post,
      url: url,
      headers: headers,
      body: body,
      # Completions are not idempotent; a retry could bill or generate twice.
      retry: false
    ]
    |> Keyword.merge(base_options())
    |> Req.request()
    |> normalize_result()
  end

  @doc """
  Streaming POST: body chunks of a 200 response are folded into `acc` via
  `reducer.(chunk, acc)` as they arrive; non-200 bodies are collected whole
  for error reporting. Returns the final accumulator alongside the response.

  Never retried (same reasoning as `post/3`), and `accept-encoding` is
  disabled so event-stream chunks arrive uncompressed. The request runs in
  the calling process — killing that process aborts the underlying
  connection, which is what makes mid-stream chat cancellation work.
  """
  @spec post_stream(String.t(), %{String.t() => String.t()}, binary(), acc, (binary(), acc ->
                                                                               acc)) ::
          {:ok, %{status: non_neg_integer(), headers: map(), body: binary()}, acc}
          | {:error, term()}
        when acc: term()
  def post_stream(url, headers, body, acc, reducer)
      when is_binary(url) and is_map(headers) and is_binary(body) and is_function(reducer, 2) do
    into = fn {:data, data}, {req, resp} ->
      resp =
        if resp.status == 200 do
          next_acc = reducer.(data, Req.Response.get_private(resp, :bds_stream_acc, acc))
          Req.Response.put_private(resp, :bds_stream_acc, next_acc)
        else
          %{resp | body: collected_body(resp.body) <> data}
        end

      {:cont, {req, resp}}
    end

    [
      method: :post,
      url: url,
      headers: headers,
      body: body,
      retry: false,
      compressed: false,
      into: into
    ]
    |> Keyword.merge(base_options())
    |> Req.request()
    |> case do
      {:ok, %Req.Response{} = resp} ->
        {:ok, %{status: resp.status, headers: normalize_headers(resp.headers), body: collected_body(resp.body)},
         Req.Response.get_private(resp, :bds_stream_acc, acc)}

      {:error, %Req.TransportError{reason: reason}} ->
        {:error, reason}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp collected_body(body) when is_binary(body), do: body
  defp collected_body(_body), do: ""

  defp base_options do
    [
      connect_options: [timeout: config(:connect_timeout_ms, @default_connect_timeout_ms)],
      receive_timeout: config(:receive_timeout_ms, @default_receive_timeout_ms),
      # Callers parse the body themselves; keep it a raw binary.
      decode_body: false
    ]
  end

  defp normalize_result({:ok, %Req.Response{status: status, headers: headers, body: body}}) do
    {:ok, %{status: status, headers: normalize_headers(headers), body: body}}
  end

  defp normalize_result({:error, %Req.TransportError{reason: reason}}), do: {:error, reason}
  defp normalize_result({:error, reason}), do: {:error, reason}

  # Req header names are already downcased; values arrive as lists.
  defp normalize_headers(headers) do
    Map.new(headers, fn {name, values} -> {name, Enum.join(List.wrap(values), ", ")} end)
  end

  defp config(key, default) do
    Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
  end
end
