//! End-to-end f01..f18 chain step: fetch surface + pressure GRIBs, decode,
//! build per-cell `Conditions`, score every band, write score-grid files
//! (`<band>/<iso>.ntms`) to the shared scores directory.
//!
//! This is the Rust equivalent of `PropagationGridWorker.process_forecast_hour/4`
//! minus the f00-only enrichment (native duct, NEXRAD, commercial link
//! boost, ProfilesFile write) — Elixir retains f00.

use std::path::Path;
use std::sync::Arc;

use chrono::{DateTime, Datelike, Timelike, Utc};

use crate::band_config;
use crate::commercial::{self, LinkLookupEntry};
use crate::decoder::{self, CellValues, PointGrid};
use crate::fetcher::{self, HrrrClient, Product};
use crate::grid::wgrib2_grid_spec;
use crate::native_duct;
use crate::nexrad::{self, NexradObservation};
use crate::profiles_file::{self, CellEntry};
use crate::scorer::{self, Conditions};
use crate::scores_file::{self, ScorePoint};
use crate::sounding_params::{self, Level};

#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
    #[error("fetch: {0}")]
    Fetch(#[from] fetcher::FetchError),
    #[error("decode: {0}")]
    Decode(#[from] decoder::DecodeError),
    #[error("write: {0}")]
    Write(#[from] scores_file::WriteError),
    #[error("profile write: {0}")]
    ProfileWrite(#[from] profiles_file::WriteError),
    #[error("native duct: {0}")]
    NativeDuct(#[from] native_duct::NativeDuctError),
    #[error("nexrad: {0}")]
    Nexrad(#[from] nexrad::NexradError),
    #[error("commercial: {0}")]
    Commercial(#[from] commercial::CommercialError),
    #[error("forecast hour 0 is reserved for Elixir")]
    F00Reserved,
    #[error("surface grib was empty")]
    EmptyGrib,
}

#[derive(Debug, Clone)]
pub struct ChainStepInput {
    pub run_time: DateTime<Utc>,
    pub forecast_hour: u8,
}

impl ChainStepInput {
    pub fn valid_time(&self) -> DateTime<Utc> {
        self.run_time + chrono::Duration::hours(self.forecast_hour as i64)
    }
}

#[derive(Debug, Clone)]
pub struct ChainStepStats {
    pub score_files_written: u32,
    pub point_count: u32,
    pub band_count: u32,
}

#[tracing::instrument(
    name = "pipeline.run_chain_step",
    skip(client, scores_dir),
    fields(
        run_time = %step.run_time,
        forecast_hour = step.forecast_hour,
    )
)]
pub async fn run_chain_step(
    client: &HrrrClient,
    scores_dir: &Path,
    step: &ChainStepInput,
) -> Result<ChainStepStats, PipelineError> {
    if step.forecast_hour == 0 {
        return Err(PipelineError::F00Reserved);
    }
    let valid_time = step.valid_time();
    let date = step.run_time.date_naive();
    let hour = step.run_time.hour() as u8;

    let sfc_wanted = fetcher::surface_messages_owned();
    let prs_wanted = fetcher::pressure_messages_grid();
    let sfc_fut = client.fetch_product_blob(
        date,
        hour,
        Product::Surface,
        step.forecast_hour,
        &sfc_wanted,
    );
    let prs_fut = client.fetch_product_blob(
        date,
        hour,
        Product::Pressure,
        step.forecast_hour,
        &prs_wanted,
    );
    let (sfc_blob, prs_blob) = tokio::try_join!(sfc_fut, prs_fut)?;
    if sfc_blob.is_empty() {
        return Err(PipelineError::EmptyGrib);
    }

    let grid_spec = wgrib2_grid_spec();
    let sfc_pattern = match_pattern(&sfc_wanted);
    let prs_pattern = match_pattern(&prs_wanted);

    // Decoder runs wgrib2 subprocess — block so we don't hog the tokio
    // reactor. `spawn_blocking` lifts it onto the dedicated pool.
    let grid_spec_sfc = grid_spec;
    let sfc_grid = tokio::task::spawn_blocking(move || {
        decoder::extract_grid(&sfc_blob, &sfc_pattern, grid_spec_sfc)
    })
    .await
    .expect("blocking join")?;

    let grid_spec_prs = grid_spec;
    let prs_grid = tokio::task::spawn_blocking(move || {
        decoder::extract_grid(&prs_blob, &prs_pattern, grid_spec_prs)
    })
    .await
    .expect("blocking join")?;

    let merged = merge_grids(sfc_grid, prs_grid);
    let point_count = merged.len() as u32;

    // Step 1: consume `merged` into a compact `Vec<(lat, lon, Conditions,
    // invariants)>`. This is the biggest win — `merged`'s inner HashMaps
    // of `String -> f32` account for ~200 MB of heap (92k × ~60 string
    // keys) and are dropped as soon as this loop finishes.
    let prepared: Vec<(f64, f64, Conditions, scorer::BandInvariants)> = merged
        .into_iter()
        .filter_map(|(key, cell)| {
            let (lat, lon) = decoder::key_to_latlon(key);
            let conditions = cell_to_conditions(&cell, lat, lon, &valid_time)?;
            let invariants = scorer::precompute_band_invariants(&conditions);
            Some((lat, lon, conditions, invariants))
        })
        .collect();

    // Step 2: score and write every band in parallel.
    //
    // Scoring: rayon's thread pool over the 23 bands. Each band is
    // independent pure math across the ~92k-cell `prepared` slice;
    // sharing is read-only so no locking. This cuts per-step scoring
    // wall from ~20s serial to ~5-8s on a 4-core box.
    //
    // Writing: each band file is an independent NFS fsync. Launching
    // them as parallel `spawn_blocking` tasks lets them overlap on the
    // blocking pool instead of serializing one-at-a-time on the hot
    // path. Memory stays bounded — each Vec<ScorePoint> is ~1.8 MB so
    // 23 concurrent ≈ ~40 MB peak.
    let bands = band_config::all_bands();
    let scores_dir = scores_dir.to_path_buf();
    let prepared = Arc::new(prepared);
    let scored: Vec<(u32, Vec<ScorePoint>)> = {
        use rayon::prelude::*;
        bands
            .par_iter()
            .map(|band| {
                let mut scores: Vec<ScorePoint> = Vec::with_capacity(prepared.len());
                for (lat, lon, conditions, invariants) in prepared.iter() {
                    let r = scorer::composite_score_with(conditions, band, Some(*invariants));
                    scores.push(ScorePoint {
                        lat: *lat,
                        lon: *lon,
                        score: r.score,
                    });
                }
                (band.freq_mhz, scores)
            })
            .collect()
    };

    let write_futs = scored.into_iter().map(|(band_mhz, scores)| {
        let dir = scores_dir.clone();
        tokio::task::spawn_blocking(move || {
            scores_file::write_atomic(&dir, band_mhz, valid_time, &scores)
        })
    });
    let results = futures::future::try_join_all(write_futs)
        .await
        .expect("blocking join");
    for r in results {
        r?;
    }
    let files_written = bands.len() as u32;

    Ok(ChainStepStats {
        score_files_written: files_written,
        point_count,
        band_count: bands.len() as u32,
    })
}

/// wgrib2 `-match` regex: `":(A|B|C):"`. Var names only — levels are
/// post-filtered when we read the binary back.
fn match_pattern(wanted: &[(String, String)]) -> String {
    let mut vars: Vec<&str> = wanted.iter().map(|(v, _)| v.as_str()).collect();
    vars.sort();
    vars.dedup();
    format!(":({}):", vars.join("|"))
}

fn merge_grids(mut sfc: PointGrid, prs: PointGrid) -> PointGrid {
    for (k, v) in prs {
        sfc.entry(k).or_default().extend(v);
    }
    sfc
}

fn cell_to_conditions(
    cell: &CellValues,
    lat: f64,
    lon: f64,
    valid_time: &DateTime<Utc>,
) -> Option<Conditions> {
    let tmp_k = cell.get("TMP:2 m above ground").copied()?;
    let dpt_k = cell.get("DPT:2 m above ground").copied()?;
    let temp_c = (tmp_k - 273.15) as f64;
    let dewpoint_c = (dpt_k - 273.15) as f64;
    let temp_f = scorer::c_to_f(temp_c);
    let dewpoint_f = scorer::c_to_f(dewpoint_c);
    let abs_humidity = scorer::absolute_humidity(temp_c, dewpoint_c);

    let wind_speed_kts = match (
        cell.get("UGRD:10 m above ground").copied(),
        cell.get("VGRD:10 m above ground").copied(),
    ) {
        (Some(u), Some(v)) => Some(scorer::wind_speed_kts(u as f64, v as f64)),
        _ => None,
    };

    let sky_cover_pct = cell
        .get("TCDC:entire atmosphere")
        .copied()
        .map(|v| v as f64);
    let pwat_mm = cell
        .get("PWAT:entire atmosphere (considered as a single layer)")
        .copied()
        .map(|v| v as f64);
    let pressure_mb = cell
        .get("PRES:surface")
        .copied()
        .map(|pa| pa as f64 / 100.0);
    let rain_rate_mmhr = cell
        .get("APCP:surface")
        .copied()
        .map(|mm| mm as f64)
        .filter(|v| *v > 0.0);
    let bl_depth_m = cell.get("HPBL:surface").copied().map(|v| v as f64);

    let levels: Vec<Level> = fetcher::GRID_PRESSURE_LEVELS
        .iter()
        .filter_map(|&p| {
            let pres_mb = p as f64;
            let t = cell.get(&format!("TMP:{p} mb")).copied()?;
            let d = cell.get(&format!("DPT:{p} mb")).copied();
            let h = cell.get(&format!("HGT:{p} mb")).copied()?;
            Some(Level {
                pres_mb,
                hght_m: h as f64,
                tmpc: (t - 273.15) as f64,
                dwpc: d.map(|v| (v - 273.15) as f64),
            })
        })
        .collect();
    let min_refractivity_gradient = sounding_params::min_refractivity_gradient(levels);

    Some(Conditions {
        abs_humidity,
        temp_f,
        dewpoint_f,
        wind_speed_kts,
        sky_cover_pct,
        utc_hour: valid_time.hour() as u8,
        utc_minute: valid_time.minute() as u8,
        month: valid_time.month() as u8,
        longitude: lon,
        latitude: Some(lat),
        pressure_mb,
        prev_pressure_mb: None,
        rain_rate_mmhr,
        min_refractivity_gradient,
        bl_depth_m,
        pwat_mm,
        best_duct_band_ghz: None,
        bulk_richardson: None,
    })
}

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

    #[test]
    fn match_pattern_dedups_vars() {
        let wanted = vec![
            ("TMP".into(), "2 m above ground".into()),
            ("DPT".into(), "2 m above ground".into()),
            ("TMP".into(), "1000 mb".into()),
        ];
        assert_eq!(match_pattern(&wanted), ":(DPT|TMP):");
    }

    #[test]
    fn cell_to_conditions_maps_fields() {
        use chrono::TimeZone;
        let mut cell = CellValues::new();
        cell.insert("TMP:2 m above ground".into(), 298.15); // 25 °C
        cell.insert("DPT:2 m above ground".into(), 293.15); // 20 °C
        cell.insert("UGRD:10 m above ground".into(), 3.0);
        cell.insert("VGRD:10 m above ground".into(), 4.0);
        cell.insert("TCDC:entire atmosphere".into(), 30.0);
        cell.insert("PRES:surface".into(), 101_000.0);
        cell.insert("HPBL:surface".into(), 400.0);
        cell.insert(
            "PWAT:entire atmosphere (considered as a single layer)".into(),
            25.0,
        );

        let vt = Utc.with_ymd_and_hms(2026, 6, 15, 18, 0, 0).unwrap();
        let c = cell_to_conditions(&cell, 32.0, -97.0, &vt).unwrap();
        assert!((c.temp_f - 77.0).abs() < 0.1);
        assert!((c.dewpoint_f - 68.0).abs() < 0.1);
        assert!((c.wind_speed_kts.unwrap() - 9.72).abs() < 0.1);
        assert!((c.pressure_mb.unwrap() - 1010.0).abs() < 0.01);
        assert_eq!(c.sky_cover_pct, Some(30.0));
        assert_eq!(c.bl_depth_m, Some(400.0));
        assert_eq!(c.pwat_mm, Some(25.0));
        assert_eq!(c.month, 6);
        assert_eq!(c.longitude, -97.0);
    }

    #[test]
    fn cell_missing_surface_returns_none() {
        use chrono::TimeZone;
        let cell = CellValues::new();
        let vt = Utc.with_ymd_and_hms(2026, 6, 15, 18, 0, 0).unwrap();
        assert!(cell_to_conditions(&cell, 32.0, -97.0, &vt).is_none());
    }

    #[test]
    fn rejects_forecast_hour_zero() {
        use chrono::TimeZone;
        let client = HrrrClient::default_base().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let step = ChainStepInput {
            run_time: Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap(),
            forecast_hour: 0,
        };
        let rt = tokio::runtime::Runtime::new().unwrap();
        let err = rt
            .block_on(run_chain_step(&client, dir.path(), &step))
            .unwrap_err();
        assert!(matches!(err, PipelineError::F00Reserved));
    }
}

// =====================================================================
// Analysis pipeline (f00): fetch surface+pressure + native duct, merge
// with NEXRAD + commercial-link degradation, score all bands, write the
// ProfilesFile (MessagePack) and the band score files.
// =====================================================================

#[derive(Debug, Clone)]
pub struct AnalysisStepInput {
    pub run_time: DateTime<Utc>,
}

impl AnalysisStepInput {
    pub fn valid_time(&self) -> DateTime<Utc> {
        // f00 analysis valid_time == run_time.
        self.run_time
    }
}

#[derive(Debug, Clone)]
pub struct AnalysisStepStats {
    pub score_files_written: u32,
    pub point_count: u32,
    pub band_count: u32,
    pub profile_cells_written: u32,
    pub duct_cells: u32,
    pub nexrad_cells_with_echo: u32,
    pub commercial_cells_boosted: u32,
}

#[tracing::instrument(
    name = "pipeline.run_analysis_step",
    skip(client, pool, scores_dir),
    fields(run_time = %step.run_time)
)]
pub async fn run_analysis_step(
    client: &HrrrClient,
    pool: &sqlx::PgPool,
    scores_dir: &Path,
    step: &AnalysisStepInput,
) -> Result<AnalysisStepStats, PipelineError> {
    let valid_time = step.valid_time();
    let date = step.run_time.date_naive();
    let hour = step.run_time.hour() as u8;
    let grid_spec = wgrib2_grid_spec();

    // Parallel fetch + decode of surface + pressure + native duct. All
    // three are independent HRRR products; overlap the HTTP legs and
    // the subsequent wgrib2 decodes so the long pole is a single
    // decode, not three serial decodes.
    let sfc_wanted = fetcher::surface_messages_owned();
    let prs_wanted = fetcher::pressure_messages_grid();
    let sfc_pattern = match_pattern(&sfc_wanted);
    let prs_pattern = match_pattern(&prs_wanted);

    // All three futures return different error types; lift into
    // PipelineError inside each branch so try_join! stays happy.
    let sfc_fut = async {
        client
            .fetch_product_blob(date, hour, Product::Surface, 0, &sfc_wanted)
            .await
            .map_err(PipelineError::Fetch)
    };
    let prs_fut = async {
        client
            .fetch_product_blob(date, hour, Product::Pressure, 0, &prs_wanted)
            .await
            .map_err(PipelineError::Fetch)
    };
    let duct_fut = async {
        native_duct::fetch_native_duct_grid(client, date, hour, 0, grid_spec)
            .await
            .map_err(PipelineError::NativeDuct)
    };

    let (sfc_blob, prs_blob, duct_map) = tokio::try_join!(sfc_fut, prs_fut, duct_fut)?;

    let duct_count = duct_map.len() as u32;

    let sfc_grid = tokio::task::spawn_blocking(move || {
        decoder::extract_grid(&sfc_blob, &sfc_pattern, grid_spec)
    })
    .await
    .expect("blocking join")?;

    let prs_grid = tokio::task::spawn_blocking(move || {
        decoder::extract_grid(&prs_blob, &prs_pattern, grid_spec)
    })
    .await
    .expect("blocking join")?;

    // Merge pressure into surface, then fold duct metrics into every
    // cell that has both. The duct values live alongside the raw
    // CellValues strings so build_grid_cache_rows can pick them up
    // under their atom keys after the Elixir reader normalises.
    let mut merged = merge_grids(sfc_grid, prs_grid);
    merged = native_duct::merge_duct_grid(merged, &duct_map);

    // NEXRAD composite reflectivity overlay — only valid for f00
    // because forecast hours can't see the future radar image.
    let nexrad_http = reqwest::Client::builder()
        .user_agent("prop-grid-rs/0.1")
        .build()
        .expect("reqwest client");
    let nexrad_points: Vec<(f64, f64)> =
        merged.keys().map(|&k| decoder::key_to_latlon(k)).collect();
    let nexrad_obs: Vec<NexradObservation> = match nexrad::fetch_frame(
        &nexrad_http,
        valid_time,
        &nexrad_points,
    )
    .await
    {
        Ok(obs) => obs,
        Err(e) => {
            tracing::warn!(error = %e, "NEXRAD fetch failed — continuing without radar overlay");
            Vec::new()
        }
    };
    let nexrad_cells_with_echo = apply_nexrad(&mut merged, &nexrad_obs);

    // Commercial-link degradation — precompute once, then fold into
    // every in-range cell. Only ~7 links cluster around DFW so most
    // cells stay untouched.
    let commercial_lookup: Vec<LinkLookupEntry> = commercial::build_link_lookup(pool, valid_time)
        .await
        .map_err(PipelineError::Commercial)?;
    let commercial_cells_boosted = apply_commercial(&mut merged, &commercial_lookup);

    let point_count = merged.len() as u32;

    // Build the ProfilesFile cell list (the raw enriched grid) and
    // the Conditions vector (for scoring) in one pass. Profile keeps
    // atmospheric + duct + NEXRAD + commercial fields under
    // string-keyed msgpack; Rust doesn't need to mutate those further.
    let mut profile_entries: Vec<CellEntry> = Vec::with_capacity(merged.len());
    let mut prepared: Vec<(f64, f64, Conditions, scorer::BandInvariants)> =
        Vec::with_capacity(merged.len());
    for (key, cell) in merged.iter() {
        let (lat, lon) = decoder::key_to_latlon(*key);
        if let Some(conditions) = cell_to_conditions(cell, lat, lon, &valid_time) {
            let invariants = scorer::precompute_band_invariants(&conditions);
            prepared.push((lat, lon, conditions, invariants));
            profile_entries.push(cell_to_profile_entry(lat, lon, cell));
        }
    }

    // Write the profile file on the blocking pool — msgpack encode +
    // gzip + atomic rename. One file per run, ~2 MB compressed, so the
    // write runs well inside the scoring loop's wall time.
    let scores_dir_owned = scores_dir.to_path_buf();
    let scores_dir_for_profiles = scores_dir_owned.clone();
    let profile_future = {
        let profiles = profile_entries;
        tokio::task::spawn_blocking(move || {
            profiles_file::write_atomic(&scores_dir_for_profiles, valid_time, &profiles)
                .map(|_| 0u32)
        })
    };

    // Bands and score writes run in parallel, same shape as
    // run_chain_step. Rayon across bands, try_join_all across writes.
    let bands = band_config::all_bands();
    let prepared_arc = Arc::new(prepared);
    let scored: Vec<(u32, Vec<ScorePoint>)> = {
        use rayon::prelude::*;
        bands
            .par_iter()
            .map(|band| {
                let mut scores: Vec<ScorePoint> = Vec::with_capacity(prepared_arc.len());
                for (lat, lon, conditions, invariants) in prepared_arc.iter() {
                    let r = scorer::composite_score_with(conditions, band, Some(*invariants));
                    scores.push(ScorePoint {
                        lat: *lat,
                        lon: *lon,
                        score: r.score,
                    });
                }
                (band.freq_mhz, scores)
            })
            .collect()
    };

    let write_futs = scored.into_iter().map(|(band_mhz, scores)| {
        let dir = scores_dir_owned.clone();
        tokio::task::spawn_blocking(move || {
            scores_file::write_atomic(&dir, band_mhz, valid_time, &scores)
        })
    });
    let results = futures::future::try_join_all(write_futs)
        .await
        .expect("blocking join");
    for r in results {
        r?;
    }
    profile_future
        .await
        .expect("blocking join")
        .map_err(PipelineError::ProfileWrite)?;

    let files_written = bands.len() as u32;
    Ok(AnalysisStepStats {
        score_files_written: files_written,
        point_count,
        band_count: bands.len() as u32,
        profile_cells_written: prepared_arc.len() as u32,
        duct_cells: duct_count,
        nexrad_cells_with_echo,
        commercial_cells_boosted,
    })
}

fn apply_nexrad(grid: &mut PointGrid, obs: &[NexradObservation]) -> u32 {
    let mut with_echo = 0u32;
    for o in obs {
        let key = (
            (o.lat * 1000.0).round() as i32,
            (o.lon * 1000.0).round() as i32,
        );
        // merged's keys are whatever extract_grid produced; use direct
        // lat/lon match by scanning. This is O(N*M) worst-case but N is
        // ~92k and obs mirrors the same point list, so in practice each
        // observation matches exactly one cell by int-key.
        if let Some(cell) = grid.get_mut(&key) {
            if o.max_reflectivity_dbz > 0.0 {
                with_echo += 1;
            }
            cell.insert(
                "nexrad_max_reflectivity_dbz".into(),
                o.max_reflectivity_dbz as f32,
            );
        }
    }
    with_echo
}

fn apply_commercial(grid: &mut PointGrid, lookup: &[LinkLookupEntry]) -> u32 {
    let mut boosted = 0u32;
    for (key, cell) in grid.iter_mut() {
        let (lat, lon) = decoder::key_to_latlon(*key);
        if let Some(d) = commercial::degradation_at((lat, lon), lookup) {
            cell.insert("commercial_degradation_db".into(), d.degradation_db as f32);
            cell.insert("commercial_baseline_dbm".into(), d.baseline_dbm as f32);
            cell.insert("commercial_current_dbm".into(), d.current_dbm as f32);
            cell.insert("commercial_n_links".into(), d.n_links as f32);
            boosted += 1;
        }
    }
    boosted
}

/// Turn one enriched cell into a ProfilesFile entry. The msgpack
/// `profile` map keeps string keys; the Elixir reader atomizes the
/// whitelisted subset (surface_*, hpbl_m, pwat_mm, duct metrics, …).
fn cell_to_profile_entry(lat: f64, lon: f64, cell: &CellValues) -> CellEntry {
    use profiles_file::value_map;
    use rmpv::Value as V;

    let mut kv: Vec<(&'static str, V)> = Vec::with_capacity(16);

    if let Some(&v) = cell.get("TMP:2 m above ground") {
        kv.push(("surface_temp_c", V::F64((v as f64) - 273.15)));
    }
    if let Some(&v) = cell.get("DPT:2 m above ground") {
        kv.push(("surface_dewpoint_c", V::F64((v as f64) - 273.15)));
    }
    if let Some(&v) = cell.get("PRES:surface") {
        kv.push(("surface_pressure_mb", V::F64((v as f64) / 100.0)));
    }
    if let Some(&v) = cell.get("HPBL:surface") {
        kv.push(("hpbl_m", V::F64(v as f64)));
    }
    if let Some(&v) = cell.get("PWAT:entire atmosphere (considered as a single layer)") {
        kv.push(("pwat_mm", V::F64(v as f64)));
    }
    if let Some(&v) = cell.get("native_min_gradient") {
        kv.push(("native_min_gradient", V::F64(v as f64)));
    }
    if let Some(&v) = cell.get("best_duct_freq_ghz") {
        kv.push(("best_duct_freq_ghz", V::F64(v as f64)));
    }
    if let Some(&v) = cell.get("max_duct_thickness_m") {
        kv.push(("max_duct_thickness_m", V::F64(v as f64)));
    }
    if let Some(&v) = cell.get("duct_count") {
        kv.push(("duct_count", V::Integer((v as i64).into())));
    }
    if let Some(&v) = cell.get("nexrad_max_reflectivity_dbz") {
        kv.push(("nexrad_max_reflectivity_dbz", V::F64(v as f64)));
    }
    if let Some(&d) = cell.get("commercial_degradation_db") {
        let links = cell.get("commercial_n_links").copied().unwrap_or(0.0);
        let baseline = cell.get("commercial_baseline_dbm").copied().unwrap_or(0.0);
        let current = cell.get("commercial_current_dbm").copied().unwrap_or(0.0);
        kv.push((
            "commercial_link_degradation",
            value_map([
                ("degradation_db", V::F64(d as f64)),
                ("baseline_dbm", V::F64(baseline as f64)),
                ("current_dbm", V::F64(current as f64)),
                ("n_links", V::Integer((links as i64).into())),
            ]),
        ));
    }

    // Pressure-level profile list for SoundingParams.derive on the
    // Elixir side. Mirrors the fields derive expects.
    let levels: Vec<V> = fetcher::GRID_PRESSURE_LEVELS
        .iter()
        .filter_map(|&p| {
            let pres_mb = p as f64;
            let t = cell.get(&format!("TMP:{p} mb")).copied()?;
            let d = cell.get(&format!("DPT:{p} mb")).copied();
            let h = cell.get(&format!("HGT:{p} mb")).copied()?;
            let mut pairs: Vec<(V, V)> = Vec::with_capacity(4);
            pairs.push((V::String("pres_mb".into()), V::F64(pres_mb)));
            pairs.push((V::String("hght_m".into()), V::F64(h as f64)));
            pairs.push((V::String("tmpc".into()), V::F64((t as f64) - 273.15)));
            if let Some(dv) = d {
                pairs.push((V::String("dwpc".into()), V::F64((dv as f64) - 273.15)));
            }
            Some(V::Map(pairs))
        })
        .collect();
    if !levels.is_empty() {
        kv.push(("profile", V::Array(levels)));
    }

    let profile = value_map(kv);

    CellEntry { lat, lon, profile }
}
