#!/usr/bin/env python3
"""ORION-AI voice client (STT -> assistant -> TTS)."""

from __future__ import annotations

import argparse
import logging
import os
import sys
import threading
import time
import uuid
from dataclasses import dataclass
from threading import Event
from typing import Any, Callable

from app.core.config import settings
from app.services import runtime_config
from app.services.intent_router import normalize_text
from app.services.voice_runtime import voice_runtime
from app.services.voice_service import (
    DEFAULT_WAKE_WORD,
    AudioInputProbe,
    AudioInputUnavailableError,
    VoiceEngineError,
    build_speaker,
    listen_push_to_talk,
    probe_audio_input,
    query_assistant,
    select_stt_backend,
)

SPANISH_TRIGGER_PREFIXES = ("que", "dime", "tareas", "manana", "cuando", "cumple")
ENGLISH_TRIGGER_PREFIXES = ("what", "tell", "tasks", "tomorrow", "when", "birthday")
USER_EOS_SILENCE_SECONDS = 1.2
UI_POST_TTS_HOLD_SECONDS = 3.0
UI_FADE_OUT_SECONDS = 0.52
PTT_END_SILENCE_SECONDS = 1.2
MIC_RETRY_INTERVAL_SECONDS = 5.0
POST_TTS_MIC_GUARD_SECONDS = 0.9
NO_SPEECH_LOG_INTERVAL = 6
IDLE_POLL_SECONDS = 0.08
DEFAULT_WAKE_ALIASES: tuple[str, ...] = ("orion", "ok orion")
STOP_RESPONSE_HOLD_SECONDS = 3.0

VoiceEventCallback = Callable[[dict[str, Any]], None]
LOGGER = logging.getLogger("sentineledge.voice_client")


@dataclass(frozen=True)
class VoiceClientConfig:
    """Runtime voice client configuration."""

    server: str
    api_key: str | None
    wake_word: str
    wake_aliases: tuple[str, ...]
    disable_wake_word: bool
    vosk_model_path: str | None
    stt_backend: str
    whisper_model: str | None
    whisper_language: str
    whisper_beam_size: int
    whisper_compute: str
    whisper_vad_filter: bool
    whisper_condition_on_previous_text: bool
    whisper_temperature: float
    whisper_no_speech_threshold: float | None
    whisper_log_prob_threshold: float | None
    mic_device: str | None
    mic_sample_rate: int
    voice_debug: bool
    listen_timeout: float
    phrase_time_limit: float
    user_eos_silence_seconds: float
    ui_post_tts_hold_seconds: float
    request_timeout: int
    once: bool
    no_tts: bool


def _default_user_eos_silence_seconds() -> float:
    """Resolve default end-of-speech silence threshold from environment."""
    raw = os.getenv("SE_USER_EOS_SILENCE_SECONDS") or os.getenv("SE_END_SILENCE_SECONDS")
    if raw is None:
        return USER_EOS_SILENCE_SECONDS
    try:
        parsed = float(raw)
    except ValueError:
        return USER_EOS_SILENCE_SECONDS
    return max(parsed, 0.2)


def _default_ui_post_tts_hold_seconds() -> float:
    """Resolve default post-response hold from environment."""
    raw = (
        os.getenv("SE_UI_POST_TTS_HOLD_SECONDS")
        or os.getenv("SE_UI_POST_RESPONSE_HOLD_SECONDS")
        or os.getenv("COOLDOWN_SECONDS")
        or os.getenv("SENTINELEDGE_UI_COOLDOWN_SECONDS")
    )
    if raw is None:
        return UI_POST_TTS_HOLD_SECONDS
    try:
        parsed = float(raw)
    except ValueError:
        return UI_POST_TTS_HOLD_SECONDS
    return max(parsed, 0.0)


def _default_ui_fade_out_seconds() -> float:
    """Resolve default UI fade duration from environment."""
    raw = os.getenv("SE_UI_FADE_OUT_SECONDS") or os.getenv("SE_UI_FADE_SECONDS")
    if raw is None:
        return UI_FADE_OUT_SECONDS
    try:
        parsed = float(raw)
    except ValueError:
        return UI_FADE_OUT_SECONDS
    return max(parsed, 0.0)


def add_voice_cli_args(parser: argparse.ArgumentParser, default_server: str = "http://127.0.0.1:8000") -> None:
    """Attach voice client CLI arguments to a parser."""
    parser.add_argument("--server", default=default_server, help="ORION-AI API base URL")
    parser.add_argument(
        "--api-key",
        default=os.getenv("SENTINELEDGE_API_KEY"),
        help="Optional API key for protected /assistant/query in production",
    )
    parser.add_argument(
        "--wake-word",
        default=DEFAULT_WAKE_WORD,
        help=f"Deprecated in App Mode (PTT-only). Kept for compatibility (default: {DEFAULT_WAKE_WORD})",
    )
    parser.add_argument(
        "--wake-alias",
        action="append",
        default=[],
        help="Deprecated in App Mode (PTT-only). Kept for compatibility.",
    )
    parser.add_argument(
        "--disable-wake-word",
        action="store_true",
        help="Deprecated in App Mode (PTT-only). Kept for compatibility.",
    )
    parser.add_argument("--vosk-model-path", default=None, help="Path to local Vosk model folder")
    parser.add_argument(
        "--stt-backend",
        default="whisper",
        choices=["auto", "whisper", "vosk", "google"],
        help="STT backend selection strategy (default: whisper)",
    )
    parser.add_argument(
        "--whisper-model",
        default="small",
        help="faster-whisper model (default: small)",
    )
    parser.add_argument(
        "--whisper-language",
        default="es",
        choices=["auto", "es", "en"],
        help="Whisper language mode: auto (sticky), es, or en (default: es)",
    )
    parser.add_argument(
        "--whisper-beam-size",
        type=int,
        default=3,
        help="Whisper beam size (default: 3). Higher values increase accuracy but add latency",
    )
    parser.add_argument(
        "--whisper-compute",
        default="int8",
        choices=["auto", "int8", "float16", "float32"],
        help="Whisper compute type (default: int8)",
    )
    parser.add_argument(
        "--whisper-vad-filter",
        default="on",
        choices=["on", "off"],
        help="Enable/disable Whisper VAD filter (default: on)",
    )
    parser.add_argument(
        "--whisper-temperature",
        type=float,
        default=0.0,
        help="Whisper temperature (default: 0.0)",
    )
    parser.add_argument(
        "--whisper-no-speech-threshold",
        type=float,
        default=None,
        help="Optional Whisper no_speech_threshold override",
    )
    parser.add_argument(
        "--whisper-logprob-threshold",
        type=float,
        default=None,
        help="Optional Whisper log_prob_threshold override",
    )
    parser.add_argument(
        "--whisper-condition-on-previous-text",
        action="store_true",
        help="Enable condition_on_previous_text for Whisper (default: off)",
    )
    parser.add_argument(
        "--mic-device",
        default=os.getenv("SE_MIC_DEVICE"),
        help="Microphone selector by index or case-insensitive name",
    )
    parser.add_argument(
        "--mic-sample-rate",
        type=int,
        default=int(os.getenv("SE_MIC_SAMPLE_RATE", "16000")),
        help="Microphone capture sample rate hint (default: 16000)",
    )
    parser.add_argument(
        "--voice-debug",
        action="store_true",
        help="Enable verbose STT/audio diagnostics in logs",
    )
    parser.add_argument("--listen-timeout", type=float, default=8.0, help="Seconds to wait for speech")
    parser.add_argument("--phrase-time-limit", type=float, default=10.0, help="Max phrase length in seconds")
    parser.add_argument(
        "--user-eos-silence-seconds",
        "--end-silence-seconds",
        "--silence-seconds",
        dest="user_eos_silence_seconds",
        type=float,
        default=_default_user_eos_silence_seconds(),
        help=(
            "Continuous silence required to finalize utterance "
            f"(default: env SE_USER_EOS_SILENCE_SECONDS or {USER_EOS_SILENCE_SECONDS:.1f})"
        ),
    )
    parser.add_argument(
        "--ui-post-tts-hold-seconds",
        type=float,
        default=_default_ui_post_tts_hold_seconds(),
        help=(
            "Seconds to keep final.gif + text after TTS ends "
            f"(default: env SE_UI_POST_TTS_HOLD_SECONDS or {UI_POST_TTS_HOLD_SECONDS:.1f})"
        ),
    )
    parser.add_argument("--request-timeout", type=int, default=20, help="Assistant HTTP timeout in seconds")
    parser.add_argument("--once", action="store_true", help="Process one valid command and exit")
    parser.add_argument("--no-tts", action="store_true", help="Disable spoken output")


def build_parser() -> argparse.ArgumentParser:
    """Build standalone voice CLI parser."""
    parser = argparse.ArgumentParser(description="ORION-AI voice assistant client")
    add_voice_cli_args(parser)
    return parser


def args_to_config(args: argparse.Namespace) -> VoiceClientConfig:
    """Map parsed CLI args to immutable voice config."""
    return VoiceClientConfig(
        server=args.server,
        api_key=args.api_key,
        wake_word=args.wake_word,
        wake_aliases=tuple(args.wake_alias or ()),
        disable_wake_word=args.disable_wake_word,
        vosk_model_path=args.vosk_model_path,
        stt_backend=args.stt_backend,
        whisper_model=args.whisper_model,
        whisper_language=args.whisper_language,
        whisper_beam_size=args.whisper_beam_size,
        whisper_compute=args.whisper_compute,
        whisper_vad_filter=str(args.whisper_vad_filter).strip().lower() != "off",
        whisper_condition_on_previous_text=bool(args.whisper_condition_on_previous_text),
        whisper_temperature=float(args.whisper_temperature),
        whisper_no_speech_threshold=args.whisper_no_speech_threshold,
        whisper_log_prob_threshold=args.whisper_logprob_threshold,
        mic_device=(str(args.mic_device).strip() or None) if args.mic_device is not None else None,
        mic_sample_rate=max(int(args.mic_sample_rate), 8000),
        voice_debug=bool(args.voice_debug),
        listen_timeout=args.listen_timeout,
        phrase_time_limit=args.phrase_time_limit,
        user_eos_silence_seconds=args.user_eos_silence_seconds,
        ui_post_tts_hold_seconds=args.ui_post_tts_hold_seconds,
        request_timeout=args.request_timeout,
        once=args.once,
        no_tts=args.no_tts,
    )


def _print_status(message: str) -> None:
    """Print one status line."""
    print(message, flush=True)


def _emit_event(callback: VoiceEventCallback | None, payload: dict[str, Any]) -> None:
    """Emit one UI event payload for external subscribers."""
    if "cycle_id" not in payload:
        payload = {**payload, "cycle_id": None}
    cycle_id = payload.get("cycle_id")
    LOGGER.info(
        "voice.ui_event ts_ms=%s type=%s state=%s cycle_id=%s",
        int(time.time() * 1000),
        payload.get("type"),
        payload.get("state"),
        cycle_id,
    )
    if callback is None:
        return

    try:
        callback(payload)
    except Exception:
        # Event delivery failures must not break the voice loop.
        return


def _should_exit(command: str) -> bool:
    """Return True when command asks to stop voice loop."""
    return command.strip().lower() in {"exit", "quit", "stop", "salir"}


def _starts_with_trigger(text: str, triggers: tuple[str, ...]) -> bool:
    """Return True when normalized text starts with one trigger token."""
    return any(text == trigger or text.startswith(f"{trigger} ") for trigger in triggers)


def _detect_command_language(command: str) -> str | None:
    """Infer ES/EN from command prefix words."""
    normalized = normalize_text(command)
    if not normalized:
        return None
    if _starts_with_trigger(normalized, SPANISH_TRIGGER_PREFIXES):
        return "es"
    if _starts_with_trigger(normalized, ENGLISH_TRIGGER_PREFIXES):
        return "en"
    return None


def _runtime_whisper_language(mode: str, sticky_language: str | None) -> str | None:
    """Return language to force for the next utterance."""
    if mode in {"es", "en"}:
        return mode
    return sticky_language


def _set_whisper_language_if_supported(transcriber: Any, language: str | None) -> None:
    """Update runtime language only for Whisper transcriber."""
    set_language = getattr(transcriber, "set_language", None)
    if callable(set_language):
        set_language(language)


def run_voice_client(
    config: VoiceClientConfig,
    event_callback: VoiceEventCallback | None = None,
    stop_event: Event | None = None,
) -> int:
    """Run the interactive voice loop with push-to-talk-only microphone capture."""
    LOGGER.info(
        "voice.startup phase=begin ts_ms=%s server=%s stt_backend=%s no_tts=%s",
        int(time.time() * 1000),
        config.server,
        config.stt_backend,
        config.no_tts,
    )
    if config.whisper_beam_size < 1:
        print("Error: --whisper-beam-size must be >= 1", file=sys.stderr)
        return 1
    if config.whisper_beam_size > 5:
        _print_status("Warning: whisper beam_size > 5 may increase latency.")
    if config.user_eos_silence_seconds < 0.2:
        print("Error: --user-eos-silence-seconds must be >= 0.2", file=sys.stderr)
        return 1
    if config.ui_post_tts_hold_seconds < 0.0:
        print("Error: --ui-post-tts-hold-seconds must be >= 0.0", file=sys.stderr)
        return 1

    voice_runtime.reset()
    voice_runtime.set_worker_active(True)

    try:
        stt_selection = select_stt_backend(
            stt_backend=config.stt_backend,
            vosk_model_path=config.vosk_model_path,
            whisper_model=config.whisper_model,
            whisper_language=config.whisper_language,
            whisper_beam_size=config.whisper_beam_size,
            whisper_compute_type=config.whisper_compute,
            whisper_vad_filter=config.whisper_vad_filter,
            whisper_condition_on_previous_text=config.whisper_condition_on_previous_text,
            whisper_temperature=config.whisper_temperature,
            whisper_no_speech_threshold=config.whisper_no_speech_threshold,
            whisper_log_prob_threshold=config.whisper_log_prob_threshold,
            mic_device=config.mic_device,
            mic_sample_rate=config.mic_sample_rate,
            debug_audio=config.voice_debug,
        )
    except VoiceEngineError as exc:
        print(f"Error: could not initialize STT engine: {exc}", file=sys.stderr)
        _emit_event(event_callback, {"type": "response", "text": str(exc)})
        _emit_event(event_callback, {"type": "state", "state": "idle"})
        voice_runtime.set_worker_active(False, error=str(exc))
        return 1

    transcriber = stt_selection.provider
    speaker = None if config.no_tts else build_speaker()

    if stt_selection.backend == "google":
        _print_status("STT backend selected: google (emergency fallback)")
    else:
        _print_status(f"STT backend selected: {stt_selection.backend}")
    if stt_selection.warning:
        _print_status(stt_selection.warning)

    if stt_selection.backend == "whisper":
        model_name = getattr(transcriber, "model_name", config.whisper_model or "auto")
        compute_type = getattr(transcriber, "compute_type", config.whisper_compute)
        beam_size = getattr(transcriber, "beam_size", config.whisper_beam_size)
        _print_status(
            f"STT: whisper | model={model_name} | compute={compute_type} "
            f"| lang={config.whisper_language} | beam={beam_size}",
        )

    _print_status("Voice mode started (push-to-talk only). Press Ctrl+C to stop.")
    _print_status(f"PTT end-of-speech silence threshold: {config.user_eos_silence_seconds:.1f}s")
    _print_status(f"UI post-TTS hold: {config.ui_post_tts_hold_seconds:.1f}s")
    _print_status(f"Mic target: {config.mic_device or 'default'} @ {config.mic_sample_rate}Hz")
    if config.voice_debug:
        _print_status("Voice debug telemetry: enabled")
    hold_seconds = config.ui_post_tts_hold_seconds
    fade_seconds = _default_ui_fade_out_seconds()
    LOGGER.info(
        "voice.startup phase=ready ts_ms=%s backend=%s hold_seconds=%.3f fade_seconds=%.3f",
        int(time.time() * 1000),
        stt_selection.backend,
        hold_seconds,
        fade_seconds,
    )

    status_keys = (
        "state",
        "backend",
        "capture_backend",
        "input_device_name",
        "input_device_index",
        "input_sample_rate",
        "input_channels",
        "input_dtype",
        "microphone_available",
        "microphone_error",
        "input_level_rms",
        "input_level_peak",
        "input_level_norm",
        "worker_active",
    )
    last_status_payload: dict[str, Any] = {}
    last_noncycle_state: str | None = None

    def _emit_voice_status(**updates: Any) -> None:
        nonlocal last_status_payload
        snapshot = voice_runtime.update_status(**updates)
        payload = {key: snapshot.get(key) for key in status_keys}
        if payload == last_status_payload:
            return
        last_status_payload = payload
        _emit_event(event_callback, {"type": "voice_status", **payload})

    def _emit_state(state: str, *, cycle_id: str | None = None, seconds: float | None = None, status_state: str | None = None) -> None:
        nonlocal last_noncycle_state
        if cycle_id is None and seconds is None:
            if last_noncycle_state == state:
                _emit_voice_status(state=status_state or state)
                return
            last_noncycle_state = state
        else:
            last_noncycle_state = None
        payload: dict[str, Any] = {"type": "state", "state": state}
        if cycle_id is not None:
            payload["cycle_id"] = cycle_id
        if seconds is not None:
            payload["seconds"] = float(seconds)
        _emit_event(event_callback, payload)
        status_name = status_state or state
        status_update: dict[str, Any] = {"state": status_name}
        if status_name != "listening":
            status_update.update(
                input_level_rms=0.0,
                input_level_peak=0,
                input_level_norm=0.0,
            )
        _emit_voice_status(**status_update)

    def _sleep_with_stop(total_seconds: float) -> bool:
        if total_seconds <= 0:
            return False
        deadline = time.monotonic() + total_seconds
        while time.monotonic() < deadline:
            if stop_event is not None and stop_event.is_set():
                return True
            time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
        return False

    def _sleep_transition_after_hold() -> bool:
        if fade_seconds <= 0:
            return False
        return _sleep_with_stop(fade_seconds)

    def _probe_microphone(reason: str) -> AudioInputProbe:
        probe = probe_audio_input(transcriber, stt_selection.backend)
        if probe.available:
            LOGGER.info(
                "voice.mic_probe_ok backend=%s reason=%s device_index=%s device_name=%s sample_rate=%s channels=%s dtype=%s",
                stt_selection.backend,
                reason,
                probe.device_index,
                probe.device_name,
                probe.sample_rate,
                probe.channels,
                probe.dtype,
            )
        else:
            LOGGER.warning(
                "voice.mic_probe_failed backend=%s reason=%s device_index=%s device_name=%s error=%s",
                stt_selection.backend,
                reason,
                probe.device_index,
                probe.device_name,
                probe.error,
            )
        _emit_voice_status(
            backend=stt_selection.backend,
            capture_backend=stt_selection.backend,
            input_device_index=probe.device_index,
            input_device_name=probe.device_name,
            input_sample_rate=probe.sample_rate,
            input_channels=probe.channels,
            input_dtype=probe.dtype,
            microphone_available=probe.available,
            microphone_error=probe.error if not probe.available else None,
        )
        return probe

    _emit_voice_status(
        state="idle",
        backend=stt_selection.backend,
        capture_backend=stt_selection.backend,
        input_device_index=None,
        input_device_name="none",
        input_sample_rate=config.mic_sample_rate,
        input_channels=1,
        input_dtype="int16",
        microphone_available=False,
        microphone_error=None,
        input_level_rms=0.0,
        input_level_peak=0,
        input_level_norm=0.0,
    )
    _emit_state("idle")
    if event_callback is None and stop_event is None:
        voice_runtime.start_listening()
        _print_status("Standalone mode: microphone listening enabled.")

    sticky_language: str | None = None
    pause_announced = False
    no_mic_notice_shown = False
    next_probe_at = 0.0
    echo_guard_until = 0.0
    consecutive_no_speech = 0
    rapid_cycle_count = 0

    current_probe = _probe_microphone("startup")
    microphone_ready = current_probe.available
    if not microphone_ready:
        voice_runtime.stop_listening()
        next_probe_at = time.monotonic() + MIC_RETRY_INTERVAL_SECONDS
        message = "No microphone detected / input unavailable. Voice capture paused."
        _print_status(message)
        _emit_event(event_callback, {"type": "response", "text": message})
        no_mic_notice_shown = True

    def _run_command_cycle(command: str, *, source: str) -> int | None:
        nonlocal sticky_language, echo_guard_until
        cycle_id = str(uuid.uuid4())
        cycle_started = time.monotonic()
        LOGGER.info(
            "voice.cycle_start ts_ms=%s cycle_id=%s source=%s command_len=%s",
            int(time.time() * 1000),
            cycle_id,
            source,
            len(command),
        )

        _emit_event(event_callback, {"type": "transcript", "text": command, "cycle_id": cycle_id})

        if config.whisper_language == "auto":
            detected_language = _detect_command_language(command)
            if detected_language in {"es", "en"}:
                sticky_language = detected_language

        if _should_exit(command):
            _print_status("Voice mode stopped.")
            _emit_state("idle", cycle_id=cycle_id)
            _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
            return 0

        voice_runtime.set_active_cycle(cycle_id, state="thinking")

        def _stop_requested() -> bool:
            return bool(stop_event is not None and stop_event.is_set()) or voice_runtime.should_stop_response(cycle_id=cycle_id)

        def _interrupt_cycle(reason: str) -> int | None:
            nonlocal echo_guard_until
            LOGGER.info(
                "voice.cycle_interrupted ts_ms=%s cycle_id=%s source=%s reason=%s",
                int(time.time() * 1000),
                cycle_id,
                source,
                reason,
            )
            voice_runtime.update_active_cycle_state(cycle_id=cycle_id, state="cooldown")
            _emit_state(
                "audio_finished",
                cycle_id=cycle_id,
                seconds=STOP_RESPONSE_HOLD_SECONDS,
                status_state="cooldown",
            )
            if _sleep_with_stop(STOP_RESPONSE_HOLD_SECONDS):
                _emit_state("idle", cycle_id=cycle_id)
                _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
                voice_runtime.clear_active_cycle(cycle_id=cycle_id)
                return 0
            if _sleep_transition_after_hold():
                _emit_state("idle", cycle_id=cycle_id)
                _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
                voice_runtime.clear_active_cycle(cycle_id=cycle_id)
                return 0

            _emit_state("idle", cycle_id=cycle_id)
            _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
            voice_runtime.clear_active_cycle(cycle_id=cycle_id)
            echo_guard_until = time.monotonic() + POST_TTS_MIC_GUARD_SECONDS
            if config.once:
                return 0
            return None

        _emit_state("thinking", cycle_id=cycle_id)
        query_sent_time = time.time()
        LOGGER.info(
            "voice.query_sent_time ts_ms=%s cycle_id=%s query_sent_time=%.3f source=%s",
            int(query_sent_time * 1000),
            cycle_id,
            query_sent_time,
            source,
        )
        query_done = Event()
        query_box: dict[str, Any] = {"reply": None, "error": None}

        def _query_worker() -> None:
            try:
                query_box["reply"] = query_assistant(
                    server=config.server,
                    message=command,
                    timeout=config.request_timeout,
                    api_key=config.api_key,
                )
            except Exception as exc:  # pragma: no cover - runtime defensive branch
                query_box["error"] = exc
            finally:
                query_done.set()

        query_thread = threading.Thread(target=_query_worker, name=f"voice-query-{cycle_id[:8]}", daemon=True)
        query_thread.start()

        while not query_done.wait(timeout=0.05):
            if _stop_requested():
                interrupted = _interrupt_cycle("thinking")
                if interrupted is not None:
                    return interrupted
                return None

        if _stop_requested():
            interrupted = _interrupt_cycle("thinking_result_discarded")
            if interrupted is not None:
                return interrupted
            return None

        query_error = query_box.get("error")
        if isinstance(query_error, Exception):
            exc = query_error
            _print_status(f"Assistant error: {exc}")
            _emit_event(event_callback, {"type": "response", "text": f"Assistant error: {exc}", "cycle_id": cycle_id})
            voice_runtime.update_active_cycle_state(cycle_id=cycle_id, state="cooldown")
            _emit_state("audio_finished", cycle_id=cycle_id, seconds=hold_seconds, status_state="cooldown")
            if _sleep_with_stop(hold_seconds):
                _emit_state("idle", cycle_id=cycle_id)
                _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
                voice_runtime.clear_active_cycle(cycle_id=cycle_id)
                return 0
            if _sleep_transition_after_hold():
                _emit_state("idle", cycle_id=cycle_id)
                _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
                voice_runtime.clear_active_cycle(cycle_id=cycle_id)
                return 0
            _emit_state("idle", cycle_id=cycle_id)
            _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
            voice_runtime.clear_active_cycle(cycle_id=cycle_id)
            echo_guard_until = time.monotonic() + POST_TTS_MIC_GUARD_SECONDS
            if config.once:
                return 1
            return None

        reply = query_box.get("reply")
        if reply is None:
            _print_status("Assistant error: unknown response error")
            _emit_event(event_callback, {"type": "response", "text": "Assistant error: unknown response error", "cycle_id": cycle_id})
            voice_runtime.update_active_cycle_state(cycle_id=cycle_id, state="cooldown")
            _emit_state("audio_finished", cycle_id=cycle_id, seconds=hold_seconds, status_state="cooldown")
            if _sleep_with_stop(hold_seconds):
                _emit_state("idle", cycle_id=cycle_id)
                _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
                voice_runtime.clear_active_cycle(cycle_id=cycle_id)
                return 0
            if _sleep_transition_after_hold():
                _emit_state("idle", cycle_id=cycle_id)
                _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
                voice_runtime.clear_active_cycle(cycle_id=cycle_id)
                return 0
            _emit_state("idle", cycle_id=cycle_id)
            _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
            voice_runtime.clear_active_cycle(cycle_id=cycle_id)
            echo_guard_until = time.monotonic() + POST_TTS_MIC_GUARD_SECONDS
            if config.once:
                return 1
            return None

        query_done_time = time.time()
        if config.voice_debug:
            LOGGER.info(
                "voice.query_latency cycle_id=%s source=%s assistant_ms=%s",
                cycle_id,
                source,
                int(max((query_done_time - query_sent_time) * 1000.0, 0.0)),
            )

        if settings.privacy_mode:
            _print_status(f"Assistant ({reply.intent}): [redacted]")
        else:
            _print_status(f"Assistant ({reply.intent}): {reply.response}")

        if _stop_requested():
            interrupted = _interrupt_cycle("before_response_emit")
            if interrupted is not None:
                return interrupted
            return None

        _emit_event(event_callback, {"type": "response", "text": reply.response, "cycle_id": cycle_id})

        if reply.response.strip():
            _emit_state("speaking", cycle_id=cycle_id)
            voice_runtime.update_active_cycle_state(cycle_id=cycle_id, state="speaking")
            if speaker is not None:
                tts_start_time = time.time()
                LOGGER.info(
                    "voice.tts_start ts_ms=%s cycle_id=%s tts_start_time=%.3f chars=%s speaker=%s",
                    int(tts_start_time * 1000),
                    cycle_id,
                    tts_start_time,
                    len(reply.response),
                    type(speaker).__name__,
                )
                try:
                    speaker.speak(reply.response, should_stop=_stop_requested)
                    if config.voice_debug:
                        tts_end_time = time.time()
                        LOGGER.info(
                            "voice.tts_latency cycle_id=%s speaker=%s tts_ms=%s",
                            cycle_id,
                            type(speaker).__name__,
                            int(max((tts_end_time - tts_start_time) * 1000.0, 0.0)),
                        )
                except Exception as exc:  # pragma: no cover - runtime speaker path
                    _print_status(f"TTS error: {exc}")
                    _emit_event(event_callback, {"type": "response", "text": f"TTS error: {exc}", "cycle_id": cycle_id})
            if _stop_requested():
                interrupted = _interrupt_cycle("speaking")
                if interrupted is not None:
                    return interrupted
                return None
        voice_runtime.update_active_cycle_state(cycle_id=cycle_id, state="cooldown")
        _emit_state("audio_finished", cycle_id=cycle_id, seconds=hold_seconds, status_state="cooldown")
        if _sleep_with_stop(hold_seconds):
            _emit_state("idle", cycle_id=cycle_id)
            _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
            voice_runtime.clear_active_cycle(cycle_id=cycle_id)
            return 0
        if _sleep_transition_after_hold():
            _emit_state("idle", cycle_id=cycle_id)
            _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
            voice_runtime.clear_active_cycle(cycle_id=cycle_id)
            return 0

        _emit_state("idle", cycle_id=cycle_id)
        _emit_event(event_callback, {"type": "clear", "cycle_id": cycle_id})
        voice_runtime.clear_active_cycle(cycle_id=cycle_id)
        echo_guard_until = time.monotonic() + POST_TTS_MIC_GUARD_SECONDS
        LOGGER.info(
            "voice.cycle_complete ts_ms=%s cycle_id=%s source=%s total_elapsed=%.3fs",
            int(time.time() * 1000),
            cycle_id,
            source,
            time.monotonic() - cycle_started,
        )
        if config.once:
            return 0
        return None

    def _apply_rapid_cycle_guard(elapsed_seconds: float, *, reason: str) -> None:
        nonlocal rapid_cycle_count
        if elapsed_seconds >= 0.45:
            rapid_cycle_count = 0
            return
        rapid_cycle_count += 1
        guard_sleep = min(0.06 * rapid_cycle_count, 0.45)
        if rapid_cycle_count % 4 == 0:
            LOGGER.info(
                "voice.rapid_cycle_guard backend=%s reason=%s count=%s elapsed=%.3f sleep=%.3f",
                stt_selection.backend,
                reason,
                rapid_cycle_count,
                elapsed_seconds,
                guard_sleep,
            )
        time.sleep(guard_sleep)

    level_last_emit_mono = 0.0

    def _on_capture_level(sample: dict[str, Any]) -> None:
        nonlocal level_last_emit_mono
        now_mono = time.monotonic()
        if now_mono - level_last_emit_mono < 0.05:
            return
        level_last_emit_mono = now_mono
        rms_value = float(sample.get("rms") or 0.0)
        peak_value = int(sample.get("peak") or 0)
        level_norm = max(0.0, min(rms_value / 6000.0, 1.0))
        _emit_voice_status(
            state="listening",
            capture_backend=str(sample.get("backend") or stt_selection.backend),
            input_sample_rate=int(sample.get("sample_rate") or config.mic_sample_rate),
            input_channels=int(sample.get("channels") or 1),
            input_dtype=str(sample.get("dtype") or "int16"),
            input_level_rms=rms_value,
            input_level_peak=peak_value,
            input_level_norm=level_norm,
        )

    try:
        while True:
            if stop_event is not None and stop_event.is_set():
                voice_runtime.stop_listening()
                voice_runtime.clear_active_cycle()
                _emit_state("idle")
                _emit_event(event_callback, {"type": "clear"})
                voice_runtime.set_worker_active(False)
                return 0

            if runtime_config.is_voice_capture_paused():
                if not pause_announced:
                    voice_runtime.stop_listening()
                    _print_status("Voice capture paused while configuration is open.")
                    _emit_state("paused")
                    pause_announced = True
                time.sleep(0.2)
                continue

            if pause_announced:
                pause_announced = False
                _emit_state("idle")

            typed_prompt = voice_runtime.pop_typed_prompt()
            if typed_prompt:
                cycle_result = _run_command_cycle(typed_prompt, source="typed")
                if cycle_result is not None:
                    voice_runtime.set_worker_active(False)
                    return cycle_result
                continue

            if time.monotonic() < echo_guard_until:
                time.sleep(min(0.1, max(0.0, echo_guard_until - time.monotonic())))
                continue

            if not microphone_ready:
                voice_runtime.stop_listening()
                _emit_state("idle")
                if not no_mic_notice_shown:
                    message = "No microphone detected / input unavailable. Voice capture paused."
                    _print_status(message)
                    _emit_event(event_callback, {"type": "response", "text": message})
                    no_mic_notice_shown = True
                should_retry = voice_runtime.consume_microphone_retry_request() or time.monotonic() >= next_probe_at
                if should_retry:
                    current_probe = _probe_microphone("retry")
                    if current_probe.available:
                        microphone_ready = True
                        no_mic_notice_shown = False
                        consecutive_no_speech = 0
                        _print_status(
                            f"Microphone ready: {current_probe.device_name} "
                            f"(index={current_probe.device_index if current_probe.device_index is not None else 'default'})",
                        )
                        _emit_event(event_callback, {"type": "response", "text": "Microphone detected. Voice capture resumed."})
                    else:
                        next_probe_at = time.monotonic() + MIC_RETRY_INTERVAL_SECONDS
                time.sleep(0.2)
                continue

            runtime_language = _runtime_whisper_language(config.whisper_language, sticky_language)
            _set_whisper_language_if_supported(transcriber, runtime_language)

            if not voice_runtime.is_listening():
                _emit_state("idle")
                time.sleep(IDLE_POLL_SECONDS)
                continue

            if not settings.is_production:
                LOGGER.info(
                    "PTT start backend=%s device=%s idx=%s",
                    stt_selection.backend,
                    current_probe.device_name,
                    current_probe.device_index,
                )
            _print_status("Listening...")
            _emit_state("listening")
            listen_started = time.monotonic()
            try:
                heard = listen_push_to_talk(
                    transcriber,
                    keep_listening=voice_runtime.is_listening,
                    timeout=0.0,
                    phrase_time_limit=max(config.phrase_time_limit, 0.5),
                    end_silence_seconds=max(config.user_eos_silence_seconds, 0.2),
                    level_callback=_on_capture_level,
                )
            except AudioInputUnavailableError as exc:
                voice_runtime.stop_listening()
                microphone_ready = False
                next_probe_at = time.monotonic() + MIC_RETRY_INTERVAL_SECONDS
                error_text = str(exc) or "Microphone capture failed"
                LOGGER.warning(
                    "voice.mic_capture_failed backend=%s device_index=%s device_name=%s error=%s",
                    stt_selection.backend,
                    current_probe.device_index,
                    current_probe.device_name,
                    error_text,
                )
                _emit_voice_status(
                    state="idle",
                    backend=stt_selection.backend,
                    input_device_index=current_probe.device_index,
                    input_device_name=current_probe.device_name,
                    microphone_available=False,
                    microphone_error=error_text,
                )
                _emit_state("idle")
                continue
            except VoiceEngineError as exc:
                voice_runtime.stop_listening()
                _print_status(f"STT error: {exc}")
                _emit_event(event_callback, {"type": "response", "text": f"STT error: {exc}"})
                _emit_state("idle")
                if config.once:
                    voice_runtime.set_worker_active(False, error=str(exc))
                    return 1
                continue
            listen_elapsed = max(0.0, time.monotonic() - listen_started)
            capture_duration_seconds = float(
                getattr(transcriber, "last_capture_audio_duration_seconds", listen_elapsed) or listen_elapsed,
            )
            capture_rms_avg = float(getattr(transcriber, "last_capture_rms_avg", 0.0) or 0.0)
            capture_rms_peak = int(getattr(transcriber, "last_capture_rms_peak", 0) or 0)
            capture_sample_rate = int(
                getattr(transcriber, "last_capture_sample_rate", config.mic_sample_rate) or config.mic_sample_rate,
            )
            capture_channels = int(getattr(transcriber, "last_capture_channels", 1) or 1)
            capture_dtype = str(getattr(transcriber, "last_capture_dtype", "int16") or "int16")
            capture_stop_reason = str(getattr(transcriber, "last_capture_stop_reason", "none") or "none")
            _emit_voice_status(
                capture_backend=stt_selection.backend,
                input_sample_rate=capture_sample_rate,
                input_channels=capture_channels,
                input_dtype=capture_dtype,
                input_level_rms=capture_rms_avg,
                input_level_peak=capture_rms_peak,
                input_level_norm=max(0.0, min(capture_rms_avg / 6000.0, 1.0)),
            )
            if not settings.is_production:
                LOGGER.info(
                    "PTT stop reason=%s duration=%.3fs heard=%s backend=%s",
                    capture_stop_reason,
                    capture_duration_seconds,
                    bool(heard),
                    stt_selection.backend,
                )
            if config.voice_debug:
                LOGGER.info(
                    "stt.capture_diag backend=%s device=%s idx=%s sample_rate=%s channels=%s dtype=%s capture_elapsed=%.3fs capture_audio=%.3fs rms_avg=%.1f rms_peak=%s",
                    stt_selection.backend,
                    current_probe.device_name,
                    current_probe.device_index,
                    capture_sample_rate,
                    capture_channels,
                    capture_dtype,
                    listen_elapsed,
                    capture_duration_seconds,
                    capture_rms_avg,
                    capture_rms_peak,
                )

            if not heard:
                consecutive_no_speech += 1
                if consecutive_no_speech % NO_SPEECH_LOG_INTERVAL == 0:
                    LOGGER.info(
                        "stt.no_speech backend=%s mode=ptt count=%s phrase_limit=%.2f",
                        stt_selection.backend,
                        consecutive_no_speech,
                        config.phrase_time_limit,
                    )
                if not voice_runtime.is_listening():
                    _emit_state("idle")
                _apply_rapid_cycle_guard(listen_elapsed, reason="no_speech")
                continue
            consecutive_no_speech = 0

            if runtime_config.is_voice_capture_paused():
                if not pause_announced:
                    _emit_state("paused")
                    pause_announced = True
                voice_runtime.stop_listening()
                _print_status("Captured audio ignored because configuration screen is active.")
                continue

            if config.voice_debug:
                capture_start_wall = getattr(transcriber, "last_capture_start_wall", None)
                capture_end_wall = getattr(transcriber, "last_capture_end_wall", None)
                stt_start_wall = getattr(transcriber, "last_transcription_start_wall", None)
                stt_end_wall = getattr(transcriber, "last_transcription_end_wall", None)
                LOGGER.info(
                    "stt.timeline backend=%s capture_start=%s capture_end=%s stt_start=%s stt_end=%s capture_to_stt_ms=%s stt_ms=%s",
                    stt_selection.backend,
                    capture_start_wall,
                    capture_end_wall,
                    stt_start_wall,
                    stt_end_wall,
                    (
                        int(max((stt_start_wall - capture_end_wall) * 1000.0, 0.0))
                        if isinstance(stt_start_wall, (int, float)) and isinstance(capture_end_wall, (int, float))
                        else None
                    ),
                    (
                        int(max((stt_end_wall - stt_start_wall) * 1000.0, 0.0))
                        if isinstance(stt_end_wall, (int, float)) and isinstance(stt_start_wall, (int, float))
                        else None
                    ),
                )

            voice_runtime.stop_listening()
            _emit_state("transcribing")
            if settings.privacy_mode:
                _print_status("You said: [redacted]")
            else:
                _print_status(f"You said: {heard}")
            cycle_result = _run_command_cycle(heard, source="voice_ptt")
            if cycle_result is not None:
                voice_runtime.set_worker_active(False)
                return cycle_result
    except KeyboardInterrupt:
        voice_runtime.stop_listening()
        voice_runtime.clear_active_cycle()
        _print_status("\nVoice mode interrupted.")
        _emit_state("idle")
        _emit_event(event_callback, {"type": "clear"})
        voice_runtime.set_worker_active(False)
        return 0


def main() -> int:
    """CLI entrypoint."""
    if not logging.getLogger().handlers:
        logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
    args = build_parser().parse_args()
    config = args_to_config(args)
    return run_voice_client(config=config)


if __name__ == "__main__":
    raise SystemExit(main())
