defmodule Microwaveprop.Propagation.Scorer do
  @moduledoc """
  Scoring functions for microwave propagation conditions.

  Each of the 10 factors produces a 0-100 score. The composite score is
  a weighted sum using weights from `BandConfig`. All thresholds and
  parameters are read from `BandConfig` — no hardcoded magic numbers here.
  """

  alias Microwaveprop.Propagation.BandConfig

  # ── Temperature conversion helpers ────────────────────────────────
  alias Microwaveprop.Propagation.Region

  @doc "Converts Fahrenheit to Celsius. Returns nil for nil input."
  @spec f_to_c(number() | nil) :: float() | nil
  def f_to_c(nil), do: nil
  def f_to_c(f), do: (f - 32) * 5 / 9

  @doc "Converts Celsius to Fahrenheit. Returns nil for nil input."
  @spec c_to_f(number() | nil) :: float() | nil
  def c_to_f(nil), do: nil
  def c_to_f(c), do: c * 9 / 5 + 32

  # ── Derived value helpers ─────────────────────────────────────────

  @doc """
  Computes absolute humidity in g/m3 from temperature and dewpoint (both Celsius).

  Formula: 217 * (6.112 * exp(17.67 * Td / (Td + 243.5))) / (T + 273.15)
  """
  @spec absolute_humidity(number(), number()) :: float()
  def absolute_humidity(temp_c, dewpoint_c) do
    e_sat = 6.112 * :math.exp(17.67 * dewpoint_c / (dewpoint_c + 243.5))
    217.0 * e_sat / (temp_c + 273.15)
  end

  @doc "Computes wind speed in knots from u and v components in m/s. Returns nil if either is nil."
  @spec wind_speed_kts(number() | nil, number() | nil) :: float() | nil
  def wind_speed_kts(nil, _v), do: nil
  def wind_speed_kts(_u, nil), do: nil

  def wind_speed_kts(u_ms, v_ms) do
    :math.sqrt(u_ms * u_ms + v_ms * v_ms) * 1.94384
  end

  @doc "Converts precipitation accumulation (mm) to rate (mm/hr). Returns 0.0 for nil, zero, or negative."
  @spec precip_to_rate_mmhr(number() | nil) :: float()
  def precip_to_rate_mmhr(nil), do: 0.0
  def precip_to_rate_mmhr(mm) when mm > 0, do: mm / 1
  def precip_to_rate_mmhr(_mm), do: 0.0

  @doc """
  Converts NEXRAD composite reflectivity (dBZ) to rain rate (mm/hr) via the
  Marshall-Palmer Z-R relationship `Z = 200 * R^1.6`, so `R = (Z/200)^(1/1.6)`
  with `Z = 10^(dBZ/10)`.

  Clipping rules:
  * Below 5 dBZ — not rain (ground clutter / clear air). Returns 0.0.
  * Above 150 mm/hr — hail contamination (55+ dBZ can return >100 mm/hr
    from pure Marshall-Palmer). The ITU-R P.838 rain table tops out at
    ~150 mm/hr so we clip there to stay inside the calibrated regime.
  """
  @spec dbz_to_rain_rate_mmhr(number() | nil) :: float()
  def dbz_to_rain_rate_mmhr(nil), do: 0.0
  def dbz_to_rain_rate_mmhr(dbz) when dbz < 5.0, do: 0.0

  def dbz_to_rain_rate_mmhr(dbz) do
    z = :math.pow(10, dbz / 10)
    r = :math.pow(z / 200, 1 / 1.6)
    min(r, 150.0)
  end

  # ── Factor 1: Humidity ────────────────────────────────────────────

  @doc """
  Scores absolute humidity (g/m3) for the given band.

  Beneficial bands (10 GHz): higher humidity improves ducting.
  Harmful bands (24 GHz+): humidity causes absorption loss.
  """
  @spec score_humidity(number(), map()) :: integer()
  def score_humidity(abs_humidity, %{humidity_effect: :beneficial}) do
    thresholds = BandConfig.humidity_beneficial_thresholds()

    case Enum.find(thresholds, fn {max_h, _score} -> abs_humidity < max_h end) do
      {_max, score} -> score
      nil -> BandConfig.humidity_beneficial_default()
    end
  end

  def score_humidity(abs_humidity, %{humidity_effect: :harmful, humidity_penalty: penalty}) do
    r = abs_humidity * penalty

    cond do
      r <= 6 -> 100
      r <= 9 -> round(95 - (r - 6) / 3 * 20)
      r <= 13 -> round(75 - (r - 9) / 4 * 30)
      r <= 18 -> round(45 - (r - 13) / 5 * 35)
      true -> max(0, round(10 - (r - 18) * 2))
    end
  end

  # ── Factor 2: Time of day ─────────────────────────────────────────

  @doc """
  Scores time of day for propagation conditions.

  Returns {score, label} where score is 0-100 and label describes the period.
  Uses longitude-based solar time offset (longitude / 15) so each grid point
  gets its own local time rather than a fixed timezone offset.
  """
  @spec score_time_of_day(integer(), integer(), integer(), number()) :: {integer(), String.t()}
  def score_time_of_day(utc_hour, utc_minute, month, longitude) do
    offset = longitude / 15
    local = :math.fmod(utc_hour + utc_minute / 60 + offset + 24, 24)
    sunrise = Enum.at(BandConfig.sunrise_table(), month - 1)
    d = local - sunrise

    classify_time_period(d, local)
  end

  defp classify_time_period(d, _local) when d >= -1.5 and d <= 1.5, do: {100, "Peak — inversion maximum"}

  defp classify_time_period(d, _local) when d > 1.5 and d <= 3.0, do: {78, "Good — inversion eroding"}

  defp classify_time_period(d, _local) when d > -3.0 and d < -1.5, do: {82, "Pre-dawn — inversion building"}

  defp classify_time_period(d, _local) when d > 3.0 and d <= 6.0, do: {38, "Marginal — boundary layer mixing"}

  defp classify_time_period(_d, local) when local >= 20 or local <= 1, do: {72, "Evening — cooling, inversion reforming"}

  defp classify_time_period(d, _local) when d > 6, do: {18, "Afternoon — full convective mixing"}

  defp classify_time_period(_d, _local), do: {55, "Night — gradual cooling"}

  # ── Factor 3: Temp-dewpoint depression ────────────────────────────

  @doc """
  Scores the temperature-dewpoint depression (both in Fahrenheit).

  Beneficial bands: moisture helps (tight depression = moist = better ducting,
  but too tight means fog which is bad).
  Harmful bands: dry air is better (wide depression = less absorption).
  """
  @spec score_td_depression(number(), number(), map()) :: integer()
  def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :beneficial}) do
    dep = temp_f - dewpoint_f

    cond do
      dep < 3 -> 40
      dep < 8 -> 75
      dep < 14 -> 85
      dep < 22 -> 70
      true -> 55
    end
  end

  def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :harmful}) do
    dep = temp_f - dewpoint_f

    cond do
      dep > 22 -> 96
      dep > 14 -> 80
      dep > 8 -> 60
      dep > 4 -> 38
      true -> 18
    end
  end

  # ── Factor 4: Refractivity ───────────────────────────────────────

  @doc """
  Scores minimum refractivity gradient and boundary layer depth.

  Nil gradient returns 50 (unknown). Otherwise walks thresholds from
  BandConfig and then applies an HPBL multiplier: shallow boundary layers
  amplify trapping (surface inversion → steep gradient → ducting), deep
  boundary layers indicate convective mixing that disrupts ducts.

  Empirical basis (Apr 13 2026 refresh, n=680 10 GHz contacts joined to
  HRRR profiles): 230 km avg distance for HPBL <200 m vs 100 km for HPBL
  ≥2000 m — a 2.3× difference monotonic across bins. The multiplier is
  applied to *both* the threshold-matched score and the default fallback.
  """
  @spec score_refractivity(number() | nil, number() | nil, map()) :: integer()
  def score_refractivity(min_gradient, bl_depth_m, band_config) do
    score_refractivity(min_gradient, bl_depth_m, nil, band_config)
  end

  @doc """
  Scores refractivity with optional native-profile duct info.

  `best_duct_band_ghz` comes from `hrrr_native_profiles` and represents the
  highest frequency the cell's native-resolution duct can trap. When it's
  ≥ the target band's frequency the base score is boosted 1.15× because
  HRRR pressure-level gradients systematically under-read thin ducts the
  native profile can resolve (see Part 2c Apr 13 2026 findings).

  A duct that only supports lower frequencies does NOT boost the score —
  it's a signal that the gradient we have is *all there is* at the target
  band.
  """
  @spec score_refractivity(number() | nil, number() | nil, number() | nil, map()) :: integer()
  def score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, band_config) do
    score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, nil, band_config)
  end

  @doc """
  Scores refractivity with optional native-profile duct info, gated by
  Bulk Richardson number.

  `bulk_richardson` comes from `hrrr_native_profiles` and measures
  dynamic stability (lower = more stable). Native duct cells average
  8.7-18.4 for ducting vs 38.3 for non-ducting, so a low
  `best_duct_band_ghz` reading under turbulent conditions
  (Richardson ≥ 25) is likely a duct that would be broken up by
  mechanical mixing — in that case the 1.15× boost is *not* applied.

  `nil` Richardson falls back to the 4-arity behaviour (boost applies
  whenever the duct band supports the target frequency).
  """
  @spec score_refractivity(number() | nil, number() | nil, number() | nil, number() | nil, map()) ::
          integer()
  def score_refractivity(nil, _bl_depth_m, _best_duct_band_ghz, _bulk_richardson, _band_config), do: 50

  def score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, bulk_richardson, %{
        freq_mhz: freq_mhz,
        humidity_effect: effect
      }) do
    thresholds = BandConfig.refractivity_thresholds()

    base =
      case find_refractivity_threshold(min_gradient, thresholds, effect) do
        {:ok, score} ->
          score

        :none ->
          {beneficial_default, harmful_default} = BandConfig.refractivity_default()
          if effect == :beneficial, do: beneficial_default, else: harmful_default
      end

    base
    |> apply_hpbl_multiplier(bl_depth_m)
    |> apply_native_duct_boost(best_duct_band_ghz, bulk_richardson, freq_mhz)
  end

  # HPBL multiplier — applied to the base refractivity score. Calibrated to the
  # binned 10 GHz data: <200 m → 230 km avg, 200–500 → 186, 500–1500 → ~160,
  # 1500–2000 → 137, ≥2000 → 100. nil HPBL keeps the score unchanged.
  defp apply_hpbl_multiplier(base, nil), do: base

  defp apply_hpbl_multiplier(base, hpbl) when hpbl < 200, do: clamp_score(base * 1.10)
  defp apply_hpbl_multiplier(base, hpbl) when hpbl < 500, do: clamp_score(base * 1.05)
  defp apply_hpbl_multiplier(base, hpbl) when hpbl < 1500, do: base
  defp apply_hpbl_multiplier(base, hpbl) when hpbl < 2000, do: clamp_score(base * 0.92)
  defp apply_hpbl_multiplier(base, _hpbl), do: clamp_score(base * 0.78)

  # Native-profile duct boost — 1.15× when the cell's best duct supports the
  # target band's frequency AND the Bulk Richardson number is in the stable
  # regime (< 25). HRRR pressure-level gradients systematically under-read
  # thin ducts, so when hrrr_native_profiles reports a duct band at or above
  # the target frequency under stable conditions, the base gradient score is
  # under-estimating the real channel. A duct reading with high Richardson
  # is evidence of mechanical mixing that would shred the trapping layer —
  # don't boost in that case.
  @bulk_richardson_stable_max 25.0

  defp apply_native_duct_boost(base, nil, _bulk_richardson, _freq_mhz), do: base

  defp apply_native_duct_boost(base, best_duct_band_ghz, bulk_richardson, freq_mhz) do
    target_ghz = freq_mhz / 1_000

    if best_duct_band_ghz >= target_ghz and stable_atmosphere?(bulk_richardson) do
      clamp_score(base * 1.15)
    else
      base
    end
  end

  # nil Richardson is treated as stable (no information → don't penalise).
  # Explicit Richardson ≥ 25 gates the boost.
  defp stable_atmosphere?(nil), do: true
  defp stable_atmosphere?(r) when is_number(r), do: r < @bulk_richardson_stable_max

  @doc """
  Inverse-sensor boost from commercial LOS link degradation.

  Nearby short-path commercial links (11/24/68 GHz in the DFW area) act as a
  physical refractivity anomaly sensor: when their rx_power drops 5+ dB below
  the 7-day baseline *without* an equipment cause, the same multipath fading
  that hurts their short Fresnel zone is what carries the beyond-LOS paths.
  This is a terminal boost applied once per composite score, not per-band —
  the underlying physics is band-agnostic at the resolution we can measure.

  Takes the incoming composite (or any 0-100) score and a degradation map
  from `Commercial.link_degradation_at/3`; returns a boosted integer score.

  * nil or empty result (`n_links == 0`) — no-op
  * <3 dB — noise floor, no-op
  * 3–8 dB — mild boost (+0 to +10)
  * ≥8 dB — strong boost (+10 to +25, clamped ≤ 100)
  """
  @spec commercial_link_boost(number(), map() | nil) :: integer()
  def commercial_link_boost(score, nil), do: clamp_score(score)

  def commercial_link_boost(score, %{n_links: 0}), do: clamp_score(score)

  def commercial_link_boost(score, %{degradation_db: db}) when db < 3.0, do: clamp_score(score)

  def commercial_link_boost(score, %{degradation_db: db}) when db < 8.0 do
    # 3 dB → +2; 7.9 dB → ~+10 (linear interpolation)
    bonus = 2 + (db - 3.0) * 8.0 / 5.0
    clamp_score(score + bonus)
  end

  def commercial_link_boost(score, %{degradation_db: db}) do
    # 8 dB → +10; 16 dB → +25 (linear)
    bonus = min(25.0, 10 + (db - 8.0) * 15.0 / 8.0)
    clamp_score(score + bonus)
  end

  defp clamp_score(value) do
    value |> round() |> max(0) |> min(100)
  end

  defp find_refractivity_threshold(gradient, thresholds, effect) do
    case Enum.find(thresholds, fn {max_grad, _ben, _harm} -> gradient < max_grad end) do
      {_max, beneficial_score, harmful_score} ->
        score =
          case effect do
            :beneficial -> beneficial_score
            :harmful -> harmful_score
          end

        {:ok, score}

      nil ->
        :none
    end
  end

  # ── Factor 5: Sky cover ──────────────────────────────────────────

  @doc "Scores sky cover percentage (0 = clear, 100 = overcast). Nil returns 50."
  @spec score_sky(number() | nil) :: integer()
  def score_sky(nil), do: 50

  def score_sky(pct) do
    cond do
      pct <= 6 -> 100
      pct <= 25 -> 88
      pct <= 50 -> 60
      pct <= 87 -> 25
      true -> 5
    end
  end

  # ── Factor 6: Season ─────────────────────────────────────────────

  @doc """
  Scores the seasonal effect for the given month and band config.

  When lat/lon are provided, applies a regional adjustment multiplier
  from `Propagation.Region` on top of the band's `seasonal_base` +
  `seasonal_adj`. This corrects for the meteorologist's observation
  that Gulf coast August is better than the uniform base suggests,
  while Corn Belt August is worse.
  """
  @spec score_season(integer(), float | nil, float | nil, map()) :: integer()
  def score_season(month, lat, lon, %{seasonal_base: base_map, seasonal_adj: adj_map}) do
    base = Map.get(base_map, month, 50)
    adj = Map.get(adj_map, month, 0)

    region_mult =
      if is_number(lat) and is_number(lon) do
        region = Region.for_point(lat, lon)
        Region.seasonal_adjustment(region, month)
      else
        1.0
      end

    round(min(100, max(0, (base + adj) * region_mult)))
  end

  # ── Factor 7: Wind ───────────────────────────────────────────────

  @doc "Scores wind speed in knots. Nil returns 50."
  @spec score_wind(number() | nil) :: integer()
  def score_wind(nil), do: 50

  def score_wind(speed_kts) do
    cond do
      speed_kts < 5 -> 100
      speed_kts < 10 -> 90
      speed_kts < 15 -> 75
      speed_kts < 20 -> 55
      speed_kts < 25 -> 35
      true -> 15
    end
  end

  # ── Factor 8: Rain attenuation ───────────────────────────────────

  @doc """
  Scores rain attenuation for the given rate (mm/hr) and band config.

  Uses ITU-R P.838 specific attenuation: gamma = k * R^alpha (dB/km).
  """
  @spec score_rain(number() | nil, map()) :: integer()
  def score_rain(nil, _band_config), do: 100
  def score_rain(rate, _band_config) when rate == 0, do: 100

  def score_rain(rain_rate_mmhr, %{rain_k: k, rain_alpha: alpha}) do
    gamma = k * :math.pow(rain_rate_mmhr, alpha)

    cond do
      gamma < 0.1 -> 95
      gamma < 0.5 -> 75
      gamma < 1.0 -> 50
      gamma < 2.0 -> 25
      gamma < 5.0 -> 10
      true -> 0
    end
  end

  # ── Factor 9: Precipitable water ──────────────────────────────────

  @doc """
  Scores precipitable water (mm).

  Beneficial bands (10 GHz): moderate PWAT is optimal for refractivity.
  Harmful bands (24+ GHz): lower PWAT is better (less water vapor absorption).
  """
  @spec score_pwat(number() | nil, map()) :: integer()
  def score_pwat(nil, _band_config), do: 60

  def score_pwat(pwat_mm, %{humidity_effect: :beneficial}) do
    cond do
      pwat_mm < 10 -> 55
      pwat_mm < 20 -> 75
      pwat_mm < 30 -> 90
      pwat_mm < 40 -> 70
      true -> 50
    end
  end

  def score_pwat(pwat_mm, %{humidity_effect: :harmful}) do
    cond do
      pwat_mm < 10 -> 95
      pwat_mm < 20 -> 80
      pwat_mm < 30 -> 60
      pwat_mm < 40 -> 35
      true -> 15
    end
  end

  # ── Factor 10: Pressure trend ────────────────────────────────────

  @doc """
  Scores barometric pressure and trend.

  Without previous reading, scores based on absolute pressure.
  With previous reading, scores based on pressure change (delta).
  """
  @spec score_pressure(number() | nil, number() | nil) :: integer()
  def score_pressure(nil, _previous_mb), do: 50

  def score_pressure(current_mb, nil) do
    cond do
      current_mb < 980 -> 88
      current_mb < 990 -> 82
      current_mb < 1000 -> 70
      current_mb < 1010 -> 55
      current_mb < 1020 -> 40
      true -> 30
    end
  end

  def score_pressure(current_mb, previous_mb) do
    delta = current_mb - previous_mb

    cond do
      delta > 2.5 -> 80
      delta > 0.8 -> 70
      delta > -0.5 -> 60
      delta > -2.0 -> 65
      true -> 45
    end
  end

  # ── Composite score ──────────────────────────────────────────────

  @doc """
  Precompute the four band-invariant factors so the grid scorer can
  reuse them across all 17 band iterations for a single point. Saves
  ~30% of the scoring loop by hoisting shared arithmetic out of the
  per-band call.
  """
  @spec precompute_band_invariants(map()) :: %{
          tod_score: integer(),
          sky_score: integer(),
          wind_score: integer(),
          pressure_score: integer()
        }
  def precompute_band_invariants(conditions) do
    {tod_score, _label} =
      score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month, conditions.longitude)

    %{
      tod_score: tod_score,
      sky_score: score_sky(conditions.sky_cover_pct),
      wind_score: score_wind(conditions.wind_speed_kts),
      pressure_score: score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb)
    }
  end

  @doc """
  Computes the weighted composite propagation score.

  Takes a conditions map and band config, returns %{score: 0-100, factors: %{...}}.
  """
  @spec composite_score(map(), map()) :: %{score: integer(), factors: map()}
  def composite_score(conditions, band_config) do
    tod_score = conditions[:tod_score] || band_invariant_tod(conditions)
    sky_score = conditions[:sky_score] || score_sky(conditions.sky_cover_pct)
    wind_score = conditions[:wind_score] || score_wind(conditions.wind_speed_kts)

    pressure_score =
      conditions[:pressure_score] || score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb)

    factors = %{
      humidity: score_humidity(conditions.abs_humidity, band_config),
      time_of_day: tod_score,
      td_depression: score_td_depression(conditions.temp_f, conditions.dewpoint_f, band_config),
      refractivity:
        score_refractivity(
          conditions.min_refractivity_gradient,
          conditions.bl_depth_m,
          conditions[:best_duct_band_ghz],
          conditions[:bulk_richardson],
          band_config
        ),
      sky: sky_score,
      season: score_season(conditions.month, conditions[:latitude], conditions[:longitude], band_config),
      wind: wind_score,
      rain: score_rain(conditions.rain_rate_mmhr, band_config),
      pwat: score_pwat(conditions[:pwat_mm], band_config),
      pressure: pressure_score
    }

    weights = BandConfig.weights(band_config)

    weighted_sum =
      Enum.reduce(factors, 0.0, fn {factor, score}, acc ->
        acc + score * Map.fetch!(weights, factor)
      end)

    %{score: round(weighted_sum), factors: factors}
  end

  defp band_invariant_tod(conditions) do
    {s, _} =
      score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month, conditions.longitude)

    s
  end

  @doc """
  Merges multiple HRRR profiles along a path into a single conditions map.

  Strategy:
  - Beneficial factors (refractivity, pressure): use BEST (most favorable) along path
  - Harmful factors (rain, wind): use WORST (least favorable) along path
  - Other factors (temp, dewpoint, PWAT, BL depth): use path AVERAGE
  - Time/season/sky: taken from first profile (same for entire path)
  """
  @spec path_integrated_conditions([map()], map()) :: map() | nil
  def path_integrated_conditions(profiles, contact) do
    extracted = extract_profile_fields(profiles)
    build_path_conditions(extracted, contact)
  end

  defp extract_profile_fields(profiles) do
    Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {t, d, pr, g, b, pw} ->
      {
        if(is_nil(p.surface_temp_c), do: t, else: [p.surface_temp_c | t]),
        if(is_nil(p.surface_dewpoint_c), do: d, else: [p.surface_dewpoint_c | d]),
        if(is_nil(p.surface_pressure_mb), do: pr, else: [p.surface_pressure_mb | pr]),
        if(is_nil(p.min_refractivity_gradient), do: g, else: [p.min_refractivity_gradient | g]),
        if(is_nil(p.hpbl_m), do: b, else: [p.hpbl_m | b]),
        if(is_nil(p.pwat_mm), do: pw, else: [p.pwat_mm | pw])
      }
    end)
  end

  defp build_path_conditions({temps, _dewpoints, _pressures, _gradients, _bl_depths, _pwats}, _contact) when temps == [],
    do: nil

  defp build_path_conditions({_temps, dewpoints, _pressures, _gradients, _bl_depths, _pwats}, _contact)
       when dewpoints == [], do: nil

  defp build_path_conditions({temps, dewpoints, pressures, gradients, bl_depths, pwats}, contact) do
    lon = contact.pos1["lon"] || -97.0
    avg_temp_c = Enum.sum(temps) / length(temps)
    avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)

    %{
      abs_humidity: absolute_humidity(avg_temp_c, avg_dewpoint_c),
      temp_f: c_to_f(avg_temp_c),
      dewpoint_f: c_to_f(avg_dewpoint_c),
      wind_speed_kts: nil,
      sky_cover_pct: nil,
      utc_hour: contact.qso_timestamp.hour,
      utc_minute: contact.qso_timestamp.minute,
      month: contact.qso_timestamp.month,
      longitude: lon,
      pressure_mb: safe_min(pressures),
      prev_pressure_mb: nil,
      rain_rate_mmhr: 0.0,
      min_refractivity_gradient: safe_min(gradients),
      bl_depth_m: safe_avg(bl_depths),
      pwat_mm: safe_avg(pwats)
    }
  end

  defp safe_min([]), do: nil
  defp safe_min(list), do: Enum.min(list)

  defp safe_avg([]), do: nil
  defp safe_avg(list), do: Enum.sum(list) / length(list)
end
