//! # PCM frame + tap seam (spec §3.3)
//!
//! The canonical tap format from ARCHITECTURE.md: 16-bit signed mono PCM
//! @ 24 kHz, fixed 20 ms = 480 samples. The single format every future
//! brain/tap consumer speaks. Lives in `rutster-media` (spec §3.1);
//! `rutster-tap` re-exports it in step 2 (single canonical home).
//!
//! The `AudioSource`/`AudioSink` traits are the exact splice point where
//! step 2 connects a real tap client (replacing `EchoAudioPipe`).

use std::collections::VecDeque;

/// Samples per 20 ms frame @ 24 kHz mono (spec §3.9).
///
/// 24000 Hz × 0.020 s = 480. This is a `const`, not a magic literal, so
/// every place that needs a 480-sample buffer reads the same named value.
pub const SAMPLES_PER_FRAME: usize = 480;

/// Capacity of the echo pipe's internal queue (spec §3.3: "must not
/// block"). 3 frames = 60 ms of buffering — enough to absorb jitter
/// without unbounded growth. Slice 1 has no jitter buffer of its own;
/// str0m's adaptive jitter (it doesn't have one — see str0m FAQ) is
/// not in play because we use the Frame API, which delivers already-
/// depacketized frames. This queue is our only playout buffer.
pub const ECHO_BUFFER_LEN: usize = 3;

/// Canonical PCM frame (spec §3.1, §3.9, ARCHITECTURE.md).
///
/// 16-bit signed mono @ 24 kHz, 480 samples (20 ms). `i16` is the
/// native PCM sample type on the wire — every brain/tap consumer speaks
/// this format. The slice (not a `Vec`) keeps the frame fixed-size and
/// cheap to copy through the audio pipe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PcmFrame {
    pub samples: [i16; SAMPLES_PER_FRAME],
}

impl PcmFrame {
    /// A frame of digital silence (all zeros). Used as the "no audio to
    /// send" fallback on the source side (spec §3.3: `None = silence`).
    pub fn zeroed() -> Self {
        Self {
            samples: [0; SAMPLES_PER_FRAME],
        }
    }
}

/// Produces frames to send to the peer (spec §3.3).
///
/// The poll loop calls `next_pcm_frame()` on each 20 ms tick. `None`
/// means "send silence" — the caller (loop driver) writes a comfort-
/// noise Opus frame instead of dropping the packet entirely, keeping
/// the RTP clock alive. (In slice 1, silence IS fine — str0m handles
/// pacing — but the `None` semantics encode the "no audio available"
/// case cleanly for step 2's tap client.)
pub trait AudioSource: Send {
    fn next_pcm_frame(&mut self) -> Option<PcmFrame>;
}

/// Consumes decoded frames from the peer (spec §3.3).
///
/// `on_pcm_frame` MUST NOT block — the 20 ms loop is the only caller,
/// and blocking here delays the next poll past its deadline. The
/// `EchoAudioPipe` enforces this by bounding its queue and dropping
/// the oldest frame on overflow (see `tests::sink_must_not_block`).
pub trait AudioSink: Send {
    fn on_pcm_frame(&mut self, frame: PcmFrame);
}

/// Combined `AudioSource + AudioSink` trait object (slice-2, spec §8.5 #6).
///
/// # Why a combined trait (not `Box<dyn AudioSource + AudioSink>`)
///
/// Rust's trait-object syntax forbids multiple non-auto traits in a
/// `dyn Trait1 + Trait2` position — only auto traits (`Send`, `Sync`,
/// `Unpin`) can be added after the first non-auto trait. To get a `Box`
/// that's both `AudioSource` and `AudioSink`, we declare a combined
/// trait with both as supertraits and use `Box<dyn AudioPipe>` (plus
/// `Send` as an auto-trait tail) instead. The slice-1 `EchoAudioPipe`
/// and the slice-2 `TapAudioPipe` both impl both supertraits, then
/// `impl AudioPipe for ...` explicitly (override the default
/// `clear_playout_ring` for `TapAudioPipe`).
///
/// # Why explicit impls (not a blanket `impl<T: AudioSource + AudioSink> AudioPipe for T`)
///
/// A blanket impl would conflict with a concrete override in a *different
/// crate* (`TapAudioPipe` lives in `rutster-tap`, the trait in
/// `rutster-media`) — Rust's coherence rules forbid that overlap without
/// specialization. Explicit impls in each pipe's home crate keep the seam
/// clean and let each concrete pipe override `clear_playout_ring` (or
/// inherit the default no-op).
///
/// # Why `pub` (not `pub(crate)`)
///
/// `rutster-tap::TapAudioPipe` lives in a different crate; it needs to
/// name the trait to satisfy the `P: AudioPipe` bound on
/// `RtcSession::with_pipe`. `pub(crate)` would restrict to
/// `rutster-media` only and force the binary to construct its own
/// trait. The trait is the public API of the seam (slice-2 §8.5 #6 —
/// "loop_driver's call sites unchanged"), so `pub` matches the seam's
/// outward-facing nature.
pub trait AudioPipe: AudioSource + AudioSink {
    /// Clear any buffered playout frames (slice-2 spec §5.3 step 4).
    ///
    /// Called by the binary's poll task when the engine task signals a
    /// flush (brain disconnect / WS error / graceful close). The first
    /// `audio_out` from a fresh reconnect then starts a clean playout
    /// rather than continuing to play stale brain-proposed frames from
    /// before the disconnect.
    ///
    /// Default no-op: pipes without a buffer (slice-1's `EchoAudioPipe`)
    /// have nothing to flush and inherit this default. Concrete pipes with
    /// a playout ring (slice-2's `TapAudioPipe`) override.
    fn clear_playout_ring(&mut self) {}

    /// Barge-in flush: clear the playout ring AND drain the inbound brain
    /// audio queue of any frames queued before the barge (slice-4 spec §3.3).
    /// Called by `Reflex` on `SpeechStarted`. The drain of `rx_audio_out`
    /// is what makes the resume condition race-free: the first `audio_out`
    /// observed post-barge is provably post-barge (frames queued pre-barge
    /// are dropped here).
    ///
    /// Default impl delegates to `clear_playout_ring` — sufficient for
    /// pipes without an inbound queue to drain (like `EchoAudioPipe`).
    fn barge_in_flush(&mut self) {
        self.clear_playout_ring();
    }
}

impl AudioPipe for EchoAudioPipe {}

/// Slice-1 wiring of the tap seam: a bounded queue connecting inbound
/// (sink) to outbound (source) — an echo (spec §3.3). Step 2 replaces
/// this with a real WSS tap client; no changes to `RtcSession`.
///
/// # Why `VecDeque` (not `tokio::mpsc` or `crossbeam`?)
/// The echo pipe lives behind a single `Arc<Mutex<...>>` in the
/// `RtcSession`, polled by a single tokio task. There is exactly one
/// producer (inbound decode) and one consumer (outbound encode), both
/// in the same poll loop — no cross-task messaging. A `VecDeque` under
/// the same mutex is the smallest structure that fits; a channel would
/// add async machinery we don't need in slice 1 (and would pre-pave
/// the wrong pattern for step 4's dedicated thread).
pub struct EchoAudioPipe {
    queue: VecDeque<PcmFrame>,
}

impl EchoAudioPipe {
    pub fn new() -> Self {
        Self {
            queue: VecDeque::with_capacity(ECHO_BUFFER_LEN),
        }
    }

    /// Push a frame; if full, drop the oldest. Non-blocking by construction.
    fn push_back_bounded(&mut self, frame: PcmFrame) {
        if self.queue.len() >= ECHO_BUFFER_LEN {
            self.queue.pop_front();
        }
        self.queue.push_back(frame);
    }
}

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

impl AudioSink for EchoAudioPipe {
    fn on_pcm_frame(&mut self, frame: PcmFrame) {
        self.push_back_bounded(frame);
    }
}

impl AudioSource for EchoAudioPipe {
    fn next_pcm_frame(&mut self) -> Option<PcmFrame> {
        self.queue.pop_front()
    }
}

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

    #[test]
    fn pcm_frame_holds_480_samples() {
        let frame = PcmFrame::zeroed();
        assert_eq!(frame.samples.len(), SAMPLES_PER_FRAME);
        assert!(frame.samples.iter().all(|&s| s == 0));
    }

    #[test]
    fn echo_pipe_round_trips_a_frame() {
        // EchoAudioPipe implements both AudioSink and AudioSource.
        // Push a frame in via the sink; pull it back out via the source.
        let mut pipe = EchoAudioPipe::new();
        assert!(pipe.next_pcm_frame().is_none()); // empty → silence

        let mut frame = PcmFrame::zeroed();
        frame.samples[0] = 1234;
        pipe.on_pcm_frame(frame);

        let out = pipe.next_pcm_frame().expect("echoed frame present");
        assert_eq!(out.samples[0], 1234);
        assert!(pipe.next_pcm_frame().is_none()); // drained
    }

    #[test]
    fn sink_must_not_block() {
        // The echo pipe is bounded: push more frames than it can hold,
        // and on_pcm_frame must drop the oldest silently rather than block.
        // (Hot-path invariant from spec §3.3: "Must not block.")
        let mut pipe = EchoAudioPipe::new();
        const OVERFLOW: usize = ECHO_BUFFER_LEN + 5;
        for i in 0..OVERFLOW {
            let mut f = PcmFrame::zeroed();
            f.samples[0] = i as i16;
            pipe.on_pcm_frame(f); // must not panic, must not block
        }
        // We should hold at most ECHO_BUFFER_LEN frames; the rest dropped.
        let mut count = 0;
        while pipe.next_pcm_frame().is_some() {
            count += 1;
        }
        assert_eq!(count, ECHO_BUFFER_LEN);
    }
}
