defmodule Microwaveprop.Aprs.PathParser do
  @moduledoc """
  Parses TNC2-style APRS digipeater paths into verified RF hops.

  An APRS packet's `path` column (as stored by aprs.me) is a comma-
  separated chain of digipeater callsigns and routing aliases. Tokens
  with a trailing `*` ("used flag") indicate stations that actually
  digipeated the frame on RF — a verified receive.

  Walk left-to-right; each "real callsign + used flag" is a verified
  hop from the *current source* to that callsign, then the source
  advances to that callsign for any subsequent hops.

  We deliberately ignore:
    * Q-constructs (`qAR`, `qAC`, `qAS`, `qAo`, `qAX`, `qAZ`, `qAI`)
    * `TCPIP*`, `TCPXX*` (internet-only injection)
    * Routing aliases (`WIDE1-N`, `WIDE2-N`, `TRACE[12]-N`, `RELAY`,
      `ECHO`, `GATE`, `NOGATE`, `RFONLY`)

  When the used flag lands on an alias (e.g. `WIDE1*` from
  `WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10`), we know A digi handled the
  frame but cannot attribute the hop to a callsign — we skip without
  emitting a hop.
  """

  alias Microwaveprop.Geo

  @type position :: {lat :: number(), lon :: number()}
  @type station_lookup :: (String.t() -> position() | nil)
  @type hop :: %{
          src_callsign: String.t(),
          src_pos: position(),
          dst_callsign: String.t(),
          dst_pos: position(),
          distance_km: float(),
          heard_at: DateTime.t()
        }

  # WIDEn-N / TRACEn-N allow n and N up to 7 per the APRS New-N paradigm.
  @alias_regex ~r/^(WIDE[1-7](-[1-7])?|TRACE[1-7](-[1-7])?|RELAY|ECHO|GATE|NOGATE|RFONLY)$/
  @callsign_regex ~r/^[A-Z0-9]{1,2}[0-9][A-Z]{1,3}(-[0-9]{1,2})?$/
  # Explicit allowlist of real APRS-IS Q-constructs (RFC qAR/qAC/qAo/etc.)
  # rather than `qA[A-Za-z]` which would over-match `qAa`, `qAd`, …
  @q_constructs ~w(qAC qAX qAU qAo qAO qAS qAr qAR qAZ qAI)

  @doc """
  Returns `true` if `token` matches the APRS callsign shape used by this
  parser (1-2 alpha/digit prefix, one digit, 1-3 alpha suffix, optional
  -SSID 0..99). Exposed so callers (e.g. the calibration Mix task) can
  pre-filter path tokens with the same rule the parser itself applies.
  """
  @spec valid_callsign?(String.t()) :: boolean()
  def valid_callsign?(token) when is_binary(token), do: Regex.match?(@callsign_regex, token)

  @spec parse_hops(
          sender_callsign :: String.t(),
          sender_pos :: position(),
          path_string :: String.t(),
          heard_at :: DateTime.t(),
          station_lookup :: station_lookup()
        ) :: [hop()]
  def parse_hops(sender_callsign, sender_pos, path_string, heard_at, station_lookup)
      when is_binary(sender_callsign) and is_binary(path_string) and is_function(station_lookup, 1) do
    path_string
    |> String.split(",", trim: true)
    |> Enum.map(&String.trim/1)
    |> Enum.reduce({{sender_callsign, sender_pos}, []}, fn token, {source, hops} ->
      process_token(token, source, hops, heard_at, station_lookup)
    end)
    |> elem(1)
    |> Enum.reverse()
  end

  defp process_token("", source, hops, _heard_at, _lookup), do: {source, hops}

  defp process_token(token, source, hops, heard_at, lookup) do
    used? = String.ends_with?(token, "*")
    callsign = if used?, do: String.trim_trailing(token, "*"), else: token

    callsign
    |> classify()
    |> handle(callsign, used?, source, hops, heard_at, lookup)
  end

  defp classify(token) do
    cond do
      token in @q_constructs -> :q_construct
      token in ["TCPIP", "TCPXX"] -> :internet
      Regex.match?(@alias_regex, token) -> :alias
      Regex.match?(@callsign_regex, token) -> :callsign
      true -> :unknown
    end
  end

  defp handle(:callsign, callsign, true, source, hops, heard_at, lookup) do
    case lookup.(callsign) do
      nil ->
        # Used flag present but no known position — drop, do NOT advance source.
        {source, hops}

      dst_pos ->
        {src_callsign, src_pos} = source

        if self_loop?(src_callsign, callsign) do
          {source, hops}
        else
          hop = build_hop(src_callsign, src_pos, callsign, dst_pos, heard_at)
          {{callsign, dst_pos}, [hop | hops]}
        end
    end
  end

  defp handle(:callsign, _callsign, false, source, hops, _heard_at, _lookup) do
    # Real callsign without used flag — routing target, did not digipeat.
    {source, hops}
  end

  defp handle(_other, _callsign, _used?, source, hops, _heard_at, _lookup) do
    # q_construct, internet, alias (used or not), unknown — never emit a hop.
    {source, hops}
  end

  defp self_loop?(src_callsign, dst_callsign) do
    src_callsign == dst_callsign
  end

  defp build_hop(src_callsign, src_pos, dst_callsign, dst_pos, heard_at) do
    {src_lat, src_lon} = src_pos
    {dst_lat, dst_lon} = dst_pos

    %{
      src_callsign: src_callsign,
      src_pos: src_pos,
      dst_callsign: dst_callsign,
      dst_pos: dst_pos,
      distance_km: Geo.haversine_km(src_lat, src_lon, dst_lat, dst_lon),
      heard_at: heard_at
    }
  end
end
