//! Propagation scorer — 1:1 port of `lib/microwaveprop/propagation/scorer.ex`.
//!
//! Every factor returns a 0..=100 integer score. The composite is
//! `round(Σ factor · weight)` with weights from `band_config`. All
//! thresholds live in `band_config` / `region` — nothing magic here.
//!
//! Behavior parity tests mirror the Elixir `ScorerTest` suite line-by-line;
//! integer scores match exactly.
//!
//! Elixir `Kernel.round/1` and Rust `f64::round` both use round-half-away-
//! from-zero, so no custom rounding helper is needed.

use crate::band_config::{
    BandConfig, HumidityEffect, Weights, DEFAULT_WEIGHTS, HUMIDITY_BENEFICIAL_DEFAULT,
    HUMIDITY_BENEFICIAL_THRESHOLDS, REFRACTIVITY_DEFAULT, REFRACTIVITY_THRESHOLDS, SUNRISE_TABLE,
};
use crate::region;

const BULK_RICHARDSON_STABLE_MAX: f64 = 25.0;

/// Inputs to `composite_score`. All f64 everywhere to stay close to
/// Erlang's single-float world.
#[derive(Debug, Clone, Default)]
pub struct Conditions {
    pub abs_humidity: f64,
    pub temp_f: f64,
    pub dewpoint_f: f64,
    pub wind_speed_kts: Option<f64>,
    pub sky_cover_pct: Option<f64>,
    pub utc_hour: u8,
    pub utc_minute: u8,
    pub month: u8,
    pub longitude: f64,
    pub latitude: Option<f64>,
    pub pressure_mb: Option<f64>,
    pub prev_pressure_mb: Option<f64>,
    pub rain_rate_mmhr: Option<f64>,
    pub min_refractivity_gradient: Option<f64>,
    pub bl_depth_m: Option<f64>,
    pub pwat_mm: Option<f64>,
    pub best_duct_band_ghz: Option<f64>,
    pub bulk_richardson: Option<f64>,
}

#[derive(Debug, Clone, Copy)]
pub struct BandInvariants {
    pub tod: i32,
    pub sky: i32,
    pub wind: i32,
    pub pressure: i32,
}

#[derive(Debug, Clone)]
pub struct Factors {
    pub humidity: i32,
    pub time_of_day: i32,
    pub td_depression: i32,
    pub refractivity: i32,
    pub sky: i32,
    pub season: i32,
    pub wind: i32,
    pub rain: i32,
    pub pwat: i32,
    pub pressure: i32,
}

#[derive(Debug, Clone)]
pub struct Composite {
    pub score: i32,
    pub factors: Factors,
}

// ── Helpers ──────────────────────────────────────────────────────────

pub fn f_to_c(f: f64) -> f64 {
    (f - 32.0) * 5.0 / 9.0
}

pub fn c_to_f(c: f64) -> f64 {
    c * 9.0 / 5.0 + 32.0
}

pub fn absolute_humidity(temp_c: f64, dewpoint_c: f64) -> f64 {
    let e_sat = 6.112 * (17.67 * dewpoint_c / (dewpoint_c + 243.5)).exp();
    217.0 * e_sat / (temp_c + 273.15)
}

pub fn wind_speed_kts(u_ms: f64, v_ms: f64) -> f64 {
    (u_ms * u_ms + v_ms * v_ms).sqrt() * 1.94384
}

pub fn precip_to_rate_mmhr(mm: Option<f64>) -> f64 {
    match mm {
        Some(v) if v > 0.0 => v,
        _ => 0.0,
    }
}

/// Marshall-Palmer Z-R: `Z = 200 · R^1.6` → `R = (Z/200)^(1/1.6)`, `Z = 10^(dBZ/10)`.
/// Clips below 5 dBZ to 0 (ground clutter) and above 150 mm/hr (hail).
pub fn dbz_to_rain_rate_mmhr(dbz: Option<f64>) -> f64 {
    match dbz {
        None => 0.0,
        Some(v) if v < 5.0 => 0.0,
        Some(v) => {
            let z = 10f64.powf(v / 10.0);
            (z / 200.0).powf(1.0 / 1.6).min(150.0)
        }
    }
}

fn clamp_score_f(v: f64) -> i32 {
    (v.round() as i64).clamp(0, 100) as i32
}

fn clamp_score(v: i32) -> i32 {
    v.clamp(0, 100)
}

// ── Factor 1: humidity ───────────────────────────────────────────────

pub fn score_humidity(abs_humidity: f64, band: &BandConfig) -> i32 {
    match band.humidity_effect {
        HumidityEffect::Beneficial => HUMIDITY_BENEFICIAL_THRESHOLDS
            .iter()
            .find(|(max, _)| abs_humidity < *max as f64)
            .map(|(_, s)| *s)
            .unwrap_or(HUMIDITY_BENEFICIAL_DEFAULT),
        HumidityEffect::Harmful => {
            let r = abs_humidity * band.humidity_penalty;
            if r <= 6.0 {
                100
            } else if r <= 9.0 {
                (95.0 - (r - 6.0) / 3.0 * 20.0).round() as i32
            } else if r <= 13.0 {
                (75.0 - (r - 9.0) / 4.0 * 30.0).round() as i32
            } else if r <= 18.0 {
                (45.0 - (r - 13.0) / 5.0 * 35.0).round() as i32
            } else {
                ((10.0 - (r - 18.0) * 2.0).round() as i32).max(0)
            }
        }
    }
}

// ── Factor 2: time of day ────────────────────────────────────────────

pub fn score_time_of_day(
    utc_hour: u8,
    utc_minute: u8,
    month: u8,
    longitude: f64,
) -> (i32, &'static str) {
    let offset = longitude / 15.0;
    let raw = utc_hour as f64 + utc_minute as f64 / 60.0 + offset + 24.0;
    // Match Elixir :math.fmod/2 — same semantics as fmod.
    let local = raw.rem_euclid(24.0);
    let sunrise = SUNRISE_TABLE[(month - 1) as usize];
    let d = local - sunrise;

    if (-1.5..=1.5).contains(&d) {
        (100, "Peak — inversion maximum")
    } else if d > 1.5 && d <= 3.0 {
        (78, "Good — inversion eroding")
    } else if d > -3.0 && d < -1.5 {
        (82, "Pre-dawn — inversion building")
    } else if d > 3.0 && d <= 6.0 {
        (38, "Marginal — boundary layer mixing")
    } else if local >= 20.0 || local <= 1.0 {
        (72, "Evening — cooling, inversion reforming")
    } else if d > 6.0 {
        (18, "Afternoon — full convective mixing")
    } else {
        (55, "Night — gradual cooling")
    }
}

// ── Factor 3: T-Td depression ────────────────────────────────────────

pub fn score_td_depression(temp_f: f64, dewpoint_f: f64, band: &BandConfig) -> i32 {
    let dep = temp_f - dewpoint_f;
    match band.humidity_effect {
        HumidityEffect::Beneficial => {
            if dep < 3.0 {
                40
            } else if dep < 8.0 {
                75
            } else if dep < 14.0 {
                85
            } else if dep < 22.0 {
                70
            } else {
                55
            }
        }
        HumidityEffect::Harmful => {
            if dep > 22.0 {
                96
            } else if dep > 14.0 {
                80
            } else if dep > 8.0 {
                60
            } else if dep > 4.0 {
                38
            } else {
                18
            }
        }
    }
}

// ── Factor 4: refractivity gradient (+ HPBL multiplier + native duct boost) ──

pub fn score_refractivity(
    min_gradient: Option<f64>,
    bl_depth_m: Option<f64>,
    best_duct_band_ghz: Option<f64>,
    bulk_richardson: Option<f64>,
    band: &BandConfig,
) -> i32 {
    let Some(gradient) = min_gradient else {
        return 50;
    };

    let base_match = REFRACTIVITY_THRESHOLDS
        .iter()
        .find(|(max, _, _)| gradient < *max as f64);

    let base = match base_match {
        Some((_, b, h)) => match band.humidity_effect {
            HumidityEffect::Beneficial => *b,
            HumidityEffect::Harmful => *h,
        },
        None => {
            let (b, h) = REFRACTIVITY_DEFAULT;
            if band.humidity_effect == HumidityEffect::Beneficial {
                b
            } else {
                h
            }
        }
    };

    let with_hpbl = apply_hpbl_multiplier(base, bl_depth_m);
    apply_native_duct_boost(
        with_hpbl,
        best_duct_band_ghz,
        bulk_richardson,
        band.freq_mhz,
    )
}

fn apply_hpbl_multiplier(base: i32, hpbl: Option<f64>) -> i32 {
    let mul = match hpbl {
        None => return base,
        Some(v) if v < 200.0 => 1.10,
        Some(v) if v < 500.0 => 1.05,
        Some(v) if v < 1500.0 => 1.0,
        Some(v) if v < 2000.0 => 0.92,
        Some(_) => 0.78,
    };
    clamp_score_f(base as f64 * mul)
}

fn apply_native_duct_boost(
    base: i32,
    best_duct_band_ghz: Option<f64>,
    bulk_richardson: Option<f64>,
    freq_mhz: u32,
) -> i32 {
    let Some(duct_band_ghz) = best_duct_band_ghz else {
        return base;
    };
    let target_ghz = freq_mhz as f64 / 1000.0;
    let stable = match bulk_richardson {
        None => true,
        Some(r) => r < BULK_RICHARDSON_STABLE_MAX,
    };
    if duct_band_ghz >= target_ghz && stable {
        clamp_score_f(base as f64 * 1.15)
    } else {
        base
    }
}

// ── Factor 5: sky cover ──────────────────────────────────────────────

pub fn score_sky(pct: Option<f64>) -> i32 {
    match pct {
        None => 50,
        Some(p) if p <= 6.0 => 100,
        Some(p) if p <= 25.0 => 88,
        Some(p) if p <= 50.0 => 60,
        Some(p) if p <= 87.0 => 25,
        Some(_) => 5,
    }
}

// ── Factor 6: season ─────────────────────────────────────────────────

pub fn score_season(month: u8, lat: Option<f64>, lon: Option<f64>, band: &BandConfig) -> i32 {
    let idx = (month - 1) as usize;
    let base = band.seasonal_base[idx];
    let adj = band.seasonal_adj[idx];

    let region_mult = match (lat, lon) {
        (Some(la), Some(lo)) => region::seasonal_adjustment(region::for_point(la, lo), month),
        _ => 1.0,
    };

    let raw = (base + adj) as f64 * region_mult;
    clamp_score_f(raw)
}

// ── Factor 7: wind ───────────────────────────────────────────────────

pub fn score_wind(speed_kts: Option<f64>) -> i32 {
    match speed_kts {
        None => 50,
        Some(s) if s < 5.0 => 100,
        Some(s) if s < 10.0 => 90,
        Some(s) if s < 15.0 => 75,
        Some(s) if s < 20.0 => 55,
        Some(s) if s < 25.0 => 35,
        Some(_) => 15,
    }
}

// ── Factor 8: rain attenuation (ITU-R P.838) ────────────────────────

pub fn score_rain(rate_mmhr: Option<f64>, band: &BandConfig) -> i32 {
    match rate_mmhr {
        None => 100,
        Some(0.0) => 100,
        Some(v) => {
            let gamma = band.rain_k * v.powf(band.rain_alpha);
            if gamma < 0.1 {
                95
            } else if gamma < 0.5 {
                75
            } else if gamma < 1.0 {
                50
            } else if gamma < 2.0 {
                25
            } else if gamma < 5.0 {
                10
            } else {
                0
            }
        }
    }
}

// ── Factor 9: precipitable water ────────────────────────────────────

pub fn score_pwat(pwat_mm: Option<f64>, band: &BandConfig) -> i32 {
    let Some(v) = pwat_mm else { return 60 };
    match band.humidity_effect {
        HumidityEffect::Beneficial => {
            if v < 10.0 {
                55
            } else if v < 20.0 {
                75
            } else if v < 30.0 {
                90
            } else if v < 40.0 {
                70
            } else {
                50
            }
        }
        HumidityEffect::Harmful => {
            if v < 10.0 {
                95
            } else if v < 20.0 {
                80
            } else if v < 30.0 {
                60
            } else if v < 40.0 {
                35
            } else {
                15
            }
        }
    }
}

// ── Factor 10: pressure ─────────────────────────────────────────────

pub fn score_pressure(current_mb: Option<f64>, previous_mb: Option<f64>) -> i32 {
    let Some(current) = current_mb else { return 50 };
    match previous_mb {
        None => {
            if current < 980.0 {
                88
            } else if current < 990.0 {
                82
            } else if current < 1000.0 {
                70
            } else if current < 1010.0 {
                55
            } else if current < 1020.0 {
                40
            } else {
                30
            }
        }
        Some(prev) => {
            let delta = current - prev;
            if delta > 2.5 {
                80
            } else if delta > 0.8 {
                70
            } else if delta > -0.5 {
                60
            } else if delta > -2.0 {
                65
            } else {
                45
            }
        }
    }
}

// ── Composite ───────────────────────────────────────────────────────

pub fn precompute_band_invariants(c: &Conditions) -> BandInvariants {
    let (tod, _) = score_time_of_day(c.utc_hour, c.utc_minute, c.month, c.longitude);
    BandInvariants {
        tod,
        sky: score_sky(c.sky_cover_pct),
        wind: score_wind(c.wind_speed_kts),
        pressure: score_pressure(c.pressure_mb, c.prev_pressure_mb),
    }
}

pub fn composite_score(c: &Conditions, band: &BandConfig) -> Composite {
    composite_score_with(c, band, None)
}

pub fn composite_score_with(
    c: &Conditions,
    band: &BandConfig,
    precomputed: Option<BandInvariants>,
) -> Composite {
    let inv = precomputed.unwrap_or_else(|| precompute_band_invariants(c));

    let factors = Factors {
        humidity: score_humidity(c.abs_humidity, band),
        time_of_day: inv.tod,
        td_depression: score_td_depression(c.temp_f, c.dewpoint_f, band),
        refractivity: score_refractivity(
            c.min_refractivity_gradient,
            c.bl_depth_m,
            c.best_duct_band_ghz,
            c.bulk_richardson,
            band,
        ),
        sky: inv.sky,
        season: score_season(
            c.month,
            c.latitude,
            Some(c.longitude).filter(|_| c.latitude.is_some()),
            band,
        ),
        wind: inv.wind,
        rain: score_rain(c.rain_rate_mmhr, band),
        pwat: score_pwat(c.pwat_mm, band),
        pressure: inv.pressure,
    };

    let w = band.weights.unwrap_or(DEFAULT_WEIGHTS);
    let weighted = weighted_sum(&factors, &w);
    Composite {
        score: clamp_score_f(weighted),
        factors,
    }
}

fn weighted_sum(f: &Factors, w: &Weights) -> f64 {
    f.humidity as f64 * w.humidity
        + f.time_of_day as f64 * w.time_of_day
        + f.td_depression as f64 * w.td_depression
        + f.refractivity as f64 * w.refractivity
        + f.sky as f64 * w.sky
        + f.season as f64 * w.season
        + f.wind as f64 * w.wind
        + f.rain as f64 * w.rain
        + f.pressure as f64 * w.pressure
        + f.pwat as f64 * w.pwat
}

/// Commercial-link inverse-sensor boost. Not used in the grid hot path
/// (f00-only — Elixir keeps that) but ported so unit-level correctness
/// stays testable from the Rust side.
pub fn commercial_link_boost(score: i32, n_links: u32, degradation_db: f64) -> i32 {
    if n_links == 0 || degradation_db < 3.0 {
        return clamp_score(score);
    }
    if degradation_db < 8.0 {
        let bonus = 2.0 + (degradation_db - 3.0) * 8.0 / 5.0;
        clamp_score_f(score as f64 + bonus)
    } else {
        let bonus = (10.0 + (degradation_db - 8.0) * 15.0 / 8.0).min(25.0);
        clamp_score_f(score as f64 + bonus)
    }
}

// ── Tests (mirror Elixir ScorerTest) ────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::band_config;

    fn b10g() -> &'static BandConfig {
        band_config::get(10_000).unwrap()
    }
    fn b24g() -> &'static BandConfig {
        band_config::get(24_000).unwrap()
    }
    fn b75g() -> &'static BandConfig {
        band_config::get(75_000).unwrap()
    }

    const EAST_LON: f64 = -75.0;

    #[test]
    fn f_to_c_boundaries() {
        assert!((f_to_c(32.0) - 0.0).abs() < 0.01);
        assert!((f_to_c(212.0) - 100.0).abs() < 0.01);
    }

    #[test]
    fn c_to_f_boundaries() {
        assert!((c_to_f(0.0) - 32.0).abs() < 0.01);
        assert!((c_to_f(100.0) - 212.0).abs() < 0.01);
    }

    #[test]
    fn absolute_humidity_typical_summer() {
        let ah = absolute_humidity(25.0, 20.0);
        assert!(ah > 15.0 && ah < 20.0, "{ah}");
    }

    #[test]
    fn absolute_humidity_cold_dry() {
        let ah = absolute_humidity(0.0, -5.0);
        assert!(ah > 2.0 && ah < 5.0, "{ah}");
    }

    #[test]
    fn wind_speed_3_4_kts() {
        assert!((wind_speed_kts(3.0, 4.0) - 9.72).abs() < 0.1);
    }

    #[test]
    fn precip_to_rate_handles_nil_zero_negative() {
        assert_eq!(precip_to_rate_mmhr(Some(2.5)), 2.5);
        assert_eq!(precip_to_rate_mmhr(Some(0.0)), 0.0);
        assert_eq!(precip_to_rate_mmhr(Some(-1.0)), 0.0);
        assert_eq!(precip_to_rate_mmhr(None), 0.0);
    }

    #[test]
    fn dbz_to_rain_rate_clips_and_interpolates() {
        assert_eq!(dbz_to_rain_rate_mmhr(None), 0.0);
        assert_eq!(dbz_to_rain_rate_mmhr(Some(3.0)), 0.0);
        assert!((dbz_to_rain_rate_mmhr(Some(20.0)) - 0.66).abs() < 0.05);
        assert!((dbz_to_rain_rate_mmhr(Some(30.0)) - 2.7).abs() < 0.2);
        assert!((dbz_to_rain_rate_mmhr(Some(45.0)) - 23.7).abs() < 1.0);
        assert!(dbz_to_rain_rate_mmhr(Some(55.0)) <= 150.0);
        assert!(dbz_to_rain_rate_mmhr(Some(65.0)) <= 150.0);
    }

    // ── humidity ─────────────────────────────────────────────────

    #[test]
    fn humidity_beneficial_table() {
        assert_eq!(score_humidity(3.0, b10g()), 55);
        assert_eq!(score_humidity(9.0, b10g()), 82);
        assert_eq!(score_humidity(16.0, b10g()), 95);
        assert_eq!(score_humidity(20.0, b10g()), 88);
        assert_eq!(score_humidity(25.0, b10g()), 75);
    }

    #[test]
    fn humidity_harmful_piecewise() {
        assert_eq!(score_humidity(2.0, b24g()), 100);
        assert_eq!(score_humidity(5.0, b24g()), 82);
        assert_eq!(score_humidity(10.0, b24g()), 24);
        assert_eq!(score_humidity(20.0, b24g()), 0);
    }

    // ── time of day ──────────────────────────────────────────────

    #[test]
    fn time_of_day_dawn_peak() {
        let (s, l) = score_time_of_day(11, 15, 6, EAST_LON);
        assert_eq!(s, 100);
        assert!(l.contains("Peak"));
    }

    #[test]
    fn time_of_day_afternoon() {
        let (s, _) = score_time_of_day(22, 0, 6, EAST_LON);
        assert_eq!(s, 18);
    }

    #[test]
    fn time_of_day_pre_dawn() {
        let (s, l) = score_time_of_day(9, 0, 6, EAST_LON);
        assert_eq!(s, 82);
        assert!(l.contains("Pre-dawn"));
    }

    #[test]
    fn time_of_day_evening() {
        let (s, l) = score_time_of_day(1, 0, 6, EAST_LON);
        assert_eq!(s, 72);
        assert!(l.contains("Evening"));
    }

    #[test]
    fn time_of_day_western_longitude_shifts_earlier() {
        let (s, _) = score_time_of_day(13, 24, 1, -90.0);
        assert_eq!(s, 100);
    }

    #[test]
    fn time_of_day_same_utc_different_longitudes() {
        let (east, _) = score_time_of_day(18, 0, 6, -75.0);
        let (west, _) = score_time_of_day(18, 0, 6, -120.0);
        assert!(west > east);
    }

    // ── td depression ────────────────────────────────────────────

    #[test]
    fn td_beneficial() {
        assert_eq!(score_td_depression(72.0, 70.0, b10g()), 40);
        assert_eq!(score_td_depression(80.0, 70.0, b10g()), 85);
        assert_eq!(score_td_depression(100.0, 50.0, b10g()), 55);
    }

    #[test]
    fn td_harmful() {
        assert_eq!(score_td_depression(72.0, 70.0, b24g()), 18);
        assert_eq!(score_td_depression(80.0, 70.0, b24g()), 60);
        assert_eq!(score_td_depression(100.0, 50.0, b24g()), 96);
    }

    // ── refractivity ─────────────────────────────────────────────

    const BASELINE_BL: Option<f64> = Some(750.0);

    #[test]
    fn refractivity_strong_gradient_beneficial() {
        assert_eq!(
            score_refractivity(Some(-600.0), BASELINE_BL, None, None, b10g()),
            98
        );
    }

    #[test]
    fn refractivity_strong_gradient_harmful() {
        assert_eq!(
            score_refractivity(Some(-250.0), BASELINE_BL, None, None, b24g()),
            85
        );
    }

    #[test]
    fn refractivity_moderate_gradient_beneficial() {
        assert_eq!(
            score_refractivity(Some(-160.0), BASELINE_BL, None, None, b10g()),
            92
        );
    }

    #[test]
    fn refractivity_nil_gradient_returns_50() {
        assert_eq!(
            score_refractivity(None, BASELINE_BL, None, None, b10g()),
            50
        );
    }

    #[test]
    fn refractivity_nil_bl_no_multiplier() {
        assert_eq!(
            score_refractivity(Some(-160.0), None, None, None, b10g()),
            92
        );
    }

    #[test]
    fn shallow_bl_boosts_score() {
        let shallow = score_refractivity(Some(-100.0), Some(150.0), None, None, b10g());
        let baseline = score_refractivity(Some(-100.0), BASELINE_BL, None, None, b10g());
        assert!(shallow > baseline);
        assert!(shallow <= 100);
    }

    #[test]
    fn deep_bl_penalises_score() {
        let deep = score_refractivity(Some(-100.0), Some(2200.0), None, None, b10g());
        let baseline = score_refractivity(Some(-100.0), BASELINE_BL, None, None, b10g());
        assert!(deep < baseline);
        assert!(deep >= 0);
    }

    #[test]
    fn shallow_bl_also_lifts_default_fallback() {
        let shallow = score_refractivity(Some(-30.0), Some(150.0), None, None, b10g());
        let baseline = score_refractivity(Some(-30.0), BASELINE_BL, None, None, b10g());
        assert!(shallow > baseline);
    }

    #[test]
    fn native_duct_boosts_matching_band() {
        let boosted = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), None, b10g());
        let plain = score_refractivity(Some(-80.0), Some(800.0), None, None, b10g());
        assert!(boosted > plain);
        assert!(boosted <= 100);
    }

    #[test]
    fn native_duct_below_target_no_boost() {
        let unchanged = score_refractivity(Some(-80.0), Some(800.0), Some(3.0), None, b10g());
        let plain = score_refractivity(Some(-80.0), Some(800.0), None, None, b10g());
        assert_eq!(unchanged, plain);
    }

    #[test]
    fn richardson_gates_the_boost() {
        let stable = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), Some(12.0), b10g());
        let plain = score_refractivity(Some(-80.0), Some(800.0), None, None, b10g());
        assert!(stable > plain);

        let turbulent =
            score_refractivity(Some(-80.0), Some(800.0), Some(15.0), Some(40.0), b10g());
        assert_eq!(turbulent, plain);

        let nil_r = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), None, b10g());
        let four_arity_equiv =
            score_refractivity(Some(-80.0), Some(800.0), Some(15.0), None, b10g());
        assert_eq!(nil_r, four_arity_equiv);

        let boundary = score_refractivity(Some(-80.0), Some(800.0), Some(15.0), Some(25.0), b10g());
        assert_eq!(boundary, plain);
    }

    // ── commercial link boost ────────────────────────────────────

    #[test]
    fn commercial_link_noop_cases() {
        assert_eq!(commercial_link_boost(75, 0, 10.0), 75);
        assert_eq!(commercial_link_boost(75, 3, 2.0), 75);
    }

    #[test]
    fn commercial_link_mild_boost() {
        let b = commercial_link_boost(75, 3, 5.0);
        assert!(b > 75 && b < 90, "{b}");
    }

    #[test]
    fn commercial_link_strong_boost_capped() {
        let b = commercial_link_boost(75, 3, 12.0);
        assert!(b > 85 && b <= 100, "{b}");
    }

    // ── sky ──────────────────────────────────────────────────────

    #[test]
    fn sky_table() {
        assert_eq!(score_sky(Some(5.0)), 100);
        assert_eq!(score_sky(Some(20.0)), 88);
        assert_eq!(score_sky(Some(40.0)), 60);
        assert_eq!(score_sky(Some(75.0)), 25);
        assert_eq!(score_sky(Some(95.0)), 5);
        assert_eq!(score_sky(None), 50);
    }

    // ── season ───────────────────────────────────────────────────

    #[test]
    fn season_table_basic() {
        assert_eq!(score_season(7, None, None, b10g()), 95);
        assert_eq!(score_season(3, None, None, b10g()), 22);
        assert_eq!(score_season(11, None, None, b24g()), 96);
        assert_eq!(score_season(7, None, None, b24g()), 8);
    }

    #[test]
    fn season_gulf_coast_august_boost() {
        let no_region = score_season(8, None, None, b10g());
        let gulf = score_season(8, Some(29.0), Some(-95.0), b10g());
        assert!(gulf > no_region);
    }

    #[test]
    fn season_corn_belt_august_penalty() {
        let no_region = score_season(8, None, None, b10g());
        let iowa = score_season(8, Some(42.0), Some(-93.0), b10g());
        assert!(iowa < no_region);
    }

    // ── wind ─────────────────────────────────────────────────────

    #[test]
    fn wind_table() {
        assert_eq!(score_wind(Some(3.0)), 100);
        assert_eq!(score_wind(Some(8.0)), 90);
        assert_eq!(score_wind(Some(12.0)), 75);
        assert_eq!(score_wind(Some(22.0)), 35);
        assert_eq!(score_wind(Some(30.0)), 15);
        assert_eq!(score_wind(None), 50);
    }

    // ── rain ─────────────────────────────────────────────────────

    #[test]
    fn rain_zero_and_nil_full_score() {
        assert_eq!(score_rain(Some(0.0), b24g()), 100);
        assert_eq!(score_rain(None, b24g()), 100);
    }

    #[test]
    fn rain_24g_light() {
        // gamma = 0.070 * 2.0^1.07 ≈ 0.147 → 75
        assert_eq!(score_rain(Some(2.0), b24g()), 75);
    }

    #[test]
    fn rain_10g_heavy_still_high() {
        // gamma = 0.010 * 5.0^1.28 ≈ 0.076 → 95
        assert_eq!(score_rain(Some(5.0), b10g()), 95);
    }

    #[test]
    fn rain_75g_heavy_drops() {
        // gamma = 0.345 * 10^0.84 ≈ 2.39 → 10
        assert_eq!(score_rain(Some(10.0), b75g()), 10);
    }

    // ── pwat ─────────────────────────────────────────────────────

    #[test]
    fn pwat_beneficial_table() {
        assert_eq!(score_pwat(Some(5.0), b10g()), 55);
        assert_eq!(score_pwat(Some(15.0), b10g()), 75);
        assert_eq!(score_pwat(Some(25.0), b10g()), 90);
        assert_eq!(score_pwat(Some(35.0), b10g()), 70);
        assert_eq!(score_pwat(Some(45.0), b10g()), 50);
    }

    #[test]
    fn pwat_harmful_table() {
        assert_eq!(score_pwat(Some(5.0), b24g()), 95);
        assert_eq!(score_pwat(Some(15.0), b24g()), 80);
        assert_eq!(score_pwat(Some(25.0), b24g()), 60);
        assert_eq!(score_pwat(Some(35.0), b24g()), 35);
        assert_eq!(score_pwat(Some(45.0), b24g()), 15);
    }

    #[test]
    fn pwat_nil_returns_60() {
        assert_eq!(score_pwat(None, b10g()), 60);
        assert_eq!(score_pwat(None, b24g()), 60);
    }

    // ── pressure ─────────────────────────────────────────────────

    #[test]
    fn pressure_standalone_table() {
        assert_eq!(score_pressure(Some(1028.0), None), 30);
        assert_eq!(score_pressure(Some(1020.0), None), 30);
        assert_eq!(score_pressure(Some(1000.0), None), 55);
    }

    #[test]
    fn pressure_deltas() {
        assert_eq!(score_pressure(Some(1020.0), Some(1015.0)), 80);
        assert_eq!(score_pressure(Some(1016.0), Some(1015.0)), 70);
        assert_eq!(score_pressure(Some(1015.0), Some(1015.3)), 60);
        assert_eq!(score_pressure(Some(1014.0), Some(1015.0)), 65);
        assert_eq!(score_pressure(Some(1010.0), Some(1015.0)), 45);
    }

    // ── composite ────────────────────────────────────────────────

    fn canonical_conditions() -> Conditions {
        Conditions {
            abs_humidity: 10.0,
            temp_f: 80.0,
            dewpoint_f: 70.0,
            wind_speed_kts: Some(5.0),
            sky_cover_pct: Some(20.0),
            utc_hour: 11,
            utc_minute: 15,
            month: 6,
            longitude: -75.0,
            latitude: None,
            pressure_mb: Some(1020.0),
            prev_pressure_mb: Some(1018.0),
            rain_rate_mmhr: Some(0.0),
            min_refractivity_gradient: Some(-350.0),
            bl_depth_m: Some(400.0),
            pwat_mm: Some(25.0),
            best_duct_band_ghz: None,
            bulk_richardson: None,
        }
    }

    #[test]
    fn composite_returns_0_100_score() {
        let r = composite_score(&canonical_conditions(), b10g());
        assert!(r.score >= 0 && r.score <= 100);
    }

    #[test]
    fn composite_all_factors_populated() {
        let r = composite_score(&canonical_conditions(), b10g());
        let f = &r.factors;
        for s in [
            f.humidity,
            f.time_of_day,
            f.td_depression,
            f.refractivity,
            f.sky,
            f.season,
            f.wind,
            f.rain,
            f.pwat,
            f.pressure,
        ] {
            assert!((0..=100).contains(&s), "factor out of range: {s}");
        }
    }

    #[test]
    fn precomputed_invariants_match_fresh() {
        let c = canonical_conditions();
        let inv = precompute_band_invariants(&c);
        let fresh = composite_score(&c, b10g());
        let reused = composite_score_with(&c, b10g(), Some(inv));
        assert_eq!(fresh.factors.time_of_day, reused.factors.time_of_day);
        assert_eq!(fresh.factors.sky, reused.factors.sky);
        assert_eq!(fresh.factors.wind, reused.factors.wind);
        assert_eq!(fresh.factors.pressure, reused.factors.pressure);
        assert_eq!(fresh.score, reused.score);
    }

    #[test]
    fn composite_uses_band_weights() {
        let r = composite_score(&canonical_conditions(), b10g());
        let w = DEFAULT_WEIGHTS;
        let expected = r.factors.humidity as f64 * w.humidity
            + r.factors.time_of_day as f64 * w.time_of_day
            + r.factors.td_depression as f64 * w.td_depression
            + r.factors.refractivity as f64 * w.refractivity
            + r.factors.sky as f64 * w.sky
            + r.factors.season as f64 * w.season
            + r.factors.wind as f64 * w.wind
            + r.factors.rain as f64 * w.rain
            + r.factors.pwat as f64 * w.pwat
            + r.factors.pressure as f64 * w.pressure;
        assert!((r.score as f64 - expected.round()).abs() <= 1.0);
    }

    #[test]
    fn composite_10g_vs_24g_differ() {
        let c = canonical_conditions();
        let r10 = composite_score(&c, b10g());
        let r24 = composite_score(&c, b24g());
        assert_ne!(r10.score, r24.score);
    }
}
