//! Smoke test: surrogate-noise false-positive detector (spec §4.1, Task 0.16).
//!
//! Mandatory gate. Runs a trained model against N=30 random-action surrogate
//! backtests; trained pooled Sharpe must exceed the 95th percentile of the
//! surrogate distribution. Protects against look-ahead / leakage bugs.
//!
//! The test drives the `evaluate_baseline` example binary as a subprocess:
//!   1. Trained run with `--emit-action-marginals --emit-pooled-sharpe` to
//!      capture both the marginal distribution and the pooled Sharpe.
//!   2. N=30 surrogate runs with `--surrogate-mode=random --surrogate-seed=N`
//!      using the marginals collected above, each emitting `POOLED_SHARPE:`.
//!
//! The 95th percentile of the surrogate pooled Sharpes is the significance
//! threshold: trained Sharpe must exceed it to PASS.
//!
//! ## Requirements
//!
//! - A trained checkpoint at `/workspace/output/dqn_fold0_best.safetensors`
//!   (Phase 3 deliverable). Until this exists the test asserts-out with a
//!   clear message; it is `#[ignore]`'d so regular `cargo test` skips it.
//! - `FOXHUNT_TEST_DATA` pointing at a futures-baseline data root.
//! - A compiled `evaluate_baseline` example binary at
//!   `target/release/examples/evaluate_baseline` (or the test falls back to
//!   `cargo run --release --example evaluate_baseline -- …`).
//!
//! Run:
//! ```bash
//! FOXHUNT_TEST_DATA=test_data/futures-baseline \
//!   cargo test -p ml --release --lib -- surrogate_noise_check --ignored --nocapture
//! ```

use super::helpers::{test_data_dir, workspace_root};
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;

/// Default checkpoint location produced by Phase 3 training.
const DEFAULT_CHECKPOINT: &str = "/workspace/output/dqn_fold0_best.safetensors";

/// Models dir passed to `evaluate_baseline`. The checkpoint's parent is used
/// because `evaluate_baseline` reads `<models_dir>/dqn_fold{N}_best.safetensors`.
fn models_dir_for_checkpoint(ckpt: &Path) -> PathBuf {
    ckpt.parent().map(std::path::Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))
}

#[test]
#[ignore] // Requires a trained checkpoint and substantial runtime (~30 subprocess calls).
fn test_surrogate_noise_check() -> Result<()> {
    let data_dir = test_data_dir().expect("FOXHUNT_TEST_DATA or test_data/ must exist");

    // Allow overriding the checkpoint path for local development; default to
    // the Phase 3 production path.
    let ckpt_path = std::env::var("FOXHUNT_SURROGATE_CKPT")
        .unwrap_or_else(|_| DEFAULT_CHECKPOINT.to_owned());
    let ckpt = Path::new(&ckpt_path);
    assert!(
        ckpt.exists(),
        "surrogate-noise test requires a trained checkpoint at {:?}. \
         Produce one via Phase 3 training or set FOXHUNT_SURROGATE_CKPT to a \
         local checkpoint.",
        ckpt
    );

    // 1. Trained backtest: capture pooled Sharpe + action marginals
    let (trained_sharpe, marginals_json) =
        run_trained_backtest_pooled(&data_dir, ckpt)?;
    println!("[SURROGATE] trained pooled Sharpe = {:.3}", trained_sharpe);

    // 2. Write marginals to a temp file for surrogate runs
    let marginals_path = workspace_root()
        .join("target")
        .join("smoke_surrogate_marginals.json");
    if let Some(parent) = marginals_path.parent() {
        std::fs::create_dir_all(parent).context("create marginals dir")?;
    }
    std::fs::write(&marginals_path, &marginals_json)
        .context("writing marginals JSON for surrogate runs")?;

    // 3. N=30 surrogate backtests
    //
    // Fix 4 (code-quality review): decorrelate seeds via a multiplicative hash.
    // Sequential small integers (0, 1, 2, …) fed into `StdRng::seed_from_u64`
    // produce streams whose initial bits are highly correlated — surrogate
    // distributions collapse toward the same trajectory and the p95 threshold
    // becomes non-representative. Multiplying by the golden-ratio constant
    // spreads the seeds evenly across the 64-bit space.
    //
    // Fix 5 (code-quality review): emit per-iteration progress so `--nocapture`
    // output is usable for triage even when a run hangs or diverges.
    const SEED_MULT: u64 = 0x9E37_79B9_7F4A_7C15; // golden-ratio constant
    let mut surrogate_sharpes: Vec<f64> = Vec::with_capacity(30);
    for i in 0..30_u64 {
        // Multiplicative hash to decorrelate sequential indices — consecutive
        // seeds with StdRng share initial bits.
        let seed = SEED_MULT.wrapping_mul(i.wrapping_add(1));
        let sh = run_surrogate_backtest_pooled(&data_dir, ckpt, &marginals_path, seed)?;
        println!(
            "[SURROGATE] seed {}/30 (hash={:#x}) sharpe={:.3}",
            i + 1,
            seed,
            sh,
        );
        surrogate_sharpes.push(sh);
    }
    surrogate_sharpes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let n = surrogate_sharpes.len();
    let p95_idx = ((n as f64) * 0.95) as usize;
    let p95 = *surrogate_sharpes
        .get(p95_idx.min(n.saturating_sub(1)))
        .ok_or_else(|| anyhow!("surrogate_sharpes empty"))?;
    let median = *surrogate_sharpes
        .get(n / 2)
        .ok_or_else(|| anyhow!("surrogate_sharpes empty"))?;
    let min = *surrogate_sharpes
        .first()
        .ok_or_else(|| anyhow!("surrogate_sharpes empty"))?;
    let max = *surrogate_sharpes
        .last()
        .ok_or_else(|| anyhow!("surrogate_sharpes empty"))?;

    println!(
        "[SURROGATE] N={} random-action pooled Sharpe 95th %ile = {:.3}",
        n, p95
    );
    println!(
        "[SURROGATE] distribution: min={:.3} median={:.3} max={:.3}",
        min, median, max
    );

    assert!(
        trained_sharpe > p95,
        "SURROGATE-NOISE FAILURE: trained Sharpe {:.3} is not beyond the 95th \
         percentile of random-action Sharpes ({:.3}). The apparent edge may be \
         measurement noise, data leakage, or look-ahead bias. DO NOT deploy.",
        trained_sharpe, p95
    );
    Ok(())
}

/// Locate a compiled `evaluate_baseline` example binary, or fall back to
/// `cargo run --release --example evaluate_baseline`.
///
/// Cargo examples are emitted at `target/release/examples/evaluate_baseline`
/// when built with `cargo build --release --example evaluate_baseline -p ml`.
/// Using the compiled binary directly avoids cargo's build-lock on every call
/// (which would block multiple smoke tests running in parallel) and shaves
/// ~2s of cargo overhead per surrogate iteration.
fn evaluate_baseline_command() -> Command {
    let root = workspace_root();
    let compiled = root.join("target").join("release").join("examples").join("evaluate_baseline");
    if compiled.exists() {
        Command::new(compiled)
    } else {
        let mut c = Command::new("cargo");
        c.current_dir(&root)
            .args(["run", "--release", "-p", "ml", "--example", "evaluate_baseline", "--"]);
        c
    }
}

/// Run a trained-model backtest and return `(pooled_sharpe, action_marginals_json)`.
///
/// The action-marginals JSON is the exact `{"flat": [...], ...}` payload
/// emitted by `--emit-action-marginals` — it can be written to disk as-is
/// and fed back into surrogate runs via `--surrogate-marginals`.
fn run_trained_backtest_pooled(data: &str, ckpt: &Path) -> Result<(f64, String)> {
    let models_dir = models_dir_for_checkpoint(ckpt);
    let report_path = workspace_root()
        .join("target")
        .join("smoke_surrogate_trained_report.json");
    if let Some(parent) = report_path.parent() {
        std::fs::create_dir_all(parent).ok();
    }

    let mut cmd = evaluate_baseline_command();
    cmd.env("SQLX_OFFLINE", "true")
        .env("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
        .env("FOXHUNT_TEST_DATA", data)
        .args([
            "--model", "dqn",
            "--data-dir", data,
            "--models-dir", models_dir.to_str().unwrap_or(""),
            "--symbol", "ES.FUT",
            "--output", report_path.to_str().unwrap_or(""),
            // Force CPU path to populate flat action counts + pooled returns
            "--no-gpu-eval",
            "--emit-action-marginals",
            "--emit-pooled-sharpe",
        ]);

    let output = cmd.output().context("spawning evaluate_baseline (trained)")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "evaluate_baseline (trained) exited {:?}:\nstderr:\n{}",
            output.status.code(),
            stderr.lines().rev().take(50).collect::<Vec<_>>().iter().rev().cloned().collect::<Vec<_>>().join("\n"),
        );
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    let sharpe = parse_pooled_sharpe(&stdout)
        .context("parsing POOLED_SHARPE from trained run")?;
    let marginals = parse_action_marginals(&stdout)
        .context("parsing ACTION_MARGINALS from trained run")?;
    Ok((sharpe, marginals))
}

/// Run a random-action surrogate backtest and return pooled Sharpe.
fn run_surrogate_backtest_pooled(
    data: &str,
    ckpt: &Path,
    marginals_path: &Path,
    seed: u64,
) -> Result<f64> {
    let models_dir = models_dir_for_checkpoint(ckpt);
    let report_path = workspace_root()
        .join("target")
        .join(format!("smoke_surrogate_report_seed{}.json", seed));
    if let Some(parent) = report_path.parent() {
        std::fs::create_dir_all(parent).ok();
    }

    let mut cmd = evaluate_baseline_command();
    cmd.env("SQLX_OFFLINE", "true")
        .env("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
        .env("FOXHUNT_TEST_DATA", data)
        .args([
            "--model", "dqn",
            "--data-dir", data,
            "--models-dir", models_dir.to_str().unwrap_or(""),
            "--symbol", "ES.FUT",
            "--output", report_path.to_str().unwrap_or(""),
            "--surrogate-mode", "random",
            "--surrogate-seed", &seed.to_string(),
            "--surrogate-marginals", marginals_path.to_str().unwrap_or(""),
            "--emit-pooled-sharpe",
        ]);

    let output = cmd.output().context("spawning evaluate_baseline (surrogate)")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "evaluate_baseline (surrogate seed={}) exited {:?}:\nstderr:\n{}",
            seed,
            output.status.code(),
            stderr.lines().rev().take(30).collect::<Vec<_>>().iter().rev().cloned().collect::<Vec<_>>().join("\n"),
        );
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_pooled_sharpe(&stdout)
        .with_context(|| format!("parsing POOLED_SHARPE from surrogate seed={}", seed))
}

/// Extract the single `POOLED_SHARPE: <float>` line from stdout.
fn parse_pooled_sharpe(stdout: &str) -> Result<f64> {
    for line in stdout.lines() {
        if let Some(rest) = line.strip_prefix("POOLED_SHARPE:") {
            let val: f64 = rest.trim().parse()
                .with_context(|| format!("parsing pooled Sharpe value: {:?}", rest.trim()))?;
            return Ok(val);
        }
    }
    anyhow::bail!("no POOLED_SHARPE: line found in evaluate_baseline stdout");
}

/// Extract the JSON payload following `ACTION_MARGINALS:` prefix.
///
/// Fix 3 (code-quality review): accept any `stub_<name>` marker with a clear
/// bail message that names the marker, rather than hard-coding a single
/// `stub_gpu_only_no_flat_counts` case. The emitter distinguishes between
/// `stub_no_dqn_eval`, `stub_cpu_path_not_exercised`, and `stub_zero_bars` —
/// each implies a different caller fix.
///
/// Fix 6 (code-quality review): verify the payload carries the expected
/// `schema: "action_marginals_v1"` field. Missing schema is accepted with a
/// warning for forward-compat with older emitters; a mismatched schema is a
/// hard error (prevents silent breakage if the emitter rev-bumps).
fn parse_action_marginals(stdout: &str) -> Result<String> {
    for line in stdout.lines() {
        if let Some(rest) = line.strip_prefix("ACTION_MARGINALS:") {
            let payload = rest.trim();
            if payload.starts_with("stub_") {
                anyhow::bail!(
                    "evaluate_baseline emitted stub marginals marker {:?}. \
                     Surrogate test requires real action marginals — pass \
                     --no-gpu-eval and ensure --model=dqn so the CPU DQN \
                     path is exercised and counts actions.",
                    payload
                );
            }
            // Sanity: must be valid JSON
            let parsed: serde_json::Value = serde_json::from_str(payload)
                .context("ACTION_MARGINALS payload is not valid JSON")?;
            match parsed.get("schema").and_then(|v| v.as_str()) {
                Some("action_marginals_v1") => {}
                Some(other) => anyhow::bail!(
                    "ACTION_MARGINALS schema mismatch: expected \
                     \"action_marginals_v1\", got {:?}. Update the consumer \
                     to handle the new schema before proceeding.",
                    other
                ),
                None => eprintln!(
                    "[SURROGATE] WARN: ACTION_MARGINALS payload missing \
                     \"schema\" field — accepting for forward-compat with \
                     pre-v1 emitters."
                ),
            }
            return Ok(payload.to_owned());
        }
    }
    anyhow::bail!("no ACTION_MARGINALS: line found in evaluate_baseline stdout");
}
