defmodule Microwaveprop.Radio.ImportMatcher do @moduledoc """ Pure classification for CSV/ADIF upload rows against existing Contact records. `prefix_key/5` builds a sortable key from `{station, 4-char grid prefix, band}` so callers can bulk-lookup candidates from the database. `classify_against_existing/2` compares a single upload row against a single candidate contact and decides whether the upload is a plain duplicate, a grid/mode refinement of the existing record, or an irreconcilable contradiction that must be skipped. """ alias Microwaveprop.Radio.Contact @allowed_modes ~w(CW SSB FM FT8 FT4 Q65) @type prefix_key :: {{String.t(), String.t()}, {String.t(), String.t()}, integer()} | :invalid @type change_map :: %{optional(:grid1) => String.t(), optional(:grid2) => String.t(), optional(:mode) => String.t()} @type classification :: :duplicate | {:refinement, change_map()} | :contradiction @spec prefix_key(String.t() | nil, String.t() | nil, String.t() | nil, String.t() | nil, term()) :: prefix_key() def prefix_key(station1, grid1, station2, grid2, band) do with {:ok, s1, g1_prefix} <- build_side(station1, grid1), {:ok, s2, g2_prefix} <- build_side(station2, grid2), {:ok, band_int} <- normalize_band(band) do [a, b] = Enum.sort([{s1, g1_prefix}, {s2, g2_prefix}]) {a, b, band_int} else _ -> :invalid end end defp build_side(station, grid) do with station_up when is_binary(station_up) <- upcase_trim(station), grid_up when is_binary(grid_up) <- upcase_trim(grid), true <- byte_size(grid_up) >= 4 do {:ok, station_up, binary_part(grid_up, 0, 4)} else _ -> :error end end defp upcase_trim(nil), do: nil defp upcase_trim(value) when is_binary(value) do trimmed = String.trim(value) if trimmed == "", do: nil, else: String.upcase(trimmed) end defp upcase_trim(_), do: nil defp normalize_band(%Decimal{} = d) do {:ok, Decimal.to_integer(d)} rescue _ -> :error end defp normalize_band(value) when is_integer(value), do: {:ok, value} defp normalize_band(value) when is_binary(value) do case value |> String.trim() |> Integer.parse() do {int, ""} -> {:ok, int} _ -> :error end end defp normalize_band(_), do: :error @spec classify_against_existing(map(), Contact.t()) :: classification() def classify_against_existing(upload_attrs, %Contact{} = existing) do up_s1 = upcase_trim(upload_attrs["station1"]) up_s2 = upcase_trim(upload_attrs["station2"]) ex_s1 = upcase_trim(existing.station1) ex_s2 = upcase_trim(existing.station2) case resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do :ambiguous -> :duplicate {up_grid1_key, up_grid2_key} -> compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key) end end # Returns which upload keys ("grid1"/"grid2") correspond to existing's side1/side2. # If direction matches as-is: {"grid1", "grid2"}. If swapped: {"grid2", "grid1"}. # `:ambiguous` when callsigns don't pair cleanly (e.g. same callsign both sides). defp resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do if pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do match_direction(ex_s1, ex_s2, up_s1, up_s2) else :ambiguous end end defp pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do not is_nil(ex_s1) and not is_nil(ex_s2) and not is_nil(up_s1) and not is_nil(up_s2) and ex_s1 != ex_s2 and up_s1 != up_s2 end defp match_direction(ex_s1, ex_s2, up_s1, up_s2) do cond do ex_s1 == up_s1 and ex_s2 == up_s2 -> {"grid1", "grid2"} ex_s1 == up_s2 and ex_s2 == up_s1 -> {"grid2", "grid1"} true -> :ambiguous end end defp compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key) do up_g1 = upcase_trim(upload_attrs[up_grid1_key]) up_g2 = upcase_trim(upload_attrs[up_grid2_key]) ex_g1 = upcase_trim(existing.grid1) ex_g2 = upcase_trim(existing.grid2) grid1_result = compare_grid(ex_g1, up_g1) grid2_result = compare_grid(ex_g2, up_g2) mode_result = compare_mode(existing.mode, upload_attrs["mode"]) combine_results(grid1_result, grid2_result, mode_result) end # Each compare_* returns :same | :contradiction | {:refine, new_value} defp compare_grid(nil, _up), do: :same defp compare_grid(_ex, nil), do: :same defp compare_grid(ex, up) when ex == up, do: :same defp compare_grid(ex, up) do cond do byte_size(up) > byte_size(ex) and String.starts_with?(up, ex) -> {:refine, up} byte_size(up) < byte_size(ex) and String.starts_with?(ex, up) -> :same true -> :contradiction end end defp compare_mode(_ex, nil), do: :same defp compare_mode(nil, up) do normalized = upcase_trim(up) cond do is_nil(normalized) -> :same normalized in @allowed_modes -> {:refine, normalized} true -> :same end end defp compare_mode(ex, up) do up_norm = upcase_trim(up) cond do is_nil(up_norm) -> :same String.upcase(ex) == up_norm -> :same true -> :contradiction end end defp combine_results(grid1_result, grid2_result, mode_result) do results = [{:grid1, grid1_result}, {:grid2, grid2_result}, {:mode, mode_result}] if Enum.any?(results, fn {_k, r} -> r == :contradiction end) do :contradiction else changes = Enum.reduce(results, %{}, fn {key, {:refine, value}}, acc -> Map.put(acc, key, value) _, acc -> acc end) if changes == %{}, do: :duplicate, else: {:refinement, changes} end end end