"""Voice engine helpers for STT/TTS assistant interaction."""

from __future__ import annotations

import asyncio
import audioop
from collections import deque
import importlib.util
import json
import logging
import math
import os
import shutil
import subprocess
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Protocol

import requests
from requests.exceptions import RequestException

from app.services.intent_router import normalize_text

DEFAULT_TIMEOUT_SECONDS = 20
DEFAULT_WAKE_WORD = "Orion"
DEFAULT_END_SILENCE_SECONDS = 1.2
DEFAULT_MIC_SAMPLE_RATE = 16000
DEFAULT_PRE_ROLL_SECONDS = 0.45
GOOGLE_EMERGENCY_WARNING = (
    "Warning: Using lowest-quality fallback STT (Google). Consider installing Whisper."
)
LOGGER = logging.getLogger("sentineledge.voice")


def _normalize_end_silence_seconds(value: float | None) -> float:
    """Return a sane end-of-speech silence threshold in seconds."""
    if value is None:
        return DEFAULT_END_SILENCE_SECONDS
    try:
        parsed = float(value)
    except (TypeError, ValueError):
        return DEFAULT_END_SILENCE_SECONDS
    return max(parsed, 0.2)


def _normalize_listen_timeout_seconds(value: float | None) -> float:
    """Normalize first-speech timeout where <=0 means wait indefinitely."""
    if value is None:
        return 0.0
    try:
        parsed = float(value)
    except (TypeError, ValueError):
        return 0.0
    if parsed <= 0.0:
        return 0.0
    return max(parsed, 0.2)


def _format_seconds(value: float) -> str:
    """Format seconds for stable logging output."""
    return f"{value:.1f}"


def _audio_duration_seconds(audio: Any) -> float:
    """Best-effort duration estimate for captured SpeechRecognition audio."""
    try:
        frame_data = getattr(audio, "frame_data", b"")
        sample_rate = int(getattr(audio, "sample_rate", 0) or 0)
        sample_width = int(getattr(audio, "sample_width", 0) or 0)
        if sample_rate <= 0 or sample_width <= 0:
            return 0.0
        return len(frame_data) / float(sample_rate * sample_width)
    except Exception:
        return 0.0


def _log_capture_stop_reason(
    *,
    backend: str,
    end_silence_seconds: float,
    phrase_time_limit: float,
    audio_duration_seconds: float,
) -> None:
    """Log whether capture likely ended by silence threshold or hard cap."""
    hard_cap = max(phrase_time_limit, 0.0)
    if hard_cap > 0 and audio_duration_seconds >= max(0.2, hard_cap - 0.15):
        LOGGER.info(
            "end-of-speech: phrase_time_limit reached (backend=%s cap=%ss)",
            backend,
            _format_seconds(hard_cap),
        )
        return
    LOGGER.info(
        "end-of-speech: silence>=%ss (backend=%s)",
        _format_seconds(end_silence_seconds),
        backend,
    )


def _stamp_capture_timing(
    provider: Any,
    *,
    backend: str,
    silence_seconds: float,
    finalize_wall: float,
) -> None:
    """Persist capture timing metadata for voice loop logs."""
    silence_start = finalize_wall - max(silence_seconds, 0.0)
    setattr(provider, "last_user_silence_start_wall", silence_start)
    setattr(provider, "last_user_finalize_wall", finalize_wall)
    setattr(provider, "last_transcription_start_wall", finalize_wall)
    LOGGER.info(
        "stt.capture_timing backend=%s user_silence_start=%.3f user_finalize_time=%.3f transcription_start=%.3f",
        backend,
        silence_start,
        finalize_wall,
        finalize_wall,
    )


class VoiceEngineError(RuntimeError):
    """Raised when the voice engine cannot complete an operation."""


class AudioInputUnavailableError(VoiceEngineError):
    """Raised when no usable microphone input device is available."""


class STTProvider(Protocol):
    """Speech-to-text provider interface."""

    def listen_once(
        self,
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
    ) -> str | None:
        """Capture and transcribe one utterance."""

    def listen_push_to_talk(
        self,
        keep_listening: Callable[[], bool],
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
        level_callback: Callable[[dict[str, Any]], None] | None = None,
    ) -> str | None:
        """Capture and transcribe one utterance using push-to-talk controls."""


class TTSProvider(Protocol):
    """Text-to-speech provider interface."""

    def speak(self, text: str, *, should_stop: Callable[[], bool] | None = None) -> None:
        """Speak text aloud."""


def _wait_process_interruptible(
    process: subprocess.Popen[Any],
    *,
    should_stop: Callable[[], bool] | None = None,
    poll_seconds: float = 0.05,
) -> bool:
    """Wait for process completion, optionally terminating on stop request."""
    while True:
        if process.poll() is not None:
            return False
        if callable(should_stop) and should_stop():
            try:
                process.terminate()
            except Exception:
                pass
            try:
                process.wait(timeout=0.6)
            except Exception:
                try:
                    process.kill()
                except Exception:
                    pass
                try:
                    process.wait(timeout=0.3)
                except Exception:
                    pass
            return True
        time.sleep(max(poll_seconds, 0.01))


class SystemSpeaker:
    """Deterministic OS TTS via blocking command execution."""

    def __init__(self):
        self._base_cmd = self._resolve_command()
        if not self._base_cmd:
            raise VoiceEngineError("No deterministic system TTS command found")

    @staticmethod
    def _resolve_command() -> list[str] | None:
        if shutil.which("say"):
            return ["say", "-r", "175"]
        if shutil.which("espeak"):
            return ["espeak", "-s", "175"]
        if shutil.which("espeak-ng"):
            return ["espeak-ng", "-s", "175"]
        return None

    def speak(self, text: str, *, should_stop: Callable[[], bool] | None = None) -> None:
        clean = text.strip()
        if not clean:
            return
        started_at = time.monotonic()
        process = subprocess.Popen([*self._base_cmd, clean])
        interrupted = _wait_process_interruptible(process, should_stop=should_stop)
        if interrupted:
            LOGGER.info(
                "system_tts playback interrupted command=%s elapsed=%.2fs",
                self._base_cmd[0],
                time.monotonic() - started_at,
            )
            return
        LOGGER.info(
            "system_tts playback complete command=%s elapsed=%.2fs",
            self._base_cmd[0],
            time.monotonic() - started_at,
        )


@dataclass
class AssistantReply:
    """Parsed assistant API response."""

    intent: str
    response: str


@dataclass
class STTSelection:
    """Selected STT backend and metadata for startup logs."""

    provider: STTProvider
    backend: str
    warning: str | None = None


@dataclass
class AudioInputProbe:
    """Result of probing current microphone availability."""

    available: bool
    device_index: int | None
    device_name: str
    error: str | None = None
    sample_rate: int | None = None
    channels: int | None = 1
    dtype: str | None = "int16"


@dataclass
class _PttCaptureResult:
    """Intermediate capture result for SR-based providers."""

    audio: Any | None
    stop_reason: str | None
    audio_duration_seconds: float
    sample_rate: int
    channels: int
    sample_width: int
    dtype: str
    rms_avg: float
    rms_peak: int
    chunk_count: int
    leading_discarded_ms: float
    trailing_trimmed_ms: float
    preroll_included_ms: float


def _default_whisper_model() -> str:
    """Return global default faster-whisper model."""
    return "small"


def _resolve_whisper_compute_type(compute_type: str | None) -> str:
    """Resolve compute type used by faster-whisper."""
    if not compute_type or compute_type == "auto":
        return "int8"

    allowed = {"int8", "float16", "float32"}
    normalized = compute_type.strip().lower()
    if normalized not in allowed:
        raise VoiceEngineError(
            "Invalid Whisper compute type. Use one of: auto, int8, float16, float32",
        )
    return normalized


def _is_faster_whisper_installed() -> bool:
    """Return True if faster-whisper can be imported."""
    return importlib.util.find_spec("faster_whisper") is not None


def _safe_int(value: Any, default: int | None = None) -> int | None:
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


def _safe_float(value: Any, default: float = 0.0) -> float:
    try:
        return float(value)
    except (TypeError, ValueError):
        return default


def _parse_device_selector(requested_device: str | None) -> int | str | None:
    """Parse microphone selector as index (int) or name query (str)."""
    raw = str(requested_device or "").strip()
    if not raw:
        return None
    try:
        return int(raw)
    except ValueError:
        return raw.lower()


def _resolve_sr_input_probe(sr_module: Any, requested_device: str | None = None) -> AudioInputProbe:
    """Resolve SpeechRecognition input device, honoring optional user selector."""
    try:
        pyaudio_mod = sr_module.Microphone.get_pyaudio()
        pa = pyaudio_mod.PyAudio()
    except Exception as exc:
        return AudioInputProbe(
            available=False,
            device_index=None,
            device_name="none",
            error=f"Microphone stack unavailable: {exc}",
        )

    try:
        parsed_selector = _parse_device_selector(requested_device)
        devices: list[dict[str, Any]] = []
        device_count = _safe_int(pa.get_device_count(), 0) or 0
        for idx in range(device_count):
            try:
                candidate = pa.get_device_info_by_index(idx)
            except Exception:
                continue
            if not isinstance(candidate, dict):
                continue
            if _safe_float(candidate.get("maxInputChannels")) <= 0:
                continue
            devices.append(candidate)

        if not devices:
            return AudioInputProbe(
                available=False,
                device_index=None,
                device_name="none",
                error="No microphone detected / input unavailable",
            )

        selected: dict[str, Any] | None = None
        if isinstance(parsed_selector, int):
            selected = next((item for item in devices if _safe_int(item.get("index")) == parsed_selector), None)
            if selected is None:
                return AudioInputProbe(
                    available=False,
                    device_index=None,
                    device_name="none",
                    error=f"Requested microphone index {parsed_selector} was not found",
                )
        elif isinstance(parsed_selector, str):
            exact = next(
                (item for item in devices if str(item.get("name") or "").strip().lower() == parsed_selector),
                None,
            )
            selected = exact or next(
                (item for item in devices if parsed_selector in str(item.get("name") or "").strip().lower()),
                None,
            )
            if selected is None:
                return AudioInputProbe(
                    available=False,
                    device_index=None,
                    device_name="none",
                    error=f'Requested microphone "{requested_device}" was not found',
                )

        if selected is None:
            try:
                default_info = pa.get_default_input_device_info()
                default_index = _safe_int(default_info.get("index")) if isinstance(default_info, dict) else None
                selected = next((item for item in devices if _safe_int(item.get("index")) == default_index), None)
            except Exception:
                selected = None

        if selected is None:
            selected = devices[0]

        return AudioInputProbe(
            available=True,
            device_index=_safe_int(selected.get("index")),
            device_name=str(selected.get("name") or "default input").strip() or "default input",
            sample_rate=_safe_int(_safe_float(selected.get("defaultSampleRate")), DEFAULT_MIC_SAMPLE_RATE),
            channels=_safe_int(selected.get("maxInputChannels"), 1) or 1,
            dtype="int16",
        )
    finally:
        try:
            pa.terminate()
        except Exception:
            pass


def _probe_sr_microphone_open(
    sr_module: Any,
    device_index: int | None,
    sample_rate: int | None = None,
) -> str | None:
    """Attempt to open one SpeechRecognition microphone stream."""
    kwargs: dict[str, Any] = {"device_index": device_index}
    if sample_rate:
        kwargs["sample_rate"] = int(sample_rate)
    try:
        with sr_module.Microphone(**kwargs):
            return None
    except Exception as exc:
        return str(exc) or "unknown microphone open error"


def _high_pass_filter_pcm16(raw: bytes, sample_rate: int, cutoff_hz: float = 100.0) -> bytes:
    """Apply a lightweight 1st-order high-pass filter to 16-bit PCM."""
    if not raw:
        return raw
    if sample_rate <= 0:
        return raw
    alpha = math.exp(-2.0 * math.pi * max(cutoff_hz, 10.0) / float(sample_rate))
    out = bytearray(len(raw))
    prev_x = 0.0
    prev_y = 0.0
    for idx in range(0, len(raw), 2):
        x = int.from_bytes(raw[idx : idx + 2], byteorder="little", signed=True)
        y = alpha * (prev_y + x - prev_x)
        prev_x = float(x)
        prev_y = float(y)
        clamped = max(-32768, min(32767, int(y)))
        out[idx : idx + 2] = int(clamped).to_bytes(2, byteorder="little", signed=True)
    return bytes(out)


def _normalize_pcm16_rms(raw: bytes, target_rms: int = 2200, max_gain: float = 3.2) -> bytes:
    """Apply mild RMS normalization to 16-bit PCM without aggressive clipping."""
    if not raw:
        return raw
    current_rms = audioop.rms(raw, 2)
    if current_rms <= 0:
        return raw
    gain = min(max(float(target_rms) / float(current_rms), 1.0), max_gain)
    if gain <= 1.02:
        return raw
    try:
        return audioop.mul(raw, 2, gain)
    except Exception:
        return raw


def _capture_sr_push_to_talk_audio(
    *,
    sr_module: Any,
    recognizer: Any,
    device_index: int | None,
    sample_rate_hint: int | None,
    calibrated: bool,
    keep_listening: Callable[[], bool],
    timeout: float,
    phrase_time_limit: float,
    silence_seconds: float,
    level_callback: Callable[[dict[str, Any]], None] | None = None,
    backend: str = "sr",
    pre_roll_seconds: float = DEFAULT_PRE_ROLL_SECONDS,
    debug_enabled: bool = False,
) -> _PttCaptureResult:
    """Capture SR audio continuously until manual stop or silence threshold."""
    chunk_seconds = 0.04
    started_at = time.monotonic()
    speech_started_at: float | None = None
    last_voice_at: float | None = None
    last_level_emit = 0.0
    frame_chunks: list[bytes] = []
    pre_roll_chunks: deque[bytes] = deque()
    stop_reason: str | None = None
    sample_rate = sample_rate_hint or DEFAULT_MIC_SAMPLE_RATE
    channels = 1
    sample_width = 2
    rms_sum = 0.0
    rms_peak = 0
    chunk_count = 0
    preroll_included_ms = 0.0
    leading_discarded_ms = 0.0
    trailing_trimmed_ms = 0.0
    capture_started_wall = time.time()

    try:
        mic_kwargs: dict[str, Any] = {"device_index": device_index}
        if sample_rate_hint:
            mic_kwargs["sample_rate"] = int(sample_rate_hint)
        with sr_module.Microphone(**mic_kwargs) as source:
            sample_rate = int(getattr(source, "SAMPLE_RATE", sample_rate) or sample_rate)
            sample_width = int(getattr(source, "SAMPLE_WIDTH", 2) or 2)
            channels = int(getattr(source, "CHANNELS", 1) or 1)
            if not calibrated:
                recognizer.adjust_for_ambient_noise(source, duration=0.5)
                calibrated = True

            energy_base = max(float(getattr(recognizer, "energy_threshold", 280.0) or 280.0), 80.0)
            noise_floor = max(energy_base * 0.45, 60.0)
            adaptive_threshold = max(energy_base * 0.7, noise_floor * 2.6)
            chunk_frames = int(getattr(source, "CHUNK", max(1, int(sample_rate * chunk_seconds))))
            chunk_duration = max(chunk_frames / float(sample_rate), 0.005)
            pre_roll_max_chunks = max(1, int(max(pre_roll_seconds, 0.0) / chunk_duration))
            pre_roll_chunks = deque(maxlen=pre_roll_max_chunks)
            waiting_seconds = 0.0

            if debug_enabled:
                LOGGER.info(
                    "stt.capture_start backend=%s device_index=%s sample_rate=%s channels=%s sample_width=%s chunk_frames=%s chunk_ms=%.1f pre_roll_ms=%.0f energy_base=%.1f",
                    backend,
                    device_index,
                    sample_rate,
                    channels,
                    sample_width,
                    chunk_frames,
                    chunk_duration * 1000.0,
                    pre_roll_seconds * 1000.0,
                    energy_base,
                )

            while True:
                now = time.monotonic()
                if speech_started_at is None:
                    if timeout > 0 and now - started_at > timeout:
                        return _PttCaptureResult(
                            audio=None,
                            stop_reason=None,
                            audio_duration_seconds=0.0,
                            sample_rate=sample_rate,
                            channels=channels,
                            sample_width=sample_width,
                            dtype="int16",
                            rms_avg=0.0,
                            rms_peak=0,
                            chunk_count=0,
                            leading_discarded_ms=waiting_seconds * 1000.0,
                            trailing_trimmed_ms=0.0,
                            preroll_included_ms=0.0,
                        )
                    if not keep_listening():
                        return _PttCaptureResult(
                            audio=None,
                            stop_reason=None,
                            audio_duration_seconds=0.0,
                            sample_rate=sample_rate,
                            channels=channels,
                            sample_width=sample_width,
                            dtype="int16",
                            rms_avg=0.0,
                            rms_peak=0,
                            chunk_count=0,
                            leading_discarded_ms=waiting_seconds * 1000.0,
                            trailing_trimmed_ms=0.0,
                            preroll_included_ms=0.0,
                        )
                else:
                    if phrase_time_limit > 0 and now - speech_started_at >= phrase_time_limit:
                        stop_reason = "phrase_time_limit"
                        break
                    if last_voice_at is not None and now - last_voice_at >= silence_seconds:
                        stop_reason = "silence"
                        break
                    if not keep_listening():
                        stop_reason = "manual_stop"
                        break

                try:
                    raw = source.stream.read(chunk_frames, exception_on_overflow=False)
                except TypeError:
                    # SpeechRecognition stream wrappers vary by backend/version.
                    raw = source.stream.read(chunk_frames)
                if not raw:
                    continue
                chunk_count += 1
                chunk_rms = 0
                try:
                    chunk_rms = audioop.rms(raw, sample_width)
                except Exception:
                    chunk_rms = 0
                chunk_peak = 0
                try:
                    chunk_peak = audioop.max(raw, sample_width)
                except Exception:
                    chunk_peak = 0
                rms_sum += float(chunk_rms)
                rms_peak = max(rms_peak, int(chunk_peak))

                if speech_started_at is None and chunk_rms > 0:
                    noise_floor = max((noise_floor * 0.94) + (chunk_rms * 0.06), 45.0)
                    adaptive_threshold = max(energy_base * 0.7, noise_floor * 2.45, 85.0)

                has_voice = chunk_rms >= adaptive_threshold

                if speech_started_at is None:
                    pre_roll_chunks.append(raw)
                    waiting_seconds += chunk_duration
                    if not has_voice:
                        if level_callback is not None and now - last_level_emit >= 0.05:
                            last_level_emit = now
                            level_callback(
                                {
                                    "backend": backend,
                                    "sample_rate": sample_rate,
                                    "channels": channels,
                                    "dtype": "int16",
                                    "rms": chunk_rms,
                                    "peak": chunk_peak,
                                    "threshold": adaptive_threshold,
                                },
                            )
                        continue
                    speech_started_at = time.monotonic()
                    last_voice_at = speech_started_at
                    pre_roll_bytes = b"".join(pre_roll_chunks)
                    if pre_roll_bytes:
                        frame_chunks.append(pre_roll_bytes)
                        preroll_included_ms = (
                            len(pre_roll_bytes) / float(max(sample_rate * sample_width * channels, 1))
                        ) * 1000.0
                    leading_discarded_ms = max(waiting_seconds * 1000.0 - preroll_included_ms, 0.0)
                    pre_roll_chunks.clear()

                frame_chunks.append(raw)
                if has_voice:
                    last_voice_at = time.monotonic()
                if level_callback is not None and now - last_level_emit >= 0.05:
                    last_level_emit = now
                    level_callback(
                        {
                            "backend": backend,
                            "sample_rate": sample_rate,
                            "channels": channels,
                            "dtype": "int16",
                            "rms": chunk_rms,
                            "peak": chunk_peak,
                            "threshold": adaptive_threshold,
                        },
                    )
    except Exception as exc:
        raise AudioInputUnavailableError(f"Microphone capture failed: {exc}") from exc

    if not frame_chunks:
        return _PttCaptureResult(
            audio=None,
            stop_reason=stop_reason,
            audio_duration_seconds=0.0,
            sample_rate=sample_rate,
            channels=channels,
            sample_width=sample_width,
            dtype="int16",
            rms_avg=0.0,
            rms_peak=rms_peak,
            chunk_count=chunk_count,
            leading_discarded_ms=leading_discarded_ms,
            trailing_trimmed_ms=0.0,
            preroll_included_ms=preroll_included_ms,
        )

    merged = b"".join(frame_chunks)
    if stop_reason == "silence":
        tail_keep_seconds = 0.22
        trim_seconds = max(silence_seconds - tail_keep_seconds, 0.0)
        trim_bytes = int(trim_seconds * sample_rate * sample_width * channels)
        if trim_bytes > 0 and len(merged) > trim_bytes:
            merged = merged[:-trim_bytes]
            trailing_trimmed_ms = (trim_bytes / float(max(sample_rate * sample_width * channels, 1))) * 1000.0

    audio = sr_module.AudioData(merged, sample_rate, sample_width)
    duration_seconds = _audio_duration_seconds(audio)
    rms_avg = (rms_sum / float(max(chunk_count, 1))) if chunk_count > 0 else 0.0
    if debug_enabled:
        LOGGER.info(
            "stt.capture_summary backend=%s stop_reason=%s duration=%.3fs sample_rate=%s channels=%s dtype=int16 chunks=%s rms_avg=%.1f rms_peak=%s leading_discarded_ms=%.1f preroll_ms=%.1f trailing_trimmed_ms=%.1f capture_ms=%.1f",
            backend,
            stop_reason or "none",
            duration_seconds,
            sample_rate,
            channels,
            chunk_count,
            rms_avg,
            rms_peak,
            leading_discarded_ms,
            preroll_included_ms,
            trailing_trimmed_ms,
            (time.time() - capture_started_wall) * 1000.0,
        )
    return _PttCaptureResult(
        audio=audio,
        stop_reason=stop_reason,
        audio_duration_seconds=duration_seconds,
        sample_rate=sample_rate,
        channels=channels,
        sample_width=sample_width,
        dtype="int16",
        rms_avg=rms_avg,
        rms_peak=rms_peak,
        chunk_count=chunk_count,
        leading_discarded_ms=leading_discarded_ms,
        trailing_trimmed_ms=trailing_trimmed_ms,
        preroll_included_ms=preroll_included_ms,
    )


class WhisperTranscriber:
    """Primary STT backend using local faster-whisper inference on CPU."""

    def __init__(
        self,
        model_name: str | None = None,
        language: str | None = None,
        beam_size: int = 3,
        compute_type: str = "auto",
        mic_device: str | None = None,
        mic_sample_rate: int = DEFAULT_MIC_SAMPLE_RATE,
        vad_filter: bool = True,
        condition_on_previous_text: bool = False,
        temperature: float = 0.0,
        no_speech_threshold: float | None = None,
        log_prob_threshold: float | None = None,
        enable_preprocess: bool = True,
        enable_high_pass: bool = True,
        debug_audio: bool = False,
    ):
        try:
            import speech_recognition as sr
            from faster_whisper import WhisperModel
        except ModuleNotFoundError as exc:  # pragma: no cover - runtime dependency path
            raise VoiceEngineError(
                "Whisper STT dependencies missing. Install: faster-whisper SpeechRecognition",
            ) from exc

        self._sr = sr
        self._recognizer = sr.Recognizer()
        self._calibrated = False
        self._mic_device = mic_device
        self._mic_sample_rate = int(mic_sample_rate) if int(mic_sample_rate) > 0 else DEFAULT_MIC_SAMPLE_RATE
        self._device_index: int | None = None
        self._device_name = "none"
        self._model_name = model_name or _default_whisper_model()
        self._beam_size = int(beam_size)
        if self._beam_size < 1:
            raise VoiceEngineError("Whisper beam_size must be >= 1")
        self._compute_type = _resolve_whisper_compute_type(compute_type)
        self._language: str | None = None
        self._vad_filter = bool(vad_filter)
        self._condition_on_previous_text = bool(condition_on_previous_text)
        self._temperature = float(temperature)
        self._no_speech_threshold = no_speech_threshold
        self._log_prob_threshold = log_prob_threshold
        self._enable_preprocess = bool(enable_preprocess)
        self._enable_high_pass = bool(enable_high_pass)
        self._debug_audio = bool(debug_audio)
        self.last_capture_audio_duration_seconds = 0.0
        self.last_transcription_duration_seconds = 0.0
        self.last_capture_stop_reason = "none"
        self.last_capture_rms_avg = 0.0
        self.last_capture_rms_peak = 0
        self.last_capture_sample_rate = self._mic_sample_rate
        self.last_capture_channels = 1
        self.last_capture_dtype = "int16"
        self.last_capture_audio_duration_seconds = 0.0
        self.last_transcription_duration_seconds = 0.0
        self.last_audio_data: Any | None = None
        self.set_language(language)

        try:
            self._model = WhisperModel(self._model_name, device="cpu", compute_type=self._compute_type)
        except Exception as exc:  # pragma: no cover - runtime model init path
            raise VoiceEngineError(
                f"Could not initialize faster-whisper model '{self._model_name}': {exc}",
            ) from exc

    @property
    def model_name(self) -> str:
        """Return effective Whisper model name."""
        return self._model_name

    @property
    def compute_type(self) -> str:
        """Return effective Whisper compute type."""
        return self._compute_type

    @property
    def beam_size(self) -> int:
        """Return effective Whisper beam size."""
        return self._beam_size

    @property
    def language(self) -> str | None:
        """Return current forced language, or None for auto."""
        return self._language

    def set_language(self, language: str | None) -> None:
        """Set runtime language for transcription; None uses model auto-detection."""
        if language is None:
            self._language = None
            return

        normalized = language.strip().lower()
        if normalized in {"", "auto"}:
            self._language = None
            return

        if normalized not in {"es", "en"}:
            raise VoiceEngineError("Whisper language must be one of: auto, es, en")
        self._language = normalized

    def probe_audio_input(self) -> AudioInputProbe:
        """Probe default microphone and persist selected device metadata."""
        probe = _resolve_sr_input_probe(self._sr, requested_device=self._mic_device)
        self._device_index = probe.device_index
        self._device_name = probe.device_name
        self.last_capture_sample_rate = int(probe.sample_rate or self._mic_sample_rate)
        self.last_capture_channels = int(probe.channels or 1)
        self.last_capture_dtype = str(probe.dtype or "int16")
        if not probe.available:
            return probe
        open_error = _probe_sr_microphone_open(self._sr, self._device_index, sample_rate=self._mic_sample_rate)
        if open_error and self._mic_sample_rate:
            open_error = _probe_sr_microphone_open(self._sr, self._device_index, sample_rate=None)
        if open_error:
            return AudioInputProbe(
                available=False,
                device_index=self._device_index,
                device_name=self._device_name,
                error=f"Microphone open failed: {open_error}",
            )
        return AudioInputProbe(
            available=True,
            device_index=probe.device_index,
            device_name=probe.device_name,
            error=None,
            sample_rate=self._mic_sample_rate or probe.sample_rate,
            channels=probe.channels,
            dtype=probe.dtype,
        )

    def _transcribe_sr_audio(
        self,
        audio: Any,
        *,
        silence_seconds: float,
        phrase_time_limit: float,
        stop_reason: str | None,
        audio_duration_seconds: float,
    ) -> str | None:
        self.last_capture_audio_duration_seconds = float(audio_duration_seconds)
        self.last_audio_data = audio
        finalize_wall = time.time()
        _stamp_capture_timing(
            self,
            backend="whisper",
            silence_seconds=silence_seconds,
            finalize_wall=finalize_wall,
        )
        setattr(self, "last_capture_end_wall", finalize_wall)
        if stop_reason == "manual_stop":
            LOGGER.info("end-of-speech: manual stop (backend=whisper)")
        else:
            _log_capture_stop_reason(
                backend="whisper",
                end_silence_seconds=silence_seconds,
                phrase_time_limit=phrase_time_limit,
                audio_duration_seconds=audio_duration_seconds,
            )

        raw_pcm = audio.get_raw_data(convert_rate=self._mic_sample_rate, convert_width=2)
        if self._enable_high_pass:
            raw_pcm = _high_pass_filter_pcm16(raw_pcm, sample_rate=self._mic_sample_rate)
        if self._enable_preprocess:
            raw_pcm = _normalize_pcm16_rms(raw_pcm)
        processed_audio = self._sr.AudioData(raw_pcm, self._mic_sample_rate, 2)
        self.last_capture_sample_rate = self._mic_sample_rate
        self.last_capture_channels = 1
        self.last_capture_dtype = "int16"

        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wav_file:
            wav_path = Path(wav_file.name)
            wav_file.write(processed_audio.get_wav_data())

        try:
            transcribe_kwargs: dict[str, Any] = {
                "beam_size": self._beam_size,
                "vad_filter": self._vad_filter,
                "condition_on_previous_text": self._condition_on_previous_text,
                "temperature": self._temperature,
            }
            if self._language is not None:
                transcribe_kwargs["language"] = self._language
            if self._no_speech_threshold is not None:
                transcribe_kwargs["no_speech_threshold"] = self._no_speech_threshold
            if self._log_prob_threshold is not None:
                transcribe_kwargs["log_prob_threshold"] = self._log_prob_threshold

            def _run_whisper(kwargs: dict[str, Any]) -> tuple[Any, Any]:
                try:
                    return self._model.transcribe(str(wav_path), **kwargs)
                except TypeError:
                    # Backward-compatible fallback for older faster-whisper signatures.
                    compat_kwargs = dict(kwargs)
                    for key in ("temperature", "no_speech_threshold", "log_prob_threshold"):
                        compat_kwargs.pop(key, None)
                    return self._model.transcribe(str(wav_path), **compat_kwargs)

            stt_started = time.time()
            setattr(self, "last_transcription_start_wall", stt_started)
            segments, _info = _run_whisper(transcribe_kwargs)
            stt_ended = time.time()
            self.last_transcription_duration_seconds = max(stt_ended - stt_started, 0.0)
            setattr(self, "last_transcription_end_wall", stt_ended)
            text_chunks = [segment.text.strip() for segment in segments if segment.text and segment.text.strip()]
            fallback_language_used: str | None = None
            if not text_chunks and self._language in {"es", "en"}:
                retry_language = "en" if self._language == "es" else "es"
                retry_kwargs = dict(transcribe_kwargs)
                retry_kwargs["language"] = retry_language
                retry_segments, _retry_info = _run_whisper(retry_kwargs)
                retry_chunks = [
                    segment.text.strip()
                    for segment in retry_segments
                    if segment.text and segment.text.strip()
                ]
                if retry_chunks:
                    text_chunks = retry_chunks
                    fallback_language_used = retry_language
            if self._debug_audio:
                LOGGER.info(
                    "stt.whisper_debug model=%s beam=%s lang=%s fallback_lang=%s vad_filter=%s cond_prev=%s temperature=%s no_speech_threshold=%s log_prob_threshold=%s capture_seconds=%.3f stt_seconds=%.3f",
                    self._model_name,
                    self._beam_size,
                    self._language or "auto",
                    fallback_language_used or "none",
                    self._vad_filter,
                    self._condition_on_previous_text,
                    self._temperature,
                    self._no_speech_threshold,
                    self._log_prob_threshold,
                    self.last_capture_audio_duration_seconds,
                    self.last_transcription_duration_seconds,
                )
        except Exception as exc:  # pragma: no cover - runtime inference path
            raise VoiceEngineError(f"Whisper transcription failed: {exc}") from exc
        finally:
            wav_path.unlink(missing_ok=True)

        text = " ".join(text_chunks).strip()
        return text or None

    def listen_once(
        self,
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
    ) -> str | None:
        self.last_capture_stop_reason = "silence"
        silence_seconds = _normalize_end_silence_seconds(end_silence_seconds)
        try:
            mic_kwargs: dict[str, Any] = {"device_index": self._device_index}
            if self._mic_sample_rate:
                mic_kwargs["sample_rate"] = self._mic_sample_rate
            with self._sr.Microphone(**mic_kwargs) as source:
                self.last_capture_sample_rate = int(getattr(source, "SAMPLE_RATE", self._mic_sample_rate) or self._mic_sample_rate)
                self.last_capture_channels = int(getattr(source, "CHANNELS", 1) or 1)
                self.last_capture_dtype = "int16"
                if not self._calibrated:
                    self._recognizer.adjust_for_ambient_noise(source, duration=0.6)
                    self._calibrated = True
                original_pause_threshold = self._recognizer.pause_threshold
                try:
                    self._recognizer.pause_threshold = silence_seconds
                    audio = self._recognizer.listen(
                        source,
                        timeout=max(timeout, 0.1),
                        phrase_time_limit=max(phrase_time_limit, 0.5),
                    )
                finally:
                    self._recognizer.pause_threshold = original_pause_threshold
        except self._sr.WaitTimeoutError:
            return None
        except Exception as exc:  # pragma: no cover - runtime hardware path
            if self._mic_sample_rate:
                try:
                    with self._sr.Microphone(device_index=self._device_index) as source:
                        self.last_capture_sample_rate = int(
                            getattr(source, "SAMPLE_RATE", self._mic_sample_rate) or self._mic_sample_rate,
                        )
                        if not self._calibrated:
                            self._recognizer.adjust_for_ambient_noise(source, duration=0.6)
                            self._calibrated = True
                        audio = self._recognizer.listen(
                            source,
                            timeout=max(timeout, 0.1),
                            phrase_time_limit=max(phrase_time_limit, 0.5),
                        )
                    return self._transcribe_sr_audio(
                        audio,
                        silence_seconds=silence_seconds,
                        phrase_time_limit=phrase_time_limit,
                        stop_reason="silence",
                        audio_duration_seconds=_audio_duration_seconds(audio),
                    )
                except Exception:
                    pass
            raise AudioInputUnavailableError(f"Microphone capture failed: {exc}") from exc

        return self._transcribe_sr_audio(
            audio,
            silence_seconds=silence_seconds,
            phrase_time_limit=phrase_time_limit,
            stop_reason="silence",
            audio_duration_seconds=_audio_duration_seconds(audio),
        )

    def listen_push_to_talk(
        self,
        keep_listening: Callable[[], bool],
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
        level_callback: Callable[[dict[str, Any]], None] | None = None,
    ) -> str | None:
        silence_seconds = _normalize_end_silence_seconds(end_silence_seconds)
        listen_timeout = _normalize_listen_timeout_seconds(timeout)
        setattr(self, "last_capture_start_wall", time.time())
        try:
            capture = _capture_sr_push_to_talk_audio(
                sr_module=self._sr,
                recognizer=self._recognizer,
                device_index=self._device_index,
                sample_rate_hint=self._mic_sample_rate,
                calibrated=self._calibrated,
                keep_listening=keep_listening,
                timeout=listen_timeout,
                phrase_time_limit=max(phrase_time_limit, 0.5),
                silence_seconds=silence_seconds,
                level_callback=level_callback,
                backend="whisper",
                debug_enabled=self._debug_audio,
            )
        except AudioInputUnavailableError:
            capture = _capture_sr_push_to_talk_audio(
                sr_module=self._sr,
                recognizer=self._recognizer,
                device_index=self._device_index,
                sample_rate_hint=None,
                calibrated=self._calibrated,
                keep_listening=keep_listening,
                timeout=listen_timeout,
                phrase_time_limit=max(phrase_time_limit, 0.5),
                silence_seconds=silence_seconds,
                level_callback=level_callback,
                backend="whisper",
                debug_enabled=self._debug_audio,
            )
        self._calibrated = True
        self.last_capture_rms_avg = capture.rms_avg
        self.last_capture_rms_peak = capture.rms_peak
        self.last_capture_sample_rate = capture.sample_rate
        self.last_capture_channels = capture.channels
        self.last_capture_dtype = capture.dtype
        self.last_capture_stop_reason = str(capture.stop_reason or "none")
        if capture.audio is None:
            return None
        return self._transcribe_sr_audio(
            capture.audio,
            silence_seconds=silence_seconds,
            phrase_time_limit=phrase_time_limit,
            stop_reason=capture.stop_reason,
            audio_duration_seconds=capture.audio_duration_seconds,
        )


class VoskTranscriber:
    """Offline speech-to-text using Vosk + microphone input."""

    def __init__(
        self,
        model_path: str | None = None,
        mic_device: str | None = None,
        mic_sample_rate: int = DEFAULT_MIC_SAMPLE_RATE,
        debug_audio: bool = False,
    ):
        try:
            import queue
            import sounddevice as sd
            import vosk
        except ModuleNotFoundError as exc:  # pragma: no cover - runtime dependency path
            raise VoiceEngineError(
                "Vosk STT dependencies missing. Install: vosk sounddevice",
            ) from exc

        resolved_model = self._resolve_model_path(model_path)
        if resolved_model is None:
            raise VoiceEngineError(
                "No Vosk model found. Set --vosk-model-path or VOSK_MODEL_PATH.",
            )

        self._queue_mod = queue
        self._sd = sd
        self._vosk = vosk
        self._model = vosk.Model(str(resolved_model))
        self._samplerate: int | None = None
        self._mic_sample_rate = int(mic_sample_rate) if int(mic_sample_rate) > 0 else DEFAULT_MIC_SAMPLE_RATE
        self._mic_device = mic_device
        self._debug_audio = bool(debug_audio)
        self._device_index: int | None = None
        self._device_name = "none"
        self.last_capture_rms_avg = 0.0
        self.last_capture_rms_peak = 0
        self.last_capture_sample_rate = self._mic_sample_rate
        self.last_capture_channels = 1
        self.last_capture_dtype = "int16"
        self.last_capture_audio_duration_seconds = 0.0
        self.last_transcription_duration_seconds = 0.0
        self.last_capture_stop_reason = "none"
        self.last_audio_data: Any | None = None

    @staticmethod
    def _resolve_model_path(model_path: str | None) -> Path | None:
        candidates: list[Path] = []
        if model_path:
            candidates.append(Path(model_path).expanduser())

        env_raw = os.getenv("VOSK_MODEL_PATH")
        if env_raw:
            env_candidate = Path(env_raw).expanduser()
            if not env_candidate.is_absolute():
                env_candidate = (Path(__file__).resolve().parents[2] / env_candidate).resolve()
            candidates.append(env_candidate)

        repo_root = Path(__file__).resolve().parents[2]
        candidates.extend(
            [
                repo_root / "models" / "vosk-model-small-en-us-0.15",
                repo_root / "models" / "vosk-model-small-es-0.42",
                repo_root / "vosk-model-small-en-us-0.15",
                repo_root / "vosk-model-small-es-0.42",
            ],
        )

        for candidate in candidates:
            if candidate and candidate.exists() and candidate.is_dir():
                return candidate
        return None

    def _resolve_input_probe(self) -> AudioInputProbe:
        """Resolve default input metadata using sounddevice."""
        sd = self._sd
        requested = _parse_device_selector(self._mic_device)
        info: dict[str, Any] | None = None
        error_messages: list[str] = []

        available_inputs: list[dict[str, Any]] = []
        try:
            for idx, candidate in enumerate(sd.query_devices()):
                if not isinstance(candidate, dict):
                    continue
                if _safe_float(candidate.get("max_input_channels")) <= 0:
                    continue
                enriched = dict(candidate)
                enriched.setdefault("index", idx)
                available_inputs.append(enriched)
        except Exception as exc:
            error_messages.append(str(exc) or "query_devices() failed")

        if available_inputs:
            if isinstance(requested, int):
                info = next((item for item in available_inputs if _safe_int(item.get("index")) == requested), None)
                if info is None:
                    return AudioInputProbe(
                        available=False,
                        device_index=None,
                        device_name="none",
                        error=f"Requested microphone index {requested} was not found",
                    )
            elif isinstance(requested, str):
                exact = next(
                    (item for item in available_inputs if str(item.get("name") or "").strip().lower() == requested),
                    None,
                )
                info = exact or next(
                    (item for item in available_inputs if requested in str(item.get("name") or "").strip().lower()),
                    None,
                )
                if info is None:
                    return AudioInputProbe(
                        available=False,
                        device_index=None,
                        device_name="none",
                        error=f'Requested microphone "{self._mic_device}" was not found',
                    )

        if info is None and available_inputs:
            try:
                default_info = sd.query_devices(kind="input")
                default_index = _safe_int(default_info.get("index")) if isinstance(default_info, dict) else None
                info = next((item for item in available_inputs if _safe_int(item.get("index")) == default_index), None)
            except Exception as exc:
                error_messages.append(str(exc) or "query_devices(kind=input) failed")

        if info is None and available_inputs:
            info = available_inputs[0]

        if info is None:
            detail = "; ".join(part for part in error_messages if part)
            return AudioInputProbe(
                available=False,
                device_index=None,
                device_name="none",
                error=detail or "No microphone detected / input unavailable",
            )

        return AudioInputProbe(
            available=True,
            device_index=_safe_int(info.get("index")),
            device_name=str(info.get("name") or "default input").strip() or "default input",
            sample_rate=_safe_int(_safe_float(info.get("default_samplerate")), self._mic_sample_rate),
            channels=_safe_int(info.get("max_input_channels"), 1) or 1,
            dtype="int16",
        )

    def probe_audio_input(self) -> AudioInputProbe:
        """Probe Vosk microphone stream availability and cache resolved metadata."""
        probe = self._resolve_input_probe()
        self._device_index = probe.device_index
        self._device_name = probe.device_name
        if not probe.available:
            self._samplerate = None
            return probe

        samplerate = self._mic_sample_rate or probe.sample_rate or DEFAULT_MIC_SAMPLE_RATE
        self._samplerate = int(samplerate)
        self.last_capture_sample_rate = self._samplerate
        self.last_capture_channels = 1
        self.last_capture_dtype = "int16"

        try:
            with self._sd.RawInputStream(
                samplerate=self._samplerate,
                blocksize=8000,
                dtype="int16",
                channels=1,
                device=self._device_index,
                callback=lambda *_args: None,
            ):
                pass
        except Exception as exc:
            self._samplerate = None
            return AudioInputProbe(
                available=False,
                device_index=self._device_index,
                device_name=self._device_name,
                error=f"Microphone open failed: {exc}",
            )

        return probe

    def listen_once(
        self,
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
    ) -> str | None:
        return self._listen_stream(
            keep_listening=lambda: True,
            timeout=timeout,
            phrase_time_limit=phrase_time_limit,
            end_silence_seconds=end_silence_seconds,
        )

    def listen_push_to_talk(
        self,
        keep_listening: Callable[[], bool],
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
        level_callback: Callable[[dict[str, Any]], None] | None = None,
    ) -> str | None:
        return self._listen_stream(
            keep_listening=keep_listening,
            timeout=timeout,
            phrase_time_limit=phrase_time_limit,
            end_silence_seconds=end_silence_seconds,
            level_callback=level_callback,
        )

    def _listen_stream(
        self,
        *,
        keep_listening: Callable[[], bool],
        timeout: float,
        phrase_time_limit: float,
        end_silence_seconds: float,
        level_callback: Callable[[dict[str, Any]], None] | None = None,
    ) -> str | None:
        if self._samplerate is None:
            probe = self.probe_audio_input()
            if not probe.available:
                raise AudioInputUnavailableError(probe.error or "No microphone detected / input unavailable")

        silence_seconds = _normalize_end_silence_seconds(end_silence_seconds)
        queue_instance: Any = self._queue_mod.Queue()
        recognizer = self._vosk.KaldiRecognizer(self._model, self._samplerate)
        started_at = time.monotonic()
        speech_started_at: float | None = None
        last_speech_at: float | None = None
        capture_started_wall = time.time()
        setattr(self, "last_capture_start_wall", capture_started_wall)
        rms_sum = 0.0
        rms_peak = 0
        chunk_count = 0
        recognized_fragments: list[str] = []
        stop_reason = "none"

        def _callback(indata: bytes, _frames: int, _time_info: Any, _status: Any) -> None:
            queue_instance.put(bytes(indata))

        try:
            with self._sd.RawInputStream(
                samplerate=self._samplerate,
                blocksize=8000,
                dtype="int16",
                channels=1,
                device=self._device_index,
                callback=_callback,
            ):
                while True:
                    now = time.monotonic()
                    if speech_started_at is None and not keep_listening():
                        stop_reason = "manual_stop"
                        self.last_capture_stop_reason = stop_reason
                        return None
                    if speech_started_at is None and timeout > 0 and now - started_at > timeout:
                        stop_reason = "timeout"
                        self.last_capture_stop_reason = stop_reason
                        return None
                    if phrase_time_limit > 0 and now - started_at > phrase_time_limit:
                        stop_reason = "phrase_time_limit"
                        LOGGER.info(
                            "end-of-speech: phrase_time_limit reached (backend=vosk cap=%ss)",
                            _format_seconds(phrase_time_limit),
                        )
                        break

                    if speech_started_at is not None and not keep_listening():
                        stop_reason = "manual_stop"
                        LOGGER.info("end-of-speech: manual stop (backend=vosk)")
                        break

                    if last_speech_at is not None and now - last_speech_at >= silence_seconds:
                        stop_reason = "silence"
                        LOGGER.info(
                            "end-of-speech: silence>=%ss (backend=vosk)",
                            _format_seconds(silence_seconds),
                        )
                        break

                    try:
                        data = queue_instance.get(timeout=0.05)
                    except self._queue_mod.Empty:
                        continue
                    chunk_count += 1
                    try:
                        chunk_rms = audioop.rms(data, 2)
                        chunk_peak = audioop.max(data, 2)
                    except Exception:
                        chunk_rms = 0
                        chunk_peak = 0
                    rms_sum += float(chunk_rms)
                    rms_peak = max(rms_peak, int(chunk_peak))
                    if level_callback is not None:
                        level_callback(
                            {
                                "backend": "vosk",
                                "sample_rate": int(self._samplerate or DEFAULT_MIC_SAMPLE_RATE),
                                "channels": 1,
                                "dtype": "int16",
                                "rms": chunk_rms,
                                "peak": chunk_peak,
                                "threshold": 0.0,
                            },
                        )

                    if recognizer.AcceptWaveform(data):
                        payload = json.loads(recognizer.Result() or "{}")
                        text = str(payload.get("text", "")).strip()
                        if text:
                            recognized_fragments.append(text)
                            if speech_started_at is None:
                                speech_started_at = time.monotonic()
                            last_speech_at = time.monotonic()
                        continue

                    partial_payload = json.loads(recognizer.PartialResult() or "{}")
                    partial_text = str(partial_payload.get("partial", "")).strip()
                    if partial_text:
                        if speech_started_at is None:
                            speech_started_at = time.monotonic()
                        last_speech_at = time.monotonic()
        except Exception as exc:
            self._samplerate = None
            raise AudioInputUnavailableError(f"Microphone capture failed: {exc}") from exc

        finalize_wall = time.time()
        _stamp_capture_timing(
            self,
            backend="vosk",
            silence_seconds=silence_seconds,
            finalize_wall=finalize_wall,
        )
        setattr(self, "last_capture_end_wall", finalize_wall)
        self.last_capture_rms_avg = (rms_sum / float(max(chunk_count, 1))) if chunk_count > 0 else 0.0
        self.last_capture_rms_peak = rms_peak
        self.last_capture_sample_rate = int(self._samplerate or DEFAULT_MIC_SAMPLE_RATE)
        self.last_capture_channels = 1
        self.last_capture_dtype = "int16"
        self.last_capture_audio_duration_seconds = max(finalize_wall - capture_started_wall, 0.0)
        self.last_capture_stop_reason = stop_reason
        if self._debug_audio:
            LOGGER.info(
                "stt.capture_summary backend=vosk stop_reason=%s duration=%.3fs sample_rate=%s chunks=%s rms_avg=%.1f rms_peak=%s",
                stop_reason,
                max(finalize_wall - capture_started_wall, 0.0),
                self.last_capture_sample_rate,
                chunk_count,
                self.last_capture_rms_avg,
                self.last_capture_rms_peak,
            )
        payload = json.loads(recognizer.FinalResult() or "{}")
        text = str(payload.get("text", "")).strip()
        if text:
            return text

        fallback_text = " ".join(recognized_fragments).strip()
        return fallback_text or None


class GoogleTranscriber:
    """Online speech-to-text fallback using SpeechRecognition."""

    def __init__(
        self,
        mic_device: str | None = None,
        mic_sample_rate: int = DEFAULT_MIC_SAMPLE_RATE,
        debug_audio: bool = False,
    ):
        try:
            import speech_recognition as sr
        except ModuleNotFoundError as exc:  # pragma: no cover - runtime dependency path
            raise VoiceEngineError(
                "Google STT fallback dependency missing. Install: SpeechRecognition",
            ) from exc

        self._sr = sr
        self._recognizer = sr.Recognizer()
        self._calibrated = False
        self._mic_device = mic_device
        self._mic_sample_rate = int(mic_sample_rate) if int(mic_sample_rate) > 0 else DEFAULT_MIC_SAMPLE_RATE
        self._debug_audio = bool(debug_audio)
        self._device_index: int | None = None
        self._device_name = "none"
        self.last_capture_rms_avg = 0.0
        self.last_capture_rms_peak = 0
        self.last_capture_sample_rate = self._mic_sample_rate
        self.last_capture_channels = 1
        self.last_capture_dtype = "int16"
        self.last_capture_stop_reason = "none"

    def probe_audio_input(self) -> AudioInputProbe:
        """Probe default microphone and persist selected device metadata."""
        probe = _resolve_sr_input_probe(self._sr, requested_device=self._mic_device)
        self._device_index = probe.device_index
        self._device_name = probe.device_name
        self.last_capture_sample_rate = int(probe.sample_rate or self._mic_sample_rate)
        self.last_capture_channels = int(probe.channels or 1)
        self.last_capture_dtype = str(probe.dtype or "int16")
        if not probe.available:
            return probe
        open_error = _probe_sr_microphone_open(self._sr, self._device_index, sample_rate=self._mic_sample_rate)
        if open_error and self._mic_sample_rate:
            open_error = _probe_sr_microphone_open(self._sr, self._device_index, sample_rate=None)
        if open_error:
            return AudioInputProbe(
                available=False,
                device_index=self._device_index,
                device_name=self._device_name,
                error=f"Microphone open failed: {open_error}",
            )
        return AudioInputProbe(
            available=True,
            device_index=probe.device_index,
            device_name=probe.device_name,
            error=None,
            sample_rate=self._mic_sample_rate or probe.sample_rate,
            channels=probe.channels,
            dtype=probe.dtype,
        )

    def _transcribe_sr_audio(
        self,
        audio: Any,
        *,
        silence_seconds: float,
        phrase_time_limit: float,
        stop_reason: str | None,
        audio_duration_seconds: float,
    ) -> str | None:
        self.last_audio_data = audio
        self.last_capture_audio_duration_seconds = float(audio_duration_seconds)
        finalize_wall = time.time()
        _stamp_capture_timing(
            self,
            backend="google",
            silence_seconds=silence_seconds,
            finalize_wall=finalize_wall,
        )
        setattr(self, "last_capture_end_wall", finalize_wall)
        if stop_reason == "manual_stop":
            LOGGER.info("end-of-speech: manual stop (backend=google)")
        else:
            _log_capture_stop_reason(
                backend="google",
                end_silence_seconds=silence_seconds,
                phrase_time_limit=phrase_time_limit,
                audio_duration_seconds=audio_duration_seconds,
            )
        try:
            stt_started = time.time()
            setattr(self, "last_transcription_start_wall", stt_started)
            text = self._recognizer.recognize_google(audio)
            stt_ended = time.time()
            setattr(self, "last_transcription_end_wall", stt_ended)
            self.last_transcription_duration_seconds = max(stt_ended - stt_started, 0.0)
        except self._sr.UnknownValueError:
            return None
        except self._sr.RequestError as exc:
            raise VoiceEngineError(f"Google STT request failed: {exc}") from exc
        text = text.strip()
        return text or None

    def listen_once(
        self,
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
    ) -> str | None:
        self.last_capture_stop_reason = "silence"
        silence_seconds = _normalize_end_silence_seconds(end_silence_seconds)
        try:
            mic_kwargs: dict[str, Any] = {"device_index": self._device_index}
            if self._mic_sample_rate:
                mic_kwargs["sample_rate"] = self._mic_sample_rate
            with self._sr.Microphone(**mic_kwargs) as source:
                if not self._calibrated:
                    self._recognizer.adjust_for_ambient_noise(source, duration=0.6)
                    self._calibrated = True
                original_pause_threshold = self._recognizer.pause_threshold
                try:
                    self._recognizer.pause_threshold = silence_seconds
                    audio = self._recognizer.listen(
                        source,
                        timeout=max(timeout, 0.1),
                        phrase_time_limit=max(phrase_time_limit, 0.5),
                    )
                finally:
                    self._recognizer.pause_threshold = original_pause_threshold
        except self._sr.WaitTimeoutError:
            return None
        except Exception as exc:  # pragma: no cover - runtime hardware path
            raise AudioInputUnavailableError(f"Microphone capture failed: {exc}") from exc

        return self._transcribe_sr_audio(
            audio,
            silence_seconds=silence_seconds,
            phrase_time_limit=phrase_time_limit,
            stop_reason="silence",
            audio_duration_seconds=_audio_duration_seconds(audio),
        )

    def listen_push_to_talk(
        self,
        keep_listening: Callable[[], bool],
        timeout: float = 8.0,
        phrase_time_limit: float = 10.0,
        end_silence_seconds: float = DEFAULT_END_SILENCE_SECONDS,
        level_callback: Callable[[dict[str, Any]], None] | None = None,
    ) -> str | None:
        silence_seconds = _normalize_end_silence_seconds(end_silence_seconds)
        listen_timeout = _normalize_listen_timeout_seconds(timeout)
        setattr(self, "last_capture_start_wall", time.time())
        try:
            capture = _capture_sr_push_to_talk_audio(
                sr_module=self._sr,
                recognizer=self._recognizer,
                device_index=self._device_index,
                sample_rate_hint=self._mic_sample_rate,
                calibrated=self._calibrated,
                keep_listening=keep_listening,
                timeout=listen_timeout,
                phrase_time_limit=max(phrase_time_limit, 0.5),
                silence_seconds=silence_seconds,
                level_callback=level_callback,
                backend="google",
                debug_enabled=self._debug_audio,
            )
        except AudioInputUnavailableError:
            capture = _capture_sr_push_to_talk_audio(
                sr_module=self._sr,
                recognizer=self._recognizer,
                device_index=self._device_index,
                sample_rate_hint=None,
                calibrated=self._calibrated,
                keep_listening=keep_listening,
                timeout=listen_timeout,
                phrase_time_limit=max(phrase_time_limit, 0.5),
                silence_seconds=silence_seconds,
                level_callback=level_callback,
                backend="google",
                debug_enabled=self._debug_audio,
            )
        self._calibrated = True
        self.last_capture_rms_avg = capture.rms_avg
        self.last_capture_rms_peak = capture.rms_peak
        self.last_capture_sample_rate = capture.sample_rate
        self.last_capture_channels = capture.channels
        self.last_capture_dtype = capture.dtype
        self.last_capture_stop_reason = str(capture.stop_reason or "none")
        if capture.audio is None:
            return None
        return self._transcribe_sr_audio(
            capture.audio,
            silence_seconds=silence_seconds,
            phrase_time_limit=phrase_time_limit,
            stop_reason=capture.stop_reason,
            audio_duration_seconds=capture.audio_duration_seconds,
        )


class PyttsxSpeaker:
    """Offline TTS using pyttsx3."""

    def __init__(self):
        try:
            import pyttsx3
        except ModuleNotFoundError as exc:  # pragma: no cover - runtime dependency path
            raise VoiceEngineError("pyttsx3 dependency missing") from exc
        self._engine = pyttsx3.init()
        self._engine.setProperty("rate", 175)
        self._lock = threading.Lock()
        self._utterance_counter = 0

    def _estimated_playback_seconds(self, text: str) -> float:
        """Estimate expected utterance duration from words and speech rate."""
        words = max(len(text.split()), 1)
        try:
            rate = float(self._engine.getProperty("rate") or 175.0)
        except Exception:
            rate = 175.0
        # Basic words-per-minute estimate with short fixed startup overhead.
        return max((words / max(rate, 80.0)) * 60.0 + 0.35, 0.75)

    def estimate_playback_seconds(self, text: str) -> float:
        """Public duration estimate used by voice pipeline safety guards."""
        return self._estimated_playback_seconds(text)

    def speak(self, text: str, *, should_stop: Callable[[], bool] | None = None) -> None:
        clean = text.strip()
        if not clean:
            return
        with self._lock:
            self._utterance_counter += 1
            utterance_id = f"se-{int(time.time() * 1000)}-{self._utterance_counter}"
            started_event = threading.Event()
            finished_event = threading.Event()
            error_box: dict[str, Any] = {"value": None}

            def _on_started(name: str) -> None:
                if name == utterance_id:
                    started_event.set()

            def _on_finished(name: str, completed: bool) -> None:
                if name == utterance_id:
                    finished_event.set()

            def _on_error(name: str, exception: Exception) -> None:
                if name == utterance_id:
                    error_box["value"] = exception
                    finished_event.set()

            started_token = self._engine.connect("started-utterance", _on_started)
            finished_token = self._engine.connect("finished-utterance", _on_finished)
            error_token = self._engine.connect("error", _on_error)

            started_at = time.monotonic()
            expected_seconds = self._estimated_playback_seconds(clean)
            callback_grace_seconds = 0.3
            run_finished = threading.Event()
            run_error_box: dict[str, Any] = {"value": None}

            try:
                def _run_engine() -> None:
                    try:
                        self._engine.say(clean, utterance_id)
                        self._engine.runAndWait()
                    except Exception as exc:  # pragma: no cover - runtime driver path
                        run_error_box["value"] = exc
                    finally:
                        run_finished.set()

                worker = threading.Thread(target=_run_engine, name="pyttsx-speak", daemon=True)
                worker.start()

                interrupted = False
                while not run_finished.wait(timeout=0.05):
                    if callable(should_stop) and should_stop():
                        interrupted = True
                        try:
                            self._engine.stop()
                        except Exception:
                            pass
                        break

                if interrupted:
                    worker.join(timeout=0.8)
                    LOGGER.info(
                        "pyttsx playback interrupted elapsed=%.2fs started_cb=%s finished_cb=%s",
                        time.monotonic() - started_at,
                        started_event.is_set(),
                        finished_event.is_set(),
                    )
                    return

                worker.join(timeout=0.1)
                if run_error_box["value"] is not None:
                    raise VoiceEngineError(f"pyttsx3 playback error: {run_error_box['value']}")
                if error_box["value"] is not None:
                    raise VoiceEngineError(f"pyttsx3 playback error: {error_box['value']}")
                if not finished_event.is_set():
                    # Root-cause guard: some driver combinations can return from runAndWait
                    # before hardware playback actually finishes.
                    LOGGER.warning(
                        "pyttsx runAndWait returned before finished callback; waiting grace=%.2fs",
                        callback_grace_seconds,
                    )
                    finished_event.wait(timeout=callback_grace_seconds)
                if not finished_event.is_set():
                    elapsed = time.monotonic() - started_at
                    remaining_estimate = max(expected_seconds - elapsed, 0.0)
                    if remaining_estimate > 0:
                        LOGGER.warning(
                            "pyttsx waiting remaining estimate=%.2fs because finished callback is still missing",
                            remaining_estimate,
                        )
                        finished_event.wait(timeout=min(remaining_estimate, expected_seconds))
                    LOGGER.warning(
                        "pyttsx finished callback missing after grace; elapsed=%.2fs expected~%.2fs",
                        time.monotonic() - started_at,
                        expected_seconds,
                    )
                LOGGER.info(
                    "pyttsx playback complete elapsed=%.2fs expected~%.2fs started_cb=%s finished_cb=%s",
                    time.monotonic() - started_at,
                    expected_seconds,
                    started_event.is_set(),
                    finished_event.is_set(),
                )
            finally:
                try:
                    self._engine.disconnect(started_token)
                except Exception:
                    pass
                try:
                    self._engine.disconnect(finished_token)
                except Exception:
                    pass
                try:
                    self._engine.disconnect(error_token)
                except Exception:
                    pass


class EdgeTTSSpeaker:
    """TTS fallback using edge-tts with external player."""

    def __init__(self):
        try:
            import edge_tts
        except ModuleNotFoundError as exc:  # pragma: no cover - runtime dependency path
            raise VoiceEngineError("edge-tts dependency missing") from exc

        self._edge_tts = edge_tts
        self._player = self._resolve_player()
        if not self._player:
            raise VoiceEngineError("No audio player found for edge-tts output")

    @staticmethod
    def _resolve_player() -> list[str] | None:
        if shutil.which("afplay"):
            return ["afplay"]
        if shutil.which("ffplay"):
            return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"]
        if shutil.which("mpg123"):
            return ["mpg123", "-q"]
        return None

    def speak(self, text: str, *, should_stop: Callable[[], bool] | None = None) -> None:
        if not text.strip():
            return
        if callable(should_stop) and should_stop():
            return

        with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp_file:
            target = Path(tmp_file.name)

        async def _synthesize() -> None:
            communicator = self._edge_tts.Communicate(text=text, voice="en-US-AriaNeural")
            await communicator.save(str(target))

        try:
            asyncio.run(_synthesize())
            if callable(should_stop) and should_stop():
                return
            process = subprocess.Popen([*self._player, str(target)])
            interrupted = _wait_process_interruptible(process, should_stop=should_stop)
            if interrupted:
                LOGGER.info("edge_tts playback interrupted")
        finally:
            target.unlink(missing_ok=True)


def build_transcriber(
    vosk_model_path: str | None = None,
    stt_backend: str = "whisper",
    whisper_model: str | None = "small",
    whisper_language: str | None = "es",
    whisper_beam_size: int = 3,
    whisper_compute_type: str = "int8",
    whisper_vad_filter: bool = True,
    whisper_condition_on_previous_text: bool = False,
    whisper_temperature: float = 0.0,
    whisper_no_speech_threshold: float | None = None,
    whisper_log_prob_threshold: float | None = None,
    mic_device: str | None = None,
    mic_sample_rate: int = DEFAULT_MIC_SAMPLE_RATE,
    debug_audio: bool = False,
) -> STTProvider:
    """Backward-compatible helper that returns the selected STT provider only."""
    selection = select_stt_backend(
        stt_backend=stt_backend,
        vosk_model_path=vosk_model_path,
        whisper_model=whisper_model,
        whisper_language=whisper_language,
        whisper_beam_size=whisper_beam_size,
        whisper_compute_type=whisper_compute_type,
        whisper_vad_filter=whisper_vad_filter,
        whisper_condition_on_previous_text=whisper_condition_on_previous_text,
        whisper_temperature=whisper_temperature,
        whisper_no_speech_threshold=whisper_no_speech_threshold,
        whisper_log_prob_threshold=whisper_log_prob_threshold,
        mic_device=mic_device,
        mic_sample_rate=mic_sample_rate,
        debug_audio=debug_audio,
    )
    return selection.provider


def probe_audio_input(provider: STTProvider, backend: str) -> AudioInputProbe:
    """Probe active STT provider microphone availability."""
    probe_method = getattr(provider, "probe_audio_input", None)
    if not callable(probe_method):
        return AudioInputProbe(
            available=True,
            device_index=None,
            device_name="default input",
            error=None,
        )
    try:
        probe = probe_method()
    except Exception as exc:
        return AudioInputProbe(
            available=False,
            device_index=None,
            device_name="none",
            error=f"Microphone probe failed for {backend}: {exc}",
        )
    if isinstance(probe, AudioInputProbe):
        return probe
    return AudioInputProbe(
        available=False,
        device_index=None,
        device_name="none",
        error=f"Unexpected microphone probe result from {backend}",
    )


def listen_push_to_talk(
    provider: STTProvider,
    *,
    keep_listening: Callable[[], bool],
    timeout: float,
    phrase_time_limit: float,
    end_silence_seconds: float,
    level_callback: Callable[[dict[str, Any]], None] | None = None,
) -> str | None:
    """Capture one utterance in push-to-talk mode."""
    method = getattr(provider, "listen_push_to_talk", None)
    if callable(method):
        try:
            return method(
                keep_listening=keep_listening,
                timeout=timeout,
                phrase_time_limit=phrase_time_limit,
                end_silence_seconds=end_silence_seconds,
                level_callback=level_callback,
            )
        except TypeError:
            return method(
                keep_listening=keep_listening,
                timeout=timeout,
                phrase_time_limit=phrase_time_limit,
                end_silence_seconds=end_silence_seconds,
            )
    return provider.listen_once(
        timeout=timeout,
        phrase_time_limit=phrase_time_limit,
        end_silence_seconds=end_silence_seconds,
    )


def select_stt_backend(
    stt_backend: str = "whisper",
    vosk_model_path: str | None = None,
    whisper_model: str | None = "small",
    whisper_language: str | None = "es",
    whisper_beam_size: int = 3,
    whisper_compute_type: str = "int8",
    whisper_vad_filter: bool = True,
    whisper_condition_on_previous_text: bool = False,
    whisper_temperature: float = 0.0,
    whisper_no_speech_threshold: float | None = None,
    whisper_log_prob_threshold: float | None = None,
    mic_device: str | None = None,
    mic_sample_rate: int = DEFAULT_MIC_SAMPLE_RATE,
    debug_audio: bool = False,
) -> STTSelection:
    """Select STT backend based on requested mode and strict priority policy."""
    backend = stt_backend.strip().lower()

    if backend == "whisper":
        if not _is_faster_whisper_installed():
            raise VoiceEngineError("Whisper backend requested but faster-whisper is not installed.")
        provider = WhisperTranscriber(
            model_name=whisper_model,
            language=whisper_language,
            beam_size=whisper_beam_size,
            compute_type=whisper_compute_type,
            mic_device=mic_device,
            mic_sample_rate=mic_sample_rate,
            vad_filter=whisper_vad_filter,
            condition_on_previous_text=whisper_condition_on_previous_text,
            temperature=whisper_temperature,
            no_speech_threshold=whisper_no_speech_threshold,
            log_prob_threshold=whisper_log_prob_threshold,
            debug_audio=debug_audio,
        )
        return STTSelection(provider=provider, backend="whisper")

    if backend == "vosk":
        resolved_model = VoskTranscriber._resolve_model_path(vosk_model_path)
        if resolved_model is None:
            raise VoiceEngineError(
                "Vosk backend requested but no model was found. "
                "Set --vosk-model-path or VOSK_MODEL_PATH.",
            )
        provider = VoskTranscriber(
            model_path=str(resolved_model),
            mic_device=mic_device,
            mic_sample_rate=mic_sample_rate,
            debug_audio=debug_audio,
        )
        return STTSelection(provider=provider, backend="vosk")

    if backend == "google":
        provider = GoogleTranscriber(
            mic_device=mic_device,
            mic_sample_rate=mic_sample_rate,
            debug_audio=debug_audio,
        )
        return STTSelection(
            provider=provider,
            backend="google",
            warning=GOOGLE_EMERGENCY_WARNING,
        )

    if backend != "auto":
        raise VoiceEngineError("Invalid STT backend. Use one of: auto, whisper, vosk, google")

    # Strict priority: Whisper -> Vosk -> Google.
    # If faster-whisper is installed, it is always selected.
    if _is_faster_whisper_installed():
        try:
            provider = WhisperTranscriber(
                model_name=whisper_model,
                language=whisper_language,
                beam_size=whisper_beam_size,
                compute_type=whisper_compute_type,
                mic_device=mic_device,
                mic_sample_rate=mic_sample_rate,
                vad_filter=whisper_vad_filter,
                condition_on_previous_text=whisper_condition_on_previous_text,
                temperature=whisper_temperature,
                no_speech_threshold=whisper_no_speech_threshold,
                log_prob_threshold=whisper_log_prob_threshold,
                debug_audio=debug_audio,
            )
        except Exception as exc:  # pragma: no cover - runtime dependency path
            raise VoiceEngineError(
                "faster-whisper is installed and must be used by priority, "
                f"but initialization failed: {exc}",
            ) from exc
        return STTSelection(provider=provider, backend="whisper")

    resolved_model = VoskTranscriber._resolve_model_path(vosk_model_path)
    if resolved_model is not None:
        try:
            provider = VoskTranscriber(
                model_path=str(resolved_model),
                mic_device=mic_device,
                mic_sample_rate=mic_sample_rate,
                debug_audio=debug_audio,
            )
        except Exception as exc:  # pragma: no cover - runtime dependency path
            raise VoiceEngineError(
                "Vosk model was found and selected by priority, "
                f"but initialization failed: {exc}",
            ) from exc
        return STTSelection(provider=provider, backend="vosk")

    try:
        provider = GoogleTranscriber(
            mic_device=mic_device,
            mic_sample_rate=mic_sample_rate,
            debug_audio=debug_audio,
        )
    except Exception as exc:  # pragma: no cover - runtime dependency path
        raise VoiceEngineError(
            "No offline STT backend is available and Google fallback could not be initialized: "
            f"{exc}",
        ) from exc
    return STTSelection(provider=provider, backend="google", warning=GOOGLE_EMERGENCY_WARNING)


def build_speaker() -> TTSProvider | None:
    """Create TTS provider with deterministic-system-first strategy."""
    try:
        speaker = SystemSpeaker()
        LOGGER.info("Selected TTS provider: %s", type(speaker).__name__)
        return speaker
    except VoiceEngineError:
        pass
    try:
        speaker = PyttsxSpeaker()
        LOGGER.info("Selected TTS provider: %s", type(speaker).__name__)
        return speaker
    except VoiceEngineError:
        pass
    try:
        speaker = EdgeTTSSpeaker()
        LOGGER.info("Selected TTS provider: %s", type(speaker).__name__)
        return speaker
    except VoiceEngineError:
        LOGGER.warning("No TTS provider available")
        return None


def strip_wake_word(
    text: str,
    wake_word: str = DEFAULT_WAKE_WORD,
    wake_aliases: list[str] | None = None,
) -> str | None:
    """Return command text when wake word/alias is a prefix; None if wake was not used."""
    normalized_text = normalize_text(text)
    if not normalized_text:
        return None

    candidates = [wake_word, *(wake_aliases or [])]
    normalized_candidates: list[str] = []
    for item in candidates:
        normalized = normalize_text(item)
        if normalized:
            normalized_candidates.append(normalized)
    if not normalized_candidates:
        return normalized_text or None

    # Prefer longer aliases first (e.g. "ok orion" before "orion").
    for wake_candidate in sorted(normalized_candidates, key=len, reverse=True):
        if normalized_text == wake_candidate:
            return ""
        prefix = f"{wake_candidate} "
        if normalized_text.startswith(prefix):
            return normalized_text[len(prefix) :].strip()
    return None


def query_assistant(
    server: str,
    message: str,
    timeout: int = DEFAULT_TIMEOUT_SECONDS,
    api_key: str | None = None,
) -> AssistantReply:
    """Send a text query to ORION-AI assistant endpoint."""
    endpoint = f"{server.rstrip('/')}/assistant/query"
    payload = {"message": message}
    headers: dict[str, str] = {}
    if api_key:
        headers["X-API-Key"] = api_key
    try:
        response = requests.post(endpoint, json=payload, headers=headers or None, timeout=timeout)
    except RequestException as exc:
        raise VoiceEngineError(f"Assistant request failed: {exc}") from exc

    if not response.ok:
        detail = response.text.strip()
        raise VoiceEngineError(f"Assistant returned HTTP {response.status_code}: {detail or 'unknown error'}")

    try:
        body = response.json()
    except ValueError as exc:
        raise VoiceEngineError("Assistant returned non-JSON response") from exc
    intent = str(body.get("intent", "GENERAL"))
    reply = str(body.get("response", "")).strip()
    return AssistantReply(intent=intent, response=reply)
