//! Integration tests for the VAD backends on real and synthetic audio.
//!
//! Covers the regression that surfaced in the meet-bot canary: the
//! energy-RMS VAD emits exactly one `SpeechStart` and then never reaches
//! `turn_ended()` on continuous Meet ambience, so segments are only
//! finalized by the 6 s force-finalize safety valve in
//! `cpal_listener.rs`. The neural Silero VAD discriminates speech from
//! ambient/typing/etc. and emits a clean `turn_ended` after silence.
//!
//! Audio fixture: `tests/fixtures/jfk.wav` is the public-domain JFK
//! "Ask not what your country can do for you" sample shipped with
//! `whisper.cpp` (mono 16 kHz PCM-16, ~11 s). Public domain — US
//! government work.

use car_voice::vad_backend::{EnergyVadBackend, SileroVadBackend, VadBackend};
use car_voice::VoiceConfig;
use std::path::PathBuf;
use std::thread::sleep;
use std::time::Duration;

const SAMPLE_RATE: u32 = 16_000;

fn fixture_path(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join(name)
}

fn read_jfk_wav() -> (Vec<f32>, u32) {
    let mut reader = hound::WavReader::open(fixture_path("jfk.wav"))
        .expect("open jfk.wav fixture (should be PCM 16-bit mono 16 kHz)");
    let spec = reader.spec();
    assert_eq!(spec.channels, 1, "jfk fixture must be mono");
    assert_eq!(spec.sample_rate, SAMPLE_RATE, "jfk fixture must be 16 kHz");
    let samples: Vec<f32> = reader
        .samples::<i16>()
        .map(|s| s.expect("decode i16 sample") as f32 / i16::MAX as f32)
        .collect();
    (samples, spec.sample_rate)
}

/// Generate a 220 Hz sine wave at the given sample rate, normalised
/// to ±0.5 amplitude. Loud enough to trip the energy VAD's threshold,
/// not speech so a neural backend should reject it.
fn sine_wave(sample_rate: u32, duration_ms: u32, freq_hz: f32) -> Vec<f32> {
    let n = (sample_rate as u64 * duration_ms as u64 / 1000) as usize;
    let two_pi = std::f32::consts::TAU;
    (0..n)
        .map(|i| 0.5 * (two_pi * freq_hz * i as f32 / sample_rate as f32).sin())
        .collect()
}

fn silence(sample_rate: u32, duration_ms: u32) -> Vec<f32> {
    let n = (sample_rate as u64 * duration_ms as u64 / 1000) as usize;
    vec![0.0; n]
}

/// Real human speech (JFK inaugural snippet) must register as speech
/// on the Silero backend. The backend reads in 32 ms windows; we feed
/// the whole clip and assert speech became active at some point.
#[test]
fn silero_detects_real_speech_in_jfk_clip() {
    let (samples, rate) = read_jfk_wav();
    let cfg = VoiceConfig::default();
    let mut vad =
        SileroVadBackend::new(rate, &cfg).expect("Silero backend should construct on test host");

    // Feed the whole clip; the Silero crate may panic on certain
    // ort runtime/version combos (see vad_backend.rs notes), so
    // wrap to keep the test from killing the whole runner.
    let detected = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        let mut saw_speech = false;
        for chunk in samples.chunks(SAMPLE_RATE as usize / 50 /* 20 ms */) {
            vad.process_samples(chunk);
            if vad.is_speech_active() {
                saw_speech = true;
            }
        }
        saw_speech
    }));

    match detected {
        Ok(true) => {}
        Ok(false) => panic!("Silero failed to detect any speech in JFK clip"),
        Err(_) => eprintln!(
            "[skip] Silero panicked at runtime (ort/voice_activity_detector version skew); \
             cpal_listener catches this and falls back to energy VAD"
        ),
    }
}

/// After speech ends, Silero must report `turn_ended` once the
/// configured silence window has elapsed. This is the property the
/// energy VAD breaks under continuous Meet ambience: it never reaches
/// `turn_ended` so segments are only cut by the 6 s force-finalize.
#[test]
fn silero_emits_turn_ended_after_silence() {
    let (samples, rate) = read_jfk_wav();
    let cfg = VoiceConfig::default();
    let mut vad = match SileroVadBackend::new(rate, &cfg) {
        Ok(v) => v,
        Err(_) => return, // No model on this host; covered elsewhere.
    };

    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        for chunk in samples.chunks(SAMPLE_RATE as usize / 50) {
            vad.process_samples(chunk);
        }
        // Feed silence to drive the silence_onset_at clock. Then
        // wait long enough for the configured turn_end_ms to lapse.
        vad.process_samples(&silence(rate, 200));
        let turn_wait = Duration::from_millis(cfg.turn_end_ms as u64 + 100);
        sleep(turn_wait);
        // Re-poll with a short silence chunk so the backend re-checks
        // the elapsed silence window.
        vad.process_samples(&silence(rate, 50));
        vad.turn_ended()
    }));

    match result {
        Ok(true) => {}
        Ok(false) => panic!("Silero failed to emit turn_ended after silence"),
        Err(_) => eprintln!("[skip] Silero panicked at runtime"),
    }
}

/// Discrimination test: a pure sine wave is loud but isn't speech.
/// A neural backend should reject it. The energy backend will happily
/// flag it as speech because it crosses the RMS threshold — that's
/// the bug Silero fixes for meet-bot, where laptop fans / Meet
/// notification sounds / typing kept the energy VAD locked on.
#[test]
fn silero_rejects_pure_sine_but_energy_locks_on() {
    let cfg = VoiceConfig::default();
    let sine = sine_wave(SAMPLE_RATE, 2_000, 220.0);

    // Energy backend: expected to flag sine as "speech" (it's loud).
    let mut energy = EnergyVadBackend::from_config(SAMPLE_RATE, &cfg);
    // Calibrate against silence first so the noise floor settles.
    energy.process_samples(&silence(SAMPLE_RATE, 1_500));
    energy.process_samples(&sine);
    let energy_flagged = energy.is_speech_active();

    // Silero backend: expected to reject sine.
    let silero_flagged = match SileroVadBackend::new(SAMPLE_RATE, &cfg) {
        Ok(mut silero) => {
            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                silero.process_samples(&silence(SAMPLE_RATE, 200));
                silero.process_samples(&sine);
                silero.is_speech_active()
            }));
            result.ok()
        }
        Err(_) => None,
    };

    // The energy VAD almost always locks on a 220 Hz sine at -6 dBFS.
    // Don't make this assertion strict — the noise-floor adapt rate
    // differs across hosts — but log it so the diff is visible if it
    // changes.
    if !energy_flagged {
        eprintln!(
            "[note] energy VAD did NOT flag the sine wave on this host; \
             the discrimination contrast is weaker than expected"
        );
    }

    if let Some(flagged) = silero_flagged {
        assert!(
            !flagged,
            "Silero should reject a 220 Hz sine wave as non-speech \
             (energy VAD flagged: {energy_flagged})"
        );
    } else {
        eprintln!("[skip] Silero unavailable on this host");
    }
}
