//! Phase R6 smoke: `IntegratedTrainer::step_with_lobsim` runs one
//! GPU-pure step end-to-end against a real `LobSimCuda` (synthetic
//! book) without panic, returning finite losses.
//!
//! Replaces the flawed Phase F+G `MockLobEnv` toy-bandit fixture per
//! the "no toys" feedback that drove the rebuild. The convergence
//! assertions formerly in `dqn_toy.rs` / `ppo_toy.rs` (Thompson →
//! argmax on a state-invariant 1-step bandit) don't transfer to real
//! `LobSimCuda` semantics; per `pearl_tests_must_prove_not_lock_observations`
//! the right gate at this layer is the invariant "the GPU-pure
//! pipeline runs and produces finite training-step stats". G7's
//! bull-market calibration test (forced-long, mean reward > 0) lives
//! separately in `ml-backtesting/tests/reward_calibration.rs`
//! (added by R6b).
//!
//! Run with:
//!   `cargo test -p ml-alpha --test integrated_trainer_smoke -- --ignored --nocapture`

use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;

fn synthetic_window(seq_len: usize) -> Vec<Mbp10RawInput> {
    let mut out = Vec::with_capacity(seq_len);
    let mut prev_mid = 5500.0_f32;
    let mut ts_ns = 1_000_000_u64;
    for _ in 0..seq_len {
        let next_mid = prev_mid + 0.25;
        let mut bid_px = [0.0_f32; 10];
        let mut bid_sz = [0.0_f32; 10];
        let mut ask_px = [0.0_f32; 10];
        let mut ask_sz = [0.0_f32; 10];
        for i in 0..10 {
            bid_px[i] = next_mid - 0.125 - 0.25 * i as f32;
            ask_px[i] = next_mid + 0.125 + 0.25 * i as f32;
            bid_sz[i] = 10.0;
            ask_sz[i] = 10.0;
        }
        let prev_ts = ts_ns;
        ts_ns += 20_000_000;
        out.push(Mbp10RawInput {
            bid_px,
            bid_sz,
            ask_px,
            ask_sz,
            prev_mid,
            trade_signed_vol: 0.0,
            trade_count: 0,
            ts_ns,
            prev_ts_ns: prev_ts,
            regime: [0.0; 6],
        });
        prev_mid = next_mid;
    }
    out
}

#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn integrated_trainer_step_with_lobsim_runs_without_panic() {
    let dev = match MlDevice::cuda(0) {
        Ok(d) => d,
        Err(_) => {
            eprintln!("CUDA 0 not available — skipping integrated_trainer smoke");
            return;
        }
    };

    let perception = PerceptionTrainerConfig {
        seq_len: 4,
        n_batch: 1,
        ..PerceptionTrainerConfig::default()
    };
    let cfg = IntegratedTrainerConfig {
        perception,
        dqn_seed: 0xC0DE,
        ppo_seed: 0xC0DF,
        ..IntegratedTrainerConfig::default()
    };
    let mut trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
    let snapshots = synthetic_window(4);
    let mut sim = LobSimCuda::new(1, &dev).expect("LobSimCuda::new");

    // One step end-to-end (encoder fwd ×2 [snapshots + next_snapshots
    // for R7c data correctness], Q/π/V fwd on h_t + Q/V fwd on h_tp1,
    // GPU Thompson + Double-DQN argmax kernels, actions_to_market_targets
    // kernel, step_fill_from_market_targets, extract_realized_pnl_delta,
    // GPU compute_advantage_return with true V(s_{t+1}), step_synthetic
    // delegation).
    //
    // next_snapshots is the same window shifted by one step. R8's CLI
    // binary uses MultiHorizonLoader::next_sequence_pair (R2) to load
    // adjacent (s_t, s_{t+1}) windows from real MBP-10 data; for the
    // smoke we synthesise a +1-tick window directly.
    let next_snapshots = synthetic_window(4);
    let stats = trainer
        .step_with_lobsim(&snapshots, &next_snapshots, &mut sim)
        .expect("step_with_lobsim");

    // Loss components are finite.
    assert!(stats.l_q.is_finite(), "l_q is not finite: {}", stats.l_q);
    assert!(stats.l_pi.is_finite(), "l_pi is not finite: {}", stats.l_pi);
    assert!(stats.l_v.is_finite(), "l_v is not finite: {}", stats.l_v);
    assert!(stats.l_total.is_finite(), "l_total is not finite: {}", stats.l_total);

    // Each λ is finite + positive. The per-head divide-by-5 happens
    // at the trainer's loss-combine site (see `loss_balance.rs::Default`
    // doc), so each λ defaults to 1.0 (sum 5.0) on first observation.
    // Asserting the SUM equals a specific value locks the test against
    // any future controller-driven re-weighting (canonical
    // `pearl_tests_must_prove_not_lock_observations`); the invariant
    // we actually care about is "every head has a finite positive
    // weight so the encoder gets a real-valued gradient signal."
    for (name, lam) in [
        ("bce", stats.lambdas.bce),
        ("q",   stats.lambdas.q),
        ("pi",  stats.lambdas.pi),
        ("v",   stats.lambdas.v),
        ("aux", stats.lambdas.aux),
    ] {
        assert!(
            lam.is_finite() && lam > 0.0,
            "lambda[{name}] must be finite + positive; got {lam}"
        );
    }

    eprintln!(
        "R6 OK — step_with_lobsim end-to-end: l_bce={} l_q={} l_pi={} l_v={} l_aux={} l_total={}",
        stats.l_bce, stats.l_q, stats.l_pi, stats.l_v, stats.l_aux, stats.l_total
    );
}
