//! Deterministic synthetic inputs: the seeded price walk (engine surface), the
//! 48-byte M1 zip archive writer (ingest + campaign surfaces), and the scratch
//! temp-dir guard. Everything here is a pure function of its arguments — same
//! bytes on every host, every run (C1 is what makes the engine benchmarkable).

use aura_core::{Scalar, Timestamp};
use std::io::Write;
use std::path::{Path, PathBuf};

/// A seeded LCG random walk as a `VecSource` stream: 1-minute spacing in
/// epoch-ns, prices around 100. Same constants, same bytes, everywhere.
pub fn synthetic_walk(n: usize) -> Vec<(Timestamp, Scalar)> {
    let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
    let mut price = 100.0_f64;
    (0..n)
        .map(|i| {
            x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
            let step = ((x >> 11) as f64 / (1u64 << 53) as f64) - 0.5;
            price += step;
            (Timestamp(i as i64 * 60_000_000_000), Scalar::f64(price))
        })
        .collect()
}

/// FNV-1a over raw bytes — the fingerprint hash for child-process stdout.
pub fn fnv1a(bytes: &[u8]) -> u64 {
    let mut h = 0xcbf2_9ce4_8422_2325_u64;
    for b in bytes {
        h ^= u64::from(*b);
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    h
}

/// A self-deleting scratch directory under the system temp dir.
pub struct ScratchDir(pub PathBuf);

impl ScratchDir {
    pub fn new(tag: &str) -> std::io::Result<Self> {
        let base = std::env::temp_dir().join(format!(
            "aura-bench-{}-{}-{}",
            std::process::id(),
            tag,
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&base)?;
        Ok(Self(base))
    }
}

impl Drop for ScratchDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

/// Days since the Unix epoch for a proleptic-Gregorian civil date
/// (Howard Hinnant's `days_from_civil` — dependency-free calendar math).
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
    let y = i64::from(if m <= 2 { y - 1 } else { y });
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400; // [0, 399]
    let mp = (i64::from(m) + 9) % 12; // [0, 11], Mar=0 .. Feb=11
    let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
    era * 146_097 + doe - 719_468
}

/// Unix milliseconds at `y-m-d hh:mm:00` UTC.
pub fn unix_ms(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> i64 {
    days_from_civil(y, m, d) * 86_400_000 + (i64::from(hh) * 3600 + i64::from(mm) * 60) * 1000
}

/// 0 = Sunday .. 6 = Saturday (1970-01-01 was a Thursday, day 0).
fn weekday(y: i32, m: u32, d: u32) -> i64 {
    (days_from_civil(y, m, d) % 7 + 4 + 7) % 7
}

fn days_in_month(y: i32, m: u32) -> u32 {
    match m {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
        2 => 28,
        _ => unreachable!("month out of range: {m}"),
    }
}

/// A deterministic price at continuous minute offset `t` (minutes since
/// 2024-01-01T00:00 UTC): a gentle upward trend plus a 180-minute sine.
fn price_at(t_minutes: f64) -> f64 {
    let hours = t_minutes / 60.0;
    100.0 + 0.01 * hours + 5.0 * (2.0 * std::f64::consts::PI * t_minutes / 180.0).sin()
}

/// Packs one `RawM1Record`-shaped 48-byte little-endian record: `time`
/// (Delphi `TDateTime`, days since 1899-12-30), `open/high/low/close`,
/// `spread` (f32), `volume` (i32).
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
    const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
    const MS_PER_DAY: f64 = 86_400_000.0;
    let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
    let mut rec = [0u8; 48];
    rec[0..8].copy_from_slice(&dt.to_le_bytes());
    rec[8..16].copy_from_slice(&open.to_le_bytes());
    rec[16..24].copy_from_slice(&high.to_le_bytes());
    rec[24..32].copy_from_slice(&low.to_le_bytes());
    rec[32..40].copy_from_slice(&close.to_le_bytes());
    rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
    rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
    rec
}

/// One month's worth of weekday, 08:00-16:00 UTC, 1-minute bars (~10k).
fn month_records(year: i32, month: u32) -> Vec<[u8; 48]> {
    let epoch_ms = unix_ms(2024, 1, 1, 0, 0);
    let mut records = Vec::new();
    for day in 1..=days_in_month(year, month) {
        if !(1..=5).contains(&weekday(year, month, day)) {
            continue; // weekend
        }
        for hh in 8..16 {
            for mm in 0..60 {
                let ms = unix_ms(year, month, day, hh, mm);
                let t = (ms - epoch_ms) as f64 / 60_000.0;
                let (open, close) = (price_at(t), price_at(t + 1.0));
                let high = open.max(close) + 0.2;
                let low = open.min(close) - 0.2;
                records.push(pack_record(ms, open, high, low, close));
            }
        }
    }
    records
}

/// Writes one `SYMBOL_YYYY_MM.m1` zip (a single `<SYMBOL>.bin` entry).
fn write_month_zip(data_dir: &Path, symbol: &str, year: i32, month: u32) {
    let path = data_dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
    let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
    let mut zip = zip::ZipWriter::new(file);
    zip.start_file::<String, ()>(format!("{symbol}.bin"), Default::default())
        .expect("start synthetic m1 zip entry");
    for rec in month_records(year, month) {
        zip.write_all(&rec).expect("write synthetic m1 record");
    }
    zip.finish().expect("finish synthetic m1 zip");
}

/// Writes every month in `[from, to]` (inclusive `(year, month)` pairs) for
/// `symbol`, plus its `<SYMBOL>.meta.json` geometry sidecar (GER40-shaped).
pub fn write_symbol_archive(data_dir: &Path, symbol: &str, from: (i32, u32), to: (i32, u32)) {
    let (mut y, mut m) = from;
    loop {
        write_month_zip(data_dir, symbol, y, m);
        if (y, m) == to {
            break;
        }
        m += 1;
        if m > 12 {
            m = 1;
            y += 1;
        }
    }
    std::fs::write(
        data_dir.join(format!("{symbol}.meta.json")),
        format!(
            "{{\"schemaVersion\":2,\"symbol\":\"{symbol}\",\"digits\":1,\"pipSize\":1,\
             \"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"{symbol}\",\"quoteAsset\":\"EUR\"}}"
        ),
    )
    .expect("write synthetic geometry sidecar");
}

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

    #[test]
    fn synthetic_walk_is_deterministic_and_sized() {
        let a = synthetic_walk(1000);
        let b = synthetic_walk(1000);
        assert_eq!(a.len(), 1000);
        assert_eq!(a, b, "same seed, same bytes");
        assert_eq!(a[0].0, Timestamp(0));
        assert_eq!(a[1].0, Timestamp(60_000_000_000));
    }

    #[test]
    fn write_symbol_archive_names_months_and_sidecar() {
        let dir = ScratchDir::new("synthtest").expect("scratch dir");
        write_symbol_archive(&dir.0, "TSYM", (2024, 11), (2025, 2));
        let mut names: Vec<String> = std::fs::read_dir(&dir.0)
            .expect("read scratch")
            .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
            .collect();
        names.sort();
        assert_eq!(
            names,
            vec![
                "TSYM.meta.json".to_string(),
                "TSYM_2024_11.m1".to_string(),
                "TSYM_2024_12.m1".to_string(),
                "TSYM_2025_01.m1".to_string(),
                "TSYM_2025_02.m1".to_string(),
            ],
            "month rollover + sidecar"
        );
    }

    #[test]
    fn fnv1a_is_stable() {
        assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
        assert_eq!(fnv1a(b"a"), fnv1a(b"a"));
        assert_ne!(fnv1a(b"a"), fnv1a(b"b"));
    }

    /// #251: the synthetic archive is the ingest surface's benchmark input
    /// (fed through the CLI wiring), so its bytes must be more than
    /// internally self-consistent — they must actually parse through the
    /// real production M1 reader
    /// (`aura_ingest::DataServer`/`load_m1_window`), the same path a real
    /// GER40 archive goes through. This is not a round trip against our own
    /// `pack_record`: it is the production parser recovering `pack_record`'s
    /// 48-byte layout (Delphi `TDateTime`, 4 little-endian f64 prices) and
    /// the `price_at` OHLC shape (`low <= open,close <= high` on every bar) —
    /// pins the record layout against future drift in either writer or
    /// reader.
    #[test]
    fn synthetic_archive_parses_through_the_real_ingest_reader() {
        let dir = ScratchDir::new("ingest-roundtrip").expect("scratch dir");
        write_symbol_archive(&dir.0, "BENCHSYM", (2024, 1), (2024, 1));
        let server = std::sync::Arc::new(aura_ingest::DataServer::new(&dir.0));
        let cols = aura_ingest::load_m1_window(&server, "BENCHSYM", None, None)
            .expect("the synthetic archive is a recognized data source");

        assert!(!cols.close.is_empty(), "January 2024 has weekday 08:00-16:00 bars");
        for i in 0..cols.close.len() {
            assert!(
                cols.high[i] >= cols.open[i] && cols.high[i] >= cols.close[i],
                "bar {i}: high must dominate open/close"
            );
            assert!(
                cols.low[i] <= cols.open[i] && cols.low[i] <= cols.close[i],
                "bar {i}: low must be dominated by open/close"
            );
        }
    }
}
