defmodule Microwaveprop.Aprs do
  @moduledoc """
  Read-only access to aprs.me's `packets` table for 144 MHz calibration.

  This module never writes, never schemas, never mirrors. Each call
  issues a SELECT against `Microwaveprop.AprsRepo` (which connects to
  aprs.me's database) and returns raw maps for downstream parsing by
  `Microwaveprop.Aprs.PathParser` and consumption by the
  `Calibrate.Aprs144` mix task.

  aprs.me retains 24 h of packet history in production
  (`PACKET_RETENTION_DAYS=1`), so any "historical" query is bounded by
  that window.

  Tests run against a local `aprsme_test` database whose schema mirrors
  aprs.me's partitioned `packets` table; rows are inserted via raw SQL
  in a setup block under the `Ecto.Adapters.SQL.Sandbox`.

  > #### Error semantics {: .warning}
  >
  > Both queries call `Ecto.Adapters.SQL.query!/3` and will raise on
  > connection or SQL failures. Today the only caller is the
  > `Calibrate.Aprs144` mix task, where a hard exit is the right
  > ergonomic. Async callers (`Task.async_stream`, LiveView
  > `start_async`, etc.) MUST catch and log per the project's
  > swallowed-error rule in CLAUDE.md.
  """

  alias Ecto.Adapters.SQL
  alias Microwaveprop.AprsRepo

  @type packet_row :: %{
          id: binary(),
          sender: String.t(),
          base_callsign: String.t(),
          lat: float(),
          lon: float(),
          path: String.t(),
          received_at: DateTime.t()
        }

  @type position :: {lat :: float(), lon :: float(), last_heard_at :: DateTime.t()}

  @recent_packets_sql """
  SELECT id, sender, base_callsign, lat, lon, path, received_at
    FROM packets
    WHERE has_position = true
      AND lat IS NOT NULL
      AND lon IS NOT NULL
      AND path IS NOT NULL
      AND path <> ''
      AND is_item = false
      AND is_object = false
      AND sender IS NOT NULL
      AND received_at >= $1
    ORDER BY received_at ASC
    LIMIT $2
  """

  @station_positions_sql """
  SELECT DISTINCT ON (base_callsign) base_callsign, lat, lon, received_at
    FROM packets
    WHERE base_callsign = ANY($1)
      AND has_position = true
      AND lat IS NOT NULL
      AND lon IS NOT NULL
    ORDER BY base_callsign, received_at DESC
  """

  @doc """
  Returns recent position-bearing packets with non-empty paths, oldest first.

  Filters out item/object packets and rows missing position or path.
  Raises on connection or SQL failure — see moduledoc on async-context safety.

  ## Options

    * `:since` — `%DateTime{}`. Default `now - 1h`.
    * `:limit` — non-neg integer. Default `50_000`.
  """
  @spec recent_packets_with_paths(keyword()) :: [packet_row()]
  def recent_packets_with_paths(opts \\ []) do
    since = Keyword.get_lazy(opts, :since, fn -> DateTime.add(DateTime.utc_now(), -3600, :second) end)
    limit = Keyword.get(opts, :limit, 50_000)

    # Force the bound to UTC so a non-UTC %DateTime{} doesn't silently
    # match a wrong window after `to_naive` strips the tz tag.
    naive_since = since |> DateTime.shift_zone!("Etc/UTC") |> DateTime.to_naive()

    %Postgrex.Result{rows: rows} =
      SQL.query!(AprsRepo, @recent_packets_sql, [naive_since, limit])

    Enum.map(rows, &decode_packet_row/1)
  end

  @doc """
  Returns the most recent known position for each callsign in `callsigns`.

  Callsigns with no positioned packets simply don't appear in the result.
  Empty input list short-circuits to `%{}` without querying.

  Raises on connection or SQL failure — see moduledoc on async-context safety.
  """
  @spec station_positions([String.t()]) :: %{String.t() => position()}
  def station_positions([]), do: %{}

  def station_positions(callsigns) when is_list(callsigns) do
    %Postgrex.Result{rows: rows} = SQL.query!(AprsRepo, @station_positions_sql, [callsigns])

    Map.new(rows, fn [base_callsign, lat, lon, received_at] ->
      {base_callsign, {to_float(lat), to_float(lon), to_utc_datetime(received_at)}}
    end)
  end

  defp decode_packet_row([id, sender, base_callsign, lat, lon, path, received_at]) do
    %{
      id: id,
      sender: sender,
      base_callsign: base_callsign,
      lat: Decimal.to_float(lat),
      lon: Decimal.to_float(lon),
      path: path,
      received_at: DateTime.from_naive!(received_at, "Etc/UTC")
    }
  end

  defp to_float(%Decimal{} = d), do: Decimal.to_float(d)

  defp to_utc_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC")
end
