"""Speech gate for the Discord STT path.

Whisper will transcribe, and frequently *hallucinate*, on non-speech audio:
silence, background noise, or a brief loud blip (a cough, a key clack, a mic
pop) that momentarily opens the voice gate without anyone actually speaking.
Left unfiltered those produce phantom transcripts ("MBC 뉴스", "감사합니다", ...)
and the assistant ends up replying to noise.

This mirrors the desktop listener's ``_filter_noisy_segments`` policy
(``src/jarvis/listening/listener.py``) so both entry points apply identical
rules, both driven by the same config thresholds:

  1. Hard ``no_speech_prob`` cutoff (``whisper_no_speech_threshold``): Whisper's
     own "this segment is not speech" probability. Checked first and
     independently of confidence, because Whisper can be *confident* about a
     hallucinated phrase on pure noise.
  2. ``avg_logprob`` confidence floor (``whisper_min_confidence``): drops
     low-quality decodes that survive the no-speech check.

A segment must pass both to count as real human speech.
"""

from __future__ import annotations

from typing import Callable, Optional


def has_speech(
    audio,
    sampling_rate: int = 16000,
    *,
    threshold: float = 0.4,
    min_speech_duration_ms: int = 200,
    min_silence_duration_ms: int = 100,
    log: Optional[Callable[[str], None]] = None,
) -> bool:
    """Pre-STT speech gate: ``True`` only if there is at least one real speech
    region in ``audio`` (16 kHz mono float32).

    This runs BEFORE Whisper so the model is never invoked on pure noise or a
    brief loud blip (a clap, a key clack, a mic pop) that momentarily opened the
    voice gate without anyone speaking. It uses the Silero VAD bundled with
    faster-whisper (no extra dependency). The threshold is deliberately a little
    below the faster-whisper default (0.5) so quiet but real speech is not
    dropped; precision against confident noise-hallucinations is provided by the
    downstream ``filter_speech_segments`` no_speech_prob gate.

    Fail-open: if the VAD is unavailable or errors, return ``True`` so STT still
    runs rather than silently swallowing a real utterance.
    """
    try:
        from faster_whisper.vad import get_speech_timestamps, VadOptions
    except Exception:  # VAD not available in this build -> don't block STT
        return True
    try:
        if getattr(audio, "size", 1) == 0:
            return False
        opts = VadOptions(
            threshold=threshold,
            min_speech_duration_ms=min_speech_duration_ms,
            min_silence_duration_ms=min_silence_duration_ms,
        )
        timestamps = get_speech_timestamps(audio, opts, sampling_rate)
        return len(timestamps) > 0
    except Exception as e:  # pragma: no cover - defensive
        if log:
            log(f"VAD check failed, falling back to STT: {e}")
        return True


def is_non_speech(no_speech_prob: float, threshold: float) -> bool:
    """True when Whisper flags a segment as non-speech (``>= threshold``)."""
    return no_speech_prob >= threshold


def segment_confidence(seg) -> Optional[float]:
    """Map a Whisper segment to a 0..1 confidence.

    Prefers ``avg_logprob`` (mapped to 0..1 the same way the desktop listener
    does), falling back to ``1 - no_speech_prob`` when the log-prob is absent.
    Returns ``None`` when neither signal is available so the caller keeps the
    segment rather than dropping it on missing metadata.
    """
    avg = getattr(seg, "avg_logprob", None)
    if avg is not None:
        return min(1.0, max(0.0, avg + 1.0))
    nsp = getattr(seg, "no_speech_prob", None)
    if nsp is not None:
        return 1.0 - nsp
    return None


def filter_speech_segments(
    segments,
    *,
    no_speech_threshold: float = 0.5,
    min_confidence: float = 0.3,
    log: Optional[Callable[[str], None]] = None,
) -> list:
    """Keep only the segments that look like real human speech, in order.

    ``log(msg)``, if given, is called with a short reason for each dropped
    segment (used by the bridge to surface why a noisy turn produced no reply).
    """
    kept = []
    for seg in segments:
        nsp = getattr(seg, "no_speech_prob", None)
        if nsp is not None and is_non_speech(nsp, no_speech_threshold):
            if log:
                log(f"segment dropped (no_speech_prob={nsp:.2f}): {_preview(seg)}")
            continue
        conf = segment_confidence(seg)
        if conf is not None and conf < min_confidence:
            if log:
                log(f"segment dropped (confidence={conf:.2f}): {_preview(seg)}")
            continue
        kept.append(seg)
    return kept


def _preview(seg) -> str:
    return repr(getattr(seg, "text", "").strip()[:50])
