//! Regression tests for specific GPU training bugs.
//!
//! Each test targets a bug that was found and fixed, serving as a guard
//! against reintroduction. Tests are named after the symptom, not the fix.

use super::helpers::*;

/// Regression: NaN must not appear in params after CUDA graph capture.
///
/// Root cause was warmup graph launches in capture_training_graphs() producing
/// extreme gradients from random Xavier weights that overflowed the f32
/// d_logits cast and corrupted downstream buffers, causing NaN at step 0.
/// Fixed by removing warmup launches (CUDA graphs work without them after
/// cuGraphInstantiate) and adding saturation to the copy kernel.
///
/// Uses fxcache directly — NO DBN parsing. Caps at 500 bars for speed (~2s).
#[test]
#[ignore] // GPU required
fn test_no_nan_after_graph_capture() -> anyhow::Result<()> {
    use crate::fxcache;

    let cache_dir = feature_cache_dir();
    if !cache_dir.exists() {
        eprintln!("SKIP: no feature-cache at {:?}", cache_dir);
        return Ok(());
    }
    let entries: Vec<_> = std::fs::read_dir(&cache_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("fxcache"))
        .collect();
    if entries.is_empty() {
        eprintln!("SKIP: no .fxcache files in {:?}", cache_dir);
        return Ok(());
    }

    let fxcache_data = fxcache::load_fxcache(&entries[0].path())?;
    let n = fxcache_data.bar_count.min(500);
    let train_end = (n * 80) / 100;

    let mut p = smoke_params();
    p.epochs = 1;
    p.early_stopping_enabled = false;
    p.min_epochs_before_stopping = 1;
    let mut trainer = smoke_trainer_with(p)?;
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()?;

    let features = &fxcache_data.features[..n];
    let targets = &fxcache_data.targets[..n];
    let ofi = &fxcache_data.ofi[..n.min(fxcache_data.ofi.len())];

    rt.block_on(trainer.init_from_fxcache(features, targets, ofi))?;
    trainer.set_training_range(0, train_end, train_end, n);
    trainer.set_val_data_from_slices(
        &fxcache_data.features[train_end..n],
        &fxcache_data.targets[train_end..n],
        train_end,
    );
    rt.block_on(trainer.reset_for_fold())?;

    let result = rt.block_on(trainer.train_fold_from_slices(
        &features[..train_end], &targets[..train_end],
        |_epoch, _bytes, _best| Ok("skip".to_owned()),
    ));

    // Training must complete — NaN at step 0 would cause an error here
    let metrics = result.map_err(|e| {
        anyhow::anyhow!(
            "NaN REGRESSION: training failed (likely NaN at step 0 from graph capture warmup): {e}"
        )
    })?;

    assert_finite(metrics.loss, "loss");
    assert!(
        metrics.epochs_trained >= 1,
        "NaN REGRESSION: only {} epochs completed (expected 1)",
        metrics.epochs_trained
    );

    let grad_norm = metrics.additional_metrics.get("avg_gradient_norm")
        .copied().unwrap_or(f64::NAN);
    assert_finite(grad_norm, "avg_gradient_norm");

    tracing::info!(
        "NaN regression PASS: loss={:.6}, grad_norm={:.2}, epochs={}",
        metrics.loss, grad_norm, metrics.epochs_trained
    );

    drop(trainer);
    drop(rt);
    Ok(())
}

/// Regression: GPU training must not hang on first epoch.
///
/// Root cause was VRAM oversubscription from detect_gpu_hardware auto-scaling
/// n_episodes without accounting for 2x counterfactual doubling (commit 4c5f404).
/// Fixed by replacing auto-scaling with configurable gpu_n_episodes.
///
/// This test runs a single epoch with production-like features (all on) to
/// verify the full GPU pipeline completes without deadlock.
#[test]
#[ignore] // GPU + real data
fn test_no_hang_single_epoch() -> anyhow::Result<()> {
    let fxcache_data = load_smoke_fxcache()
        .expect("fxcache required — run precompute_features first");

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

    let mut trainer = smoke_trainer_with(p)?;
    init_trainer_from_fxcache(&mut trainer, &fxcache_data, 5000)?;

    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()?;
    rt.block_on(trainer.reset_for_fold())?;

    let n = fxcache_data.bar_count.min(5000);
    let train_end = (n * 80) / 100;
    let metrics = rt.block_on(trainer.train_fold_from_slices(
        &fxcache_data.features[..train_end],
        &fxcache_data.targets[..train_end],
        |_epoch, _bytes, _best| Ok("skip".to_owned()),
    ))?;

    assert!(
        metrics.epochs_trained >= 1,
        "Single-epoch test failed: {} epochs trained (expected 1). \
         Possible VRAM oversubscription hang regression.",
        metrics.epochs_trained
    );
    assert!(
        metrics.loss.is_finite(),
        "Single-epoch loss must be finite, got {}",
        metrics.loss
    );

    tracing::info!(
        "No-hang regression: loss={:.6}, epochs={}",
        metrics.loss, metrics.epochs_trained
    );
    Ok(())
}

/// Regression: counterfactual experiences must flow into PER buffer.
///
/// Root cause was build_next_states_f32 receiving n_episodes instead of
/// n_episodes*2, and PER insert total missing the 2x multiplier (commit 4c5f404).
/// ~50% of augmented training data was silently dropped.
///
/// After one epoch with buffer_size=10000, gpu_n_episodes=32, timesteps=100:
///   base = 32 * 100 = 3200
///   counterfactual = 3200 * 2 = 6400
///   PER buffer should have >= 6400 entries (6400 < 10000 cap)
///
/// If counterfactual is broken again, buffer would have ~3200 entries.
#[test]
#[ignore] // GPU + real data
fn test_counterfactual_experiences_in_buffer() -> anyhow::Result<()> {
    let data_dir = test_data_dir()
        .expect("FOXHUNT_TEST_DATA or test_data/ must exist");

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

    // Compute expected counts from the actual config
    let n_ep = p.gpu_n_episodes;
    let ts = p.gpu_timesteps_per_episode;
    let base = n_ep * ts;
    let with_cf = base * 2; // counterfactual doubles
    // Buffer must be large enough to hold 2x WITHOUT wrapping, so we can
    // distinguish 1x from 2x by counting entries.
    p.buffer_size = with_cf + 100;

    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 buffer_size = rt.block_on(async {
        let agent = trainer.get_agent().read().await;
        agent.get_replay_buffer_size().unwrap_or(0)
    });

    // With counterfactual: buffer should have more entries than base (1x).
    // Threshold: midpoint between 1x and 2x cleanly separates the cases.
    let min_expected = (base + with_cf) / 2;
    assert!(
        buffer_size >= min_expected,
        "PER buffer has {buffer_size} entries (expected >= {min_expected}). \
         base={base}, with_cf={with_cf}. \
         Counterfactual experiences may be silently dropped.",
    );

    tracing::info!(
        "Counterfactual regression: PER buffer has {buffer_size} entries \
         (expected ~{with_cf}, threshold={min_expected})",
    );
    Ok(())
}

/// Regression: gpu_n_episodes config must be honored (not auto-scaled).
///
/// Previously detect_gpu_hardware().optimal_n_episodes() overrode the config,
/// causing VRAM oversubscription on H100. Now gpu_n_episodes from TOML is used
/// directly. This test verifies a custom value propagates through.
#[test]
#[ignore] // GPU + real data
fn test_gpu_n_episodes_config_honored() -> anyhow::Result<()> {
    let data_dir = test_data_dir()
        .expect("FOXHUNT_TEST_DATA or test_data/ must exist");

    let mut p = smoke_params();
    p.epochs = 1;
    p.early_stopping_enabled = false;
    p.gpu_n_episodes = 64; // non-default to prove config is read

    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())
    }))?;

    // If auto-scaling returned, n_episodes would be >64 on any modern GPU.
    // Verify training completed with the configured value by checking the
    // replay buffer: entries = gpu_n_episodes * timesteps * 2 (counterfactual)
    let buffer_size = rt.block_on(async {
        let agent = trainer.get_agent().read().await;
        agent.get_replay_buffer_size().unwrap_or(0)
    });

    // 64 episodes * 100 timesteps * 2 = 12800
    // With auto-scaling on H100 it would be ~800K+ (4096 * 100 * 2)
    let max_expected = 20000; // generous upper bound for 64 episodes
    assert!(
        buffer_size <= max_expected,
        "PER buffer has {} entries (expected <= {max_expected} for 64 episodes). \
         gpu_n_episodes config may not be honored — auto-scaling regression.",
        buffer_size
    );

    tracing::info!(
        "Config regression: PER buffer has {} entries (expected ~12800 for gpu_n_episodes=64)",
        buffer_size
    );
    Ok(())
}
