//! ADR-119 AC2: "Presence detection latency is ≤ 1s p95 from the first
//! non-empty BFI frame in a new occupancy event." This iter pins the
//! latency property at the `BfldPipeline::process()` surface — the call
//! between the iter-21 publisher and the iter-19 facade.
//!
//! Method: warm up the pipeline, then time N consecutive `process()` calls
//! over a fresh `BfldPipeline`. Compute p50 and p95 from the sorted latency
//! samples. AC2 caps p95 at 1 second; debug-build measurements come in well
//! under 1ms per call, so we assert against a **generous** 100ms floor that
//! still catches a catastrophic regression (e.g., accidental I/O in the
//! hot path) without flaking on a busy CI runner.

#![cfg(feature = "std")]

use std::time::{Duration, Instant};

use wifi_densepose_bfld::{
    BfldConfig, BfldPipeline, IdentityEmbedding, SensingInputs, EMBEDDING_DIM,
};

const N_SAMPLES: usize = 500;
/// Generous CI floor — debug builds typically land < 1ms / call.
const DEBUG_P95_FLOOR: Duration = Duration::from_millis(100);
/// Documented ADR-119 AC2 target. CI doesn't assert against this directly
/// (release-build territory), but the constant is exported for operators
/// running `cargo test --release` to re-pin.
pub const ADR_119_AC2_P95_TARGET: Duration = Duration::from_secs(1);

fn inputs(ts_ns: u64) -> SensingInputs {
    SensingInputs {
        timestamp_ns: ts_ns,
        presence: true,
        motion: 0.3,
        person_count: 1,
        sensing_confidence: 0.9,
        sep: 0.1,
        stab: 0.1,
        consist: 0.1,
        risk_conf: 0.1,
        rf_signature_hash: None,
    }
}

fn embedding() -> IdentityEmbedding {
    IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM])
}

fn percentile(sorted_samples: &[Duration], p: f64) -> Duration {
    debug_assert!(!sorted_samples.is_empty());
    let idx = ((sorted_samples.len() as f64) * p).floor() as usize;
    let idx = idx.min(sorted_samples.len() - 1);
    sorted_samples[idx]
}

#[test]
fn process_call_p95_latency_meets_debug_floor() {
    let mut pipeline = BfldPipeline::new(BfldConfig::new("seed-latency"));

    // Warm up branch predictor + cache.
    for i in 0..50 {
        let _ = pipeline.process(inputs(i * 1_000), Some(embedding()));
    }

    let mut samples: Vec<Duration> = Vec::with_capacity(N_SAMPLES);
    for i in 0..N_SAMPLES {
        let ts_ns = (i as u64 + 50) * 1_000_000;
        let start = Instant::now();
        let _evt = pipeline.process(inputs(ts_ns), Some(embedding()));
        samples.push(start.elapsed());
    }

    samples.sort_unstable();
    let p50 = percentile(&samples, 0.50);
    let p95 = percentile(&samples, 0.95);
    let p99 = percentile(&samples, 0.99);

    eprintln!(
        "presence_latency: {N_SAMPLES} samples — p50={:.3}µs p95={:.3}µs p99={:.3}µs \
         (debug floor: {:?}, ADR-119 AC2 release target: {:?})",
        p50.as_secs_f64() * 1e6,
        p95.as_secs_f64() * 1e6,
        p99.as_secs_f64() * 1e6,
        DEBUG_P95_FLOOR,
        ADR_119_AC2_P95_TARGET,
    );

    assert!(
        p95 <= DEBUG_P95_FLOOR,
        "p95 latency {:?} exceeded debug floor {:?} — possible regression \
         (accidental I/O on the hot path, debug-build optimization regression)",
        p95,
        DEBUG_P95_FLOOR,
    );

    // ADR-119 AC2 documented target — debug build easily satisfies it
    // since DEBUG_P95_FLOOR is 100ms and AC2 is 1s.
    assert!(
        p95 <= ADR_119_AC2_P95_TARGET,
        "p95 latency {:?} exceeds ADR-119 AC2 ({:?})",
        p95,
        ADR_119_AC2_P95_TARGET,
    );
}

#[test]
fn first_call_after_pipeline_construction_is_not_pathologically_slow() {
    // Operators see "first event after node boot" as the user-visible
    // latency. Spinning up a fresh pipeline and measuring the very FIRST
    // call (no warmup) catches a constructor that does lazy work on first
    // process — would show up as a 100ms+ initial spike on a Pi 5.
    let mut pipeline = BfldPipeline::new(BfldConfig::new("seed-first"));
    let start = Instant::now();
    let _evt = pipeline.process(inputs(1_000_000), Some(embedding()));
    let first_call = start.elapsed();

    eprintln!("first-call latency: {:.3}µs", first_call.as_secs_f64() * 1e6);
    // First call is allowed to be slower than steady-state but still
    // bounded — 250ms catches a real warm-up bug without flaking.
    assert!(
        first_call < Duration::from_millis(250),
        "first-call latency {:?} suggests lazy initialization in process() \
         path — operators see this as boot-time delay",
        first_call,
    );
}

#[test]
fn latency_does_not_grow_unbounded_over_long_runs() {
    // Catch monotonically growing per-call cost (memory leak, ring buffer
    // misbehavior, unbounded internal log). Compare first-100-sample mean
    // vs last-100-sample mean.
    let mut pipeline = BfldPipeline::new(BfldConfig::new("seed-grow"));
    let mut samples = Vec::with_capacity(N_SAMPLES);
    for i in 0..N_SAMPLES {
        let ts_ns = (i as u64) * 1_000_000;
        let start = Instant::now();
        let _ = pipeline.process(inputs(ts_ns), Some(embedding()));
        samples.push(start.elapsed());
    }
    let first_mean = samples[..100].iter().sum::<Duration>() / 100;
    let last_mean = samples[N_SAMPLES - 100..].iter().sum::<Duration>() / 100;
    eprintln!(
        "first-100 mean: {:.3}µs, last-100 mean: {:.3}µs",
        first_mean.as_secs_f64() * 1e6,
        last_mean.as_secs_f64() * 1e6,
    );
    // Allow 10× growth ratio to absorb noise + warmup effects; catches
    // genuine 100×+ regressions like an unbounded log.
    let ratio = last_mean.as_nanos() as f64 / first_mean.as_nanos().max(1) as f64;
    assert!(
        ratio < 10.0,
        "per-call latency growth ratio {ratio:.2}× suggests unbounded internal state",
    );
}
