//! Smoke test: magnitude-branch exploration coverage (Track 4 from policy-quality spec).
//!
//! Measures how quickly magnitude-branch action entropy collapses during
//! training. A policy that collapses to a single magnitude bin within the
//! first few epochs has likely quenched exploration prematurely (epsilon
//! decayed too fast, entropy regularization too weak, etc.) and should be
//! flagged as a production risk.
//!
//! ## Pass criteria
//!
//! - Magnitude-branch entropy at epoch 5 ≥ 0.5 (still substantively exploring)
//! - Magnitude-branch entropy at epoch 20 ≥ 0.3 (hasn't fully collapsed)
//!
//! Entropy is normalized: 0.0 = full collapse, 1.0 = uniform.
//!
//! Run: `FOXHUNT_TEST_DATA=test_data/futures-baseline \
//!   cargo test -p ml --release --lib -- exploration_coverage --ignored --nocapture`

use super::helpers::*;
use anyhow::Result;

#[test]
#[ignore] // Requires fxcache
fn test_exploration_covers_magnitude_space() -> Result<()> {
    let data = load_smoke_fxcache().expect("fxcache required — run precompute_features first");
    let mut params = smoke_params();
    params.epochs = 20;
    params.early_stopping_enabled = false;
    params.min_epochs_before_stopping = 20;

    let mut trainer = smoke_trainer_with(params)?;
    init_trainer_from_fxcache(&mut trainer, &data, 200_000)?;

    let data_dir = test_data_dir().expect("FOXHUNT_TEST_DATA or test_data/ must exist");
    let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
    let _metrics = rt.block_on(trainer.train(
        &data_dir,
        "ES.FUT",
        |_epoch, _bytes, _best| Ok("skip".to_owned()),
    ))?;

    let history = trainer.exploration_entropy_history();
    println!("[EXPLORE] magnitude entropy history: {:?}", history);

    // ── History must actually be populated ──────────────────────────────
    // Prior weakness: `.unwrap_or(0.0)` on a `find` for ep5/ep20 would
    // silently return 0.0 on an empty history, then FAIL the ≥ 0.5 check
    // — which looked like "exploration collapsed" but actually meant "no
    // entropy was ever recorded". Distinguish the two explicitly.
    assert!(
        history.len() >= 20,
        "exploration_entropy_history has only {} entries after 20 epochs \
         — diagnostic did not emit per-epoch entropy. Full history: {:?}",
        history.len(), history
    );
    for &(e, v) in history {
        assert!(
            v.is_finite(),
            "entropy history has non-finite value at epoch {}: {} — \
             normalized entropy should be in [0, 1]. Full: {:?}",
            e, v, history
        );
        assert!(
            (0.0..=1.0).contains(&v),
            "entropy history value at epoch {} = {} out of normalized \
             range [0, 1]. Full: {:?}",
            e, v, history
        );
    }

    // Epoch numbering in history is 0-indexed per the training loop.
    // If the epoch isn't present in the populated history, that's a
    // different bug than low entropy — bail with a clear message rather
    // than substituting 0.0 and failing the threshold check.
    let ep5 = history
        .iter()
        .find(|&&(e, _)| e == 4 /* 0-indexed epoch 5 */)
        .map(|&(_, v)| v)
        .unwrap_or_else(|| panic!(
            "epoch 5 (0-indexed 4) missing from exploration history — \
             entropy emission regressed. Full history: {:?}", history
        ));
    let ep20 = history
        .iter()
        .find(|&&(e, _)| e == 19 /* 0-indexed epoch 20 */)
        .map(|&(_, v)| v)
        .unwrap_or_else(|| panic!(
            "epoch 20 (0-indexed 19) missing from exploration history — \
             entropy emission regressed. Full history: {:?}", history
        ));

    println!("[EXPLORE] entropy @ep5 = {:.3}, @ep20 = {:.3}", ep5, ep20);

    assert!(
        ep5 >= 0.5,
        "magnitude entropy at epoch 5 = {:.3} < 0.5 — exploration quenched too fast. \
         Full history: {:?}",
        ep5, history
    );
    assert!(
        ep20 >= 0.3,
        "magnitude entropy at epoch 20 = {:.3} < 0.3 — full collapse. \
         Full history: {:?}",
        ep20, history
    );

    // ── History must not be DEGENERATE ──────────────────────────────────
    // A trainer that returns a constant value (say 1.0) across all epochs
    // would pass the per-epoch thresholds trivially. Require at least some
    // variance across the trajectory. Typical entropy decays during training
    // as the policy becomes more confident, so the early-vs-late difference
    // (or the min-max spread) should be > 1e-6. A perfectly flat history
    // is a sign the emitter is stuck.
    let values: Vec<f32> = history.iter().map(|&(_, v)| v).collect();
    let min_v = values.iter().copied().fold(f32::INFINITY, f32::min);
    let max_v = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
    let spread = max_v - min_v;
    assert!(
        spread > 1e-6,
        "exploration entropy history is completely flat (min={:.6} max={:.6} \
         spread={:.6}) across 20 epochs — emitter may be stuck on a constant. \
         Full: {:?}",
        min_v, max_v, spread, history
    );
    Ok(())
}
