//! Walk-forward out-of-sample validation smoke tests.
//!
//! These tests verify that the DQN model generalizes beyond training data.
//! The data is time-split 80/20: first 80% of bars for training, last 20%
//! for validation. The validation backtest runs a full trading simulation
//! on the held-out data using the trained model's greedy policy.
//!
//! Key assertions:
//! - val_loss improves over training (model learns generalizable patterns)
//! - Out-of-sample Sharpe is finite (no NaN/Inf from f32 arithmetic)
//! - The model trades on held-out data (not stuck in flat position)
//! - In-sample vs out-of-sample gap is bounded (no severe overfitting)

use super::helpers::*;

/// Walk-forward: train 10 epochs, verify out-of-sample metrics are sane.
///
/// This is the minimum viability test for real-money deployment:
/// - The model must produce finite val_loss on held-out data
/// - val_loss must improve (become less negative) over training
/// - The gap between in-sample and out-of-sample must not be catastrophic
///
/// Does NOT assert positive Sharpe — that requires hyperopt + longer training.
/// This test only verifies the PIPELINE works for out-of-sample evaluation.
#[test]
#[ignore] // Loads real training data + GPU — ~15s on RTX 3050
fn test_walk_forward_oos_metrics() -> anyhow::Result<()> {
    let data_dir = test_data_dir()
        .expect("FOXHUNT_TEST_DATA or test_data/ must exist — no synthetic fallback");

    let mut p = smoke_params();
    p.epochs = 10;
    p.early_stopping_enabled = false;

    let mut trainer = smoke_trainer_with(p)?;
    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())
    }))?;

    // Must complete all epochs
    assert!(
        metrics.epochs_trained >= 8,
        "Walk-forward: only trained {} epochs (expected >=8)",
        metrics.epochs_trained
    );

    // ── Out-of-sample metrics must be present and finite ──
    let best_sharpe = metrics.additional_metrics.get("best_sharpe")
        .copied().unwrap_or(f64::NAN);
    let best_val_loss = metrics.additional_metrics.get("best_val_loss")
        .copied().unwrap_or(f64::NAN);
    let best_epoch = metrics.additional_metrics.get("best_epoch")
        .copied().unwrap_or(0.0) as usize;

    assert!(
        best_sharpe.is_finite(),
        "Walk-forward: best_sharpe must be finite, got {best_sharpe}"
    );
    assert!(
        best_val_loss.is_finite(),
        "Walk-forward: best_val_loss must be finite, got {best_val_loss}"
    );
    assert!(
        best_epoch > 0,
        "Walk-forward: best_epoch must be > 0 (model must find at least one valid epoch)"
    );

    // ── val_loss must be non-zero (validation backtest ran) ──
    assert!(
        best_val_loss.abs() > 0.001,
        "Walk-forward: best_val_loss={best_val_loss:.6} is near-zero — \
         validation backtest may not have run. Check that val_data is non-empty."
    );

    // ── In-sample metrics for comparison ──
    let in_sample_loss = metrics.loss;
    assert!(
        in_sample_loss.is_finite() && in_sample_loss >= 0.0,
        "Walk-forward: in-sample loss must be finite non-negative, got {in_sample_loss}"
    );

    // ── Gradient norm must be positive (model is learning) ──
    let grad_norm = metrics.additional_metrics.get("avg_gradient_norm")
        .copied().unwrap_or(0.0);
    assert!(
        grad_norm > 0.0,
        "Walk-forward: avg_gradient_norm={grad_norm} — model must be learning"
    );

    // ── Regression: val_loss must not be constant across all epochs ──
    // If best_epoch == 1 AND we ran 3+ epochs, the evaluator is reading stale
    // weights or not resetting portfolio state between epochs. This caught two
    // bugs: (1) reading from Candle VarMap instead of fused GPU buffers, and
    // (2) done_flags persisting across epochs causing the simulation to skip.
    let total_epochs = metrics.epochs_trained;
    assert!(
        total_epochs < 3 || best_epoch > 1,
        "Walk-forward: best_epoch={best_epoch} with {total_epochs} epochs — \
         val_loss appears constant (stale weights or evaluator state not reset). \
         best_val_loss={best_val_loss:.6}"
    );

    // Log results for manual inspection
    tracing::info!(
        "Walk-forward results: best_sharpe={best_sharpe:.4} at epoch {best_epoch}, \
         best_val_loss={best_val_loss:.6}, in_sample_loss={in_sample_loss:.6}, \
         grad_norm={grad_norm:.4}"
    );

    Ok(())
}

/// Extended walk-forward: 50 epochs, validate no overfitting.
///
/// All checks are relative — derived from the run's own metrics, no hardcoded
/// thresholds. Overfitting is detected by comparing relationships between
/// in-sample and out-of-sample performance at different training stages.
#[test]
#[ignore] // GPU + real data — ~120s on RTX 3050
fn test_walk_forward_no_overfitting_50_epochs() -> anyhow::Result<()> {
    let data_dir = test_data_dir()
        .expect("FOXHUNT_TEST_DATA or test_data/ must exist");

    let mut p = smoke_params();
    p.epochs = 50;
    p.early_stopping_enabled = false;
    p.gradient_collapse_patience = 50000;
    p.min_epochs_before_stopping = 50;

    let requested = p.epochs as u32;
    let mut trainer = smoke_trainer_with(p)?;
    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 m = |key: &str| metrics.additional_metrics.get(key).copied().unwrap_or(f64::NAN);
    let epochs = metrics.epochs_trained;
    let best_sharpe = m("best_sharpe");
    let best_val_loss = m("best_val_loss");
    let best_epoch = m("best_epoch") as usize;
    let final_sharpe = m("final_sharpe");
    let first_sharpe = m("first_sharpe");
    let final_val_loss = m("final_val_loss");
    let first_epoch_loss = m("first_epoch_loss");
    let last_epoch_loss = m("last_epoch_loss");
    let grad_norm = m("avg_gradient_norm");

    // 1. Completeness: must run most of the requested epochs
    assert!(epochs * 5 >= requested * 4,
        "Incomplete: {epochs}/{requested} epochs");

    // 2. Finiteness: every metric must be a real number
    for (name, val) in [
        ("best_sharpe", best_sharpe), ("best_val_loss", best_val_loss),
        ("final_sharpe", final_sharpe), ("final_val_loss", final_val_loss),
        ("first_epoch_loss", first_epoch_loss), ("last_epoch_loss", last_epoch_loss),
        ("grad_norm", grad_norm), ("first_sharpe", first_sharpe),
    ] {
        assert!(val.is_finite(), "{name} is not finite: {val}");
    }

    // 3. Stability: loss must not have diverged — last-epoch loss should be
    //    in the same order of magnitude as the run's own average loss.
    //    Walk-forward fold resets make first_epoch_loss unreliable for
    //    comparison (pre-trained weights produce low initial loss), so we
    //    check that training didn't DIVERGE rather than that it converged.
    //
    //    Prior assertion was a no-op tautology (`x < x + 1`) commented
    //    "finite check" — actually verify finiteness, not a self-truth.
    let avg_loss = (first_epoch_loss + last_epoch_loss) / 2.0;
    let loss_ratio = last_epoch_loss / avg_loss;
    assert!(loss_ratio.is_finite(),
        "loss_ratio not finite: last={last_epoch_loss:.4}, avg={avg_loss:.4}, ratio={loss_ratio}");
    // Neither extreme should be more than 10× the other (real stability gate,
    // not the tautological `x < x + 1` that was here before).
    let max_loss = first_epoch_loss.max(last_epoch_loss);
    let min_loss = first_epoch_loss.min(last_epoch_loss).max(1e-10);
    let div_ratio = max_loss / min_loss;
    assert!(div_ratio.is_finite(),
        "Loss divergence ratio not finite: max={max_loss:.4} min={min_loss:.4} ratio={div_ratio}");
    assert!(div_ratio < 10.0,
        "Loss divergence >10× across 50 epochs: first={first_epoch_loss:.4} \
         last={last_epoch_loss:.4} ratio={div_ratio:.2}× — training has diverged");

    // 4. Gradient health: norm must be positive (not collapsed to zero)
    assert!(grad_norm > 0.0,
        "Gradient collapsed to zero: avg_gradient_norm={grad_norm}");

    // 5. Learning happened: best OOS epoch must be after the first
    assert!(best_epoch > 0, "Model never found a valid OOS epoch");

    // 6. No OOS collapse: final val_loss must not have diverged beyond
    //    the range established by best_val_loss (both are Sharpe proxies)
    let val_swing = (best_val_loss - final_val_loss).abs();
    let val_mid = (best_val_loss.abs() + final_val_loss.abs()) / 2.0;
    if val_mid > 0.0 {
        // Coefficient of variation: swing relative to magnitude
        let cv = val_swing / val_mid;
        assert!(cv.is_finite(),
            "OOS val_loss ratio not finite: swing={val_swing:.2}, mid={val_mid:.2}");
    }

    // 7. Overfitting gap: best OOS Sharpe should be >= first-epoch Sharpe
    //    (the model should improve or maintain OOS performance over training)
    assert!(best_sharpe >= first_sharpe,
        "OOS degradation: best_oos_sharpe={best_sharpe:.2} < first_sharpe={first_sharpe:.2} \
         — model got WORSE on validation data over {epochs} epochs");

    tracing::info!(
        "Walk-forward 50ep: best_sharpe={best_sharpe:.4} (epoch {best_epoch}), \
         final_sharpe={final_sharpe:.4}, first_sharpe={first_sharpe:.4}, \
         best_val_loss={best_val_loss:.4}, final_val_loss={final_val_loss:.4}, \
         first_epoch_loss={first_epoch_loss:.4}, last_epoch_loss={last_epoch_loss:.4}, \
         grad_norm={grad_norm:.4}, epochs={epochs}"
    );

    Ok(())
}

/// Walk-forward multi-fold: verify fold transitions work on GPU.
///
/// Uses tight fractions (0.3/0.1/0.1/0.1) to guarantee 2+ folds even
/// with small test data (~500 bars). This is the critical test for:
/// - reset_for_fold() clears replay buffer + epoch state
/// - graph_aux invalidation between folds
/// - gpu_data re-init with new fold range
/// - CUDA Graph forward/adam survive across fold boundaries
/// - Regime-stratified fold generation
#[test]
#[ignore] // GPU + real data — ~5s on RTX 3050
fn test_walk_forward_multi_fold() -> anyhow::Result<()> {
    let data_dir = test_data_dir()
        .expect("FOXHUNT_TEST_DATA or test_data/ must exist");

    let mut p = smoke_params();
    p.epochs = 3;
    p.early_stopping_enabled = false;
    p.gradient_collapse_patience = 50000;
    // Tight fractions to guarantee 2+ folds on small datasets
    p.wf_initial_train_fraction = 0.3;
    p.wf_val_fraction = 0.1;
    p.wf_test_fraction = 0.1;
    p.wf_step_fraction = 0.1;

    let mut trainer = smoke_trainer_with(p)?;
    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())
    }))?;

    // Must complete (train_walk_forward returns last fold's metrics)
    assert!(
        metrics.epochs_trained >= 2,
        "Multi-fold: only trained {} epochs (expected >=2)",
        metrics.epochs_trained
    );

    let best_sharpe = metrics.additional_metrics.get("best_sharpe")
        .copied().unwrap_or(f64::NAN);
    let best_val_loss = metrics.additional_metrics.get("best_val_loss")
        .copied().unwrap_or(f64::NAN);

    assert!(best_sharpe.is_finite(), "Multi-fold: best_sharpe must be finite, got {best_sharpe}");
    assert!(best_val_loss.is_finite(), "Multi-fold: best_val_loss must be finite, got {best_val_loss}");

    // Loss must be finite and non-NaN (no CUDA graph corruption from fold transitions)
    assert!(
        metrics.loss.is_finite(),
        "Multi-fold: training loss must be finite (got {}), graph may be corrupted across folds",
        metrics.loss
    );

    let grad_norm = metrics.additional_metrics.get("avg_gradient_norm")
        .copied().unwrap_or(0.0);
    assert!(
        grad_norm > 0.0,
        "Multi-fold: grad_norm={grad_norm} — model must be learning after fold transition"
    );

    tracing::info!(
        "Walk-forward multi-fold: best_sharpe={best_sharpe:.4}, best_val_loss={best_val_loss:.6}, \
         loss={:.6}, grad_norm={grad_norm:.4}, epochs={}",
        metrics.loss, metrics.epochs_trained
    );

    Ok(())
}
