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

  # Compile-time inverse of the Marshall-Palmer b exponent (1/1.6).
  # Pre-computed to avoid one float division per rain pixel in the
  # dBZ → rain-rate hot path.
  @mp_inv_b 1 / 1.6

  @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, @mp_inv_b)
    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
    score_humidity_harmful(abs_humidity * penalty)
  end

  defp score_humidity_harmful(r) when r <= 6, do: 100
  defp score_humidity_harmful(r) when r <= 9, do: round(95 - (r - 6) / 3 * 20)
  defp score_humidity_harmful(r) when r <= 13, do: round(75 - (r - 9) / 4 * 30)
  defp score_humidity_harmful(r) when r <= 18, do: round(45 - (r - 13) / 5 * 35)
  defp score_humidity_harmful(r), do: max(0, round(10 - (r - 18) * 2))

  # ── 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
    score_td_beneficial(temp_f - dewpoint_f)
  end

  def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :harmful}) do
    score_td_harmful(temp_f - dewpoint_f)
  end

  defp score_td_beneficial(dep) when dep < 3, do: 40
  defp score_td_beneficial(dep) when dep < 8, do: 75
  defp score_td_beneficial(dep) when dep < 14, do: 85
  defp score_td_beneficial(dep) when dep < 22, do: 70
  defp score_td_beneficial(_dep), do: 55

  defp score_td_harmful(dep) when dep > 22, do: 96
  defp score_td_harmful(dep) when dep > 14, do: 80
  defp score_td_harmful(dep) when dep > 8, do: 60
  defp score_td_harmful(dep) when dep > 4, do: 38
  defp score_td_harmful(_dep), do: 18

  # ── 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.

  As of the 2026-04-25 algo revisions the HPBL boundary-layer multiplier
  is retired — `bl_depth_m` is accepted (and the schema still stores it
  for diagnostics) but it does not modify the score. See
  `docs/algo-reports/2026-04-25-algo-revisions.md` Recommendation 2: on
  the n=47,418 10 GHz HRRR-matched corpus rho_hpbl was +0.004, well below
  the 0.05 noise floor; the bin tables agreed within ±5 % across
  200–2,000 m. The previously reported "shallow-BL → longer-distance"
  effect was a small-sample artefact and disappeared once the matched
  corpus grew past ~5,000 contacts.
  """
  @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, ignoring `best_duct_band_ghz`.

  Accepts the native-profile duct band so existing call sites
  (`Scorer.score_grid_point/2`, `Recalibrator`, the Rust comparator)
  continue to compile, but the previous 1.15× boost was retired in the
  2026-04-25 revisions: at 10 GHz on n=52,341 matched contacts, cells
  whose native duct supported the band ran *shorter* (198 km, n=173)
  than no-duct cells (211 km, n=44,658). Refractivity is now the
  gradient-only score.
  """
  @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, ignoring `best_duct_band_ghz` and `bulk_richardson`.

  The Bulk Richardson gate existed only to suppress the native-duct
  boost during mechanically-mixed conditions. With the boost retired
  (see 4-arity doc), Richardson is irrelevant — every input collapses
  onto the gradient-only base score. Arity preserved so prod call
  sites don't need an emergency rewrite.
  """
  @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, %{humidity_effect: effect}) do
    thresholds = BandConfig.refractivity_thresholds()

    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
  end

  @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

  @doc """
  Aurora-conditioned terminal boost for VHF/low-UHF bands during
  active geomagnetic conditions.

  Auroral E-region scatter is observed almost exclusively on
  50/144/222 MHz, occasionally on 432 MHz, and essentially never at
  microwave (>432 MHz). The boost magnitude follows the NOAA G-scale:
  Kp 5+ storms open auroral paths up to ~2500 km, Kp 6 events are
  reliably workable from much of CONUS, and Kp 7+ are continent-wide.

  * `freq_mhz > 432` — pass-through (microwave doesn't see aurora)
  * `kp < 4` — pass-through (geomagnetically quiet)
  * `kp 4` (unsettled) — +5 (mild)
  * `kp 5` (G1) — +15 (moderate)
  * `kp 6` (G2) — +25 (strong)
  * `kp >= 7` (G3+) — +35 (saturated)

  Fractional `estimated_kp` from SWPC's per-minute feed rounds down
  to the lower integer band so the threshold edges are deterministic.
  Returns the score clamped to 0-100.
  """
  @spec aurora_boost(number(), number() | nil, map()) :: integer()
  def aurora_boost(score, nil, _band_config), do: clamp_score(score)

  def aurora_boost(score, _kp, %{freq_mhz: f}) when f > 432, do: clamp_score(score)

  def aurora_boost(score, kp, _band_config) when kp >= 7, do: clamp_score(score + 35)
  def aurora_boost(score, kp, _band_config) when kp >= 6, do: clamp_score(score + 25)
  def aurora_boost(score, kp, _band_config) when kp >= 5, do: clamp_score(score + 15)
  def aurora_boost(score, kp, _band_config) when kp >= 4, do: clamp_score(score + 5)
  def aurora_boost(score, _kp, _band_config), do: clamp_score(score)

  @doc """
  Standalone score for one propagation mechanism on this band.

  Returns a 0–100 integer. Unlike `aurora_boost/3` this does not
  add to anything — it's the score *of that mechanism alone*, so
  `cell_score/2` can take the max across the band's mechanism stack
  rather than summing tropo with a post-hoc bonus.

  ## `:tropo`

  Delegates to `composite_score/2`'s integer score — the existing
  weighted sum of HRRR-derived factors.

  ## `:aurora`

  Auroral E-region scatter is observed on 50 / 144 / 222 MHz,
  occasionally on 432 MHz, essentially never above. Scores follow
  the NOAA G-scale ceiling for each Kp band:

  * `kp` nil or `freq_mhz > 432` → 0
  * `kp < 4` → 0 (geomagnetically quiet)
  * `kp 4` (unsettled) → 40
  * `kp 5` (G1) → 65
  * `kp 6` (G2) → 85
  * `kp ≥ 7` (G3+) → 100

  Fractional `estimated_kp` from SWPC's per-minute feed truncates to
  the lower integer band so threshold edges stay deterministic.
  """
  @spec mechanism_score(atom(), map(), map()) :: integer()
  def mechanism_score(:tropo, conditions, band_config) do
    composite_score(conditions, band_config).score
  end

  def mechanism_score(:aurora, conditions, band_config) do
    aurora_only_score(conditions[:kp_index], band_config)
  end

  @doc """
  Cell-level propagation score for a band: `max` over the band's
  active mechanisms.

  The mechanism list comes from `BandConfig.mechanisms/1`. Tropo
  applies everywhere; aurora applies on VHF / low UHF when Kp is
  elevated. Es and F2 are inherently path-dependent and don't enter
  cell-level scoring — `Propagation.PathCompute` handles those.

  Conditions must include whatever each mechanism reads. For
  `:aurora` that's `:kp_index`; for `:tropo` it's the usual
  composite-score inputs.
  """
  @spec cell_score(map(), map()) :: integer()
  def cell_score(conditions, band_config) do
    band_config
    |> BandConfig.mechanisms()
    |> Enum.map(&mechanism_score(&1, conditions, band_config))
    |> Enum.max()
    |> clamp_score()
  end

  defp aurora_only_score(nil, _band_config), do: 0
  defp aurora_only_score(_kp, %{freq_mhz: f}) when f > 432, do: 0
  defp aurora_only_score(kp, _band_config) when kp >= 7, do: 100
  defp aurora_only_score(kp, _band_config) when kp >= 6, do: 85
  defp aurora_only_score(kp, _band_config) when kp >= 5, do: 65
  defp aurora_only_score(kp, _band_config) when kp >= 4, do: 40
  defp aurora_only_score(_kp, _band_config), do: 0

  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) when pct <= 6, do: 100
  def score_sky(pct) when pct <= 25, do: 88
  def score_sky(pct) when pct <= 50, do: 60
  def score_sky(pct) when pct <= 87, do: 25
  def score_sky(_pct), do: 5

  # ── 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) when speed_kts < 5, do: 100
  def score_wind(speed_kts) when speed_kts < 10, do: 90
  def score_wind(speed_kts) when speed_kts < 15, do: 75
  def score_wind(speed_kts) when speed_kts < 20, do: 55
  def score_wind(speed_kts) when speed_kts < 25, do: 35
  def score_wind(_speed_kts), do: 15

  # ── 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
    score_rain_gamma(k * :math.pow(rain_rate_mmhr, alpha))
  end

  defp score_rain_gamma(gamma) when gamma < 0.1, do: 95
  defp score_rain_gamma(gamma) when gamma < 0.5, do: 75
  defp score_rain_gamma(gamma) when gamma < 1.0, do: 50
  defp score_rain_gamma(gamma) when gamma < 2.0, do: 25
  defp score_rain_gamma(gamma) when gamma < 5.0, do: 10
  defp score_rain_gamma(_gamma), do: 0

  # ── 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: score_pwat_beneficial(pwat_mm)
  def score_pwat(pwat_mm, %{humidity_effect: :harmful}), do: score_pwat_harmful(pwat_mm)

  defp score_pwat_beneficial(pwat) when pwat < 10, do: 55
  defp score_pwat_beneficial(pwat) when pwat < 20, do: 75
  defp score_pwat_beneficial(pwat) when pwat < 30, do: 90
  defp score_pwat_beneficial(pwat) when pwat < 40, do: 70
  defp score_pwat_beneficial(_pwat), do: 50

  defp score_pwat_harmful(pwat) when pwat < 10, do: 95
  defp score_pwat_harmful(pwat) when pwat < 20, do: 80
  defp score_pwat_harmful(pwat) when pwat < 30, do: 60
  defp score_pwat_harmful(pwat) when pwat < 40, do: 35
  defp score_pwat_harmful(_pwat), do: 15

  # ── 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: score_pressure_absolute(current_mb)
  def score_pressure(current_mb, previous_mb), do: score_pressure_delta(current_mb - previous_mb)

  defp score_pressure_absolute(mb) when mb < 980, do: 88
  defp score_pressure_absolute(mb) when mb < 990, do: 82
  defp score_pressure_absolute(mb) when mb < 1000, do: 70
  defp score_pressure_absolute(mb) when mb < 1010, do: 55
  defp score_pressure_absolute(mb) when mb < 1020, do: 40
  defp score_pressure_absolute(_mb), do: 30

  defp score_pressure_delta(delta) when delta > 2.5, do: 80
  defp score_pressure_delta(delta) when delta > 0.8, do: 70
  defp score_pressure_delta(delta) when delta > -0.5, do: 60
  defp score_pressure_delta(delta) when delta > -2.0, do: 65
  defp score_pressure_delta(_delta), do: 45

  # ── 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
    # Contract: band invariants are either all precomputed (via
    # `precompute_band_invariants/1`, typically merged in by the grid
    # scorer for per-point reuse across bands) or all absent. We check
    # one key and derive the rest from the same branch so we never
    # silently mix cached and freshly-computed values.
    %{
      tod_score: tod_score,
      sky_score: sky_score,
      wind_score: wind_score,
      pressure_score: pressure_score
    } =
      if Map.has_key?(conditions, :tod_score) do
        conditions
      else
        precompute_band_invariants(conditions)
      end

    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

  @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

  @path_fields [
    {:temps, :surface_temp_c},
    {:dewpoints, :surface_dewpoint_c},
    {:pressures, :surface_pressure_mb},
    {:gradients, :min_refractivity_gradient},
    {:bl_depths, :hpbl_m},
    {:pwats, :pwat_mm}
  ]

  defp extract_profile_fields(profiles) do
    empty = Map.new(@path_fields, fn {bucket, _} -> {bucket, []} end)
    Enum.reduce(profiles, empty, &accumulate_profile/2)
  end

  defp accumulate_profile(profile, acc) do
    Enum.reduce(@path_fields, acc, fn {bucket, key}, acc ->
      maybe_prepend(acc, bucket, Map.get(profile, key))
    end)
  end

  defp maybe_prepend(acc, _bucket, nil), do: acc
  defp maybe_prepend(acc, bucket, value), do: Map.update!(acc, bucket, &[value | &1])

  defp build_path_conditions(%{temps: []}, _contact), do: nil
  defp build_path_conditions(%{dewpoints: []}, _contact), do: nil

  defp build_path_conditions(%{temps: temps, dewpoints: dewpoints} = buckets, contact) do
    lon = contact.pos1["lon"] || -97.0
    {sum_t, count_t} = Enum.reduce(temps, {0, 0}, fn x, {s, c} -> {s + x, c + 1} end)
    avg_temp_c = sum_t / count_t
    {sum_d, count_d} = Enum.reduce(dewpoints, {0, 0}, fn x, {s, c} -> {s + x, c + 1} end)
    avg_dewpoint_c = sum_d / count_d

    %{
      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: Enum.min(buckets.pressures, fn -> nil end),
      prev_pressure_mb: nil,
      rain_rate_mmhr: 0.0,
      min_refractivity_gradient: Enum.min(buckets.gradients, fn -> nil end),
      bl_depth_m: safe_avg(buckets.bl_depths),
      pwat_mm: safe_avg(buckets.pwats)
    }
  end

  defp safe_avg([]), do: nil

  defp safe_avg(list) do
    {sum, count} = Enum.reduce(list, {0, 0}, fn x, {s, c} -> {s + x, c + 1} end)
    sum / count
  end
end
