defmodule Microwaveprop.Propagation.RainScatterClassifier do
  @moduledoc """
  Rule-based classifier that labels a contact with its most likely
  propagation mechanism: rain scatter vs tropospheric ducting vs plain
  troposcatter vs unknown.

  Takes a small input map so callers can assemble it from whatever data
  they have (schema rows at submit time, aggregated SQL in backfill,
  etc.) without this module depending on Ecto.

  ## Inputs

      %{
        band_mhz: integer(),
        distance_km: float(),
        # common-volume radar stats (nil if no radar sweep covers the CV)
        radar: nil | %{
          max_dbz: float() | nil,
          heavy_rain_pixel_count: non_neg_integer(),
          coverage_pct: float() | nil
        },
        # true if HRRR detected a trapping layer at *either* endpoint
        duct_either_endpoint: boolean()
      }

  ## Outputs

    * `:likely_rainscatter` — 5-11 GHz, path ≤ 800 km, heavy rain (≥ 35 dBZ
      with several pixels) inside the common volume, no duct at either end.
    * `:rainscatter_possible` — light rain (25-35 dBZ) in the CV, no duct;
      could be rainscatter, could be weak tropo.
    * `:tropo_duct` — HRRR ducting signature at either endpoint dominates.
    * `:troposcatter` — no duct, no meaningful rain → default tropospheric
      forward scatter, which carries most "clear-air" microwave contacts.
    * `:unknown` — not enough information (no radar sweep, poor coverage).

  These labels are coarse on purpose: with post-hoc HRRR + n0q data you
  can't prove the mechanism, but you *can* separate the cohorts enough
  to calibrate band-weights and to show the likely mode on a QSO detail
  page.
  """

  # Rainscatter is feasible roughly 5-11 GHz. Below, scattering cross-section
  # is too small; above, absorption kills the signal before it scatters.
  @rs_band_min_mhz 5_000
  @rs_band_max_mhz 11_000

  # Geometry limit: common volume stops existing beyond ~2R = 800 km.
  @rs_max_distance_km 800.0

  # Reflectivity thresholds (dBZ) on the n0q composite.
  @light_rain_dbz 25.0
  @heavy_rain_dbz 35.0

  # How many heavy pixels count as a "real" thunderstorm cell in the CV.
  @heavy_pixel_floor 3

  # Minimum radar coverage pct to trust the reading. Below this the CV
  # scan didn't have enough valid pixels to say either way.
  @min_coverage_pct 25.0

  @type result ::
          :likely_rainscatter
          | :rainscatter_possible
          | :tropo_duct
          | :troposcatter
          | :unknown

  @spec classify(map()) :: result()
  def classify(%{duct_either_endpoint: true}), do: :tropo_duct

  def classify(%{band_mhz: band, distance_km: dist} = input) do
    if rainscatter_geometry?(band, dist) do
      classify_from_radar(input)
    else
      fallback_non_rainscatter(input)
    end
  end

  defp rainscatter_geometry?(band_mhz, distance_km) do
    band_mhz >= @rs_band_min_mhz and band_mhz <= @rs_band_max_mhz and
      distance_km <= @rs_max_distance_km
  end

  defp classify_from_radar(%{radar: nil}), do: :unknown

  defp classify_from_radar(%{radar: radar}) do
    coverage = radar[:coverage_pct] || 0.0
    max_dbz = radar[:max_dbz] || 0.0
    heavy = radar[:heavy_rain_pixel_count] || 0

    cond do
      coverage < @min_coverage_pct ->
        :unknown

      max_dbz >= @heavy_rain_dbz and heavy >= @heavy_pixel_floor ->
        :likely_rainscatter

      max_dbz >= @light_rain_dbz ->
        :rainscatter_possible

      true ->
        :troposcatter
    end
  end

  defp fallback_non_rainscatter(%{radar: nil}), do: :unknown
  defp fallback_non_rainscatter(_), do: :troposcatter
end
