//! SP20 Phase 2 Task 2.2-fix (2026-05-10) — production-path producer
//! diagnostic for the `is_win_per_env` segment-level win-flag buffer.
//!
//! The aggregate-kernel oracle test
//! (`wr_ema_uses_segment_pnl_via_is_win_per_env_predicate` in
//! `sp20_aggregate_inputs_test`) validates the **consumer** side: when the
//! buffer is pre-populated with synthetic 1s, the kernel correctly
//! aggregates them into `wins_count`. That test passes.
//!
//! This test validates the **producer** side: when `experience_env_step`
//! runs the production launch path with synthetic OHLCV that forces
//! profitable trade closes, the kernel MUST write `1` into
//! `is_win_per_env[i]` at the segment_complete branch.
//!
//! Bug class targeted: production HEALTH_DIAG observed `wr_ema=0.0000`
//! across all training epochs even AFTER the kernel-body fix landed in
//! commit `64bbbe418`. Two runs (pre-fix vs post-fix) produced near
//! bit-identical EMA values for `alpha_ema` (which is written at the
//! SAME site), strongly suggesting the `is_win_per_env` write site is
//! NOT firing in production despite passing the consumer-side oracle
//! test. Companion test pins a parallel invariant: the `alpha_per_env`
//! producer DOES fire (alpha_ema evolves with non-zero values in
//! production), so any test where alpha_per_env got writes but
//! is_win_per_env did not would be a smoking gun for the bug.
//!
//! Run on a GPU host:
//!
//!     SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//!       cargo test -p ml --test sp20_is_win_producer_test --features cuda \
//!         -- --ignored --nocapture

#![allow(clippy::tests_outside_test_module)]

#[cfg(feature = "cuda")]
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
mod gpu {
    use std::sync::Arc;

    use ml::cuda_pipeline::gpu_experience_collector::{
        ExperienceCollectorConfig, GpuExperienceCollector,
    };
    use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
    use ml::cuda_pipeline::shared_cublas_handle::PerStreamCublasHandles;
    use ml_core::device::MlDevice;

    type CudaStream = cudarc::driver::CudaStream;

    fn try_cuda() -> Option<Arc<CudaStream>> {
        match MlDevice::cuda(0) {
            Ok(dev) => Some(dev.cuda_stream().expect("CUDA stream required").clone()),
            Err(_) => None,
        }
    }

    /// Build a 6-float-per-bar target buffer matching
    /// `experience_kernels.cu::experience_env_step`'s contract:
    ///   `[preproc_close, preproc_next, raw_close, raw_next, raw_open, mid_open]`
    ///
    /// `prices[i]` is used as the raw close at bar i; raw_open is set to
    /// `prices[i]` (intra-bar move = 0 — fine for synthetic test).
    /// preproc_* are set to 0.0 (the Q-network reads them but our test
    /// doesn't depend on Q-values).
    fn build_targets(prices: &[f32]) -> MappedF32Buffer {
        let n = prices.len();
        let mut data = vec![0.0_f32; n * 6];
        for i in 0..n {
            let p = prices[i];
            let p_next = if i + 1 < n { prices[i + 1] } else { p };
            data[i * 6] = 0.0; // preproc_close
            data[i * 6 + 1] = 0.0; // preproc_next
            data[i * 6 + 2] = p; // raw_close
            data[i * 6 + 3] = p_next; // raw_next
            data[i * 6 + 4] = p; // raw_open
            data[i * 6 + 5] = p; // mid_open
        }
        let buf = unsafe { MappedF32Buffer::new(n * 6) }.expect("alloc targets");
        buf.write_from_slice(&data);
        buf
    }

    /// Build a feature buffer where every bar has features that yield a
    /// strong "long" signal (positive market values across all 51 dims).
    /// Combined with `epsilon=1.0` random action selection, this gives
    /// enough action diversity to trigger trade openings + closings.
    fn build_market_features(n_bars: usize) -> MappedF32Buffer {
        const MARKET_DIM: usize = 51;
        let mut data = vec![0.0_f32; n_bars * MARKET_DIM];
        for i in 0..n_bars {
            for j in 0..MARKET_DIM {
                // Slightly-positive features so the network's untrained
                // Q-values lean toward longs/shorts roughly equally with
                // epsilon=1 random override.
                data[i * MARKET_DIM + j] = (i as f32 * 0.001).sin() * 0.5;
            }
        }
        let buf = unsafe { MappedF32Buffer::new(n_bars * MARKET_DIM) }
            .expect("alloc market");
        buf.write_from_slice(&data);
        buf
    }

    /// SP20 Phase 2 Task 2.2-fix — production-path producer diagnostic.
    ///
    /// Constructs a synthetic monotonic upward price series so that any
    /// long position with positive hold time closes profitably
    /// (`segment_return > 0`). With `epsilon=1.0` (full random action),
    /// the experience collector explores enough action space to open AND
    /// close trades within `timesteps_per_episode=200` bars.
    ///
    /// Asserts:
    /// 1. `alpha_per_env` has at least one non-zero slot — the producer
    ///    site at the segment_complete branch fires for at least one env
    ///    (the existing reference behavior — alpha_ema evolves in
    ///    production).
    /// 2. `is_win_per_env` has at least one slot == 1 — the SP20 Phase 2
    ///    Task 2.2-fix write site (`is_win_per_env[i] = (segment_return >
    ///    0.0f) ? 1 : 0`) fires for at least one profitable trade close.
    ///
    /// **Without the fix**: assertion 2 FAILS even though assertion 1
    /// passes. With the fix wired correctly: BOTH pass.
    ///
    /// **The two assertions are at the SAME site in the kernel** (both
    /// inside `if (segment_complete && segment_hold_time > 0.0f) {...}`),
    /// so the alpha-passes-but-is-win-fails outcome is the structural
    /// signature of the kernel-arg-threading or buffer-wireup bug
    /// described in the audit-doc 2026-05-10 entry.
    #[test]
    #[ignore = "requires GPU"]
    fn is_win_per_env_producer_fires_at_segment_complete() {
        let Some(stream) = try_cuda() else {
            eprintln!("SKIP: CUDA not available");
            return;
        };

        // Strong upward trend — every long position with hold > 0 closes
        // profitably (segment_return > 0). This forces is_win_per_env=1
        // for at least one env if the producer fires.
        let n_bars = 500_usize;
        let mut prices = Vec::with_capacity(n_bars);
        for i in 0..n_bars {
            // Linear ramp from 100 to 200 — robust >0 segment_return on
            // any long position closed after at least one bar.
            prices.push(100.0 + (i as f32) * 0.2);
        }

        let market_buf = build_market_features(n_bars);
        let target_buf = build_targets(&prices);

        let shared_cublas = Arc::new(
            PerStreamCublasHandles::new(&stream).expect("cublas handles"),
        );
        // Run multiple episodes to maximize chance of trade close
        // happening in at least one env. alloc_episodes >= n_episodes is
        // required by the collector contract. Match the existing smoke
        // test's (4, 50) allocation pattern that's known to work without
        // explicit weight wiring.
        let n_episodes = 4_usize;
        let timesteps = 50_i32;
        let mut collector = GpuExperienceCollector::new(
            stream.clone(),
            shared_cublas,
            0,    // bottleneck_dim
            51,   // market_dim
            100_000.0,
            0.01, // tx_cost_multiplier
            0.05, // max_position
            (64, 64, 32, 32),
            (51, 51),
            n_episodes,            // alloc_episodes
            timesteps as usize,    // alloc_timesteps
            0.0,  // curiosity_weight
        )
        .expect("create collector");
        let episode_starts: Vec<i32> = (0..n_episodes)
            .map(|i| (i * (n_bars / n_episodes)) as i32)
            .collect();

        let mut exp_config = ExperienceCollectorConfig::default();
        exp_config.n_episodes = n_episodes as i32;
        exp_config.timesteps_per_episode = timesteps;
        exp_config.total_bars = n_bars as i32;
        exp_config.episode_length = timesteps;
        exp_config.epsilon = 1.0; // full random — maximizes trade diversity
        exp_config.gamma = 0.99;

        let _batch = collector
            .collect_experiences_gpu(&market_buf, &target_buf, &episode_starts, &exp_config)
            .expect("collect_experiences_gpu");

        // ── Read back per-env producer buffers ──────────────────────────
        let alpha = collector
            .read_alpha_per_env_for_test()
            .expect("read alpha_per_env");
        let is_win = collector
            .read_is_win_per_env_for_test()
            .expect("read is_win_per_env");

        // Assertion 1 — alpha producer fires (reference behavior).
        let alpha_nonzero = alpha.iter().any(|&v| v != 0.0);
        let alpha_writes = alpha.iter().filter(|&&v| v != 0.0).count();
        assert!(
            alpha_nonzero,
            "PRECONDITION FAILED — alpha_per_env has zero non-zero slots \
             out of {} after {} episodes × {} steps. The synthetic \
             upward-trend data must produce at least one trade close \
             with hold_time > 0 (alpha=R_event != 0). If this assertion \
             fails, the test scaffold is broken (no trade closes \
             happened); fix the test, not the production code.",
            alpha.len(),
            n_episodes,
            timesteps,
        );

        eprintln!(
            "alpha_per_env: {}/{} slots non-zero, sum={:.4}, max={:.4}",
            alpha_writes,
            alpha.len(),
            alpha.iter().sum::<f32>(),
            alpha.iter().cloned().fold(f32::NEG_INFINITY, f32::max),
        );

        // Assertion 2 — is_win producer fires (SP20 Phase 2 Task 2.2-fix).
        let is_win_count = is_win.iter().filter(|&&v| v == 1).count();
        let is_win_max: i32 = is_win.iter().copied().max().unwrap_or(0);

        eprintln!(
            "is_win_per_env: {} slots == 1 (out of {}), max={}",
            is_win_count,
            is_win.len(),
            is_win_max,
        );

        assert!(
            is_win_max > 0,
            "SP20 Phase 2 Task 2.2-fix REGRESSION — is_win_per_env has \
             ZERO slots set to 1 across {} alloc_episodes, but \
             alpha_per_env has {} non-zero slots. Both buffers are \
             written at the SAME segment_complete site in \
             experience_env_step (line 3322 alpha, line 3337 is_win). \
             If alpha was written and is_win was not, the kernel-arg \
             threading for is_win_per_env is broken — likely the \
             pointer is being passed as NULL despite the launcher \
             alloc'ing the buffer. This is the production WR_EMA=0.0000 \
             pinning bug — the fix in commit `64bbbe418` did NOT \
             actually fire at runtime.",
            is_win.len(),
            alpha_writes,
        );

        // Tighter contract: every slot where alpha_per_env has a
        // **positive** value (R_event > 0 ⇒ segment_return > 0) MUST
        // have is_win_per_env == 1 at the same env index. This is the
        // structural co-occurrence the kernel guarantees:
        //
        //   alpha[i] = R_event ∈ {-loss_cap, -0.5, 0, +0.5, +1.0}
        //   is_win[i] = (segment_return > 0) ? 1 : 0
        //
        // R_event > 0 implies segment_return > 0 implies is_win[i] = 1.
        //
        // Skip slot 0 (first-write may not yet be set if env didn't
        // close), check ANY env where alpha > 0 has is_win = 1.
        let positive_alpha_slots: Vec<usize> = alpha
            .iter()
            .enumerate()
            .filter_map(|(i, &v)| if v > 0.0 { Some(i) } else { None })
            .collect();

        if !positive_alpha_slots.is_empty() {
            let mismatched: Vec<usize> = positive_alpha_slots
                .iter()
                .copied()
                .filter(|&i| is_win[i] == 0)
                .collect();
            assert!(
                mismatched.is_empty(),
                "SP20 Phase 2 Task 2.2-fix CO-OCCURRENCE VIOLATION — \
                 envs with alpha_per_env > 0 (R_event positive ⇒ \
                 segment_return > 0) MUST have is_win_per_env == 1. \
                 Found {} envs where alpha > 0 but is_win == 0: {:?}. \
                 This is the structural smoking gun for the kernel-arg \
                 wiring bug (the producer site writes both fields in \
                 succession at the same gating; if one fires the other \
                 must too).",
                mismatched.len(),
                mismatched,
            );
        }
    }
}
