//! Score-grid file format (v1). 1:1 port of
//! `lib/microwaveprop/propagation/scores_file.ex`.
//!
//! Layout (all little-endian):
//!   magic      : 4 bytes   "PROP"  (decoder also accepts legacy "NTMS")
//!   version    : 1 byte    0x01
//!   band_mhz   : 4 bytes   uint32
//!   valid_time : 8 bytes   int64 unix seconds
//!   lat_min    : 4 bytes   float32
//!   lon_min    : 4 bytes   float32
//!   step_deg   : 4 bytes   float32
//!   n_rows     : 2 bytes   uint16 (lat)
//!   n_cols     : 2 bytes   uint16 (lon)
//!   scores     : n_rows × n_cols bytes (uint8, 0-100 or 255 = no-data)
//!
//! Writes go through `rename(2)` — we write to `path.tmp.<nanos.<uniq>>`
//! then atomically rename to the final path. On the shared NFSv4 mount
//! this is safe for concurrent readers.

use std::fs::OpenOptions;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use chrono::{DateTime, Utc};

use crate::grid;

pub const MAGIC: &[u8; 4] = b"PROP";
pub const LEGACY_MAGIC: &[u8; 4] = b"NTMS";
pub const VERSION: u8 = 1;
pub const NO_DATA: u8 = 255;

static UNIQUE: AtomicU64 = AtomicU64::new(1);

/// Absolute path `<base>/<band_mhz>/<iso>.prop`. New HRRR writes use
/// this; HRDPS writes go to `<base>/<band_mhz>/<iso>.hrdps.prop` via
/// `path_for_hrdps`. Readers accept both plus the legacy `.ntms`
/// extension while previously-written files drain through the normal
/// retention window.
pub fn path_for(base_dir: &Path, band_mhz: u32, valid_time: DateTime<Utc>) -> PathBuf {
    let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
    base_dir
        .join(band_mhz.to_string())
        .join(format!("{iso}.prop"))
}

/// HRDPS-source path `<base>/<band_mhz>/<iso>.hrdps.prop`. Sibling of
/// `path_for/3` so HRRR (CONUS) and HRDPS (Canadian) score grids for the
/// same `(band, valid_time)` coexist as separate files. Readers stitch
/// them together at request time.
pub fn path_for_hrdps(base_dir: &Path, band_mhz: u32, valid_time: DateTime<Utc>) -> PathBuf {
    let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
    base_dir
        .join(band_mhz.to_string())
        .join(format!("{iso}.hrdps.prop"))
}

/// Legacy path used by scores written before the `.prop` rename. Kept so
/// readers can discover still-on-disk `.ntms` files during rollout.
pub fn legacy_path_for(base_dir: &Path, band_mhz: u32, valid_time: DateTime<Utc>) -> PathBuf {
    let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
    base_dir
        .join(band_mhz.to_string())
        .join(format!("{iso}.ntms"))
}

#[derive(Debug, thiserror::Error)]
pub enum WriteError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("grid dimensions exceed uint16 (rows={rows}, cols={cols})")]
    GridTooLarge { rows: usize, cols: usize },
}

/// Encode the full on-disk representation into a `Vec<u8>` using the
/// CONUS HRRR grid spec. Backwards-compatible wrapper around
/// `encode_with_spec` for HRRR callers; HRDPS callers pass
/// `grid::hrdps_grid_spec()` explicitly.
pub fn encode(
    band_mhz: u32,
    valid_time: DateTime<Utc>,
    scores: &[ScorePoint],
) -> Result<Vec<u8>, WriteError> {
    encode_with_spec(band_mhz, valid_time, scores, grid::wgrib2_grid_spec())
}

/// Encode score scrubs into the on-disk format using an explicit
/// `GridSpec`. Used by both HRRR (CONUS spec) and HRDPS (Canadian spec)
/// writers. Cells outside the spec's bbox are silently dropped — for
/// HRDPS that's the expected case for the HRRR-overlap cells we mask
/// out at scoring time anyway.
pub fn encode_with_spec(
    band_mhz: u32,
    valid_time: DateTime<Utc>,
    scores: &[ScorePoint],
    spec: grid::GridSpec,
) -> Result<Vec<u8>, WriteError> {
    let n_rows: u16 = spec
        .lat_count
        .try_into()
        .map_err(|_| WriteError::GridTooLarge {
            rows: spec.lat_count,
            cols: spec.lon_count,
        })?;
    let n_cols: u16 = spec
        .lon_count
        .try_into()
        .map_err(|_| WriteError::GridTooLarge {
            rows: spec.lat_count,
            cols: spec.lon_count,
        })?;

    let row_count = n_rows as usize;
    let col_count = n_cols as usize;
    let mut body = vec![NO_DATA; row_count * col_count];

    for s in scores {
        let row = (((s.lat - spec.lat_start) / spec.lat_step).round()) as isize;
        let col = (((s.lon - spec.lon_start) / spec.lon_step).round()) as isize;
        if row < 0 || col < 0 || row as usize >= row_count || col as usize >= col_count {
            continue;
        }
        body[(row as usize) * col_count + (col as usize)] = clamp_score(s.score);
    }

    let mut out = Vec::with_capacity(4 + 1 + 4 + 8 + 4 + 4 + 4 + 2 + 2 + body.len());
    out.extend_from_slice(MAGIC);
    out.push(VERSION);
    out.extend_from_slice(&band_mhz.to_le_bytes());
    out.extend_from_slice(&valid_time.timestamp().to_le_bytes());
    out.extend_from_slice(&(spec.lat_start as f32).to_le_bytes());
    out.extend_from_slice(&(spec.lon_start as f32).to_le_bytes());
    out.extend_from_slice(&(spec.lon_step as f32).to_le_bytes());
    out.extend_from_slice(&n_rows.to_le_bytes());
    out.extend_from_slice(&n_cols.to_le_bytes());
    out.extend_from_slice(&body);
    Ok(out)
}

/// Atomic write to `<base>/<band_mhz>/<iso>.prop` (HRRR/CONUS).
/// HRDPS callers use `write_atomic_hrdps` which writes to `.hrdps.prop`
/// with the Canadian grid spec.
pub fn write_atomic(
    base_dir: &Path,
    band_mhz: u32,
    valid_time: DateTime<Utc>,
    scores: &[ScorePoint],
) -> Result<(), WriteError> {
    write_atomic_at(
        path_for(base_dir, band_mhz, valid_time),
        encode(band_mhz, valid_time, scores)?,
    )
}

/// Atomic write to `<base>/<band_mhz>/<iso>.hrdps.prop` using the
/// Canadian grid spec. Coexists with the sibling HRRR `.prop` file for
/// the same `(band, valid_time)`.
pub fn write_atomic_hrdps(
    base_dir: &Path,
    band_mhz: u32,
    valid_time: DateTime<Utc>,
    scores: &[ScorePoint],
) -> Result<(), WriteError> {
    let bytes = encode_with_spec(band_mhz, valid_time, scores, grid::hrdps_grid_spec())?;
    write_atomic_at(path_for_hrdps(base_dir, band_mhz, valid_time), bytes)
}

fn write_atomic_at(final_path: PathBuf, bytes: Vec<u8>) -> Result<(), WriteError> {
    let parent = final_path
        .parent()
        .ok_or_else(|| std::io::Error::other("path has no parent"))?;
    std::fs::create_dir_all(parent)?;

    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
    let tmp_path = final_path.with_extension(format!("prop.tmp.{nanos}.{uniq}"));

    {
        let file = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&tmp_path)?;
        let mut w = BufWriter::new(file);
        w.write_all(&bytes)?;
        w.flush()?;
    }

    match std::fs::rename(&tmp_path, &final_path) {
        Ok(()) => Ok(()),
        Err(e) => {
            let _ = std::fs::remove_file(&tmp_path);
            Err(WriteError::Io(e))
        }
    }
}

#[cfg(test)]
mod hrdps_tests {
    use super::*;
    use chrono::TimeZone;

    #[test]
    fn hrdps_path_uses_hrdps_prop_extension() {
        let base = Path::new("/data/scores");
        let vt = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
        let path = path_for_hrdps(base, 10_000, vt);
        assert!(path.to_string_lossy().ends_with("/2026-04-29T12:00:00Z.hrdps.prop"));
    }

    #[test]
    fn hrdps_encode_uses_canadian_bbox() {
        let vt = Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap();
        let scores = vec![
            ScorePoint {
                lat: 53.32,
                lon: -60.42,
                score: 75,
            },
            // Cell outside HRDPS bbox (Hawaii) — should be silently dropped.
            ScorePoint {
                lat: 21.3,
                lon: -157.8,
                score: 50,
            },
        ];

        let bytes =
            encode_with_spec(10_000, vt, &scores, grid::hrdps_grid_spec()).expect("encodes");
        let decoded = decode(&bytes).expect("decodes");

        // HRDPS bbox lat_start=49, lon_start=-141
        assert!((decoded.lat_min - 49.0).abs() < 1e-6);
        assert!((decoded.lon_min - (-141.0)).abs() < 1e-6);
        // HRDPS uses the coarser 0.5° step (see grid::HRDPS_STEP), so
        // the bbox produces 23 lat × 179 lon cells.
        assert_eq!(decoded.n_rows, 23);
        assert_eq!(decoded.n_cols, 179);
    }
}

#[derive(Debug, Clone, Copy)]
pub struct ScorePoint {
    pub lat: f64,
    pub lon: f64,
    pub score: i32,
}

fn clamp_score(s: i32) -> u8 {
    if s < 0 {
        0
    } else if s > 100 {
        100
    } else {
        s as u8
    }
}

/// Minimal decoder used only in tests + the shadow comparator. Returns
/// the raw row-major body plus the metadata. Mirrors the Elixir
/// `decode/1` sans Access-behavior niceties.
pub fn decode(bytes: &[u8]) -> Option<Decoded> {
    if bytes.len() < 4 + 1 + 4 + 8 + 4 + 4 + 4 + 2 + 2 {
        return None;
    }
    if &bytes[0..4] != MAGIC && &bytes[0..4] != LEGACY_MAGIC {
        return None;
    }
    if bytes[4] != VERSION {
        return None;
    }
    let band_mhz = u32::from_le_bytes(bytes[5..9].try_into().ok()?);
    let valid_time_unix = i64::from_le_bytes(bytes[9..17].try_into().ok()?);
    let lat_min = f32::from_le_bytes(bytes[17..21].try_into().ok()?);
    let lon_min = f32::from_le_bytes(bytes[21..25].try_into().ok()?);
    let step = f32::from_le_bytes(bytes[25..29].try_into().ok()?);
    let n_rows = u16::from_le_bytes(bytes[29..31].try_into().ok()?);
    let n_cols = u16::from_le_bytes(bytes[31..33].try_into().ok()?);
    let body_len = (n_rows as usize) * (n_cols as usize);
    if bytes.len() < 33 + body_len {
        return None;
    }
    let valid_time = DateTime::<Utc>::from_timestamp(valid_time_unix, 0)?;
    Some(Decoded {
        band_mhz,
        valid_time,
        lat_min,
        lon_min,
        step,
        n_rows,
        n_cols,
        body: bytes[33..33 + body_len].to_vec(),
    })
}

#[derive(Debug)]
pub struct Decoded {
    pub band_mhz: u32,
    pub valid_time: DateTime<Utc>,
    pub lat_min: f32,
    pub lon_min: f32,
    pub step: f32,
    pub n_rows: u16,
    pub n_cols: u16,
    pub body: Vec<u8>,
}

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

    #[test]
    fn round_trip_header() {
        let scores = vec![ScorePoint {
            lat: 32.0,
            lon: -97.0,
            score: 73,
        }];
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let bytes = encode(10_000, ts, &scores).unwrap();
        let decoded = decode(&bytes).unwrap();
        assert_eq!(decoded.band_mhz, 10_000);
        assert_eq!(decoded.valid_time, ts);
        assert_eq!(decoded.n_rows, 201);
        assert_eq!(decoded.n_cols, 473);
        // Score at (32, -97): row = (32-25)/0.125 = 56, col = (-97 - -125)/0.125 = 224
        let idx = 56 * 473 + 224;
        assert_eq!(decoded.body[idx], 73);
    }

    #[test]
    fn missing_cells_use_no_data_sentinel() {
        let scores: Vec<ScorePoint> = vec![];
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let bytes = encode(10_000, ts, &scores).unwrap();
        let decoded = decode(&bytes).unwrap();
        assert!(decoded.body.iter().all(|&b| b == NO_DATA));
    }

    #[test]
    fn clamps_out_of_range_scores() {
        let scores = vec![
            ScorePoint {
                lat: 25.0,
                lon: -125.0,
                score: -5,
            },
            ScorePoint {
                lat: 25.125,
                lon: -125.0,
                score: 150,
            },
        ];
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let bytes = encode(10_000, ts, &scores).unwrap();
        let decoded = decode(&bytes).unwrap();
        assert_eq!(decoded.body[0], 0);
        // row = 1, col = 0 → idx = 1 * 473
        assert_eq!(decoded.body[473], 100);
    }

    #[test]
    fn path_for_layout() {
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let p = path_for(Path::new("/data/scores"), 10_000, ts);
        assert_eq!(
            p.to_str().unwrap(),
            "/data/scores/10000/2026-04-19T15:00:00Z.prop"
        );
    }

    #[test]
    fn legacy_path_for_layout_matches_pre_rename() {
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let p = legacy_path_for(Path::new("/data/scores"), 10_000, ts);
        assert_eq!(
            p.to_str().unwrap(),
            "/data/scores/10000/2026-04-19T15:00:00Z.ntms"
        );
    }

    #[test]
    fn decode_accepts_legacy_magic() {
        // Flip the 4-byte magic on an otherwise-valid encoded blob and
        // verify decode still accepts it. Readers must keep tolerating
        // `NTMS`-prefixed files written before the rename.
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let scores = vec![ScorePoint {
            lat: 32.0,
            lon: -97.0,
            score: 73,
        }];
        let mut bytes = encode(10_000, ts, &scores).unwrap();
        bytes[0..4].copy_from_slice(LEGACY_MAGIC);
        let decoded = decode(&bytes).expect("legacy magic must decode");
        assert_eq!(decoded.band_mhz, 10_000);
    }

    #[test]
    fn decode_rejects_unknown_magic() {
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let mut bytes = encode(10_000, ts, &[]).unwrap();
        bytes[0..4].copy_from_slice(b"XXXX");
        assert!(decode(&bytes).is_none());
    }

    #[test]
    fn atomic_write_and_decode() {
        let dir = tempfile::tempdir().unwrap();
        let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
        let scores = vec![ScorePoint {
            lat: 32.0,
            lon: -97.0,
            score: 73,
        }];

        write_atomic(dir.path(), 10_000, ts, &scores).unwrap();

        let path = path_for(dir.path(), 10_000, ts);
        let bytes = std::fs::read(&path).unwrap();
        let decoded = decode(&bytes).unwrap();
        assert_eq!(decoded.band_mhz, 10_000);
        assert_eq!(decoded.body[56 * 473 + 224], 73);
    }
}
