//! RMS-energy-backed VAD chunker.
//!
//! This is the fallback backend the plan (`docs/whisper-ecosystem/
//! workstream-A.md`, Phase A.3 "Known unknowns") permits while the ort
//! 2.0.0-rc.10 vs rc.12 ecosystem conflict prevents a drop-in Silero
//! dep. The `VadChunker` trait surface is identical to what a Silero
//! backend will present, so the live-session path does not change when
//! Silero lands.
//!
//! The chunker emits a `VadChunk` when a sustained-speech region ends
//! (RMS drops below `exit_threshold` for `silence_close_samples`) or
//! when an in-progress region exceeds `max_chunk_samples` (so Whisper
//! is not fed a 30-second monolith). It applies hysteresis — an
//! `enter_threshold` higher than `exit_threshold` — so a VAD score
//! bouncing around the threshold does not toggle state every frame.

use super::{VadChunk, VadChunker};

/// Sample window used to compute a single RMS reading. 50 ms at 16
/// kHz. Shorter windows twitch on transients; longer windows blur the
/// speech-onset boundary.
const FRAME_SAMPLES: usize = 800;

/// Default thresholds tuned to match the existing `evaluate_speech_gate`
/// behaviour in `src-tauri/src/commands/live.rs`. The underlying
/// constants live in that file; this chunker exposes them as fields so
/// they can be tuned per-session without a recompile.
const DEFAULT_ENTER_RMS_THRESHOLD: f32 = 0.003;
const DEFAULT_EXIT_RMS_THRESHOLD: f32 = 0.0014;
/// Frames of sustained speech required before the chunker enters the
/// "in-speech" state. Filters out single-frame transients (keyboard
/// clicks, door closes).
const DEFAULT_SPEECH_ONSET_FRAMES: usize = 3;
/// Silence duration that closes an in-progress chunk, in samples.
/// 500 ms = 10 frames at 16 kHz / 50 ms-frames.
const DEFAULT_SILENCE_CLOSE_SAMPLES: usize = 8_000;
/// Hard cap on a single chunk. Matches the existing `CHUNK_SAMPLES`
/// (2 s) so the live-streaming experience is not delayed arbitrarily
/// by a user speaking continuously.
const DEFAULT_MAX_CHUNK_SAMPLES: usize = 32_000;
/// Sample rate the thresholds above assume. Exposed so future backends
/// (Parakeet, Moonshine) at different rates can construct a chunker
/// matching their native sample rate.
const DEFAULT_SAMPLE_RATE_HZ: u32 = 16_000;

#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
    /// Nothing buffered. Waiting for the first RMS excursion over
    /// `enter_threshold`.
    Idle,
    /// In-progress speech. Samples accumulate; closes on
    /// `silence_close_samples` of sub-threshold audio or on
    /// `max_chunk_samples`.
    InSpeech,
}

pub struct RmsVadChunker {
    // Tunables
    enter_threshold: f32,
    exit_threshold: f32,
    speech_onset_frames: usize,
    silence_close_samples: usize,
    max_chunk_samples: usize,

    // Running state
    state: State,
    /// Frame-boundary reassembly: samples that did not complete a
    /// frame on the previous `push`. Always shorter than `FRAME_SAMPLES`.
    pending: Vec<f32>,
    /// Samples belonging to the current in-progress chunk (State::InSpeech).
    active_chunk: Vec<f32>,
    /// Trailing silence sample count inside the current chunk. Resets
    /// to zero whenever a speech frame is seen.
    silent_tail_samples: usize,
    /// Consecutive speech frames observed while `State::Idle`. When
    /// this hits `speech_onset_frames`, state transitions to InSpeech.
    pending_onset_frames: usize,
    /// Samples buffered from the onset window that should be attached
    /// to the front of the emitted chunk so Whisper sees the speech
    /// onset itself, not just the post-onset audio.
    onset_buffer: Vec<f32>,
    /// Absolute sample index of the next sample `push` will consume.
    next_sample_index: u64,
    /// Absolute sample index where the current in-progress chunk
    /// started. Valid only while `state == InSpeech`.
    active_chunk_start: u64,
}

impl RmsVadChunker {
    pub fn new() -> Self {
        Self::with_thresholds(
            DEFAULT_ENTER_RMS_THRESHOLD,
            DEFAULT_EXIT_RMS_THRESHOLD,
            DEFAULT_SPEECH_ONSET_FRAMES,
            DEFAULT_SILENCE_CLOSE_SAMPLES,
            DEFAULT_MAX_CHUNK_SAMPLES,
        )
    }

    pub fn with_thresholds(
        enter_threshold: f32,
        exit_threshold: f32,
        speech_onset_frames: usize,
        silence_close_samples: usize,
        max_chunk_samples: usize,
    ) -> Self {
        debug_assert!(
            exit_threshold <= enter_threshold,
            "exit_threshold must not exceed enter_threshold (hysteresis requires enter >= exit)"
        );
        Self {
            enter_threshold,
            exit_threshold,
            speech_onset_frames,
            silence_close_samples,
            max_chunk_samples,
            state: State::Idle,
            pending: Vec::new(),
            active_chunk: Vec::new(),
            silent_tail_samples: 0,
            pending_onset_frames: 0,
            onset_buffer: Vec::new(),
            next_sample_index: 0,
            active_chunk_start: 0,
        }
    }

    pub fn sample_rate_hz(&self) -> u32 {
        DEFAULT_SAMPLE_RATE_HZ
    }

    fn frame_rms(frame: &[f32]) -> f32 {
        if frame.is_empty() {
            return 0.0;
        }
        let sum_sq: f32 = frame.iter().map(|x| x * x).sum();
        (sum_sq / frame.len() as f32).sqrt()
    }

    /// Consume one complete frame's worth of samples and update state.
    /// `frame_start` is the absolute sample index of `frame[0]` in the
    /// stream fed since `reset`. Returns a `VadChunk` if this frame
    /// closed the in-progress chunk.
    fn consume_frame(&mut self, frame: Vec<f32>, frame_start: u64) -> Option<VadChunk> {
        let rms = Self::frame_rms(&frame);
        match self.state {
            State::Idle => self.consume_frame_idle(frame, frame_start, rms),
            State::InSpeech => self.consume_frame_in_speech(frame, rms),
        }
    }

    fn consume_frame_idle(
        &mut self,
        frame: Vec<f32>,
        frame_start: u64,
        rms: f32,
    ) -> Option<VadChunk> {
        if rms >= self.enter_threshold {
            self.pending_onset_frames += 1;
            // Keep a rolling buffer of onset audio so once we confirm
            // speech, the emitted chunk contains the speech attack
            // rather than starting mid-syllable.
            self.onset_buffer.extend_from_slice(&frame);
            let onset_cap = self.speech_onset_frames * FRAME_SAMPLES;
            if self.onset_buffer.len() > onset_cap {
                let overflow = self.onset_buffer.len() - onset_cap;
                self.onset_buffer.drain(..overflow);
            }

            if self.pending_onset_frames >= self.speech_onset_frames {
                // Transition: flush the onset buffer into active_chunk
                // and begin accumulating. The onset buffer includes
                // the current frame, so its start index is
                // `frame_start + FRAME_SAMPLES - onset_buffer.len()`.
                self.state = State::InSpeech;
                self.active_chunk_start = frame_start
                    .saturating_add(FRAME_SAMPLES as u64)
                    .saturating_sub(self.onset_buffer.len() as u64);
                self.active_chunk.clear();
                self.active_chunk.append(&mut self.onset_buffer);
                self.silent_tail_samples = 0;
                self.pending_onset_frames = 0;
            }
        } else {
            // Sub-threshold frame while idle — reset the onset counter
            // and drop any onset buffer. The gate demands *sustained*
            // speech, not a single frame over threshold.
            self.pending_onset_frames = 0;
            self.onset_buffer.clear();
        }
        None
    }

    fn consume_frame_in_speech(&mut self, frame: Vec<f32>, rms: f32) -> Option<VadChunk> {
        self.active_chunk.extend_from_slice(&frame);
        if rms >= self.exit_threshold {
            self.silent_tail_samples = 0;
        } else {
            self.silent_tail_samples += frame.len();
        }

        let end_of_utterance = self.silent_tail_samples >= self.silence_close_samples;
        if end_of_utterance {
            return Some(self.emit_active_chunk_and_close());
        }
        let hit_max = self.active_chunk.len() >= self.max_chunk_samples;
        if hit_max {
            return Some(self.emit_active_chunk_continue());
        }
        None
    }

    /// Emit the active chunk as an end-of-utterance close: trailing
    /// silence is trimmed off (Whisper does not need dead air) and
    /// state returns to Idle. Next speech onset must re-cross the
    /// sustained-speech threshold before a new chunk begins.
    fn emit_active_chunk_and_close(&mut self) -> VadChunk {
        let mut samples = std::mem::take(&mut self.active_chunk);
        if self.silent_tail_samples > 0 && samples.len() > self.silent_tail_samples {
            let keep = samples.len() - self.silent_tail_samples;
            samples.truncate(keep);
        }
        let start_sample = self.active_chunk_start;

        self.state = State::Idle;
        self.silent_tail_samples = 0;
        self.pending_onset_frames = 0;
        self.onset_buffer.clear();

        VadChunk {
            start_sample,
            samples,
        }
    }

    /// Emit the active chunk as a mid-utterance split because we hit
    /// `max_chunk_samples`. State stays `InSpeech` and `active_chunk`
    /// resets to empty — the very next frame in this still-ongoing
    /// speech region accumulates into the new chunk, so no audio is
    /// dropped across the split. `active_chunk_start` advances by the
    /// emitted length so the next chunk's `start_sample` is contiguous
    /// with this one's end.
    ///
    /// No trailing-silence truncation: we are by definition still in
    /// speech when this fires (end-of-utterance takes priority in the
    /// caller), so any brief silent stretch is legitimately part of
    /// the continuing utterance and belongs to one of the chunks.
    fn emit_active_chunk_continue(&mut self) -> VadChunk {
        let samples = std::mem::take(&mut self.active_chunk);
        let chunk_len = samples.len() as u64;
        let start_sample = self.active_chunk_start;
        self.active_chunk_start = start_sample.saturating_add(chunk_len);
        // Reset silent_tail so any silence accumulated just before
        // the split does not carry over into the next chunk's
        // end-of-utterance detector. onset_buffer stays empty
        // (we never leave InSpeech).
        self.silent_tail_samples = 0;
        VadChunk {
            start_sample,
            samples,
        }
    }
}

impl Default for RmsVadChunker {
    fn default() -> Self {
        Self::new()
    }
}

impl VadChunker for RmsVadChunker {
    fn push(&mut self, samples: &[f32]) -> Vec<VadChunk> {
        if samples.is_empty() {
            return Vec::new();
        }
        self.pending.extend_from_slice(samples);
        self.next_sample_index = self.next_sample_index.saturating_add(samples.len() as u64);

        let mut emitted = Vec::new();
        while self.pending.len() >= FRAME_SAMPLES {
            // Absolute index of the first sample in the frame we are
            // about to consume: total fed minus what is still pending.
            let frame_start = self
                .next_sample_index
                .saturating_sub(self.pending.len() as u64);
            let frame: Vec<f32> = self.pending.drain(..FRAME_SAMPLES).collect();
            if let Some(chunk) = self.consume_frame(frame, frame_start) {
                emitted.push(chunk);
            }
        }
        emitted
    }

    fn flush(&mut self) -> Vec<VadChunk> {
        let mut emitted = Vec::new();

        // Consume any tail of fewer-than-frame samples so the last
        // utterance is not lost when a user stops recording mid-word.
        // The padded frame can legitimately trigger a chunk emission
        // (end-of-utterance if the zeros close a near-expired silent
        // tail, or `max_chunk_samples` if the speech pushes past the
        // cap). Both must be surfaced — dropping them loses audio.
        if !self.pending.is_empty() {
            let frame_start = self
                .next_sample_index
                .saturating_sub(self.pending.len() as u64);
            let pad_len = FRAME_SAMPLES - self.pending.len();
            let mut padded = std::mem::take(&mut self.pending);
            padded.extend(std::iter::repeat_n(0.0_f32, pad_len));
            if let Some(chunk) = self.consume_frame(padded, frame_start) {
                emitted.push(chunk);
            }
        }

        // If the backend is still mid-speech after the padded frame
        // (no end-of-utterance, or it was a hit_max continue that
        // left state in InSpeech with an empty active_chunk), emit
        // whatever is still open as the closing chunk.
        if self.state == State::InSpeech && !self.active_chunk.is_empty() {
            emitted.push(self.emit_active_chunk_and_close());
        }

        // Defence in depth: every flush exit-path must leave the chunker
        // in the same clean state a freshly-constructed one is in,
        // bar `next_sample_index` (the running total-samples counter,
        // intentionally preserved across flush). Without this, a flush
        // that emitted via `consume_frame`'s hit_max branch could leave
        // `state == InSpeech` with stale `silent_tail_samples` or a
        // populated `onset_buffer`, so the next feed() bleeds prior-
        // session state into the first chunk of a fresh recording.
        // The earlier branches already did most of this; the explicit
        // clear here is a single source of truth.
        self.state = State::Idle;
        self.pending.clear();
        self.active_chunk.clear();
        self.silent_tail_samples = 0;
        self.pending_onset_frames = 0;
        self.onset_buffer.clear();

        emitted
    }

    fn reset(&mut self) {
        self.state = State::Idle;
        self.pending.clear();
        self.active_chunk.clear();
        self.silent_tail_samples = 0;
        self.pending_onset_frames = 0;
        self.onset_buffer.clear();
        self.next_sample_index = 0;
        self.active_chunk_start = 0;
    }

    fn next_sample_index(&self) -> u64 {
        self.next_sample_index
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Generate a vector of `len` samples at amplitude `amp`. The
    /// signal is a constant DC offset, which gives a deterministic
    /// RMS of exactly `amp.abs()` — simpler than a sinusoid for
    /// threshold-crossing tests.
    fn constant_signal(len: usize, amp: f32) -> Vec<f32> {
        vec![amp; len]
    }

    #[test]
    fn pure_silence_emits_nothing() {
        let mut c = RmsVadChunker::new();
        let silence = constant_signal(16_000, 0.0); // 1 s of zero
        let chunks = c.push(&silence);
        assert!(chunks.is_empty());
        assert!(c.flush().is_empty());
    }

    #[test]
    fn below_enter_threshold_does_not_trigger() {
        let mut c = RmsVadChunker::new();
        // 0.002 is between the default exit (0.0014) and enter (0.003)
        // thresholds — must NOT transition Idle → InSpeech.
        let hum = constant_signal(16_000, 0.002);
        let chunks = c.push(&hum);
        assert!(
            chunks.is_empty(),
            "samples below enter_threshold must not trigger onset"
        );
    }

    #[test]
    fn single_loud_frame_does_not_trigger_onset() {
        let mut c = RmsVadChunker::new();
        // One frame above enter, surrounded by silence. With
        // speech_onset_frames=3 this should NOT transition.
        let mut signal = Vec::new();
        signal.extend(constant_signal(FRAME_SAMPLES, 0.0));
        signal.extend(constant_signal(FRAME_SAMPLES, 0.01)); // loud, one frame
        signal.extend(constant_signal(FRAME_SAMPLES * 4, 0.0));
        let chunks = c.push(&signal);
        assert!(
            chunks.is_empty(),
            "single-frame transient must not cross sustained-speech onset"
        );
    }

    #[test]
    fn sustained_speech_followed_by_silence_emits_one_chunk() {
        let mut c = RmsVadChunker::new();
        // 8 frames of speech (well over onset) followed by 12 frames of
        // silence (well over silence_close). Must emit exactly one
        // chunk.
        let mut signal = Vec::new();
        signal.extend(constant_signal(FRAME_SAMPLES * 8, 0.01));
        signal.extend(constant_signal(FRAME_SAMPLES * 12, 0.0));
        let chunks = c.push(&signal);
        assert_eq!(chunks.len(), 1, "one speech region → one chunk");
        let chunk = &chunks[0];
        assert!(
            !chunk.samples.is_empty(),
            "emitted chunk must contain samples"
        );
    }

    #[test]
    fn hysteresis_prevents_mid_utterance_close_on_brief_dip() {
        let mut c = RmsVadChunker::new();
        // Onset → loud → brief dip between enter and exit → loud again
        // → silence. The dip is above exit_threshold so the chunk must
        // NOT close across it.
        let loud = constant_signal(FRAME_SAMPLES * 4, 0.01);
        let dip = constant_signal(FRAME_SAMPLES, 0.002);
        let more_loud = constant_signal(FRAME_SAMPLES * 4, 0.01);
        let silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
        let mut signal = Vec::new();
        signal.extend(loud);
        signal.extend(dip);
        signal.extend(more_loud);
        signal.extend(silence);
        let chunks = c.push(&signal);
        assert_eq!(
            chunks.len(),
            1,
            "hysteresis dip between enter and exit thresholds must not split a chunk"
        );
    }

    #[test]
    fn max_chunk_samples_caps_continuous_speech() {
        let mut c = RmsVadChunker::with_thresholds(
            DEFAULT_ENTER_RMS_THRESHOLD,
            DEFAULT_EXIT_RMS_THRESHOLD,
            DEFAULT_SPEECH_ONSET_FRAMES,
            DEFAULT_SILENCE_CLOSE_SAMPLES,
            FRAME_SAMPLES * 4, // tighter cap for the test
        );
        // Feed 12 frames of sustained speech with no silence break.
        // The 4-frame cap must cause at least one emission mid-stream.
        let signal = constant_signal(FRAME_SAMPLES * 12, 0.01);
        let chunks = c.push(&signal);
        assert!(
            !chunks.is_empty(),
            "continuous speech over the cap must emit at least one chunk"
        );
        for chunk in &chunks {
            assert!(
                chunk.samples.len() <= FRAME_SAMPLES * 4,
                "emitted chunk exceeded max_chunk_samples"
            );
        }
    }

    #[test]
    fn max_chunk_split_preserves_audio_contiguity() {
        // Regression: a max_chunk emission in the middle of continuous
        // speech used to reset state to Idle, which dropped 1-2 frames
        // of post-split speech into the onset buffer where they were
        // cleared if silence arrived before the onset threshold.
        //
        // Property under test: across a multi-chunk continuous-speech
        // session, (a) chunk starts are contiguous with previous chunk
        // ends, and (b) the total emitted+flushed sample count equals
        // the input speech sample count (sans the pre-onset frames
        // that are correctly dropped as silence).
        let max_chunk = FRAME_SAMPLES * 4;
        let mut c = RmsVadChunker::with_thresholds(
            DEFAULT_ENTER_RMS_THRESHOLD,
            DEFAULT_EXIT_RMS_THRESHOLD,
            DEFAULT_SPEECH_ONSET_FRAMES,
            DEFAULT_SILENCE_CLOSE_SAMPLES,
            max_chunk,
        );
        // 17 frames of continuous speech. 3 onset + 14 post-onset.
        // With a 4-frame max cap, we expect multiple chunks.
        let total_frames = 17;
        let signal = constant_signal(FRAME_SAMPLES * total_frames, 0.01);
        let mut chunks = c.push(&signal);
        chunks.extend(c.flush());
        assert!(
            chunks.len() >= 2,
            "continuous speech past the cap must produce at least 2 chunks"
        );
        // Contiguity: chunk[i+1].start == chunk[i].start + chunk[i].samples.len()
        for pair in chunks.windows(2) {
            let prev = &pair[0];
            let next = &pair[1];
            assert_eq!(
                next.start_sample,
                prev.start_sample + prev.samples.len() as u64,
                "chunk starts must be contiguous across the max-chunk split \
                 (prev start={}, prev len={}, next start={})",
                prev.start_sample,
                prev.samples.len(),
                next.start_sample,
            );
        }
        // Every chunk honours the cap.
        for chunk in &chunks {
            assert!(
                chunk.samples.len() <= max_chunk,
                "chunk exceeded max_chunk_samples cap"
            );
        }
        // No audio loss: total emitted samples covers the full speech
        // region (from the onset start — samples before onset are
        // legitimately dropped).
        let first_start = chunks.first().unwrap().start_sample;
        let total_emitted: u64 = chunks.iter().map(|c| c.samples.len() as u64).sum();
        let end = first_start + total_emitted;
        assert_eq!(
            end,
            (FRAME_SAMPLES * total_frames) as u64,
            "emitted sample region must reach the end of the fed speech"
        );
    }

    #[test]
    fn flush_emits_in_flight_speech() {
        let mut c = RmsVadChunker::new();
        // Sustained speech with NO closing silence. Without flush this
        // stays buffered; flush must surface it as a final chunk.
        let signal = constant_signal(FRAME_SAMPLES * 5, 0.01);
        let chunks = c.push(&signal);
        assert!(
            chunks.is_empty(),
            "in-progress speech with no silence close stays buffered until flush"
        );
        let flushed = c.flush();
        assert_eq!(
            flushed.len(),
            1,
            "flush must emit exactly one in-flight chunk"
        );
    }

    #[test]
    fn flush_returns_empty_when_idle() {
        let mut c = RmsVadChunker::new();
        assert!(c.flush().is_empty());
        let _ = c.push(&constant_signal(16_000, 0.0));
        assert!(c.flush().is_empty(), "flushing pure silence emits nothing");
    }

    #[test]
    fn flush_preserves_hit_max_chunk_from_padded_final_frame() {
        // Regression for CRITICAL C2 (2026-04-22 audit): if the zero-
        // padded final frame in flush() triggers `max_chunk_samples`,
        // the continue-variant emission was previously discarded by
        // `let _ = consume_frame(...)`. Must now surface in the
        // returned Vec.
        //
        // Setup: tight max_chunk so 4 frames of accumulated speech
        // (3 onset + 1) plus the padded tail exceeds the cap during
        // consume_frame, triggering a hit_max continue emission.
        let max_chunk = FRAME_SAMPLES * 4;
        let mut c = RmsVadChunker::with_thresholds(
            DEFAULT_ENTER_RMS_THRESHOLD,
            DEFAULT_EXIT_RMS_THRESHOLD,
            DEFAULT_SPEECH_ONSET_FRAMES,
            DEFAULT_SILENCE_CLOSE_SAMPLES,
            max_chunk,
        );
        // 3 onset frames — transitions to InSpeech, active_chunk = 3 frames.
        let onset = constant_signal(FRAME_SAMPLES * 3, 0.01);
        let mid = c.push(&onset);
        assert!(mid.is_empty());
        // Sub-frame tail of speech that padding will push to 4 full
        // frames in active_chunk = max_chunk, triggering hit_max.
        let half_frame = constant_signal(FRAME_SAMPLES / 2, 0.01);
        let mid2 = c.push(&half_frame);
        assert!(mid2.is_empty());

        let flushed = c.flush();
        assert!(
            !flushed.is_empty(),
            "flush must surface the hit_max chunk triggered by the padded frame"
        );
        // Coverage of the onset + half-frame speech is the property
        // under test. Emitted samples across all chunks must add up
        // to at least the active-speech duration (some trailing
        // zero-pad may be included in the final chunk — that is
        // acceptable, dropping live speech is not).
        let total: usize = flushed.iter().map(|c| c.samples.len()).sum();
        let speech_samples = FRAME_SAMPLES * 3 + FRAME_SAMPLES / 2;
        assert!(
            total >= speech_samples,
            "flush lost audio: emitted {total} samples, expected at least {speech_samples}"
        );
    }

    #[test]
    fn flush_preserves_end_of_utterance_chunk_from_padded_final_frame() {
        // Second regression for CRITICAL C2: if the padded final
        // frame's zeros close a near-expired silent tail (triggering
        // end_of_utterance → emit_active_chunk_and_close inside
        // consume_frame), state flips to Idle and the outer check
        // previously returned None. Must now surface.
        //
        // Setup: speak long enough to enter InSpeech, then trail with
        // near-silence so the silent_tail is just below the close
        // threshold. A padded zero frame during flush pushes it over.
        let silence_close = FRAME_SAMPLES * 2;
        let mut c = RmsVadChunker::with_thresholds(
            DEFAULT_ENTER_RMS_THRESHOLD,
            DEFAULT_EXIT_RMS_THRESHOLD,
            DEFAULT_SPEECH_ONSET_FRAMES,
            silence_close,
            DEFAULT_MAX_CHUNK_SAMPLES,
        );
        // 3 onset frames → InSpeech.
        let _ = c.push(&constant_signal(FRAME_SAMPLES * 3, 0.01));
        // 1 frame of near-silence: pushes silent_tail to 1 frame.
        // Needs to stay below silence_close so no emit happens during push.
        let _ = c.push(&constant_signal(FRAME_SAMPLES, 0.0));
        // Push a sub-frame tail of silence — after padding this
        // produces a full zero frame, pushing silent_tail from 1 to 2
        // frames = silence_close, triggering end_of_utterance inside
        // consume_frame.
        let _ = c.push(&constant_signal(FRAME_SAMPLES / 4, 0.0));

        let flushed = c.flush();
        assert_eq!(
            flushed.len(),
            1,
            "flush must surface the end-of-utterance chunk triggered by the padded frame"
        );
    }

    #[test]
    fn reset_clears_state() {
        let mut c = RmsVadChunker::new();
        let signal = constant_signal(FRAME_SAMPLES * 5, 0.01);
        let _ = c.push(&signal);
        c.reset();
        assert_eq!(c.next_sample_index(), 0);
        // After reset, silence must not emit a chunk derived from pre-reset state.
        let silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
        let chunks = c.push(&silence);
        assert!(chunks.is_empty());
        assert!(c.flush().is_empty());
    }

    #[test]
    fn start_sample_includes_onset_audio() {
        let mut c = RmsVadChunker::new();
        // First 2 frames silent (so next_sample_index is advanced but
        // no onset). Then speech.
        let silence = constant_signal(FRAME_SAMPLES * 2, 0.0);
        let _ = c.push(&silence);
        assert_eq!(c.next_sample_index(), (FRAME_SAMPLES * 2) as u64);

        let speech = constant_signal(FRAME_SAMPLES * 5, 0.01);
        let closing_silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
        let mut signal = Vec::new();
        signal.extend(speech);
        signal.extend(closing_silence);
        let chunks = c.push(&signal);
        assert_eq!(chunks.len(), 1);
        let chunk = &chunks[0];
        // The chunk's start_sample should reflect the absolute index
        // of the first onset-buffered sample, NOT the post-onset index.
        assert!(
            chunk.start_sample >= (FRAME_SAMPLES * 2) as u64,
            "start_sample must be at or after the pre-speech silence"
        );
        assert!(
            chunk.start_sample
                <= (FRAME_SAMPLES * 2 + FRAME_SAMPLES * DEFAULT_SPEECH_ONSET_FRAMES) as u64,
            "start_sample must not skip past the onset frames"
        );
    }

    #[test]
    fn flush_is_idempotent_and_leaves_clean_state() {
        // Drive the chunker through a full speech-then-silence cycle so
        // most of the state-machine fields are exercised, flush once,
        // then assert that flushing again is a no-op AND that feed-with-
        // silence emits nothing (i.e. no stale onset / silent_tail
        // bookkeeping leaks into the next feed).
        let mut c = RmsVadChunker::with_thresholds(
            0.01,
            0.005,
            DEFAULT_SPEECH_ONSET_FRAMES,
            FRAME_SAMPLES * 4,
            FRAME_SAMPLES * 50,
        );

        let speech = constant_signal(FRAME_SAMPLES * 6, 0.02);
        let _ = c.push(&speech);
        // Force a partial pending tail so flush exercises the padded-
        // final-frame branch.
        let partial = constant_signal(FRAME_SAMPLES / 3, 0.02);
        let _ = c.push(&partial);

        let _first = c.flush();

        let second = c.flush();
        assert!(
            second.is_empty(),
            "second flush must be a no-op; got {} chunk(s)",
            second.len()
        );

        // A subsequent silent feed must emit nothing — proves nothing
        // about prior speech leaked into the new session's bookkeeping.
        let silence = constant_signal(FRAME_SAMPLES * 4, 0.0);
        let chunks = c.push(&silence);
        assert!(
            chunks.is_empty(),
            "post-flush silence must not emit any chunk; got {chunks:?}"
        );
    }
}
