defmodule Microwaveprop.Propagation.MechanismClassifier do
  @moduledoc """
  Classifies the propagation mechanism of an individual contact from
  whatever evidence we have: user-declared ADIF PROP_MODE (if any),
  moon ephemeris, HRRR / native-duct profile, common-volume radar,
  ionosonde foEs, Kp index, and active meteor showers.

  Evaluates mechanisms in priority order and returns the first match.
  User-declared PROP_MODE always wins when present — operator ground
  truth beats any inference we can do post-hoc.

  ## Input map

      %{
        band_mhz: integer(),
        distance_km: float(),
        qso_timestamp: DateTime.t(),
        pos1: %{"lat" => float(), "lon" => float()},
        pos2: %{"lat" => float(), "lon" => float()},
        user_declared_prop_mode: String.t() | nil,
        radar: nil | %{max_dbz: float() | nil, heavy_rain_pixel_count: integer(), coverage_pct: float() | nil},
        duct_either_endpoint: boolean(),
        native_best_duct_ghz: float() | nil,
        kp_index: integer() | nil,
        foes_mhz: float() | nil,
        active_meteor_shower: String.t() | nil
      }

  ## Output

      %{mechanism: atom(), confidence: :high | :medium | :low}

  Mechanisms (atoms) returned:

  - `:eme` — moon bounce
  - `:aurora` — aurora scatter
  - `:sporadic_e` — sporadic-E ionospheric
  - `:meteor_scatter` — meteor trail ionization
  - `:aircraft_scatter` — bistatic radar off aircraft
  - `:rain_scatter` — precipitation scatter
  - `:rain_scatter_possible` — light rain in the common volume
  - `:tropo_duct` — surface or elevated refractivity duct
  - `:line_of_sight` — within radio horizon
  - `:troposcatter` — forward scatter off tropospheric refractive fluctuations (default)
  - `:unknown` — not enough data to classify
  """

  alias Microwaveprop.Propagation.MoonEphemeris

  # ADIF PROP_MODE values we recognize. Anything else falls through to
  # physics-based classification.
  @user_declared_map %{
    "EME" => :eme,
    "ES" => :sporadic_e,
    "SPE" => :sporadic_e,
    "F2" => :f2,
    "MS" => :meteor_scatter,
    "METEOR" => :meteor_scatter,
    "RS" => :rain_scatter,
    "RAIN" => :rain_scatter,
    "AS" => :aircraft_scatter,
    "AIRCRAFT" => :aircraft_scatter,
    "AUR" => :aurora,
    "AURE" => :aurora,
    "TR" => :troposcatter,
    "TROPO" => :troposcatter,
    "LOS" => :line_of_sight
  }

  # Band windows for each mechanism. "2 m" means practical EME floor;
  # "below 432" means we drop EME for 6 m and below.
  @eme_min_band_mhz 144
  @eme_min_distance_km 1_800.0

  # Es: 50-432 MHz typical. foEs → MUF ≈ 5 × foEs (single hop at 1500 km).
  # For a band to propagate via Es, foEs must support it.
  @es_band_min_mhz 50
  @es_band_max_mhz 432
  # Single-hop Es path range (geometry-limited by reflection from ~100 km ionosphere).
  @es_min_distance_km 400.0
  @es_max_distance_km 2_500.0

  # Aurora: VHF primarily. Kp threshold conventionally ≥ 5 for auroral
  # scatter to be plausible; stronger events (Kp ≥ 7) make it likely.
  @aurora_band_min_mhz 50
  @aurora_band_max_mhz 432
  @aurora_kp_floor 5
  @aurora_min_latitude 40.0

  # Meteor scatter: VHF/UHF range, peaks during active showers.
  @ms_band_min_mhz 50
  @ms_band_max_mhz 432
  @ms_min_distance_km 600.0
  @ms_max_distance_km 2_300.0

  # Aircraft scatter thresholds intentionally omitted until we land ADS-B
  # history ingestion. Without flight data we can only classify aircraft
  # scatter when the operator declared PROP_MODE=AS.

  # Rain scatter: see `RainScatterClassifier` for derivation.
  @rs_band_min_mhz 5_000
  @rs_band_max_mhz 11_000
  @rs_max_distance_km 800.0
  @rs_light_dbz 25.0
  @rs_heavy_dbz 35.0
  @rs_heavy_pixel_floor 3
  @rs_min_coverage_pct 25.0

  @typedoc "Inputs expected by `classify/1`"
  @type inputs :: %{
          required(:band_mhz) => integer(),
          required(:distance_km) => number(),
          required(:qso_timestamp) => DateTime.t(),
          required(:pos1) => %{String.t() => number()},
          required(:pos2) => %{String.t() => number()},
          optional(:user_declared_prop_mode) => String.t() | nil,
          optional(:radar) => map() | nil,
          optional(:duct_either_endpoint) => boolean(),
          optional(:native_best_duct_ghz) => float() | nil,
          optional(:kp_index) => integer() | nil,
          optional(:foes_mhz) => float() | nil,
          optional(:active_meteor_shower) => String.t() | nil
        }

  @type mechanism ::
          :eme
          | :aurora
          | :sporadic_e
          | :f2
          | :meteor_scatter
          | :aircraft_scatter
          | :rain_scatter
          | :rain_scatter_possible
          | :tropo_duct
          | :line_of_sight
          | :troposcatter
          | :unknown

  @type result :: %{mechanism: mechanism(), confidence: :high | :medium | :low}

  @spec classify(inputs()) :: result()
  def classify(inputs) do
    inputs
    |> apply_defaults()
    |> evaluate()
  end

  defp apply_defaults(inputs) do
    Map.merge(
      %{
        user_declared_prop_mode: nil,
        radar: nil,
        duct_either_endpoint: false,
        native_best_duct_ghz: nil,
        kp_index: nil,
        foes_mhz: nil,
        active_meteor_shower: nil
      },
      inputs
    )
  end

  # Priority: user-declared → EME → aurora → Es → meteor_scatter →
  # aircraft_scatter (we don't have flight data yet, so this only fires
  # when user declared) → rain_scatter → tropo_duct → line_of_sight →
  # troposcatter.
  defp evaluate(inputs) do
    with :no_match <- try_user_declared(inputs),
         :no_match <- try_eme(inputs),
         :no_match <- try_aurora(inputs),
         :no_match <- try_sporadic_e(inputs),
         :no_match <- try_meteor_scatter(inputs),
         :no_match <- try_rain_scatter(inputs),
         :no_match <- try_tropo_duct(inputs),
         :no_match <- try_line_of_sight(inputs) do
      %{mechanism: :troposcatter, confidence: :low}
    else
      %{mechanism: _, confidence: _} = result -> result
    end
  end

  # — User-declared ADIF PROP_MODE — always wins when present —

  defp try_user_declared(%{user_declared_prop_mode: nil}), do: :no_match

  defp try_user_declared(%{user_declared_prop_mode: raw}) when is_binary(raw) do
    key = raw |> String.trim() |> String.upcase()

    case Map.fetch(@user_declared_map, key) do
      {:ok, mechanism} -> %{mechanism: mechanism, confidence: :high}
      :error -> :no_match
    end
  end

  # — EME (moon bounce) — calc-only, no new data source —

  defp try_eme(%{band_mhz: band}) when band < @eme_min_band_mhz, do: :no_match
  defp try_eme(%{distance_km: dist}) when dist < @eme_min_distance_km, do: :no_match

  defp try_eme(inputs) do
    lat1 = get_lat(inputs.pos1)
    lon1 = get_lon(inputs.pos1)
    lat2 = get_lat(inputs.pos2)
    lon2 = get_lon(inputs.pos2)

    if MoonEphemeris.above_horizon?(lat1, lon1, inputs.qso_timestamp) and
         MoonEphemeris.above_horizon?(lat2, lon2, inputs.qso_timestamp) do
      %{mechanism: :eme, confidence: :high}
    else
      # Very long VHF+ path but moon not mutually visible — physically
      # implausible as EME, fall through.
      :no_match
    end
  end

  # — Aurora (Kp + high-lat N-S path) —

  defp try_aurora(%{band_mhz: band}) when band > @aurora_band_max_mhz, do: :no_match
  defp try_aurora(%{band_mhz: band}) when band < @aurora_band_min_mhz, do: :no_match
  defp try_aurora(%{kp_index: nil}), do: :no_match
  defp try_aurora(%{kp_index: kp}) when kp < @aurora_kp_floor, do: :no_match

  defp try_aurora(inputs) do
    lat1 = get_lat(inputs.pos1)
    lat2 = get_lat(inputs.pos2)
    midpoint_lat = abs((lat1 + lat2) / 2)

    if midpoint_lat >= @aurora_min_latitude do
      # Kp ≥ 7 is "likely" aurora; Kp 5-6 is "possible" aurora (medium).
      confidence = if inputs.kp_index >= 7, do: :high, else: :medium
      %{mechanism: :aurora, confidence: confidence}
    else
      :no_match
    end
  end

  # — Sporadic-E (foEs strong enough for the band) —

  defp try_sporadic_e(%{band_mhz: band}) when band > @es_band_max_mhz, do: :no_match
  defp try_sporadic_e(%{band_mhz: band}) when band < @es_band_min_mhz, do: :no_match

  defp try_sporadic_e(%{distance_km: dist}) when dist < @es_min_distance_km or dist > @es_max_distance_km, do: :no_match

  defp try_sporadic_e(%{foes_mhz: nil}), do: :no_match

  defp try_sporadic_e(%{foes_mhz: foes, band_mhz: band}) do
    # Es MUF at 1500-2000 km obliquity is roughly 5 × foEs. If that
    # exceeds the working band frequency, Es can support the path.
    muf_mhz = 5.0 * foes

    if muf_mhz >= band do
      confidence = if muf_mhz >= 1.5 * band, do: :high, else: :medium
      %{mechanism: :sporadic_e, confidence: confidence}
    else
      :no_match
    end
  end

  # — Meteor scatter (during active shower, on VHF/UHF) —

  defp try_meteor_scatter(%{active_meteor_shower: nil}), do: :no_match
  defp try_meteor_scatter(%{band_mhz: band}) when band > @ms_band_max_mhz, do: :no_match
  defp try_meteor_scatter(%{band_mhz: band}) when band < @ms_band_min_mhz, do: :no_match

  defp try_meteor_scatter(%{distance_km: dist}) when dist < @ms_min_distance_km or dist > @ms_max_distance_km,
    do: :no_match

  defp try_meteor_scatter(_inputs), do: %{mechanism: :meteor_scatter, confidence: :medium}

  # — Rain scatter (5–11 GHz, path ≤ 800 km, heavy rain in CV) —

  defp try_rain_scatter(%{radar: nil}), do: :no_match

  defp try_rain_scatter(%{band_mhz: band}) when band < @rs_band_min_mhz or band > @rs_band_max_mhz, do: :no_match

  defp try_rain_scatter(%{distance_km: dist}) when dist > @rs_max_distance_km, do: :no_match

  # Duct signature dominates — covered by try_tropo_duct below.
  defp try_rain_scatter(%{duct_either_endpoint: true}), do: :no_match

  defp try_rain_scatter(%{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 < @rs_min_coverage_pct ->
        :no_match

      max_dbz >= @rs_heavy_dbz and heavy >= @rs_heavy_pixel_floor ->
        %{mechanism: :rain_scatter, confidence: :high}

      max_dbz >= @rs_light_dbz ->
        %{mechanism: :rain_scatter_possible, confidence: :medium}

      true ->
        :no_match
    end
  end

  # — Tropospheric duct (HRRR native best_duct supports the band, or legacy flag) —

  defp try_tropo_duct(%{duct_either_endpoint: true}), do: %{mechanism: :tropo_duct, confidence: :medium}

  defp try_tropo_duct(%{native_best_duct_ghz: ghz, band_mhz: band}) when is_number(ghz) and ghz * 1_000 >= band,
    do: %{mechanism: :tropo_duct, confidence: :high}

  defp try_tropo_duct(_), do: :no_match

  # — Line of sight — conservative 50 km radio-horizon approximation.

  defp try_line_of_sight(%{distance_km: dist}) when dist <= 50.0, do: %{mechanism: :line_of_sight, confidence: :high}

  defp try_line_of_sight(_), do: :no_match

  defp get_lat(%{"lat" => lat}), do: lat / 1.0
  defp get_lat(%{lat: lat}), do: lat / 1.0
  defp get_lon(%{"lon" => lon}), do: lon / 1.0
  defp get_lon(%{lon: lon}), do: lon / 1.0
end
