"""Central voice pipeline: VAD -> STT -> Claude -> TTS for Discord voice.

``VoiceSession`` binds per-call state — voice_client, TTS queue, transcript
JSONL buffer, whitelist, presence — and exposes a single idempotent
``cleanup()`` invoked from every exit path (user /voice leave, network
disconnect, crash via ``__exit__``, auto-leave timer, user leaves channel).

``EchoVoiceSink`` is the discord-ext-voice-recv ``AudioSink`` subclass that
runs in the voice_recv reader thread. It batches 20ms PCM packets into
100ms windows for silero-vad inference, marks per-user speech timestamps,
and on 800ms cumulative silence flushes the accumulated audio through
faster-whisper. Hallucinated segments (``no_speech_prob > 0.6``) are
dropped. Valid transcripts are scheduled onto the session's event loop
via ``asyncio.run_coroutine_threadsafe``.

The bot's own ``user.id`` is filtered FIRST inside ``write()`` — load-bearing
echo prevention so a future whitelist expansion (Bianca, etc.) never lets
the bot transcribe itself.

See plan: ``src/voice/pipeline.py`` (Pas 5), Engineering decisions #4
(VAD 100ms batched), #5 (cleanup centralizat), #7 (bot.user.id explicit
guard).
"""
from __future__ import annotations

import asyncio
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any, Callable, Optional

import numpy as np

from src.voice._discord_voice_adapter import AudioSink, VoiceData
from src.voice.voice_commands import detect_voice_change

log = logging.getLogger(__name__)


# Discord delivers 48kHz s16le stereo PCM, 20ms per packet (3840 bytes).
SAMPLE_RATE_DISCORD = 48000
SAMPLE_RATE_WHISPER = 16000
PACKET_MS = 20
PACKET_BYTES = 3840  # 48000 Hz * 0.020 s * 2 channels * 2 bytes
VAD_WINDOW_MS = 100  # batch 5 * 20ms packets per VAD inference (Decision #4)
VAD_WINDOW_BYTES = PACKET_BYTES * (VAD_WINDOW_MS // PACKET_MS)
VAD_THRESHOLD = 0.5
SILENCE_FLUSH_MS = 800
NO_SPEECH_DROP_THRESHOLD = 0.6
# Hallucination rejection (no re-decode). faster-whisper's default temperature
# is a fallback ladder [0.0..1.0]; on unclear audio it re-decodes the segment up
# to 6x, which is what produced the 16-24s outliers in voice_stt_log.jsonl
# against a >7s conversational-abort budget. We pin temperature=0.0 (no fallback)
# and instead REJECT low-quality segments using the avg_logprob / compression_ratio
# that faster-whisper already computes per segment — zero extra latency.
AVG_LOGPROB_DROP_THRESHOLD = -1.0      # drop seg if avg_logprob below this
COMPRESSION_RATIO_DROP_THRESHOLD = 2.4  # drop seg if gzip ratio above this (repetition/garbage)

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
LOGS_DIR = PROJECT_ROOT / "logs"
VOICE_METRICS_PATH = LOGS_DIR / "voice_metrics.jsonl"
VOICE_STT_LOG_PATH = LOGS_DIR / "voice_stt_log.jsonl"
_stt_log_lock = threading.Lock()


def _append_stt_log(entry: dict) -> None:
    """Append one Whisper transcript to ``voice_stt_log.jsonl``.

    Separate from ``record_enabled``/``transcripts_jsonl_path`` (which feed
    KB). This log is always-on, scoped to STT debugging — used to mine
    code-switching mistranscriptions (English words in Romanian flow) over
    several days and build a personal vocabulary correction table.
    """
    try:
        LOGS_DIR.mkdir(parents=True, exist_ok=True)
        with _stt_log_lock, VOICE_STT_LOG_PATH.open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
    except Exception as e:  # noqa: BLE001
        log.debug("STT log write failed: %s", e)


# ---------- Lazy model singletons ----------

_whisper_model: Any = None
_whisper_lock = threading.Lock()
_silero_model: Any = None
_silero_get_timestamps: Any = None
_silero_lock = threading.Lock()


def _get_whisper_model() -> Any:
    """Lazy-load faster-whisper int8 with the spike-validated ``cpu_threads=4``
    (see ``tasks/voice-bench-results.md``).

    Model is configurable via ``voice.stt_model`` (default ``"small"``). It may be
    a faster-whisper model name or a path to a local CT2 dir — e.g. the Romanian
    Common-Voice finetune that halved WER and fixed number transcription in the
    D1 spike (``tools/voice_stt_spike.py``). Custom paths still load with
    ``local_files_only=True`` since they live on disk."""
    global _whisper_model
    if _whisper_model is not None:
        return _whisper_model
    with _whisper_lock:
        if _whisper_model is not None:
            return _whisper_model
        from src.config import Config
        model_id = Config().get("voice.stt_model", "small") or "small"
        from faster_whisper import WhisperModel
        _whisper_model = WhisperModel(
            model_id, device="cpu", compute_type="int8", cpu_threads=4,
            local_files_only=True,
        )
        log.info("STT model loaded: %s", model_id)
        return _whisper_model


def _get_silero_vad():
    """Lazy-load silero-vad. Returns ``(model, get_speech_timestamps)``."""
    global _silero_model, _silero_get_timestamps
    if _silero_model is not None:
        return _silero_model, _silero_get_timestamps
    with _silero_lock:
        if _silero_model is not None:
            return _silero_model, _silero_get_timestamps
        from silero_vad import get_speech_timestamps, load_silero_vad
        _silero_model = load_silero_vad()
        _silero_get_timestamps = get_speech_timestamps
        return _silero_model, _silero_get_timestamps


# ---------- Audio helpers ----------

def _pcm48_stereo_to_16_mono(pcm: bytes) -> np.ndarray:
    """Discord 48kHz s16le stereo bytes -> 16kHz mono float32 in [-1, 1].

    Mix channels to mono, then resample 48k→16k with torchaudio's polyphase
    Kaiser-windowed sinc (``lowpass_filter_width=16``) instead of a naive
    every-3-samples average. The previous decimation had no anti-aliasing,
    which folded HF content (sibilants, fricatives) back into the
    speech band and degraded Whisper's accuracy on short wake phrases
    like "Salut, Eco". faster-whisper + silero-vad accept the resulting
    ``np.float32`` array directly.
    """
    if not pcm:
        return np.zeros(0, dtype=np.float32)
    samples = np.frombuffer(pcm, dtype=np.int16)
    if samples.size % 2 != 0:
        samples = samples[:-1]
    if samples.size == 0:
        return np.zeros(0, dtype=np.float32)
    stereo = samples.reshape(-1, 2)
    mono48 = stereo.mean(axis=1).astype(np.float32) / 32768.0
    import torch
    import torchaudio.functional as taF
    wav = torch.from_numpy(mono48).unsqueeze(0)
    mono16 = taF.resample(
        wav, SAMPLE_RATE_DISCORD, SAMPLE_RATE_WHISPER,
        lowpass_filter_width=16,
    ).squeeze(0).numpy()
    return np.ascontiguousarray(mono16, dtype=np.float32)


def _filter_segments(segments: Any) -> tuple[list[str], float]:
    """Keep transcribable segments, drop silence and hallucinations.

    Pure + side-effect free (no model, no I/O) so the rejection thresholds are
    unit-testable with fake segment objects. A segment is dropped when:
      - ``no_speech_prob`` is high (silence/non-speech), OR
      - ``avg_logprob`` is below ``AVG_LOGPROB_DROP_THRESHOLD`` (decoder unsure), OR
      - ``compression_ratio`` exceeds ``COMPRESSION_RATIO_DROP_THRESHOLD`` (looped/garbage).
    The avg_logprob/compression checks replace faster-whisper's temperature-fallback
    re-decode (the source of the 16-24s latency outliers) with zero-cost rejection.
    Returns ``(kept_text_parts, worst_no_speech_prob)``.
    """
    text_parts: list[str] = []
    worst_no_speech = 0.0
    for seg in segments:
        no_sp = float(getattr(seg, "no_speech_prob", 0.0) or 0.0)
        if no_sp > worst_no_speech:
            worst_no_speech = no_sp
        if no_sp > NO_SPEECH_DROP_THRESHOLD:
            continue
        avg_lp = getattr(seg, "avg_logprob", None)
        if avg_lp is not None and float(avg_lp) < AVG_LOGPROB_DROP_THRESHOLD:
            continue
        comp = getattr(seg, "compression_ratio", None)
        if comp is not None and float(comp) > COMPRESSION_RATIO_DROP_THRESHOLD:
            continue
        seg_text = (getattr(seg, "text", "") or "").strip()
        if seg_text:
            text_parts.append(seg_text)
    return text_parts, worst_no_speech


# ---------- VoiceSession ----------

class VoiceSession:
    """Per-voice-call state with a single idempotent ``cleanup()``."""

    def __init__(
        self,
        *,
        text_channel_id: int,
        voice_channel_id: int,
        guild_id: int,
        voice_client: Any,
        bot: Any,
        ttsq: Any,
        whitelist: Optional[set] = None,
        record_enabled: bool = False,
        mirror_enabled: bool = True,
        transcripts_jsonl_path: Optional[Path] = None,
        loop: Optional[asyncio.AbstractEventLoop] = None,
        router_route_message: Optional[Callable] = None,
    ):
        self.text_channel_id = int(text_channel_id)
        self.voice_channel_id = int(voice_channel_id)
        self.guild_id = int(guild_id)
        self.voice_client = voice_client
        self.bot = bot
        self.ttsq = ttsq
        self.whitelist: set = set(whitelist or set())
        self.record_enabled = bool(record_enabled)
        self.mirror_enabled = bool(mirror_enabled)
        self.transcripts_jsonl_path = transcripts_jsonl_path
        self.loop = loop
        # Injection seam so tests can replace router.route_message without
        # mocking the whole module.
        if router_route_message is None:
            from src.router import route_message as _rm
            self._route_message = _rm
        else:
            self._route_message = router_route_message

        self.last_activity_ts = time.monotonic()
        self._jsonl_fh = None
        self._lock = threading.Lock()
        self._cleaned_up = False
        self._lock_owner_thread: Optional[int] = None

    # ----- context manager -----

    def __enter__(self) -> "VoiceSession":
        self._lock.acquire()
        self._lock_owner_thread = threading.get_ident()
        if self.record_enabled and self.transcripts_jsonl_path is not None:
            try:
                self.transcripts_jsonl_path.parent.mkdir(
                    parents=True, exist_ok=True,
                )
                self._jsonl_fh = open(
                    self.transcripts_jsonl_path, "a",
                    buffering=1, encoding="utf-8",
                )
            except OSError as e:
                log.warning("voice transcript open failed: %s", e)
                self._jsonl_fh = None
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
        self.cleanup("exit")
        return False  # never suppress exceptions

    # ----- cleanup (centralized, idempotent) -----

    def cleanup(self, reason: str) -> None:
        """Single drain path for ALL 5 exit scenarios. Safe to call twice."""
        if self._cleaned_up:
            return
        self._cleaned_up = True

        # 1. Flush or discard JSONL transcript.
        if self._jsonl_fh is not None:
            try:
                self._jsonl_fh.flush()
                self._jsonl_fh.close()
            except Exception as e:  # noqa: BLE001
                log.warning("voice transcript flush failed: %s", e)
            self._jsonl_fh = None
        if (not self.record_enabled
                and self.transcripts_jsonl_path is not None
                and self.transcripts_jsonl_path.exists()):
            try:
                self.transcripts_jsonl_path.unlink()
            except OSError:
                pass

        # 2. Restore bot presence (clear Listening activity).
        if self.bot is not None:
            try:
                change = getattr(self.bot, "change_presence", None)
                if callable(change):
                    coro = change(activity=None)
                    if asyncio.iscoroutine(coro):
                        if self.loop is not None and self.loop.is_running():
                            asyncio.run_coroutine_threadsafe(coro, self.loop)
                        else:
                            # Best-effort: close the coroutine so Python
                            # doesn't emit "coroutine was never awaited".
                            coro.close()
            except Exception as e:  # noqa: BLE001
                log.warning("voice presence restore failed: %s", e)

        # 3. Tear down the voice client.
        if self.voice_client is not None:
            try:
                self.voice_client.cleanup()
            except Exception as e:  # noqa: BLE001
                log.warning("voice_client.cleanup failed: %s", e)

        # 4. Stop the TTS queue worker.
        if self.ttsq is not None:
            try:
                self.ttsq.stop()
            except Exception as e:  # noqa: BLE001
                log.warning("ttsq.stop failed: %s", e)

        # 5. Release the session lock (held since __enter__).
        try:
            if self._lock.locked():
                self._lock.release()
        except RuntimeError:
            # Released from a different thread than acquired it — already
            # free for the next caller; nothing to do.
            pass

        self._log_metric({"event": "cleanup", "reason": reason})

    # ----- segment completion (scheduled from sink) -----

    async def _resolve_text_channel(self) -> Any:
        """Resolve the Discord text channel id to a fresh channel object.

        Re-resolved per-send so a websocket resume that invalidates cached
        objects doesn't leave us with a stale reference.
        """
        if self.bot is None:
            return None
        try:
            getter = getattr(self.bot, "get_channel", None)
            ch = getter(self.text_channel_id) if callable(getter) else None
            if ch is not None:
                return ch
            fetch = getattr(self.bot, "fetch_channel", None)
            if callable(fetch):
                coro = fetch(self.text_channel_id)
                if asyncio.iscoroutine(coro):
                    return await coro
                return coro
        except Exception as e:  # noqa: BLE001
            log.warning("voice text_channel resolve failed: %s", e)
        return None

    async def on_segment_done(
        self,
        speaker_id: int,
        text: str,
        no_speech_prob: float,
    ) -> None:
        """Mirror, persist, route to Claude, drive TTS via streaming callback."""
        if self._cleaned_up:
            return
        self.last_activity_ts = time.monotonic()
        speaker_name = self._resolve_speaker_name(speaker_id)

        # Drop any TTS frames from the previous turn so a new utterance cuts off
        # stale Echo speech (barge-in) and never mixes with the new response.
        try:
            self.ttsq.clear()
        except Exception as e:  # noqa: BLE001
            log.warning("ttsq.clear failed: %s", e)

        # In-band voice command: change TTS voice without round-tripping Claude.
        new_voice = detect_voice_change(text)
        if new_voice is not None:
            await self._handle_voice_change(speaker_name, text, new_voice)
            return

        # 1. Mirror user utterance to text channel.
        text_channel = await self._resolve_text_channel() if self.mirror_enabled else None
        if self.mirror_enabled and text_channel is not None:
            try:
                send = getattr(text_channel, "send", None)
                if callable(send):
                    coro = send(f"\U0001f3a4 {speaker_name}: \"{text}\"")
                    if asyncio.iscoroutine(coro):
                        await coro
            except Exception as e:  # noqa: BLE001
                log.warning("voice mirror send failed: %s", e)

        # 2. Append to JSONL transcript buffer if recording.
        if self._jsonl_fh is not None:
            try:
                self._jsonl_fh.write(
                    json.dumps({
                        "ts": time.time(),
                        "speaker_id": speaker_id,
                        "speaker": speaker_name,
                        "text": text,
                        "no_speech_prob": no_speech_prob,
                    }, ensure_ascii=False) + "\n"
                )
            except Exception as e:  # noqa: BLE001
                log.warning("voice transcript write failed: %s", e)

        block_count = [0]

        def voice_stream_callback(block: str) -> None:
            """Called once per Claude streamed text block — pushes to TTS."""
            block_count[0] += 1
            log.info("voice stream block #%d (%d chars): %r",
                     block_count[0], len(block or ""), (block or "")[:80])
            try:
                self.ttsq.push_text(block)
            except Exception as e:  # noqa: BLE001
                log.warning("ttsq.push_text failed: %s", e)

        # Dispatch to Claude. send_message is sync subprocess, run on
        # a worker thread so the loop stays responsive for mirror/TTS.
        response_text = ""
        try:
            result = await asyncio.to_thread(
                self._route_message,
                str(self.text_channel_id),
                str(speaker_id),
                text,
                None,                       # model
                voice_stream_callback,      # on_text
                "discord-voice",            # adapter_name
            )
            if isinstance(result, tuple) and result:
                response_text = result[0] or ""
        except Exception as e:  # noqa: BLE001
            log.error("route_message voice path failed: %s", e)

        # 3. Mirror Echo's reply back into the text channel so voice + text
        # stay aligned. Resolved per-send to avoid stale refs after reconnect.
        if self.mirror_enabled and response_text and response_text.strip():
            reply_channel = await self._resolve_text_channel()
            if reply_channel is not None:
                from src.adapters._text_chunks import split_message
                try:
                    for chunk in split_message(response_text):
                        send = getattr(reply_channel, "send", None)
                        if not callable(send):
                            break
                        coro = send(chunk)
                        if asyncio.iscoroutine(coro):
                            await coro
                except Exception as e:  # noqa: BLE001
                    log.warning("voice echo-reply mirror send failed: %s", e)

    async def _handle_voice_change(
        self, speaker_name: str, original_text: str, new_voice: str,
    ) -> None:
        """Apply an in-band 'change voice' command: swap live, persist to
        config, mirror to chat, speak a short acknowledgment in the new voice.
        Does NOT forward the utterance to Claude."""
        # 1. Live-swap on the TTS queue. Next clause synth uses the new voice.
        try:
            self.ttsq.voice_id = new_voice
        except Exception as e:  # noqa: BLE001
            log.warning("ttsq voice swap failed: %s", e)
        # 2. Persist as the new default for future sessions.
        try:
            from src.config import Config
            cfg = Config()
            cfg.set("voice.default_voice", new_voice)
            cfg.save()
        except Exception as e:  # noqa: BLE001
            log.warning("voice default persist failed: %s", e)
        # 3. Mirror what was heard + show the swap in the text channel.
        if self.mirror_enabled:
            text_channel = await self._resolve_text_channel()
            if text_channel is not None:
                try:
                    send = getattr(text_channel, "send", None)
                    if callable(send):
                        coro = send(
                            f"\U0001f3a4 {speaker_name}: \"{original_text}\"\n"
                            f"\U0001f50a Voce → **{new_voice}**"
                        )
                        if asyncio.iscoroutine(coro):
                            await coro
                except Exception as e:  # noqa: BLE001
                    log.warning("voice mirror send failed: %s", e)
        # 4. Verbal acknowledgment in the NEW voice.
        try:
            self.ttsq.push_text(f"Vocea {new_voice}.")
        except Exception as e:  # noqa: BLE001
            log.warning("voice ack push failed: %s", e)
        self._log_metric({"event": "voice_change", "new_voice": new_voice})

    # ----- helpers -----

    def _resolve_speaker_name(self, speaker_id: int) -> str:
        """Best-effort display name lookup via the bot user cache."""
        try:
            if self.bot is not None and hasattr(self.bot, "get_user"):
                user = self.bot.get_user(speaker_id)
                if user is not None:
                    name = getattr(user, "display_name", None) or getattr(
                        user, "name", None,
                    )
                    if name:
                        return str(name)
        except Exception:  # noqa: BLE001
            pass
        return str(speaker_id)

    def _log_metric(self, payload: dict) -> None:
        """Append a structured event to ``logs/voice_metrics.jsonl``.

        ``claude_session_key`` is the channel id used to key the unified
        Claude session (text channel where the user invoked /voice join);
        ``voice_channel_id`` is the actual Discord voice channel id.
        """
        event = {
            "ts": time.time(),
            "claude_session_key": str(self.text_channel_id),
            "voice_channel_id": self.voice_channel_id,
            **payload,
        }
        try:
            LOGS_DIR.mkdir(parents=True, exist_ok=True)
            with open(VOICE_METRICS_PATH, "a", buffering=1, encoding="utf-8") as f:
                f.write(json.dumps(event, ensure_ascii=False) + "\n")
        except OSError:
            pass


# ---------- EchoVoiceSink ----------

class EchoVoiceSink(AudioSink):
    """PCM-in sink: per-user 20ms buffer -> 100ms VAD windows -> 800ms
    silence triggers Whisper STT -> schedules ``on_segment_done`` on the
    session loop.

    Lives in the voice_recv reader thread; uses ``threading`` primitives
    only (no asyncio in the hot path).
    """

    def __init__(self, session: VoiceSession, bot_user_id: int):
        super().__init__()
        self.session = session
        self.bot_user_id = int(bot_user_id) if bot_user_id is not None else 0
        self.whitelist: set = set(session.whitelist or set())
        self._user_buffers: dict[int, bytearray] = {}
        self._packet_accum: dict[int, bytearray] = {}
        self._last_speech_ts: dict[int, float] = {}
        self._has_speech: dict[int, bool] = {}
        self._sink_lock = threading.Lock()
        # Diagnostics: log once-per-user when packets first arrive and when
        # VAD first detects speech. Cheap, but tells us exactly where the
        # chain breaks when "I spoke but Echo heard nothing" happens.
        self._first_packet_logged: set[int] = set()
        self._first_speech_logged: set[int] = set()
        # Track consecutive VAD-positive windows per user. Used to delay
        # barge-in (don't cut Echo off on a single jittery VAD hit; require
        # ≥2 windows ≈ 200ms of sustained speech).
        self._vad_consecutive: dict[int, int] = {}
        # Background poller that triggers the silence flush even when Discord
        # DTX stops delivering RTP packets after the user stops speaking. Without
        # this, sink.write would stop firing and STT would never run on the
        # final utterance.
        self._poller_stop = threading.Event()
        self._poller_thread = threading.Thread(
            target=self._silence_flush_poller,
            name="echo-voice-flush-poller",
            daemon=True,
        )
        self._poller_thread.start()

    def wants_opus(self) -> bool:
        return False

    def cleanup(self) -> None:
        self._poller_stop.set()
        with self._sink_lock:
            self._user_buffers.clear()
            self._packet_accum.clear()
            self._last_speech_ts.clear()
            self._has_speech.clear()

    def write(self, user, voice_data: VoiceData) -> None:
        # ---- FIRST GUARD (LOAD-BEARING): bot's own voice ---------------
        if user is None:
            return
        uid = int(getattr(user, "id", 0) or 0)
        if uid == 0:
            return
        if uid == self.bot_user_id:
            return

        # ---- SECOND GUARD: whitelist filter ----------------------------
        if self.whitelist and uid not in self.whitelist:
            return

        pcm = getattr(voice_data, "pcm", None)
        if not pcm:
            return

        if uid not in self._first_packet_logged:
            self._first_packet_logged.add(uid)
            log.info("voice sink: first PCM packet from user %s (%d bytes)", uid, len(pcm))

        window_pcm: Optional[bytes] = None
        pcm_for_stt: Optional[bytes] = None

        try:
            with self._sink_lock:
                buf = self._user_buffers.setdefault(uid, bytearray())
                accum = self._packet_accum.setdefault(uid, bytearray())
                buf.extend(pcm)
                accum.extend(pcm)
                if len(accum) >= VAD_WINDOW_BYTES:
                    window_pcm = bytes(accum[:VAD_WINDOW_BYTES])
                    del accum[:VAD_WINDOW_BYTES]

            if window_pcm is not None:
                if self._vad_detects_speech(window_pcm):
                    if uid not in self._first_speech_logged:
                        self._first_speech_logged.add(uid)
                        log.info("voice sink: VAD detected speech from user %s", uid)
                    self._vad_consecutive[uid] = self._vad_consecutive.get(uid, 0) + 1
                    with self._sink_lock:
                        self._last_speech_ts[uid] = time.monotonic()
                        self._has_speech[uid] = True
                    # Fast barge-in: after ≥2 consecutive VAD windows (~200ms
                    # of sustained speech), cut Echo's TTS mid-sentence so the
                    # user doesn't have to wait the full silence-flush + STT
                    # cycle (~3s).
                    if self._vad_consecutive[uid] >= 2:
                        try:
                            ttsq = self.session.ttsq
                            if ttsq is not None and not ttsq.is_empty():
                                ttsq.clear()
                                log.info(
                                    "voice sink: barge-in cleared TTS queue (user=%s)",
                                    uid,
                                )
                        except Exception as e:  # noqa: BLE001
                            log.warning("barge-in clear failed: %s", e)
                else:
                    self._vad_consecutive[uid] = 0

            pcm_for_stt = self._take_flushable_pcm(uid)
            if pcm_for_stt:
                self._flush_to_stt(uid, pcm_for_stt)
        except Exception as e:  # noqa: BLE001
            log.warning("EchoVoiceSink.write failed: %s", e)

    def _take_flushable_pcm(self, uid: int) -> Optional[bytes]:
        """If user `uid` has buffered speech that's been silent ≥SILENCE_FLUSH_MS,
        consume the buffer and return it. Otherwise return None."""
        with self._sink_lock:
            if not self._has_speech.get(uid):
                return None
            last = self._last_speech_ts.get(uid, 0.0)
            silence_ms = (time.monotonic() - last) * 1000.0
            if silence_ms < SILENCE_FLUSH_MS:
                return None
            pcm = bytes(self._user_buffers.get(uid, b""))
            self._user_buffers[uid] = bytearray()
            self._packet_accum[uid] = bytearray()
            self._has_speech[uid] = False
            return pcm if pcm else None

    def _silence_flush_poller(self) -> None:
        """Background tick: Discord DTX stops sending RTP packets when the user
        goes silent, so the inline flush check in `write()` never fires for the
        last utterance. Poll every 200ms so the trailing audio actually reaches
        Whisper."""
        while not self._poller_stop.wait(0.2):
            try:
                with self._sink_lock:
                    pending = [uid for uid, has in self._has_speech.items() if has]
                for uid in pending:
                    pcm = self._take_flushable_pcm(uid)
                    if pcm:
                        self._flush_to_stt(uid, pcm)
            except Exception as e:  # noqa: BLE001
                log.warning("silence flush poller iter failed: %s", e)

    # ----- VAD -----

    def _vad_detects_speech(self, pcm48_stereo: bytes) -> bool:
        """Run silero-vad on a 100ms window. silero-vad v5+ requires exactly
        512 samples per call at 16kHz, so we slice the window into 512-sample
        chunks and return True if any chunk crosses the threshold."""
        try:
            mono16 = _pcm48_stereo_to_16_mono(pcm48_stereo)
            if mono16.size == 0:
                return False
            try:
                import torch
            except ImportError:
                rms = float(np.sqrt(np.mean(mono16.astype(np.float64) ** 2)))
                return rms > 0.02
            model, _ = _get_silero_vad()
            chunk = 512  # silero-vad v5+ hard requirement at 16kHz
            max_prob = 0.0
            with torch.no_grad():
                for start in range(0, mono16.size - chunk + 1, chunk):
                    seg = mono16[start:start + chunk]
                    p = float(model(torch.from_numpy(seg), SAMPLE_RATE_WHISPER).item())
                    if p > max_prob:
                        max_prob = p
                    if p >= VAD_THRESHOLD:
                        return True
            return False
        except Exception as e:  # noqa: BLE001
            log.debug("VAD inference failed: %s", e)
            return False

    # ----- STT flush -----

    def _flush_to_stt(self, user_id: int, pcm48_stereo: bytes) -> None:
        """Downsample, Whisper-transcribe RO, drop hallucinations, dispatch."""
        try:
            t_start = time.monotonic()
            mono16 = _pcm48_stereo_to_16_mono(pcm48_stereo)
            if mono16.size == 0:
                return
            audio_duration_s = float(mono16.size) / float(SAMPLE_RATE_WHISPER)
            model = _get_whisper_model()
            segments, _info = model.transcribe(
                mono16, language="ro", beam_size=5,
                temperature=0.0,  # no fallback ladder — reject bad segments instead (see thresholds above)
                initial_prompt=(
                    "Conversatie in romana cu asistentul Eco (Echo Core). "
                    "Marius i se adreseaza cu 'Salut, Eco', 'Eco' sau 'Echo Core' "
                    "la inceputul mesajului. Exemple: 'Salut, Eco, ce mai faci?', "
                    "'Eco, adauga pe agenda de maine sa sun la Bianca', "
                    "'Echo Core, vreau sa-mi reamintesti diseara'. "
                    "Comenzi voce recunoscute: schimba vocea pe M1, M2, M3, M4, M5, "
                    "F1, F2, F3, F4, F5. Exemple: vorbeste cu vocea M5, voce F3, "
                    "treci pe vocea F1."
                ),
                hotwords="Eco Echo Core Marius Bianca Bitcoin",
                condition_on_previous_text=False,
            )
            text_parts, worst_no_speech = _filter_segments(segments)
            if not text_parts:
                return
            text = " ".join(text_parts).strip()
            if not text:
                return
            _append_stt_log({
                "ts": time.time(),
                "channel_id": self.session.voice_channel_id,
                "user_id": int(user_id),
                "text": text,
                "no_speech_prob": round(worst_no_speech, 3),
                "audio_duration_s": round(audio_duration_s, 3),
                "stt_latency_s": round(time.monotonic() - t_start, 3),
                "model": "small",
            })
            self._schedule_segment_done(user_id, text, worst_no_speech)
        except Exception as e:  # noqa: BLE001
            log.warning("Whisper transcribe failed: %s", e)

    def _schedule_segment_done(
        self, user_id: int, text: str, no_speech_prob: float,
    ) -> None:
        loop = self.session.loop
        if loop is None or not loop.is_running():
            log.debug("voice session loop missing — dropping segment")
            return
        try:
            asyncio.run_coroutine_threadsafe(
                self.session.on_segment_done(user_id, text, no_speech_prob),
                loop,
            )
        except Exception as e:  # noqa: BLE001
            log.warning("voice segment dispatch failed: %s", e)


__all__ = [
    "VoiceSession",
    "EchoVoiceSink",
    "SILENCE_FLUSH_MS",
    "VAD_THRESHOLD",
    "VAD_WINDOW_MS",
    "NO_SPEECH_DROP_THRESHOLD",
]
