//! Score-grid file format (v1). 1:1 port of
//! `lib/microwaveprop/propagation/scores_file.ex`.
//!
//! Layout (all little-endian):
//!   magic      : 4 bytes   "NTMS"  (literal already on disk)
//!   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"NTMS";
pub const VERSION: u8 = 1;
pub const NO_DATA: u8 = 255;

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

/// Absolute path `<base>/<band_mhz>/<iso>.ntms`.
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}.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>`. Separated
/// from the file I/O so tests can round-trip without a tempdir.
pub fn encode(
    band_mhz: u32,
    valid_time: DateTime<Utc>,
    scores: &[ScorePoint],
) -> Result<Vec<u8>, WriteError> {
    let spec = grid::wgrib2_grid_spec();
    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>.ntms`. Creates the band
/// subdirectory on demand.
pub fn write_atomic(
    base_dir: &Path,
    band_mhz: u32,
    valid_time: DateTime<Utc>,
    scores: &[ScorePoint],
) -> Result<(), WriteError> {
    let bytes = encode(band_mhz, valid_time, scores)?;
    let final_path = path_for(base_dir, band_mhz, valid_time);
    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!("ntms.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))
        }
    }
}

#[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> {
    use byteorder::{ByteOrder, LittleEndian};
    if bytes.len() < 4 + 1 + 4 + 8 + 4 + 4 + 4 + 2 + 2 {
        return None;
    }
    if &bytes[0..4] != MAGIC {
        return None;
    }
    if bytes[4] != VERSION {
        return None;
    }
    let band_mhz = LittleEndian::read_u32(&bytes[5..9]);
    let valid_time_unix = LittleEndian::read_i64(&bytes[9..17]);
    let lat_min = LittleEndian::read_f32(&bytes[17..21]);
    let lon_min = LittleEndian::read_f32(&bytes[21..25]);
    let step = LittleEndian::read_f32(&bytes[25..29]);
    let n_rows = LittleEndian::read_u16(&bytes[29..31]);
    let n_cols = LittleEndian::read_u16(&bytes[31..33]);
    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.ntms"
        );
    }

    #[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);
    }
}
