//! B-9 invariants for C51 Bellman-target saturation observability.
//!
//! Validates four invariants across a 200+50 b=16 fold-1 smoke run:
//!
//! 1. Rates lie in [0.0, 1.0] (proportion invariant, every row).
//! 2. `target_max_pre_proj >= target_min_pre_proj` (sanity, every row).
//! 3. Consistency: if `target_top_saturation_rate > 0`, then
//!    `target_max_pre_proj > atom_max` (V_MAX_eff). Bottom-side check
//!    is NOT symmetric: V_MAX_eff EWMAs `win_bound`; V_MIN_eff
//!    independently EWMAs `-loss_bound`, with `loss_bound = win_bound ×
//!    ratio` where `ratio` is itself adaptive (see
//!    `rl_reward_clamp_controller.cu` Step 4). Since `atom_min` is not
//!    currently emitted to diag, we cannot tightly assert the bot
//!    consistency direction — we only assert that when `bot_rate > 0`,
//!    `target_min_pre_proj` lies below the *less negative* of
//!    `-atom_max` or any reasonable V_MIN bound (catches gross drift
//!    without false-failing on the asymmetric adaptation).
//! 4. Saturation flow direction: `target_top_saturation_rate` and
//!    `target_bot_saturation_rate` are mutually exclusive in the sense
//!    that no SINGLE `t_z` can simultaneously exceed V_MAX and fall
//!    below V_MIN; the two rates sum to at most 1.0.
//!
//! Per `feedback_no_cpu_test_fallbacks.md`: GPU oracle binary end-to-end.
//! No host-side reimplementation of the Bellman projection.
//!
//! Run with:
//!   `cargo test -p ml-alpha --test c51_atom_saturation_diagnostic -- --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 c51_atom_saturation_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-b9-c51-sat");
    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");

    let train_rows = read_jsonl(&diag)?;
    let eval_rows  = read_jsonl(&eval_diag)?;
    anyhow::ensure!(train_rows.len() == n_steps, "train row count mismatch");
    anyhow::ensure!(eval_rows.len() == n_eval_steps, "eval row count mismatch");

    // Skip row 0 — bootstrap-only (saturation reducer may not have run
    // yet at snapshot time depending on launch ordering).
    let mut total_checked = 0usize;
    let mut peak_top_rate = 0.0f64;
    let mut peak_bot_rate = 0.0f64;
    let mut peak_max_overshoot = 0.0f64;  // max(target_max_pre_proj - V_MAX_eff, 0)
    let mut peak_min_overshoot = 0.0f64;  // max(V_MIN_eff - target_min_pre_proj, 0)

    for (phase, rows) in &[("train", &train_rows), ("eval", &eval_rows)] {
        let start = if *phase == "train" { 1 } else { 0 };
        for (i, row) in rows.iter().enumerate().skip(start) {
            let path = "risk_stack.atom_calibration";
            let top_rate = dot_get(row, &format!("{path}.target_top_saturation_rate"))?;
            let bot_rate = dot_get(row, &format!("{path}.target_bot_saturation_rate"))?;
            let max_pre  = dot_get(row, &format!("{path}.target_max_pre_proj"))?;
            let min_pre  = dot_get(row, &format!("{path}.target_min_pre_proj"))?;
            let v_max    = dot_get(row, &format!("{path}.atom_max"))?;
            // V_MIN_eff is NOT -V_MAX_eff: it independently EWMAs
            // `-loss_bound`. We use `-v_max` as a LOWER-FREQUENCY proxy
            // for the bot consistency invariant — `target_min_pre_proj`
            // having `bot_rate > 0` means it crossed *some* V_MIN_eff,
            // whose magnitude is at most that of V_MAX_eff in the
            // symmetric-clamp case but could be larger if ratio > 1.
            // Either way, `target_min_pre_proj < -v_max` is a necessary
            // condition for sustained `bot_rate > 0` only when the ratio
            // is close to 1; we relax the check to a looser bound below.
            let v_min    = -v_max;

            // (1) Rates in [0, 1].
            anyhow::ensure!(
                (0.0..=1.0).contains(&top_rate),
                "{phase}[{i}] target_top_saturation_rate = {top_rate} not in [0, 1]"
            );
            anyhow::ensure!(
                (0.0..=1.0).contains(&bot_rate),
                "{phase}[{i}] target_bot_saturation_rate = {bot_rate} not in [0, 1]"
            );

            // (2) max >= min.
            anyhow::ensure!(
                max_pre >= min_pre,
                "{phase}[{i}] target_max_pre_proj ({max_pre}) < target_min_pre_proj ({min_pre})"
            );

            // (3) Consistency: rate > 0 ⇒ overshoot exists.
            //     Use a small tolerance for V_MAX_eff comparison because
            //     the projection's clamp is a strict `<` / `>` whereas
            //     adaptive ISV updates may align V_MAX_eff with peak t_z.
            let eps = 1e-5_f64 * v_max.abs().max(1.0);
            if top_rate > 0.0 {
                anyhow::ensure!(
                    max_pre > v_max - eps,
                    "{phase}[{i}] top_rate={top_rate} > 0 but max_pre={max_pre} <= v_max={v_max}"
                );
            }
            if bot_rate > 0.0 {
                // Relaxed: with asymmetric WIN/LOSS ratio (Step 4 of
                // rl_reward_clamp_controller), V_MIN_eff can be more or
                // less negative than -V_MAX_eff. `min_pre` must lie
                // below SOMETHING the projection clamped against; we
                // assert it's at least below -1.0 (the v_bound_floor)
                // which is the tightest possible V_MIN_eff. Catches
                // gross drift without false-failing on legitimate
                // asymmetric atom-span adaptation.
                anyhow::ensure!(
                    min_pre < -1.0 + eps,
                    "{phase}[{i}] bot_rate={bot_rate} > 0 but min_pre={min_pre} \
                     >= -1.0 (the v_bound_floor); projection should not have \
                     clamped (no V_MIN_eff that small is reachable)"
                );
            }

            // (4) top + bot ≤ 1.0 (single t_z can't be on both tails).
            anyhow::ensure!(
                top_rate + bot_rate <= 1.0 + 1e-6,
                "{phase}[{i}] top+bot rate {} > 1.0", top_rate + bot_rate
            );

            // Track diagnostics for eprintln summary.
            peak_top_rate = peak_top_rate.max(top_rate);
            peak_bot_rate = peak_bot_rate.max(bot_rate);
            peak_max_overshoot = peak_max_overshoot.max((max_pre - v_max).max(0.0));
            peak_min_overshoot = peak_min_overshoot.max((v_min - min_pre).max(0.0));
            total_checked += 1;
        }
    }

    eprintln!(
        "c51_atom_saturation_invariants OK: {} rows validated\n  \
         peak top rate = {:.4}    peak bot rate = {:.4}\n  \
         peak max overshoot = {:.4}    peak min overshoot = {:.4}",
        total_checked, peak_top_rate, peak_bot_rate, peak_max_overshoot, peak_min_overshoot,
    );
    Ok(())
}
