//! Roundtrip tests for the `.fxcache` binary format.
//!
//! Covers f32 serialization, cache-key lookup, header validation,
//! and the empty-bars error path.

use ml::fxcache::{find_fxcache, load_fxcache, write_fxcache, FxCacheData, FXCACHE_VERSION, OFI_DIM};
use tempfile::TempDir;

// ── Helpers ─────────────────────────────────────────────────────────────────

/// Deterministic feature vector for bar `i`.
fn make_features(n: usize) -> Vec<[f64; 42]> {
    (0..n)
        .map(|i| {
            let mut row = [0.0_f64; 42];
            for j in 0..42 {
                row[j] = (i * 42 + j) as f64 * 0.01;
            }
            row
        })
        .collect()
}

/// Deterministic target vector for bar `i`.
fn make_targets(n: usize) -> Vec<[f64; 6]> {
    (0..n)
        .map(|i| {
            let mut row = [0.0_f64; 6];
            for j in 0..6 {
                row[j] = 5000.0 + (i * 6 + j) as f64 * 0.25;
            }
            row
        })
        .collect()
}

/// Deterministic OFI vector for bar `i`.
fn make_ofi(n: usize) -> Vec<[f64; OFI_DIM]> {
    (0..n)
        .map(|i| {
            let mut row = [0.0_f64; OFI_DIM];
            for j in 0..OFI_DIM {
                row[j] = (i * OFI_DIM + j) as f64 * 0.001;
            }
            row
        })
        .collect()
}

/// Deterministic per-bar timestamps (1-minute intervals starting 2022-01-01).
fn make_timestamps(n: usize) -> Vec<i64> {
    (0..n)
        .map(|i| 1640995200_000_000_000i64 + i as i64 * 60_000_000_000)
        .collect()
}

/// A fixed 32-byte cache key for tests.
fn test_cache_key() -> [u8; 32] {
    let mut key = [0u8; 32];
    for (i, slot) in key.iter_mut().enumerate() {
        *slot = (i as u8).wrapping_mul(7).wrapping_add(13);
    }
    key
}

// ── Tests ───────────────────────────────────────────────────────────────────

/// Write 100 bars as f32 (version 1), read back, verify within f32 tolerance.
#[test]
fn test_fxcache_f32_roundtrip() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("test_f32.fxcache");

    let n = 100;
    let features = make_features(n);
    let targets = make_targets(n);
    let ofi = make_ofi(n);
    let timestamps = make_timestamps(n);
    let key = test_cache_key();

    let bytes_written =
        write_fxcache(&path, &features, &targets, &ofi, &timestamps, key, false, None).unwrap();
    assert!(bytes_written > 0, "expected nonzero bytes written");

    let data: FxCacheData = load_fxcache(&path).unwrap();
    assert_eq!(data.bar_count, n);
    assert_eq!(data.cache_key, key);
    assert_eq!(data.features.len(), n);
    assert_eq!(data.targets.len(), n);
    assert_eq!(data.ofi.len(), n);
    assert_eq!(data.timestamps.len(), n);

    // Timestamps are always i64 — bit-exact.
    for i in 0..n {
        assert_eq!(
            data.timestamps[i], timestamps[i],
            "timestamp mismatch at bar {i}"
        );
    }

    // f32 roundtrip tolerance checks (~7 significant digits).
    for i in 0..n {
        for j in 0..42 {
            let diff = (data.features[i][j] - features[i][j]).abs();
            assert!(
                diff < 1e-4,
                "feature[{i}][{j}]: expected {}, got {}, diff={diff}",
                features[i][j],
                data.features[i][j]
            );
        }
        for j in 0..6 {
            let diff = (data.targets[i][j] - targets[i][j]).abs();
            // f32 at ~5000 has resolution of ~0.0005, allow generous margin.
            assert!(
                diff < 0.01,
                "target[{i}][{j}]: expected {}, got {}, diff={diff}",
                targets[i][j],
                data.targets[i][j]
            );
        }
        for j in 0..OFI_DIM {
            let diff = (data.ofi[i][j] - ofi[i][j]).abs();
            assert!(
                diff < 1e-4,
                "ofi[{i}][{j}]: expected {}, got {}, diff={diff}",
                ofi[i][j],
                data.ofi[i][j]
            );
        }
    }
}

/// Write a file with a known cache key, verify `find_fxcache` locates it.
/// Also verify that a different key returns `None`.
#[test]
fn test_fxcache_find() {
    let dir = TempDir::new().unwrap();
    let key = test_cache_key();
    let hex_key = hex::encode(key);
    let path = dir.path().join(format!("{hex_key}.fxcache"));

    let features = make_features(5);
    let targets = make_targets(5);
    let ofi = make_ofi(5);
    let timestamps = make_timestamps(5);

    write_fxcache(&path, &features, &targets, &ofi, &timestamps, key, false, None).unwrap();

    // Should find the file.
    let found = find_fxcache(dir.path(), &key);
    assert!(found.is_some(), "expected to find fxcache file");
    assert_eq!(found.unwrap(), path);

    // Wrong key should miss.
    let mut wrong_key = [0xFFu8; 32];
    wrong_key[0] = 0x00;
    let miss = find_fxcache(dir.path(), &wrong_key);
    assert!(miss.is_none(), "expected miss for wrong key");
}

/// Write garbage bytes to a file, verify `load_fxcache` returns an error
/// whose message contains "magic".
#[test]
fn test_fxcache_header_validation() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("garbage.fxcache");

    // Write 128 bytes of garbage (more than header size).
    std::fs::write(&path, vec![0xABu8; 128]).unwrap();

    let err = load_fxcache(&path).unwrap_err();
    let msg = format!("{err:#}");
    assert!(
        msg.to_lowercase().contains("magic"),
        "expected error about magic bytes, got: {msg}"
    );
}

/// Attempting to write 0 bars should fail (writer rejects empty data).
/// Attempting to read a hand-crafted header with bar_count=0 should also fail.
#[test]
fn test_fxcache_empty() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("empty.fxcache");

    // Writer rejects 0 bars.
    let features: Vec<[f64; 42]> = vec![];
    let targets: Vec<[f64; 6]> = vec![];
    let ofi: Vec<[f64; OFI_DIM]> = vec![];
    let timestamps: Vec<i64> = vec![];
    let key = test_cache_key();

    let err = write_fxcache(&path, &features, &targets, &ofi, &timestamps, key, false, None).unwrap_err();
    let msg = format!("{err:#}");
    assert!(
        msg.contains("0 bars") || msg.contains("empty"),
        "expected empty/0-bars error from writer, got: {msg}"
    );

    // Reader also rejects a header with bar_count=0.
    // Hand-craft a valid-magic header with current version + bar_count=0.
    let mut header = [0u8; 64];
    header[0..8].copy_from_slice(b"FXCACHE\0");
    header[8..10].copy_from_slice(&FXCACHE_VERSION.to_le_bytes()); // current version
    header[10..12].copy_from_slice(&42u16.to_le_bytes()); // feat_dim
    header[12..14].copy_from_slice(&6u16.to_le_bytes()); // target_dim
    header[14..16].copy_from_slice(&(OFI_DIM as u16).to_le_bytes()); // ofi_dim
    header[16..24].copy_from_slice(&0u64.to_le_bytes()); // bar_count=0
    // cache_key + reserved stay zeroed.

    let path_reader = dir.path().join("empty_header.fxcache");
    std::fs::write(&path_reader, &header).unwrap();

    let err = load_fxcache(&path_reader).unwrap_err();
    let msg = format!("{err:#}");
    assert!(
        msg.contains("zero") || msg.contains("bar_count"),
        "expected bar_count/zero error from reader, got: {msg}"
    );
}
