defmodule MicrowavepropWeb.LocationResolver do @moduledoc """ Parses user-supplied location input into a normalized lat/lon map. Accepted formats: * `"32.9, -96.8"` — decimal coordinates (lat, lon) * `"EM12kx"` — Maidenhead grid square (4–10 chars) * `"W5XYZ"` — amateur callsign (resolved via QRZ + cache) """ alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.Maidenhead @type kind :: :coordinates | :grid | :callsign @type resolved :: %{ required(:lat) => float(), required(:lon) => float(), required(:label) => String.t(), required(:kind) => kind(), optional(:grid) => String.t() | nil } @doc """ Resolves an input string. Returns `{:ok, resolved}`, `{:error, msg}`, or `:empty` for nil/blank input — callers decide whether `:empty` is an error or a no-op. """ @spec resolve(String.t() | nil) :: {:ok, resolved()} | {:error, String.t()} | :empty def resolve(nil), do: :empty def resolve(input) when is_binary(input) do trimmed = String.trim(input) cond do trimmed == "" -> :empty coordinate_pair?(trimmed) -> resolve_coordinates(trimmed) maidenhead?(trimmed) -> resolve_grid(trimmed) true -> resolve_callsign(trimmed) end end @doc "True when `input` parses as a `lat, lon` decimal pair." @spec coordinate_pair?(String.t()) :: boolean() def coordinate_pair?(input) do case String.split(input, ",", parts: 2) do [a, b] -> match?({_, _}, Float.parse(String.trim(a))) and match?({_, _}, Float.parse(String.trim(b))) _ -> false end end @doc "True when `input` looks like a Maidenhead grid square (4-10 chars, AA00 prefix)." @spec maidenhead?(String.t()) :: boolean() def maidenhead?(input) do len = String.length(input) len >= 4 and len <= 10 and Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) end defp resolve_coordinates(input) do [lat_s, lon_s] = String.split(input, ",", parts: 2) {lat, _} = Float.parse(String.trim(lat_s)) {lon, _} = Float.parse(String.trim(lon_s)) {:ok, %{ lat: lat, lon: lon, label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}", kind: :coordinates }} end defp resolve_grid(input) do case Maidenhead.to_latlon(input) do {:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, label: String.upcase(input), kind: :grid}} :error -> {:error, "Invalid grid square: #{input}"} end end defp resolve_callsign(input) do case CallsignClient.locate(input) do {:ok, info} -> {:ok, %{ lat: info.lat, lon: info.lon, label: String.upcase(info.callsign), grid: info.gridsquare, kind: :callsign }} {:error, reason} -> {:error, "Could not find #{input}: #{reason}"} end end end