//! SP20 Phase 1.2 (2026-05-09) — `sp20_emas_compute_kernel` GPU oracle tests.
//!
//! Verifies the 8 Wiener-α EMA producer kernel (Component 5 / Kernel 1
//! of the SP20 design) updates the ISV slots [511, 512, 515, 516] and
//! 4 internal scratch slots correctly under the
//! `pearl_first_observation_bootstrap` + `pearl_wiener_alpha_floor_for_
//! nonstationary` discipline.
//!
//! ## Path C kernel-arg refactor (2026-05-09, Phase 1.4)
//!
//! These tests exercise the post-Path-C kernel signature: the kernel
//! reads its 9 fields from a device struct pointer (`SP20EmaInputs*`),
//! not 9 scalar value-args. The tests use a mapped-pinned f32 buffer
//! sized to [`SP20_EMA_INPUTS_F32_LEN`] (= 9), pack the inputs via
//! [`pack_inputs_into_f32_view`], and pass the buffer's `dev_ptr` to
//! the kernel. Math semantics are bit-identical to the pre-Path-C
//! 9-scalar-arg version — every blend formula reads the same fields
//! by name.
//!
//! Tests cover:
//!
//!   1. **First-observation bootstrap** — sentinel (count==0) replaces
//!      directly without blend, even when the observed value happens
//!      to be 0 (e.g., first-loss WR=0). Exercises the per-EMA
//!      observation counter mechanism.
//!   2. **Wiener-α convergence with alternating wins** — 100 trade-
//!      close events with alternating is_win 1/0/1/0 land WR_EMA
//!      within tolerance of 0.5 (the long-run mean of the indicator).
//!   3. **Hold-reward gating** — `HOLD_REWARD_EMA` only updates on
//!      Hold-state bars (`action_is_hold == 1`); non-Hold steps leave
//!      it at sentinel.
//!   4. **Per-step EMAs always fire** — `HOLD_PCT_EMA`, the three
//!      internal aux EMAs (p50, std, dir_acc) update every step
//!      regardless of `is_close` / `action_is_hold` gates.
//!
//! Run on a GPU host:
//!
//!     SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//!       cargo test -p ml --test sp20_emas_compute_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 cudarc::driver::{CudaContext, CudaFunction, CudaStream};
    use ml::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
    use ml::cuda_pipeline::sp14_isv_slots::{
        ALPHA_EMA_INDEX, HOLD_PCT_EMA_INDEX, HOLD_REWARD_EMA_INDEX, WR_EMA_INDEX,
    };
    use ml::cuda_pipeline::sp20_emas_compute::{
        launch_sp20_emas_compute, pack_inputs_into_f32_view, EmaInputs,
        EMA_INTERNAL_AUX_DIR_ACC, EMA_INTERNAL_AUX_P50, EMA_INTERNAL_AUX_STD,
        EMA_INTERNAL_SLOT_COUNT, EMA_INTERNAL_TRADE_DURATION, OBS_COUNT_SLOTS,
        SP20_EMA_INPUTS_F32_LEN, WIENER_ALPHA_FLOOR,
    };

    /// Total ISV slot count is the trainer's contract; for tests we
    /// allocate just enough to cover the highest SP20 EMA slot (516).
    const ISV_TEST_LEN: usize = 520;

    const SP20_EMAS_COMPUTE_CUBIN: &[u8] = include_bytes!(concat!(
        env!("OUT_DIR"),
        "/sp20_emas_compute_kernel.cubin"
    ));

    fn make_test_stream() -> Arc<CudaStream> {
        let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
        ctx.default_stream()
    }

    fn load_sp20_emas_compute(stream: &Arc<CudaStream>) -> CudaFunction {
        let module = stream
            .context()
            .load_cubin(SP20_EMAS_COMPUTE_CUBIN.to_vec())
            .expect("load sp20_emas_compute_kernel cubin");
        module
            .load_function("sp20_emas_compute_kernel")
            .expect("load sp20_emas_compute_kernel function")
    }

    /// Test harness: allocates ISV + internal + obs_count + ema_inputs
    /// buffers, then fires `inputs.len()` sequential kernel invocations
    /// on the same stream. Returns final ISV + internal + obs_count
    /// state for assertions.
    ///
    /// Per Path C: the `ema_inputs` buffer is a 9-f32 mapped-pinned
    /// scratch byte-aliased to `SP20EmaInputs`. We rewrite it before
    /// each launch via `pack_inputs_into_f32_view`. The kernel reads
    /// it as `SP20EmaInputs*` — the f32-aliased writes land at the
    /// same byte offsets the device struct's fields do.
    fn run_kernel_sequence(inputs: &[EmaInputs]) -> (Vec<f32>, Vec<f32>, Vec<i32>) {
        let stream = make_test_stream();
        let kernel = load_sp20_emas_compute(&stream);

        let isv = unsafe { MappedF32Buffer::new(ISV_TEST_LEN) }.expect("alloc isv");
        isv.write_from_slice(&vec![0.0_f32; ISV_TEST_LEN]);

        let internal = unsafe { MappedF32Buffer::new(EMA_INTERNAL_SLOT_COUNT) }
            .expect("alloc internal");
        internal.write_from_slice(&vec![0.0_f32; EMA_INTERNAL_SLOT_COUNT]);

        let obs_count =
            unsafe { MappedI32Buffer::new(OBS_COUNT_SLOTS) }.expect("alloc obs_count");
        obs_count.write_from_slice(&vec![0_i32; OBS_COUNT_SLOTS]);

        let mut ema_inputs_buf =
            unsafe { MappedF32Buffer::new(SP20_EMA_INPUTS_F32_LEN) }
                .expect("alloc ema_inputs");

        for inp in inputs {
            // Re-pack the 9-field struct view before each launch.
            pack_inputs_into_f32_view(ema_inputs_buf.host_slice_mut(), inp);
            unsafe {
                launch_sp20_emas_compute(
                    &stream,
                    &kernel,
                    isv.dev_ptr,
                    ema_inputs_buf.dev_ptr,
                    internal.dev_ptr,
                    obs_count.dev_ptr,
                )
                .expect("launch sp20_emas_compute_kernel");
            }
            // Synchronise after each launch so the kernel finishes
            // reading the input buffer before we overwrite it for the
            // next iteration. (Pre-Path-C the inputs travelled as
            // kernel-arg by-value; under Path C the kernel reads the
            // buffer in-flight, so we must sync between writes.)
            stream.synchronize().expect("sync between sp20_emas launches");
        }

        (isv.read_all(), internal.read_all(), obs_count.read_all())
    }

    /// Test 1 — first-observation bootstrap on a trade-close event with
    /// `is_win=1, alpha=0.5`. Per `pearl_first_observation_bootstrap`,
    /// the sentinel (count==0) MUST be replaced directly without blend.
    /// WR_EMA → 1.0, ALPHA_EMA → 0.5 (not 0.4 × 1 or 0.4 × 0.5 from
    /// any blend formula).
    #[test]
    #[ignore = "requires GPU"]
    fn first_observation_replaces_sentinel() {
        let inp = EmaInputs {
            is_close: 1,
            win_fraction: 1.0,
            trade_duration: 7,
            hold_fraction: 0.0,
            alpha: 0.5,
            per_bar_hold_reward: 0.0,
            aux_logits_p50: 0.0,
            aux_logits_std: 0.0,
            aux_dir_acc: 0.0,
        };
        let (isv, internal, obs_count) = run_kernel_sequence(&[inp]);

        // Per-trade EMAs fired by is_close=1.
        assert_eq!(
            isv[WR_EMA_INDEX], 1.0,
            "WR_EMA: first observation must REPLACE sentinel directly (got {})",
            isv[WR_EMA_INDEX]
        );
        assert_eq!(
            isv[ALPHA_EMA_INDEX], 0.5,
            "ALPHA_EMA: first observation must REPLACE sentinel directly (got {})",
            isv[ALPHA_EMA_INDEX]
        );
        assert_eq!(
            internal[EMA_INTERNAL_TRADE_DURATION], 7.0,
            "trade_duration_ema: first observation must REPLACE sentinel directly",
        );

        // Per-step EMAs that fired (action_is_hold=0 ⇒ HOLD_PCT first
        // observation = 0.0 directly, so we read it as 0).
        assert_eq!(
            isv[HOLD_PCT_EMA_INDEX], 0.0,
            "HOLD_PCT_EMA: first observation = 0.0 (action_is_hold=0)",
        );

        // HOLD_REWARD_EMA was NOT fired (action_is_hold=0) — sentinel
        // remains at 0.0 with obs_count == 0.
        assert_eq!(
            isv[HOLD_REWARD_EMA_INDEX], 0.0,
            "HOLD_REWARD_EMA must remain at sentinel when action_is_hold=0",
        );

        // Observation counters: WR, ALPHA, TRADE_DURATION, HOLD_PCT,
        // AUX_P50, AUX_STD, AUX_DIR_ACC fired = 1; HOLD_REWARD did not.
        // The counter layout is fixed by the launcher's contract; verify
        // HOLD_REWARD's slot stayed at 0.
        // (Slot indices come from launcher constants; we just check a
        // non-zero count for at least one fired EMA and zero for HOLD_REWARD.)
        let hold_reward_count = obs_count[4]; // HOLD_REWARD slot
        let wr_count = obs_count[1];
        assert_eq!(hold_reward_count, 0, "HOLD_REWARD obs_count must stay 0");
        assert_eq!(wr_count, 1, "WR obs_count must be 1 after one trade");
    }

    /// Test 2 — Wiener-α convergence with alternating wins.
    ///
    /// Fire 100 trade-close events with alternating `is_win` 1/0/1/0...
    /// Expected: WR_EMA → 0.5 (long-run mean of the indicator).
    /// Tolerance: at α=0.4 the EMA tracks within ≈ √(α/(2-α)) of the
    /// mean for an alternating signal — about 0.5 amplitude × √0.25 ≈
    /// 0.25, so we widen to 0.30 to absorb the alternation noise of
    /// the last few samples.
    ///
    /// First observation is a win (is_win=1) → WR_EMA = 1.0 directly,
    /// then alternation drives it to ≈ 0.5 long-run.
    #[test]
    #[ignore = "requires GPU"]
    fn wiener_alpha_converges_to_long_run_mean() {
        let inputs: Vec<EmaInputs> = (0..100)
            .map(|i| EmaInputs {
                is_close: 1,
                win_fraction: if i % 2 == 0 { 1.0 } else { 0.0 },
                trade_duration: 1,
                hold_fraction: 0.0,
                alpha: 0.0,
                per_bar_hold_reward: 0.0,
                aux_logits_p50: 0.0,
                aux_logits_std: 0.0,
                aux_dir_acc: 0.0,
            })
            .collect();
        let (isv, _internal, _obs_count) = run_kernel_sequence(&inputs);

        let wr_ema = isv[WR_EMA_INDEX];
        let amplitude = 0.30_f32; // generous bound, absorbs alternation
        assert!(
            (wr_ema - 0.5).abs() < amplitude,
            "WR_EMA after 100 alternating events should be near 0.5; got {wr_ema}",
        );
    }

    /// Test 3 — HOLD_REWARD_EMA only updates on Hold-state bars.
    ///
    /// Fire 5 non-Hold bars (action_is_hold=0, r_per_bar=−0.05) then
    /// 5 Hold bars (action_is_hold=1, r_per_bar=−0.05). Expected:
    /// after all 10 calls, HOLD_REWARD_EMA == −0.05 (only the 5 Hold
    /// bars updated; the first Hold bar bootstrapped to −0.05, and
    /// subsequent identical observations leave it at −0.05).
    ///
    /// Per the spec: "Update only on Hold-state bars (gate on
    /// action_is_hold == 1)."
    #[test]
    #[ignore = "requires GPU"]
    fn hold_reward_ema_gated_on_hold_bars() {
        let mut inputs: Vec<EmaInputs> = Vec::new();
        for _ in 0..5 {
            inputs.push(EmaInputs {
                is_close: 0,
                win_fraction: 0.0,
                trade_duration: 0,
                hold_fraction: 0.0, // gated OFF
                alpha: 0.0,
                per_bar_hold_reward: -0.05,
                aux_logits_p50: 0.0,
                aux_logits_std: 0.0,
                aux_dir_acc: 0.0,
            });
        }
        for _ in 0..5 {
            inputs.push(EmaInputs {
                is_close: 0,
                win_fraction: 0.0,
                trade_duration: 0,
                hold_fraction: 1.0, // > 0.5 gate ON (SP21 T3.2)
                alpha: 0.0,
                per_bar_hold_reward: -0.05,
                aux_logits_p50: 0.0,
                aux_logits_std: 0.0,
                aux_dir_acc: 0.0,
            });
        }
        let (isv, _internal, obs_count) = run_kernel_sequence(&inputs);

        // After 5 non-Hold bars the EMA is at sentinel (0.0) and
        // counter is 0; the first Hold bar (step 6) bootstraps to
        // −0.05; the next 4 Hold bars Wiener-blend toward −0.05 (all
        // identical) — the EMA stays at −0.05 to fp32 precision.
        let hold_reward = isv[HOLD_REWARD_EMA_INDEX];
        let tol = 1e-6_f32;
        assert!(
            (hold_reward - (-0.05)).abs() < tol,
            "HOLD_REWARD_EMA: after 5 non-Hold + 5 Hold bars (all r_per_bar=−0.05) \
             expected −0.05, got {hold_reward}",
        );

        // Counter must reflect ONLY the 5 Hold bars.
        let hold_reward_count = obs_count[4];
        assert_eq!(
            hold_reward_count, 5,
            "HOLD_REWARD obs_count must equal Hold-bar count (5), got {hold_reward_count}",
        );
    }

    /// Test 4 — per-step always-fire EMAs (HOLD_PCT, aux_p50, aux_std,
    /// aux_dir_acc) update every step regardless of trade-close /
    /// hold-state gates.
    ///
    /// Fire 10 steps with `is_close=0, action_is_hold=0`, so the
    /// per-trade and Hold-reward EMAs never fire, but the per-step
    /// EMAs receive consistent inputs (aux_p50=0.2, aux_std=0.1,
    /// aux_dir_acc=1.0) and must converge there.
    ///
    /// First observation bootstraps directly (count==0); subsequent
    /// 9 observations Wiener-blend toward the same value. With
    /// identical inputs after bootstrap, the EMA stays put — the
    /// blend `(1-α)·prev + α·curr = curr` when prev == curr.
    #[test]
    #[ignore = "requires GPU"]
    fn per_step_emas_fire_unconditionally() {
        let inputs: Vec<EmaInputs> = (0..10)
            .map(|_| EmaInputs {
                is_close: 0,
                win_fraction: 0.0,
                trade_duration: 0,
                hold_fraction: 0.0,
                alpha: 0.0,
                per_bar_hold_reward: 0.0,
                aux_logits_p50: 0.2,
                aux_logits_std: 0.1,
                aux_dir_acc: 1.0,
            })
            .collect();
        let (isv, internal, obs_count) = run_kernel_sequence(&inputs);

        let tol = 1e-6_f32;
        assert!(
            (isv[HOLD_PCT_EMA_INDEX] - 0.0).abs() < tol,
            "HOLD_PCT_EMA: 10 non-Hold steps should land at 0.0, got {}",
            isv[HOLD_PCT_EMA_INDEX]
        );
        assert!(
            (internal[EMA_INTERNAL_AUX_P50] - 0.2).abs() < tol,
            "aux_conf_p50_ema: 10 identical steps should land at 0.2, got {}",
            internal[EMA_INTERNAL_AUX_P50]
        );
        assert!(
            (internal[EMA_INTERNAL_AUX_STD] - 0.1).abs() < tol,
            "aux_conf_std_ema: 10 identical steps should land at 0.1, got {}",
            internal[EMA_INTERNAL_AUX_STD]
        );
        assert!(
            (internal[EMA_INTERNAL_AUX_DIR_ACC] - 1.0).abs() < tol,
            "aux_dir_acc_ema: 10 identical steps should land at 1.0, got {}",
            internal[EMA_INTERNAL_AUX_DIR_ACC]
        );

        // Per-step EMAs fired all 10 times.
        let hold_pct_count = obs_count[3];
        let aux_p50_count = obs_count[5];
        assert_eq!(hold_pct_count, 10, "HOLD_PCT obs_count must be 10");
        assert_eq!(aux_p50_count, 10, "AUX_P50 obs_count must be 10");

        // Per-trade EMAs never fired (is_close=0).
        let wr_count = obs_count[1];
        assert_eq!(wr_count, 0, "WR obs_count must remain 0");
    }

    /// Test 5 — Wiener-α floor 0.4 contract.
    ///
    /// The launcher exports `WIENER_ALPHA_FLOOR = 0.4` per
    /// `pearl_wiener_alpha_floor_for_nonstationary`. Lock the value
    /// against silent drift.
    #[test]
    fn wiener_alpha_floor_locked_at_0_4() {
        assert!(
            (WIENER_ALPHA_FLOOR - 0.4).abs() < 1e-9,
            "Wiener-α floor must be 0.4 per pearl_wiener_alpha_floor_for_nonstationary",
        );
    }
}
