//! HRRR native hybrid-sigma duct detection for the CONUS grid.
//!
//! Port of `Microwaveprop.Weather.HrrrNativeClient.fetch_native_duct_grid`.
//! The native-level HRRR file (`wrfnatf{fh}.grib2`) is ~566 MB with TMP /
//! SPFH / HGT / PRES on all 50 hybrid-sigma levels — the ~10-50 m vertical
//! resolution needed to resolve surface ducts and boundary-layer
//! inversions. `HrrrClient` only covers surface + pressure levels, which
//! is ~250 m resolution near the surface and loses the ducts entirely.
//!
//! Flow: fetch idx → select duct byte ranges (4 vars × 50 levels = 200
//! messages, ~300 MB subset) → download via merged byte ranges → run
//! wgrib2 `-lola` to grid → per-cell build a `NativeProfile` and run
//! `duct::analyze`. Returns a HashMap keyed by (lat, lon) to per-cell
//! duct metrics.

use std::collections::HashMap;
use std::path::Path;

use chrono::NaiveDate;

use crate::decoder::{self, CellValues, DecodeError, PointGrid};
use crate::duct::{self, NativeProfile};
use crate::fetcher::{self, byte_ranges_for_messages, parse_idx, HrrrClient};
use crate::grid::GridSpec;

/// The 4 variables needed for duct detection. UGRD/VGRD/TKE were
/// features we never used; skipping them saves ~230 MB per file.
pub const DUCT_VARIABLES: [&str; 4] = ["TMP", "SPFH", "HGT", "PRES"];

/// HRRR's 50 native hybrid-sigma levels. Level 1 is surface.
pub const NATIVE_LEVEL_COUNT: u8 = 50;

#[derive(Debug, Clone, Copy)]
pub struct DuctMetrics {
    pub native_min_gradient: f64,
    pub best_duct_freq_ghz: Option<f64>,
    pub max_duct_thickness_m: Option<f64>,
    pub duct_count: u32,
}

#[derive(Debug, thiserror::Error)]
pub enum NativeDuctError {
    #[error("fetch: {0}")]
    Fetch(#[from] fetcher::FetchError),
    #[error("decode: {0}")]
    Decode(#[from] DecodeError),
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
}

/// Build the (var, level) tuples for the duct-variable messages. 4
/// vars × 50 levels = 200 messages, matching the Elixir
/// `HrrrNativeClient.duct_messages/0`.
pub fn duct_messages() -> Vec<(String, String)> {
    let mut out = Vec::with_capacity((NATIVE_LEVEL_COUNT as usize) * DUCT_VARIABLES.len());
    for level in 1..=NATIVE_LEVEL_COUNT {
        for var in DUCT_VARIABLES {
            out.push((var.to_string(), format!("{level} hybrid level")));
        }
    }
    out
}

/// wgrib2 `-match` regex: "`:(TMP|SPFH|HGT|PRES):.*hybrid level:`".
pub fn match_pattern() -> String {
    format!(":({}):.*hybrid level:", DUCT_VARIABLES.join("|"))
}

/// URL builder for native-level HRRR files.
///
/// ```
/// use chrono::NaiveDate;
/// use prop_grid_rs::native_duct::hrrr_native_url;
/// assert_eq!(
///   hrrr_native_url("https://example", NaiveDate::from_ymd_opt(2026, 4, 19).unwrap(), 14, 0),
///   "https://example/hrrr.20260419/conus/hrrr.t14z.wrfnatf00.grib2"
/// );
/// ```
pub fn hrrr_native_url(base: &str, date: NaiveDate, hour: u8, forecast_hour: u8) -> String {
    format!(
        "{}/hrrr.{:04}{:02}{:02}/conus/hrrr.t{:02}z.wrfnatf{:02}.grib2",
        base.trim_end_matches('/'),
        date.format("%Y").to_string().parse::<u32>().unwrap_or(0),
        date.format("%m").to_string().parse::<u32>().unwrap_or(0),
        date.format("%d").to_string().parse::<u32>().unwrap_or(0),
        hour,
        forecast_hour
    )
}

/// Fetch the native-level duct grid for a (date, hour, forecast_hour)
/// triple. Downloads the byte-range subset, decodes via wgrib2, and
/// computes duct metrics per cell.
pub async fn fetch_native_duct_grid(
    client: &HrrrClient,
    date: NaiveDate,
    hour: u8,
    forecast_hour: u8,
    grid_spec: GridSpec,
) -> Result<HashMap<(i32, i32), DuctMetrics>, NativeDuctError> {
    let url = hrrr_native_url(client.base(), date, hour, forecast_hour);
    let idx_url = format!("{url}.idx");
    let idx_body = client.fetch_idx(&idx_url).await?;
    let entries = parse_idx(&idx_body);
    let messages = duct_messages();
    let ranges = byte_ranges_for_messages(&entries, &messages);
    let blob = client.download_grib_ranges(&url, ranges).await?;

    // wgrib2 decode must run on the blocking pool — it forks a
    // subprocess and the stdout read is synchronous IO.
    let pattern = match_pattern();
    let grid: PointGrid =
        tokio::task::spawn_blocking(move || decoder::extract_grid(&blob, &pattern, grid_spec))
            .await
            .expect("blocking join")?;

    Ok(reduce_grid_to_ducts(grid))
}

/// Per-cell reducer: turn the `CellValues` map into a
/// `NativeProfile` and run the duct analyser.
pub fn reduce_grid_to_ducts(grid: PointGrid) -> HashMap<(i32, i32), DuctMetrics> {
    let mut out = HashMap::with_capacity(grid.len());
    for (key, cell) in grid {
        if let Some(profile) = build_native_profile(&cell) {
            let analysis = duct::analyze(&profile);
            out.insert(
                key,
                DuctMetrics {
                    native_min_gradient: duct::min_m_gradient(&profile),
                    best_duct_freq_ghz: analysis.best_duct_band_ghz,
                    max_duct_thickness_m: duct::max_duct_thickness_m(&analysis.ducts),
                    duct_count: analysis.ducts.len() as u32,
                },
            );
        }
    }
    out
}

/// Build a `NativeProfile` from one cell's raw values. Mirrors the
/// Elixir `build_native_profile/1`: walk all 50 hybrid levels, keep
/// only those with both HGT and TMP present, then sort ascending by
/// height. Returns `None` if fewer than 3 levels survive (too sparse
/// to analyse).
pub fn build_native_profile(cell: &CellValues) -> Option<NativeProfile> {
    // (height, tmp, spfh, pres) per level. Filter out rows where HGT
    // or TMP is absent — those levels are junk.
    let mut levels: Vec<(f64, f64, f64, f64)> = Vec::with_capacity(NATIVE_LEVEL_COUNT as usize);
    for level in 1..=NATIVE_LEVEL_COUNT {
        let lvl_str = format!("{level} hybrid level");
        let Some(&hgt) = cell.get(&format!("HGT:{lvl_str}")) else {
            continue;
        };
        let Some(&tmp) = cell.get(&format!("TMP:{lvl_str}")) else {
            continue;
        };
        // SPFH / PRES default to 0.0 if absent — the duct math handles
        // degenerate rows (returns 0.0 gradient / no duct) without
        // raising, matching Elixir's nil-tolerance.
        let spfh = cell.get(&format!("SPFH:{lvl_str}")).copied().unwrap_or(0.0);
        let pres = cell.get(&format!("PRES:{lvl_str}")).copied().unwrap_or(0.0);
        levels.push((hgt as f64, tmp as f64, spfh as f64, pres as f64));
    }

    if levels.len() < 3 {
        return None;
    }

    levels.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));

    let heights_m = levels.iter().map(|x| x.0).collect();
    let temp_k = levels.iter().map(|x| x.1).collect();
    let spfh = levels.iter().map(|x| x.2).collect();
    let pressure_pa = levels.iter().map(|x| x.3).collect();
    Some(NativeProfile {
        heights_m,
        temp_k,
        spfh,
        pressure_pa,
    })
}

/// Merge native duct metrics into an existing surface+pressure grid.
/// Mirrors Elixir's `apply_duct_grid` — cells missing from the duct
/// map keep their previous contents; cells present overwrite the
/// `native_min_gradient` / `best_duct_freq_ghz` / etc. fields.
///
/// Consumers that only need the raw duct HashMap can skip this; the
/// merge helper exists for symmetry with the Elixir call site.
pub fn merge_duct_grid(
    mut base: HashMap<(i32, i32), CellValues>,
    ducts: &HashMap<(i32, i32), DuctMetrics>,
) -> HashMap<(i32, i32), CellValues> {
    for (key, metrics) in ducts {
        if let Some(cell) = base.get_mut(key) {
            cell.insert(
                "native_min_gradient".into(),
                metrics.native_min_gradient as f32,
            );
            if let Some(f) = metrics.best_duct_freq_ghz {
                cell.insert("best_duct_freq_ghz".into(), f as f32);
            }
            if let Some(t) = metrics.max_duct_thickness_m {
                cell.insert("max_duct_thickness_m".into(), t as f32);
            }
            cell.insert("duct_count".into(), metrics.duct_count as f32);
        }
    }
    base
}

/// Legacy entry used by goldens/tests: decode a GRIB2 blob already
/// on disk with the duct match pattern and reduce.
pub fn duct_grid_from_file(
    grib_path: &Path,
    grid_spec: GridSpec,
) -> Result<HashMap<(i32, i32), DuctMetrics>, NativeDuctError> {
    let grid = decoder::extract_grid_from_file(grib_path, &match_pattern(), grid_spec)?;
    Ok(reduce_grid_to_ducts(grid))
}

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

    #[test]
    fn duct_messages_counts_200() {
        let m = duct_messages();
        assert_eq!(m.len(), 200);
        // First and last samples.
        assert_eq!(m[0], ("TMP".into(), "1 hybrid level".into()));
        assert_eq!(m[m.len() - 1], ("PRES".into(), "50 hybrid level".into()));
    }

    #[test]
    fn match_pattern_includes_all_duct_vars() {
        let p = match_pattern();
        assert_eq!(p, ":(TMP|SPFH|HGT|PRES):.*hybrid level:");
    }

    #[test]
    fn hrrr_native_url_format() {
        let d = NaiveDate::from_ymd_opt(2026, 4, 19).unwrap();
        assert_eq!(
            hrrr_native_url("https://x.y", d, 14, 0),
            "https://x.y/hrrr.20260419/conus/hrrr.t14z.wrfnatf00.grib2"
        );
        assert_eq!(
            hrrr_native_url("https://x.y/", d, 4, 7),
            "https://x.y/hrrr.20260419/conus/hrrr.t04z.wrfnatf07.grib2"
        );
    }

    fn cell_with_levels(count: usize) -> CellValues {
        let mut c = CellValues::new();
        for i in 1..=count {
            c.insert(format!("HGT:{i} hybrid level"), (i as f32) * 50.0);
            c.insert(format!("TMP:{i} hybrid level"), 290.0 - (i as f32) * 0.5);
            c.insert(
                format!("SPFH:{i} hybrid level"),
                0.008 - (i as f32) * 0.0001,
            );
            c.insert(
                format!("PRES:{i} hybrid level"),
                101_000.0 - (i as f32) * 600.0,
            );
        }
        c
    }

    #[test]
    fn build_profile_drops_cells_with_fewer_than_3_levels() {
        assert!(build_native_profile(&cell_with_levels(0)).is_none());
        assert!(build_native_profile(&cell_with_levels(2)).is_none());
        assert!(build_native_profile(&cell_with_levels(3)).is_some());
    }

    #[test]
    fn build_profile_sorts_by_height() {
        let mut cell = CellValues::new();
        // Interleaved: level 3 comes first in insertion order, level 1 last.
        cell.insert("HGT:3 hybrid level".into(), 150.0);
        cell.insert("TMP:3 hybrid level".into(), 287.0);
        cell.insert("HGT:1 hybrid level".into(), 10.0);
        cell.insert("TMP:1 hybrid level".into(), 291.0);
        cell.insert("HGT:2 hybrid level".into(), 50.0);
        cell.insert("TMP:2 hybrid level".into(), 289.0);
        let p = build_native_profile(&cell).unwrap();
        assert_eq!(p.heights_m, vec![10.0, 50.0, 150.0]);
        assert_eq!(p.temp_k, vec![291.0, 289.0, 287.0]);
    }

    #[test]
    fn reduce_grid_builds_metrics_for_cells_with_enough_levels() {
        let mut grid: PointGrid = HashMap::new();
        grid.insert((0, 0), cell_with_levels(50));
        grid.insert((1, 0), cell_with_levels(2)); // too sparse
        let out = reduce_grid_to_ducts(grid);
        assert_eq!(out.len(), 1);
        let m = &out[&(0, 0)];
        // Temperature decreases with height and moisture decreases too
        // — constructed profile has no strong inversion, so the
        // count should be 0 but min_gradient is still computed.
        assert!(m.native_min_gradient.is_finite());
    }

    #[test]
    fn merge_duct_grid_only_touches_shared_keys() {
        let mut base: HashMap<(i32, i32), CellValues> = HashMap::new();
        base.insert((0, 0), {
            let mut c = CellValues::new();
            c.insert("PRES:surface".into(), 101_000.0);
            c
        });
        let ducts = HashMap::from([(
            (0, 0),
            DuctMetrics {
                native_min_gradient: -150.0,
                best_duct_freq_ghz: Some(24.0),
                max_duct_thickness_m: Some(80.0),
                duct_count: 1,
            },
        )]);
        let merged = merge_duct_grid(base, &ducts);
        let cell = &merged[&(0, 0)];
        assert_eq!(cell.get("PRES:surface").copied(), Some(101_000.0));
        assert_eq!(cell.get("native_min_gradient").copied(), Some(-150.0));
        assert_eq!(cell.get("best_duct_freq_ghz").copied(), Some(24.0));
        assert_eq!(cell.get("duct_count").copied(), Some(1.0));
    }
}
