//! Band configuration tables. 1:1 port of
//! `lib/microwaveprop/propagation/band_config.ex`.
//!
//! Keep the data layout identical to the Elixir module; any numerical
//! drift here will show up immediately in golden-fixture scorer tests.

use std::sync::OnceLock;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HumidityEffect {
    Beneficial,
    Harmful,
}

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

impl Weights {
    #[allow(clippy::too_many_arguments)]
    pub const fn new(
        humidity: f64,
        time_of_day: f64,
        td_depression: f64,
        refractivity: f64,
        sky: f64,
        season: f64,
        wind: f64,
        rain: f64,
        pressure: f64,
        pwat: f64,
    ) -> Self {
        Self {
            humidity,
            time_of_day,
            td_depression,
            refractivity,
            sky,
            season,
            wind,
            rain,
            pressure,
            pwat,
        }
    }
}

/// Default weight vector fit by gradient descent on 5,000 QSOs
/// (2026-04-11). Used as fallback for bands that don't carry a `weights`
/// override.
pub const DEFAULT_WEIGHTS: Weights = Weights::new(
    0.1243, // humidity
    0.0496, // time_of_day
    0.0978, // td_depression
    0.1049, // refractivity
    0.08,   // sky
    0.1112, // season
    0.08,   // wind
    0.1362, // rain
    0.1032, // pressure
    0.1128, // pwat
);

/// Sunrise hour by month (1..=12) in local solar time. Index = month - 1.
pub const SUNRISE_TABLE: [f64; 12] = [
    7.4, 7.3, 7.0, 6.7, 6.35, 6.25, 6.35, 6.65, 6.9, 7.1, 7.35, 7.45,
];

pub const HUMIDITY_BENEFICIAL_THRESHOLDS: &[(u8, i32)] =
    &[(4, 55), (7, 70), (10, 82), (14, 90), (18, 95), (22, 88)];
pub const HUMIDITY_BENEFICIAL_DEFAULT: i32 = 75;

/// `(gradient_max, score_beneficial, score_harmful)`. First entry whose
/// `gradient_max` exceeds the observed gradient wins. Calibrated for
/// HRRR-derived gradients.
pub const REFRACTIVITY_THRESHOLDS: &[(i32, i32, i32)] = &[
    (-200, 98, 85),
    (-150, 92, 80),
    (-100, 82, 72),
    (-75, 68, 62),
    (-55, 55, 55),
    (-40, 48, 48),
];
pub const REFRACTIVITY_DEFAULT: (i32, i32) = (42, 42);

#[derive(Debug, Clone)]
pub struct BandConfig {
    pub freq_mhz: u32,
    pub label: &'static str,
    pub o2_db_km: f64,
    pub h2o_coeff: f64,
    pub humidity_effect: HumidityEffect,
    pub humidity_penalty: f64,
    pub rain_k: f64,
    pub rain_alpha: f64,
    /// Per-month base score for seasonal factor, index = month - 1.
    pub seasonal_base: [i32; 12],
    /// Per-month additive adjustment, index = month - 1.
    pub seasonal_adj: [i32; 12],
    pub typical_range_km: u32,
    pub extended_range_km: u32,
    pub exceptional_range_km: u32,
    /// `None` falls back to `DEFAULT_WEIGHTS`.
    pub weights: Option<Weights>,
}

impl BandConfig {
    pub fn weights(&self) -> Weights {
        self.weights.unwrap_or(DEFAULT_WEIGHTS)
    }
}

/// Fixed-size literal helper; month values given as (month, score) pairs
/// in Elixir map form. Panics at construction if a month is out of range.
const fn months(v: [(u8, i32); 12]) -> [i32; 12] {
    // Build by iterating; can't use const fn loop with indirect writes
    // cleanly, so use a match per position.
    let mut out = [0; 12];
    let mut i = 0;
    while i < 12 {
        let (m, s) = v[i];
        // Month values always 1..=12, stored at index month - 1 is the
        // convention in Elixir — we rely on the literal being in month
        // order here (it is in every band config).
        assert!(m >= 1 && m <= 12);
        out[(m - 1) as usize] = s;
        i += 1;
    }
    out
}

const fn adj_from(values: &[(u8, i32)]) -> [i32; 12] {
    let mut out = [0; 12];
    let mut i = 0;
    while i < values.len() {
        let (m, s) = values[i];
        assert!(m >= 1 && m <= 12);
        out[(m - 1) as usize] = s;
        i += 1;
    }
    out
}

// The seasonal_base tables are identical across many bands; pull out the
// common shape once so the 22 band definitions stay scannable.
const BASE_LOW_BAND: [i32; 12] = months([
    (1, 38),
    (2, 40),
    (3, 22),
    (4, 55),
    (5, 68),
    (6, 90),
    (7, 95),
    (8, 75),
    (9, 78),
    (10, 88),
    (11, 78),
    (12, 25),
]);

const BASE_50: [i32; 12] = months([
    (1, 30),
    (2, 32),
    (3, 25),
    (4, 50),
    (5, 70),
    (6, 95),
    (7, 95),
    (8, 70),
    (9, 65),
    (10, 70),
    (11, 60),
    (12, 25),
]);

const BASE_24G: [i32; 12] = months([
    (1, 88),
    (2, 84),
    (3, 68),
    (4, 62),
    (5, 51),
    (6, 34),
    (7, 18),
    (8, 18),
    (9, 48),
    (10, 68),
    (11, 96),
    (12, 88),
]);

const BASE_47G: [i32; 12] = months([
    (1, 90),
    (2, 88),
    (3, 78),
    (4, 68),
    (5, 55),
    (6, 38),
    (7, 22),
    (8, 22),
    (9, 48),
    (10, 74),
    (11, 96),
    (12, 90),
]);

const BASE_68G: [i32; 12] = months([
    (1, 90),
    (2, 88),
    (3, 78),
    (4, 65),
    (5, 50),
    (6, 32),
    (7, 18),
    (8, 18),
    (9, 44),
    (10, 70),
    (11, 92),
    (12, 90),
]);

const BASE_75G: [i32; 12] = months([
    (1, 90),
    (2, 90),
    (3, 80),
    (4, 68),
    (5, 55),
    (6, 38),
    (7, 22),
    (8, 22),
    (9, 48),
    (10, 74),
    (11, 96),
    (12, 90),
]);

const BASE_122G: [i32; 12] = months([
    (1, 92),
    (2, 90),
    (3, 78),
    (4, 62),
    (5, 45),
    (6, 28),
    (7, 15),
    (8, 15),
    (9, 38),
    (10, 68),
    (11, 92),
    (12, 92),
]);

const BASE_134G: [i32; 12] = months([
    (1, 92),
    (2, 90),
    (3, 78),
    (4, 65),
    (5, 48),
    (6, 30),
    (7, 18),
    (8, 18),
    (9, 42),
    (10, 70),
    (11, 92),
    (12, 92),
]);

const BASE_142G: [i32; 12] = months([
    (1, 92),
    (2, 90),
    (3, 78),
    (4, 65),
    (5, 47),
    (6, 28),
    (7, 16),
    (8, 16),
    (9, 40),
    (10, 68),
    (11, 92),
    (12, 92),
]);

const BASE_145G: [i32; 12] = months([
    (1, 92),
    (2, 90),
    (3, 78),
    (4, 64),
    (5, 46),
    (6, 27),
    (7, 15),
    (8, 15),
    (9, 38),
    (10, 66),
    (11, 92),
    (12, 92),
]);

const BASE_241G: [i32; 12] = months([
    (1, 95),
    (2, 92),
    (3, 75),
    (4, 55),
    (5, 35),
    (6, 15),
    (7, 8),
    (8, 8),
    (9, 30),
    (10, 65),
    (11, 95),
    (12, 95),
]);

const BASE_288G: [i32; 12] = months([
    (1, 95),
    (2, 92),
    (3, 75),
    (4, 55),
    (5, 35),
    (6, 14),
    (7, 7),
    (8, 7),
    (9, 28),
    (10, 64),
    (11, 95),
    (12, 95),
]);

const BASE_322G: [i32; 12] = months([
    (1, 96),
    (2, 92),
    (3, 74),
    (4, 52),
    (5, 32),
    (6, 12),
    (7, 6),
    (8, 6),
    (9, 26),
    (10, 62),
    (11, 96),
    (12, 96),
]);

const BASE_403G: [i32; 12] = months([
    (1, 96),
    (2, 92),
    (3, 74),
    (4, 52),
    (5, 32),
    (6, 12),
    (7, 6),
    (8, 6),
    (9, 26),
    (10, 62),
    (11, 96),
    (12, 96),
]);

const ADJ_NONE: [i32; 12] = [0; 12];
const ADJ_24G: [i32; 12] = adj_from(&[(5, -4), (6, -8), (7, -10), (8, -10), (9, -4)]);

fn bands() -> &'static [BandConfig] {
    static CELL: OnceLock<Vec<BandConfig>> = OnceLock::new();
    CELL.get_or_init(build_bands)
}

#[allow(clippy::too_many_lines)]
fn build_bands() -> Vec<BandConfig> {
    use HumidityEffect::*;
    vec![
        BandConfig {
            freq_mhz: 50,
            label: "50 MHz",
            o2_db_km: 0.0,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.0,
            rain_alpha: 1.0,
            seasonal_base: BASE_50,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 400,
            extended_range_km: 1500,
            exceptional_range_km: 4000,
            weights: None,
        },
        BandConfig {
            freq_mhz: 144,
            label: "144 MHz",
            o2_db_km: 0.0,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.0,
            rain_alpha: 1.0,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 350,
            extended_range_km: 900,
            exceptional_range_km: 2500,
            weights: None,
        },
        BandConfig {
            freq_mhz: 222,
            label: "222 MHz",
            o2_db_km: 0.0,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.0,
            rain_alpha: 1.0,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 320,
            extended_range_km: 800,
            exceptional_range_km: 2000,
            weights: Some(Weights::new(
                0.1593, 0.0350, 0.1250, 0.1401, 0.0706, 0.1276, 0.0706, 0.0120, 0.0681, 0.1916,
            )),
        },
        BandConfig {
            freq_mhz: 432,
            label: "432 MHz",
            o2_db_km: 0.0,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.0,
            rain_alpha: 1.0,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 300,
            extended_range_km: 700,
            exceptional_range_km: 1800,
            weights: Some(Weights::new(
                0.2061, 0.0329, 0.1200, 0.1186, 0.0663, 0.1106, 0.0663, 0.0113, 0.0809, 0.1870,
            )),
        },
        BandConfig {
            freq_mhz: 902,
            label: "902 MHz",
            o2_db_km: 0.006,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.000,
            rain_alpha: 1.0,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 400,
            extended_range_km: 800,
            exceptional_range_km: 1500,
            weights: Some(Weights::new(
                0.2201, 0.0437, 0.1102, 0.0888, 0.0783, 0.1197, 0.0783, 0.0133, 0.0839, 0.1635,
            )),
        },
        BandConfig {
            freq_mhz: 1_296,
            label: "1296 MHz",
            o2_db_km: 0.006,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.000,
            rain_alpha: 1.0,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 350,
            extended_range_km: 700,
            exceptional_range_km: 1200,
            weights: Some(Weights::new(
                0.2131, 0.0494, 0.0898, 0.1392, 0.0797, 0.1108, 0.0797, 0.0136, 0.0568, 0.1678,
            )),
        },
        BandConfig {
            freq_mhz: 2_304,
            label: "2304 MHz",
            o2_db_km: 0.006,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.001,
            rain_alpha: 1.15,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 300,
            extended_range_km: 600,
            exceptional_range_km: 1000,
            weights: Some(Weights::new(
                0.1941, 0.0387, 0.1385, 0.1638, 0.0625, 0.0868, 0.0625, 0.0336, 0.0432, 0.1762,
            )),
        },
        BandConfig {
            freq_mhz: 3_400,
            label: "3400 MHz",
            o2_db_km: 0.006,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.002,
            rain_alpha: 1.20,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 250,
            extended_range_km: 550,
            exceptional_range_km: 900,
            weights: Some(Weights::new(
                0.1996, 0.0398, 0.1251, 0.1310, 0.0642, 0.0893, 0.0642, 0.0489, 0.0568, 0.1811,
            )),
        },
        BandConfig {
            freq_mhz: 5_760,
            label: "5760 MHz",
            o2_db_km: 0.007,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.005,
            rain_alpha: 1.25,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 220,
            extended_range_km: 500,
            exceptional_range_km: 1000,
            weights: Some(Weights::new(
                0.1829, 0.0423, 0.1205, 0.0881, 0.0683, 0.0949, 0.0683, 0.0822, 0.0602, 0.1925,
            )),
        },
        BandConfig {
            freq_mhz: 10_000,
            label: "10 GHz",
            o2_db_km: 0.007,
            h2o_coeff: 0.0,
            humidity_effect: Beneficial,
            humidity_penalty: 0.0,
            rain_k: 0.010,
            rain_alpha: 1.28,
            seasonal_base: BASE_LOW_BAND,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 200,
            extended_range_km: 500,
            exceptional_range_km: 1000,
            weights: None,
        },
        BandConfig {
            freq_mhz: 24_000,
            label: "24 GHz",
            o2_db_km: 0.02,
            h2o_coeff: 0.002,
            humidity_effect: Harmful,
            humidity_penalty: 1.6,
            rain_k: 0.070,
            rain_alpha: 1.07,
            seasonal_base: BASE_24G,
            seasonal_adj: ADJ_24G,
            typical_range_km: 100,
            extended_range_km: 250,
            exceptional_range_km: 500,
            weights: Some(Weights::new(
                0.1481, 0.0532, 0.0360, 0.1250, 0.0477, 0.0729, 0.0477, 0.2147, 0.1203, 0.1344,
            )),
        },
        BandConfig {
            freq_mhz: 47_000,
            label: "47 GHz",
            o2_db_km: 0.04,
            h2o_coeff: 0.003,
            humidity_effect: Harmful,
            humidity_penalty: 1.0,
            rain_k: 0.187,
            rain_alpha: 0.93,
            seasonal_base: BASE_47G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 70,
            extended_range_km: 150,
            exceptional_range_km: 300,
            weights: None,
        },
        BandConfig {
            freq_mhz: 68_000,
            label: "68 GHz",
            o2_db_km: 0.90,
            h2o_coeff: 0.007,
            humidity_effect: Harmful,
            humidity_penalty: 1.4,
            rain_k: 0.310,
            rain_alpha: 0.86,
            seasonal_base: BASE_68G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 40,
            extended_range_km: 80,
            exceptional_range_km: 150,
            weights: None,
        },
        BandConfig {
            freq_mhz: 75_000,
            label: "75 GHz",
            o2_db_km: 0.012,
            h2o_coeff: 0.006,
            humidity_effect: Harmful,
            humidity_penalty: 1.2,
            rain_k: 0.345,
            rain_alpha: 0.84,
            seasonal_base: BASE_75G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 50,
            extended_range_km: 120,
            exceptional_range_km: 250,
            weights: None,
        },
        BandConfig {
            freq_mhz: 122_000,
            label: "122 GHz",
            o2_db_km: 0.80,
            h2o_coeff: 0.010,
            humidity_effect: Harmful,
            humidity_penalty: 1.0,
            rain_k: 0.498,
            rain_alpha: 0.77,
            seasonal_base: BASE_122G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 30,
            extended_range_km: 80,
            exceptional_range_km: 140,
            weights: None,
        },
        BandConfig {
            freq_mhz: 134_000,
            label: "134 GHz",
            o2_db_km: 0.08,
            h2o_coeff: 0.015,
            humidity_effect: Harmful,
            humidity_penalty: 1.3,
            rain_k: 0.520,
            rain_alpha: 0.75,
            seasonal_base: BASE_134G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 40,
            extended_range_km: 100,
            exceptional_range_km: 160,
            weights: None,
        },
        BandConfig {
            freq_mhz: 142_000,
            label: "142 GHz",
            o2_db_km: 0.05,
            h2o_coeff: 0.025,
            humidity_effect: Harmful,
            humidity_penalty: 1.4,
            rain_k: 0.530,
            rain_alpha: 0.74,
            seasonal_base: BASE_142G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 35,
            extended_range_km: 80,
            exceptional_range_km: 160,
            weights: None,
        },
        BandConfig {
            freq_mhz: 145_000,
            label: "145 GHz",
            o2_db_km: 0.06,
            h2o_coeff: 0.040,
            humidity_effect: Harmful,
            humidity_penalty: 1.5,
            rain_k: 0.535,
            rain_alpha: 0.74,
            seasonal_base: BASE_145G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 35,
            extended_range_km: 80,
            exceptional_range_km: 150,
            weights: None,
        },
        BandConfig {
            freq_mhz: 241_000,
            label: "241 GHz",
            o2_db_km: 0.08,
            h2o_coeff: 0.30,
            humidity_effect: Harmful,
            humidity_penalty: 3.0,
            rain_k: 0.550,
            rain_alpha: 0.70,
            seasonal_base: BASE_241G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 10,
            extended_range_km: 50,
            exceptional_range_km: 115,
            weights: None,
        },
        BandConfig {
            freq_mhz: 288_000,
            label: "288 GHz",
            o2_db_km: 0.10,
            h2o_coeff: 0.45,
            humidity_effect: Harmful,
            humidity_penalty: 3.5,
            rain_k: 0.560,
            rain_alpha: 0.68,
            seasonal_base: BASE_288G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 5,
            extended_range_km: 15,
            exceptional_range_km: 50,
            weights: None,
        },
        BandConfig {
            freq_mhz: 322_000,
            label: "322 GHz",
            o2_db_km: 0.12,
            h2o_coeff: 0.55,
            humidity_effect: Harmful,
            humidity_penalty: 4.0,
            rain_k: 0.570,
            rain_alpha: 0.66,
            seasonal_base: BASE_322G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 5,
            extended_range_km: 15,
            exceptional_range_km: 40,
            weights: None,
        },
        BandConfig {
            freq_mhz: 403_000,
            label: "403 GHz",
            o2_db_km: 0.15,
            h2o_coeff: 0.40,
            humidity_effect: Harmful,
            humidity_penalty: 3.0,
            rain_k: 0.580,
            rain_alpha: 0.64,
            seasonal_base: BASE_403G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 3,
            extended_range_km: 10,
            exceptional_range_km: 30,
            weights: None,
        },
        BandConfig {
            freq_mhz: 411_000,
            label: "411 GHz",
            o2_db_km: 0.15,
            h2o_coeff: 0.42,
            humidity_effect: Harmful,
            humidity_penalty: 3.2,
            rain_k: 0.580,
            rain_alpha: 0.64,
            seasonal_base: BASE_403G,
            seasonal_adj: ADJ_NONE,
            typical_range_km: 3,
            extended_range_km: 10,
            exceptional_range_km: 30,
            weights: None,
        },
    ]
}

pub fn get(freq_mhz: u32) -> Option<&'static BandConfig> {
    bands().iter().find(|b| b.freq_mhz == freq_mhz)
}

pub fn all_bands() -> &'static [BandConfig] {
    bands()
}

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

    #[test]
    fn expected_band_count() {
        assert_eq!(all_bands().len(), 23);
    }

    #[test]
    fn default_weights_sum_to_one() {
        let w = DEFAULT_WEIGHTS;
        let sum = w.humidity
            + w.time_of_day
            + w.td_depression
            + w.refractivity
            + w.sky
            + w.season
            + w.wind
            + w.rain
            + w.pressure
            + w.pwat;
        // Elixir comment says "all 10 values sum to 1.0" — allow tiny FP slop.
        assert!((sum - 1.0).abs() < 1e-9, "weights sum = {sum}");
    }

    #[test]
    fn lookup_by_freq() {
        let tenghz = get(10_000).unwrap();
        assert_eq!(tenghz.label, "10 GHz");
        assert_eq!(tenghz.humidity_effect, HumidityEffect::Beneficial);
        assert!(get(99_999).is_none());
    }

    #[test]
    fn band_with_weights_override_returns_override() {
        let b = get(10_000).unwrap();
        // 10 GHz has weights: None → defaults.
        assert_eq!(b.weights().humidity, DEFAULT_WEIGHTS.humidity);

        let b432 = get(432).unwrap();
        assert!((b432.weights().humidity - 0.2061).abs() < 1e-9);
    }

    #[test]
    fn seasonal_adj_24g_matches_elixir() {
        let b = get(24_000).unwrap();
        // Elixir: seasonal_adj: %{5 => -4, 6 => -8, 7 => -10, 8 => -10, 9 => -4}
        assert_eq!(b.seasonal_adj[4], -4); // May
        assert_eq!(b.seasonal_adj[6], -10); // July
        assert_eq!(b.seasonal_adj[11], 0); // Dec untouched
    }
}
