"""Moteur vocal STT.

Refactor de tools/hermes-voice.py en classe avec callbacks d'état (`emit`), pour que
le serveur websocket puisse pousser l'état au HUD. Boucle :
  veille → wake word "Ok Hermès" → conversation (parole libre) → retour veille.

Dépendances lourdes (sounddevice, faster_whisper, webrtcvad) importées à la volée :
`stt --help` et la config fonctionnent sans micro ni modèle installé.
"""

from __future__ import annotations

import queue
import subprocess
import tempfile
import threading
import time
import unicodedata
from collections import deque
from pathlib import Path
from typing import Any, Callable

from .asr import make_backend

SAMPLE_RATE = 16000
PRE_ROLL = 15
CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"}

PIPER_BIN = Path.home() / ".local/share/piper-runtime/piper"
PIPER_VOICE_DIR = Path.home() / ".local/share/piper"


def _deaccent(s: str) -> str:
    return (
        unicodedata.normalize("NFD", s)
        .encode("ascii", "ignore")
        .decode("ascii")
        .lower()
    )


class VoiceEngine:
    """Écoute le micro, transcrit, interroge le STT-server, synthétise la réponse.

    `responder(text) -> str` envoie le texte au serveur et renvoie la réponse.
    `emit(event: dict)` est appelé à chaque changement d'état / message, pour le HUD.
    Événements : {"type": "state", "state": idle|listening|thinking|speaking},
                 {"type": "user", "text": …}, {"type": "assistant", "text": …},
                 {"type": "error", "text": …}.
    """

    def __init__(
        self,
        cfg: dict[str, Any],
        responder: Callable[[str], str],
        emit: Callable[[dict], None],
        memory=None,
        no_tts: bool = False,
        model_label: Callable[[], str] | None = None,
    ):
        self.cfg = cfg
        self.responder = responder
        self.emit = emit
        self.memory = memory
        self.no_tts = no_tts
        # callable renvoyant l'alias modèle courant → affiché en tag dans le HUD
        self.model_label = model_label
        # routeur d'intentions locales (portail) : (text) -> réponse | None.
        # Renseigné après coup par le serveur (app.py) qui a accès au portail.
        # Si une intention matche, on répond SANS appeler le LLM.
        self.intent_router: Callable[[str], str | None] | None = None
        # backend ASR enfichable (whisper par défaut, onnx/parakeet en option)
        self._asr = make_backend(cfg)
        self._stop = False
        # sérialise un tour (voix ou texte) : pas deux réponses concurrentes
        self._respond_lock = threading.Lock()
        # micro coupé : la boucle audio ignore les trames (le HUD pilote via set_muted)
        self._muted = False
        # interruption d'un tour en cours (bouton « stop » du HUD)
        self._interrupt = threading.Event()
        # process de lecture TTS courant (aplay) → tuable pour couper la parole
        self._tts_proc: subprocess.Popen | None = None

    @property
    def muted(self) -> bool:
        return self._muted

    # ── chargement modèle ────────────────────────────────────────────────
    def load(self) -> None:
        self._asr.load()

    # ── STT ──────────────────────────────────────────────────────────────
    def _transcribe(self, audio) -> str:
        return self._asr.transcribe(audio)

    def _detect_wake(self, text: str) -> tuple[bool, str]:
        wake = self.cfg.get("wake_word", "hermes")
        words = text.strip().split()
        low = [_deaccent(w).strip(",.!?«»") for w in words]
        try:
            idx = next(i for i, w in enumerate(low) if wake in w)
            return True, " ".join(words[idx + 1:]).strip()
        except StopIteration:
            return False, ""

    # ── TTS ──────────────────────────────────────────────────────────────
    def _speak(self, text: str) -> None:
        if self.no_tts or self._interrupt.is_set():
            return
        voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx"
        if not voice.exists() or not PIPER_BIN.exists():
            return
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
            out = Path(f.name)
        try:
            subprocess.run(
                [str(PIPER_BIN), "--model", str(voice), "--output_file", str(out)],
                input=text.encode(),
                capture_output=True,
                check=True,
                env={"LD_LIBRARY_PATH": str(PIPER_BIN.parent)},
            )
            if self._interrupt.is_set():  # « stop » pressé pendant la synthèse
                return
            # Popen (pas run) → on garde la main pour tuer la lecture sur « stop »
            self._tts_proc = subprocess.Popen(["aplay", "-q", str(out)])
            self._tts_proc.wait()
        except FileNotFoundError:
            pass
        finally:
            self._tts_proc = None
            out.unlink(missing_ok=True)

    def stop_response(self) -> None:
        """Coupe le tour en cours : stoppe la lecture TTS et saute la suite.

        Appelé depuis le serveur websocket (bouton « stop » du HUD), hors du thread
        qui tient `_respond_lock` → ne bloque pas, agit sur le tour actif.
        """
        self._interrupt.set()
        proc = self._tts_proc
        if proc is not None and proc.poll() is None:
            proc.terminate()
        self.emit({"type": "state", "state": "idle"})

    def set_muted(self, muted: bool) -> None:
        """Coupe / réactive le micro (la boucle audio ignore les trames si coupé)."""
        self._muted = bool(muted)
        self.emit({"type": "mic", "muted": self._muted})
        if self._muted:
            self.emit({"type": "state", "state": "idle"})

    # ── interrogation du serveur ─────────────────────────────────────────
    def _respond(self, text: str) -> None:
        with self._respond_lock:  # un seul tour à la fois (voix ⟂ texte)
            self._interrupt.clear()  # nouveau tour → on repart d'une ardoise propre
            self.emit({"type": "user", "text": text})
            if self.memory:
                self.memory.log("user", text)
            # 1) intention locale (portail) : réponse instantanée, sans LLM
            resp = None
            mode = None
            if self.intent_router is not None:
                try:
                    resp = self.intent_router(text)
                except Exception:  # noqa: BLE001 — un intent cassé ne bloque pas le tour
                    resp = None
            if resp is not None:
                mode = "portail"
            else:
                # 2) sinon : on interroge le LLM via le STT-server
                self.emit({"type": "state", "state": "thinking"})
                try:
                    resp = self.responder(text)
                except Exception as e:  # noqa: BLE001 — on remonte au HUD, on ne crash pas
                    self.emit({"type": "error", "text": str(e)})
                    self.emit({"type": "state", "state": "idle"})
                    return
                mode = self.model_label() if self.model_label else None
            if self._interrupt.is_set():  # « stop » pressé pendant la génération
                self.emit({"type": "state", "state": "idle"})
                return
            self.emit({"type": "assistant", "text": resp, "mode": mode})
            if self.memory:
                self.memory.log("assistant", resp)
            self.emit({"type": "state", "state": "speaking"})
            self._speak(resp)

    def respond_text(self, text: str) -> None:
        """Traite une entrée texte du HUD comme un tour de conversation (sans micro).

        Appelé hors boucle audio (depuis le serveur websocket, via un executor) : le
        moteur n'a pas besoin d'être chargé/démarré pour répondre à du texte.
        """
        text = (text or "").strip()
        if not text:
            return
        self._respond(text)
        self.emit({"type": "state", "state": "idle"})

    # ── boucle principale (VAD + wake word) ──────────────────────────────
    def run(self) -> None:
        import numpy as np
        import sounddevice as sd
        import webrtcvad

        vad = webrtcvad.Vad(mode=3)
        frame_ms = 20
        frame_samples = int(SAMPLE_RATE * frame_ms / 1000)
        silence_sec = self.cfg.get("silence_sec", 1.2)
        min_speech = self.cfg.get("min_speech_sec", 0.6)
        chat_timeout = self.cfg.get("chat_timeout_sec", 60)
        required_silent = int(silence_sec * 1000 / frame_ms)

        audio_q: queue.Queue = queue.Queue()
        pre_roll: deque = deque(maxlen=PRE_ROLL)
        chat_mode = False
        last_activity = 0.0

        def callback(indata, frames, time_info, status):
            chunk = (indata[:, 0] if indata.ndim > 1 else indata.flatten()).copy()
            audio_q.put(chunk)

        def record_until_silence() -> "np.ndarray | None":
            captured, silent = [], 0
            while not self._stop:
                try:
                    frame = audio_q.get(timeout=3.0)
                except queue.Empty:
                    break
                captured.append(frame)
                try:
                    speech = vad.is_speech(frame.tobytes(), SAMPLE_RATE)
                except Exception:
                    speech = True
                silent = 0 if speech else silent + 1
                if silent >= required_silent:
                    break
            if not captured:
                return None
            audio = np.concatenate(captured)
            return audio if len(audio) / SAMPLE_RATE >= min_speech else None

        self.emit({"type": "state", "state": "idle"})
        with sd.InputStream(
            samplerate=SAMPLE_RATE, channels=1, dtype="int16",
            blocksize=frame_samples, callback=callback,
        ):
            while not self._stop:
                if chat_mode and time.time() - last_activity > chat_timeout:
                    chat_mode = False
                    self.emit({"type": "state", "state": "idle"})

                frame = audio_q.get()
                if self._muted:  # micro coupé : on vide la trame et on ignore
                    continue
                pre_roll.append(frame)
                try:
                    if not vad.is_speech(frame.tobytes(), SAMPLE_RATE):
                        continue
                except Exception:
                    continue

                self.emit({"type": "state", "state": "listening"})
                captured = list(pre_roll)
                tail = record_until_silence()
                if tail is None:
                    self.emit({"type": "state", "state": "idle" if not chat_mode else "listening"})
                    continue
                audio = np.concatenate([np.concatenate(captured), tail])
                text = self._transcribe(audio)
                if not text:
                    continue

                if chat_mode:
                    if any(w in _deaccent(text) for w in CHAT_EXIT):
                        chat_mode = False
                        self.emit({"type": "user", "text": text})
                        self._speak("À bientôt !")
                        self.emit({"type": "state", "state": "idle"})
                        continue
                    last_activity = time.time()
                    self._respond(text)
                    self.emit({"type": "state", "state": "listening"})
                else:
                    found, command = self._detect_wake(text)
                    if not found:
                        self.emit({"type": "state", "state": "idle"})
                        continue
                    chat_mode = True
                    last_activity = time.time()
                    if command:
                        self._respond(command)
                    self.emit({"type": "state", "state": "listening"})

    def stop(self) -> None:
        self._stop = True
