//! Reward-policy realignment invariants (spec v5 2026-06-01 §6).
//!
//! Validates that the `RL_SURFER_SCAFFOLD_WEIGHT` ISV slot propagates
//! correctly from trainer bootstrap → ISV → kernel
//! `rl_surfer_scaffold_controller` → `rl_fused_reward_pipeline` →
//! diag.jsonl, and that the adaptive controller stays in bounds.
//!
//! Three invariants:
//!   I1. `rewards.surfer_scaffold_weight ∈ [0, 1]` on every train row
//!       (controller output is clamped — kernel writes in [0,1]).
//!   I2. `rewards.surfer_scaffold_weight ∈ [0, 1]` on every eval row
//!       (same controller runs in eval; per-step launches preserved).
//!   I3. At step 0, scaffold_weight matches the trainer's bootstrap. As
//!       of Phase 3B-Y (HEAD 17e453a1d), the production bootstrap is
//!       0.0 (pure-pnl mode — `RL_SURFER_SCAFFOLD_WEIGHT_INDEX` written
//!       at `integrated.rs:4148`). With the agent at n_trades=0, the
//!       controller does NOT yet fire (no observations to integrate)
//!       and the slot stays at its bootstrap value. If train_t0 deviates,
//!       either the bootstrap didn't propagate (config slots wrong) or
//!       the controller is firing on sentinel inputs (math bug).
//!
//! Per `feedback_no_cpu_test_fallbacks.md`: GPU-oracle end-to-end (drives
//! the alpha_rl_train release binary; parses the diag JSONL it emits).
//! No host-side reimplementation of the reward pipeline.
//!
//! Run with:
//!   `cargo test -p ml-alpha --test reward_alignment_invariants -- --ignored --nocapture`

use anyhow::{Context, Result};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Command;

fn binary_path() -> PathBuf {
    let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    crate_root
        .parent()
        .and_then(|p| p.parent())
        .map(|root| root.join("target/release/examples/alpha_rl_train"))
        .unwrap_or_else(|| PathBuf::from("target/release/examples/alpha_rl_train"))
}

fn data_dir() -> PathBuf {
    if let Ok(p) = std::env::var("FOXHUNT_EVAL_DIAG_DATA") {
        return PathBuf::from(p);
    }
    let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    crate_root
        .parent()
        .and_then(|p| p.parent())
        .map(|root| root.join("test_data/futures-baseline/ES.FUT"))
        .unwrap_or_else(|| PathBuf::from("test_data/futures-baseline/ES.FUT"))
}

fn read_jsonl(path: &Path) -> Result<Vec<Value>> {
    let s = std::fs::read_to_string(path)
        .with_context(|| format!("read {}", path.display()))?;
    s.lines()
        .map(|l| serde_json::from_str::<Value>(l)
            .with_context(|| format!("parse line of {}", path.display())))
        .collect()
}

fn dot_get(v: &Value, path: &str) -> Result<f64> {
    let mut cur = v;
    for seg in path.split('.') {
        cur = cur
            .get(seg)
            .with_context(|| format!("missing path segment '{seg}' in {path}"))?;
    }
    cur.as_f64()
        .with_context(|| format!("path '{path}' is not numeric"))
}

#[test]
#[ignore = "requires CUDA + pre-built release binary + MBP-10 test data"]
fn reward_alignment_surfer_scaffold_invariants() -> Result<()> {
    let bin = binary_path();
    anyhow::ensure!(
        bin.exists(),
        "binary not found at {} — run `SQLX_OFFLINE=true cargo build --release \
         --example alpha_rl_train -p ml-alpha` first",
        bin.display()
    );
    let data = data_dir();
    anyhow::ensure!(data.exists(), "test data dir missing: {}", data.display());

    let out = std::env::temp_dir().join("foxhunt-reward-alignment");
    if out.exists() {
        std::fs::remove_dir_all(&out).context("rm -rf out")?;
    }
    std::fs::create_dir_all(&out).context("mkdir -p out")?;

    let n_steps: usize = 200;
    let n_eval_steps: usize = 50;
    let eval_diag = out.join("eval_diag.jsonl");
    let status = Command::new(&bin)
        .args([
            "--n-steps",       &n_steps.to_string(),
            "--n-eval-steps",  &n_eval_steps.to_string(),
            "--fold-idx",      "1",
            "--n-folds",       "3",
            "--mbp10-data-dir",   &data.display().to_string(),
            "--predecoded-dir",   &data.display().to_string(),
            "--out",              &out.display().to_string(),
            "--eval-diag-jsonl",  &eval_diag.display().to_string(),
            "--instrument-mode",  "all",
            "--n-backtests",      "16",
            "--log-every",        "100",
            "--seed",             "42",
        ])
        .env("SQLX_OFFLINE", "true")
        .status()
        .context("spawn alpha_rl_train")?;
    anyhow::ensure!(status.success(), "alpha_rl_train exited with {status}");

    let diag = out.join("diag.jsonl");
    anyhow::ensure!(
        diag.exists() && eval_diag.exists(),
        "diag files missing — train={} exists={} / eval={} exists={}",
        diag.display(), diag.exists(),
        eval_diag.display(), eval_diag.exists(),
    );

    let train_rows = read_jsonl(&diag)?;
    let eval_rows  = read_jsonl(&eval_diag)?;
    anyhow::ensure!(
        train_rows.len() == n_steps,
        "train rows {} != n_steps {n_steps}", train_rows.len()
    );
    // Eval emits an additional drain row at end-of-phase
    // (alpha_rl_train.rs:1238-1318) to surface any pnl trapped in the
    // last batch's swapped-out staging half. Schema parity is preserved
    // via `build_diag_value` so the drain row passes the same
    // scaffold_weight invariant. Accept n_eval_steps OR n_eval_steps + 1.
    anyhow::ensure!(
        eval_rows.len() == n_eval_steps || eval_rows.len() == n_eval_steps + 1,
        "eval rows {} != n_eval_steps {n_eval_steps} (or +1 drain row)", eval_rows.len()
    );

    // I1 + I2: scaffold_weight clamped to [0,1] (kernel-enforced).
    // I3: bootstrap value (0.0 per Phase 3B-Y pure-pnl mode) visible in
    // train[0] — the controller hasn't fired yet because n_trades=0
    // sentinel halts its first-observation step.
    let mut checked = 0usize;
    let train_t0 = dot_get(&train_rows[0], "rewards.surfer_scaffold_weight")?;
    anyhow::ensure!(
        train_t0 >= -1e-6 && train_t0 <= 1.0 + 1e-6,
        "I3 violated: train[0] rewards.surfer_scaffold_weight = {train_t0:.4} \
         (expected ∈ [0,1] — bootstrap propagation broken or kernel writing \
         out-of-range value)"
    );
    // I3 stronger: at step 0 the surfer-scaffold controller cannot have
    // accumulated a meaningful observation, so the slot equals the
    // trainer bootstrap. Allow either 0.0 (current pure-pnl mode) or
    // 1.0 (legacy surfer-baseline) to absorb config-mode flips without
    // breaking the test on every bootstrap-value tuning.
    anyhow::ensure!(
        (train_t0 - 0.0).abs() < 0.05 || (train_t0 - 1.0).abs() < 0.05,
        "I3 stronger: train[0] surfer_scaffold_weight = {train_t0:.4} — \
         expected close to either bootstrap (0.0 pure-pnl or 1.0 legacy); \
         intermediate values would indicate the controller fired on \
         sentinel inputs"
    );
    checked += 1;

    for (phase, rows) in &[("train", &train_rows), ("eval", &eval_rows)] {
        for (i, row) in rows.iter().enumerate() {
            let w = dot_get(row, "rewards.surfer_scaffold_weight")?;
            anyhow::ensure!(
                w >= -1e-6 && w <= 1.0 + 1e-6,
                "{phase}[{i}] rewards.surfer_scaffold_weight = {w} out of [0,1] \
                 — controller clamp broken or slot corrupted"
            );
            checked += 1;
        }
    }

    eprintln!(
        "reward_alignment_surfer_scaffold_invariants OK: {} rows validated \
         (train[0..{}] + eval[0..{}]); train[0] bootstrap = {train_t0:.3}",
        checked, n_steps, n_eval_steps
    );
    Ok(())
}

/// Phase 3D-B + 3D-C invariants — verifies the new quadratic-cost and
/// PPO-surrogate-blend diag fields are emitted and at their bootstrap
/// values when no operator has tuned them mid-run.
///
/// Invariants:
///   I1. `rewards.quadratic_cost_alpha == 0.5` (bootstrap, train[0]).
///   I2. `rewards.quadratic_cost_beta == 2.0` (bootstrap, train[0]).
///   I3. `rewards.quadratic_cost_enabled == 1.0` (bootstrap, train[0]).
///   I4. `rewards.ppo_surrogate_weight == 0.005` (bootstrap, train[0]).
///   I5. `rewards.ppo_surrogate_enabled == 1.0` (bootstrap, train[0]).
///   I6. All five fields are present and finite on every train row
///       (no NaN, no missing key).
#[test]
#[ignore = "requires CUDA + pre-built release binary + MBP-10 test data"]
fn phase_3d_diag_emission_invariants() -> Result<()> {
    let bin = binary_path();
    anyhow::ensure!(
        bin.exists(),
        "binary not found at {} — run `SQLX_OFFLINE=true cargo build --release \
         --example alpha_rl_train -p ml-alpha` first",
        bin.display()
    );
    let data = data_dir();
    anyhow::ensure!(data.exists(), "test data dir missing: {}", data.display());

    let out = std::env::temp_dir().join("foxhunt-phase-3d-diag");
    if out.exists() {
        std::fs::remove_dir_all(&out).context("rm -rf out")?;
    }
    std::fs::create_dir_all(&out).context("mkdir -p out")?;

    let n_steps: usize = 100;
    let n_eval_steps: usize = 20;
    let eval_diag = out.join("eval_diag.jsonl");
    let status = Command::new(&bin)
        .args([
            "--n-steps",       &n_steps.to_string(),
            "--n-eval-steps",  &n_eval_steps.to_string(),
            "--fold-idx",      "1",
            "--n-folds",       "3",
            "--mbp10-data-dir",   &data.display().to_string(),
            "--predecoded-dir",   &data.display().to_string(),
            "--out",              &out.display().to_string(),
            "--eval-diag-jsonl",  &eval_diag.display().to_string(),
            "--instrument-mode",  "all",
            "--n-backtests",      "16",
            "--log-every",        "50",
            "--seed",             "42",
        ])
        .env("SQLX_OFFLINE", "true")
        .status()
        .context("spawn alpha_rl_train")?;
    anyhow::ensure!(status.success(), "alpha_rl_train exited with {status}");

    let diag = out.join("diag.jsonl");
    anyhow::ensure!(diag.exists(), "diag.jsonl missing: {}", diag.display());
    let train_rows = read_jsonl(&diag)?;
    anyhow::ensure!(
        !train_rows.is_empty(),
        "diag.jsonl empty — trainer produced no rows"
    );

    // I1-I5: bootstrap values on the first observed row.
    let r0 = &train_rows[0];
    let alpha_v = dot_get(r0, "rewards.quadratic_cost_alpha")?;
    let beta_v  = dot_get(r0, "rewards.quadratic_cost_beta")?;
    let qc_en   = dot_get(r0, "rewards.quadratic_cost_enabled")?;
    let ppo_w   = dot_get(r0, "rewards.ppo_surrogate_weight")?;
    let ppo_en  = dot_get(r0, "rewards.ppo_surrogate_enabled")?;
    anyhow::ensure!(
        (alpha_v - 0.5).abs() < 1e-6,
        "I1 violated: rewards.quadratic_cost_alpha = {alpha_v} (expected 0.5 bootstrap)"
    );
    anyhow::ensure!(
        (beta_v - 2.0).abs() < 1e-6,
        "I2 violated: rewards.quadratic_cost_beta = {beta_v} (expected 2.0 bootstrap)"
    );
    anyhow::ensure!(
        (qc_en - 1.0).abs() < 1e-6,
        "I3 violated: rewards.quadratic_cost_enabled = {qc_en} (expected 1.0 bootstrap)"
    );
    anyhow::ensure!(
        (ppo_w - 0.005).abs() < 1e-6,
        "I4 violated: rewards.ppo_surrogate_weight = {ppo_w} (expected 0.005 bootstrap)"
    );
    anyhow::ensure!(
        (ppo_en - 1.0).abs() < 1e-6,
        "I5 violated: rewards.ppo_surrogate_enabled = {ppo_en} (expected 1.0 bootstrap)"
    );

    // I6: every row has the 5 fields finite (no NaN, no missing).
    let mut checked = 0usize;
    for (i, row) in train_rows.iter().enumerate() {
        for path in [
            "rewards.quadratic_cost_alpha",
            "rewards.quadratic_cost_beta",
            "rewards.quadratic_cost_enabled",
            "rewards.ppo_surrogate_weight",
            "rewards.ppo_surrogate_enabled",
        ] {
            let v = dot_get(row, path).with_context(|| format!("row {i} {path}"))?;
            anyhow::ensure!(
                v.is_finite(),
                "I6 violated: train[{i}] {path} = {v} (not finite)"
            );
            checked += 1;
        }
    }

    eprintln!(
        "phase_3d_diag_emission_invariants OK: bootstraps α={alpha_v} β={beta_v} \
         qc_enabled={qc_en} ppo_w={ppo_w} ppo_enabled={ppo_en}; \
         {checked} field-emissions validated across {} rows",
        train_rows.len()
    );
    Ok(())
}

/// Phase 3D-B/C overtrading-reduction sanity invariant. Bound test data
/// rows are too few to draw statistical conclusions about reward
/// distribution; this test only verifies that turning the quadratic cost
/// + PPO surrogate ON does NOT crash the trainer and that reward.min
/// becomes more negative (or stays comparable) than the legacy pure-pnl
/// baseline. The mid-smoke run (run from `local-mid-smoke.sh`) is the
/// authoritative behavioral check.
///
/// Specifically: the bootstrap value α=0.5, β=2.0 applied per env step
/// for any open/close/flip ensures that at least some `reward` writes
/// see a cost reduction. We verify that for at least one row,
/// `rewards.min < rewards.min_legacy_lower_bound` (i.e., some row has a
/// reward more negative than a legacy lower bound of −5 reward units).
#[test]
#[ignore = "requires CUDA + pre-built release binary + MBP-10 test data"]
fn phase_3d_quadratic_cost_visible_in_rewards() -> Result<()> {
    let bin = binary_path();
    anyhow::ensure!(bin.exists(), "binary missing: {}", bin.display());
    let data = data_dir();
    anyhow::ensure!(data.exists(), "test data missing: {}", data.display());

    let out = std::env::temp_dir().join("foxhunt-phase-3d-quadratic");
    if out.exists() {
        std::fs::remove_dir_all(&out)?;
    }
    std::fs::create_dir_all(&out)?;

    let status = Command::new(&bin)
        .args([
            "--n-steps",       "100",
            "--n-eval-steps",  "20",
            "--fold-idx",      "1",
            "--n-folds",       "3",
            "--mbp10-data-dir",   &data.display().to_string(),
            "--predecoded-dir",   &data.display().to_string(),
            "--out",              &out.display().to_string(),
            "--instrument-mode",  "all",
            "--n-backtests",      "16",
            "--log-every",        "50",
            "--seed",             "42",
        ])
        .env("SQLX_OFFLINE", "true")
        .status()?;
    anyhow::ensure!(status.success(), "alpha_rl_train exited with {status}");

    let diag = out.join("diag.jsonl");
    let train_rows = read_jsonl(&diag)?;
    anyhow::ensure!(!train_rows.is_empty(), "no diag rows");

    // Find the most negative reward observed. With α=0.5 β=2.0 enabled
    // and b_size=16, even small flip counts produce visibly negative
    // reward.min values (vs near-zero baselines when shaping/popart was
    // also off).
    let mut min_reward_observed: f64 = f64::INFINITY;
    let mut max_pos_seen: f64 = f64::NEG_INFINITY;
    for row in &train_rows {
        if let Ok(rmin) = dot_get(row, "rewards.min") {
            min_reward_observed = min_reward_observed.min(rmin);
        }
        if let Ok(rmax) = dot_get(row, "rewards.max") {
            max_pos_seen = max_pos_seen.max(rmax);
        }
    }
    anyhow::ensure!(
        min_reward_observed.is_finite(),
        "rewards.min never observed — diag malformed"
    );
    // Phase 3D-B explicit signature: 1-lot flip = α+β = 2.5 reward units,
    // 2-lot flip = 2·α + 4·β = 9.0. We expect rewards.min ≤ −2.0 in any
    // 100-step run with non-zero opens. Looser bound −1.0 to absorb very
    // sparse trade rates in 100-step horizons.
    anyhow::ensure!(
        min_reward_observed <= -1.0,
        "Phase 3D-B not observable: min reward = {min_reward_observed} (expected ≤ -1.0 \
         with α=0.5 β=2.0 enabled; check that quadratic_cost_enabled propagated)"
    );

    eprintln!(
        "phase_3d_quadratic_cost_visible_in_rewards OK: rewards.min = {min_reward_observed}, \
         rewards.max = {max_pos_seen} across {} rows",
        train_rows.len()
    );
    Ok(())
}
