//! Bit-equivalence gate for the v2 trunk-grows refactor.
//!
//! Captures a deterministic snapshot of `PerceptionTrainer.evaluate`
//! output on a 32-snapshot fixture with seed=42. Every refactor commit
//! X1..X11 must reproduce these bytes to `max_abs_diff < 1e-4`
//! (cross-GPU tolerance per spec §2.4).
//!
//! Runtime invocation (GPU required):
//!
//!     SQLX_OFFLINE=true cargo test -p ml-alpha \
//!         --test perception_forward_golden -- --ignored --nocapture
//!
//! First run: writes the golden file at
//!     crates/ml-alpha/tests/fixtures/perception_forward_golden.bin
//! Subsequent runs: re-runs the forward path and asserts bit-equivalence
//! against the golden.
//!
//! Golden bytes were captured on RTX 3050 (sm_86). L40S runs use the
//! same tight-tolerance comparison; cross-GPU drift within 1e-4 is
//! accepted per spec §2.4.

use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use ml_alpha::cfc::snap_features::{Mbp10RawInput, REGIME_DIM};
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
use ml_core::device::MlDevice;
use rand::{Rng, SeedableRng};

const SEQ_LEN: usize = 32;
const SEED: u64 = 42;
const GOLDEN_PATH: &str = "tests/fixtures/perception_forward_golden.bin";

/// Build a deterministic 32-snapshot fixture using a fixed PRNG seed.
/// Output bytes are stable across runs on the same GPU model.
fn fixture_snapshots() -> Vec<Mbp10RawInput> {
    let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
    let mut prev_mid = 100.0_f32;
    let mut prev_ts_ns = 1_000_000_000_u64;
    (0..SEQ_LEN)
        .map(|i| {
            let mid_jitter: f32 = rng.gen_range(-0.5_f32..0.5_f32);
            let mid = 100.0 + mid_jitter;
            let ts_ns = prev_ts_ns + 1_000_000;
            let bid_px: [f32; 10] = std::array::from_fn(|j| mid - 0.125 - (j as f32) * 0.25);
            let ask_px: [f32; 10] = std::array::from_fn(|j| mid + 0.125 + (j as f32) * 0.25);
            let bid_sz: [f32; 10] = [10.0; 10];
            let ask_sz: [f32; 10] = [10.0; 10];
            let regime: [f32; REGIME_DIM] = std::array::from_fn(|_| rng.gen_range(-1.0_f32..1.0_f32));
            let trade_signed_vol: f32 = rng.gen_range(-5.0_f32..5.0_f32);
            let trade_count: u32 = rng.gen_range(0_u32..50_u32);
            let snap = Mbp10RawInput {
                bid_px,
                bid_sz,
                ask_px,
                ask_sz,
                prev_mid,
                trade_signed_vol,
                trade_count,
                ts_ns,
                prev_ts_ns,
                regime,
            };
            prev_mid = mid;
            prev_ts_ns = ts_ns;
            let _ = i;
            snap
        })
        .collect()
}

/// Deterministic labels — random in [0, 1] per (position, horizon).
fn fixture_labels() -> Vec<[f32; N_HORIZONS]> {
    let mut rng = rand::rngs::StdRng::seed_from_u64(SEED.wrapping_add(1));
    (0..SEQ_LEN)
        .map(|_| {
            let mut row = [0.0_f32; N_HORIZONS];
            for x in &mut row {
                *x = rng.gen_range(0.0_f32..1.0_f32);
            }
            row
        })
        .collect()
}

fn run_forward() -> Result<(f32, Vec<f32>)> {
    let dev = MlDevice::cuda(0).context("init MlDevice")?;
    let cfg = PerceptionTrainerConfig {
        seq_len: SEQ_LEN,
        n_batch: 1,
        mamba2_state_dim: 16,
        seed: SEED,
        ..Default::default()
    };
    let mut trainer = PerceptionTrainer::new(&dev, &cfg).context("trainer init")?;
    let snapshots = fixture_snapshots();
    let labels = fixture_labels();
    let (loss, probs) = trainer.evaluate(&snapshots, &labels)?;
    Ok((loss, probs))
}

fn golden_file_path() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_PATH)
}

#[test]
#[ignore = "GPU required; gates the v2 trunk-grows refactor commits"]
fn golden_matches() -> Result<()> {
    let (loss, probs) = run_forward()?;
    let path = golden_file_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create fixture dir {}", parent.display()))?;
    }
    if !path.exists() {
        // First run: capture the golden.
        let mut bytes = Vec::with_capacity(4 + probs.len() * 4);
        bytes.extend_from_slice(&loss.to_le_bytes());
        for p in &probs {
            bytes.extend_from_slice(&p.to_le_bytes());
        }
        fs::write(&path, &bytes)
            .with_context(|| format!("write golden {}", path.display()))?;
        eprintln!(
            "WROTE golden: {} bytes, {} probs at {}",
            bytes.len(),
            probs.len(),
            path.display()
        );
        return Ok(());
    }
    let golden = fs::read(&path).context("read golden")?;
    let expected_size = 4 + probs.len() * 4;
    anyhow::ensure!(
        golden.len() == expected_size,
        "golden size {} != expected {} (probs len changed?)",
        golden.len(),
        expected_size
    );
    let golden_loss = f32::from_le_bytes(golden[0..4].try_into().unwrap());
    let mut max_diff: f32 = (loss - golden_loss).abs();
    let mut max_diff_idx: usize = usize::MAX;
    for (i, p) in probs.iter().enumerate() {
        let off = 4 + i * 4;
        let g = f32::from_le_bytes(golden[off..off + 4].try_into().unwrap());
        let d = (p - g).abs();
        if d > max_diff {
            max_diff = d;
            max_diff_idx = i;
        }
    }
    let tol = 1e-4_f32;
    eprintln!(
        "golden loss={:.6e} run loss={:.6e} max_diff={:.6e} (idx={}) tol={:.0e}",
        golden_loss, loss, max_diff, max_diff_idx, tol
    );
    anyhow::ensure!(
        max_diff < tol,
        "max_abs_diff {:.6e} >= tolerance {:.0e} — refactor regression vs golden \
         (largest diff at prob[{}])",
        max_diff,
        tol,
        max_diff_idx
    );
    Ok(())
}
