"""Mic capture + simple energy-based VAD utterance recording.

Ported from desk_client/bolt_desk.py's record_utterance() — same tuning
knobs, same behavior. Kept independent of any UI/threading model so it can
be unit tested by feeding it a fake "stream" object.
"""

from __future__ import annotations

import io
import wave
from typing import Optional, Protocol

import numpy as np

from .. import config


class AudioStream(Protocol):
    """Minimal shape of the object record_utterance() needs — matches
    sounddevice.InputStream's .read(frames) -> (data, overflowed)."""

    def read(self, frames: int): ...


def pcm_to_wav_bytes(pcm: np.ndarray, sample_rate: int = config.SAMPLE_RATE) -> bytes:
    buf = io.BytesIO()
    with wave.open(buf, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sample_rate)
        wf.writeframes(pcm.tobytes())
    return buf.getvalue()


def rms(frame: np.ndarray) -> float:
    return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))


def flush(stream, max_seconds: float = 10.0, sample_rate: int = config.SAMPLE_RATE) -> int:
    """Throw away whatever is already sitting in the mic's buffer. Returns the
    number of frames dropped.

    PortAudio keeps capturing into a ring buffer while nothing is reading it, so
    audio recorded during a long blocking stretch is still queued when the next
    read happens. That matters exactly once: at the end of a reply the pet is
    about to listen for an answer, and the last fraction of a second of its own
    TTS is in that buffer. It's above the VAD threshold, so `record_utterance`
    treats it as the start of your answer, Deepgram transcribes it, and Bolt is
    handed his own sentence as if you had said it. With barge-in on, the
    detector was draining the stream during playback and the window is small;
    with `BARGE_IN=false` nothing drains it at all.

    Only safe where the buffer is known to hold *nothing you said* — never
    before a wake-triggered recording, where the rest of "thunderbolt, what
    time is it" is legitimately queued and dropping it clips the request.

    *max_seconds* bounds a single call so this can't chase a stream that's
    filling as fast as it's read. Best-effort: a fake stream in tests has no
    `read_available` and this is a no-op, which is the correct behaviour for
    one."""
    try:
        available = int(getattr(stream, "read_available", 0) or 0)
    except (TypeError, ValueError):
        return 0
    if available <= 0:
        return 0
    frames = min(available, int(max_seconds * sample_rate))
    try:
        stream.read(frames)
    except Exception:
        return 0  # a mid-flush device error is the reader's problem, not ours
    return frames


def record_utterance(
    stream: AudioStream,
    should_continue=lambda: True,
    rms_threshold: int = None,
    silence_end_sec: float = None,
    max_utterance_s: float = None,
    min_utterance_s: float = None,
    grace_s: float = None,
    frame_len: int = config.FRAME_LEN,
    sample_rate: int = config.SAMPLE_RATE,
) -> Optional[np.ndarray]:
    """Capture one utterance from *stream*: wait for speech to start, stop
    after trailing silence. Returns None if nothing usable was heard.

    *should_continue* is polled each frame so a caller can cancel recording
    (e.g. the pet window was closed) without needing threading primitives
    baked into this function.

    *grace_s* is how long to wait for speech to *begin* before giving up.
    The controller stretches it for follow-up questions, where you're being
    asked something and need a moment to think rather than having just said
    the wake word on purpose.
    """
    rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold
    silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec
    max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s
    min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s
    grace_s = config.GRACE_SECONDS if grace_s is None else grace_s

    frames: list[np.ndarray] = []
    started = False
    silence_frames = 0
    silence_limit = int(silence_end_sec * sample_rate / frame_len)
    max_frames = int(max_utterance_s * sample_rate / frame_len)
    grace_frames = int(grace_s * sample_rate / frame_len)  # how long to wait for speech to begin
    waited = 0

    while should_continue():
        chunk, _ = stream.read(frame_len)
        frame = np.asarray(chunk)[:, 0].copy()
        frame_rms = rms(frame)
        if not started:
            waited += 1
            if frame_rms >= rms_threshold:
                started = True
                frames.append(frame)
            elif waited > grace_frames:
                return None  # woke it up but said nothing
            continue
        frames.append(frame)
        if frame_rms < rms_threshold:
            silence_frames += 1
            if silence_frames >= silence_limit:
                break
        else:
            silence_frames = 0
        if len(frames) >= max_frames:
            break

    if not frames:
        return None
    pcm = np.concatenate(frames)
    if len(pcm) < min_utterance_s * sample_rate:
        return None
    return pcm


def open_input_stream():
    """Real sounddevice input stream, imported lazily so pure-logic tests
    (record_utterance with a fake stream) don't need PortAudio installed."""
    import sounddevice as sd

    return sd.InputStream(
        samplerate=config.SAMPLE_RATE,
        channels=1,
        dtype="int16",
        blocksize=config.FRAME_LEN,
        device=config.MIC_DEVICE,
    )
