"""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. Machine à états :
  veille (`asleep`) → wake word « Asa » → éveil (`awake`, parole libre) → « Asa stop » → veille.

En veille, Asa transcrit chaque énoncé UNIQUEMENT pour y détecter le wake word (elle
« ne répond plus ») ; en éveil, elle répond à tout jusqu'à un mot de veille ou le timeout.

Transcription partielle : en éveil, le buffer en cours est re-transcrit périodiquement
et poussé au HUD (`partial`) → l'utilisateur voit le texte se former en direct.

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 os
import queue
import subprocess
import sys
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

# Journalise les transcriptions + décisions (veille/réveil) sur stderr → journalctl.
# Activé par défaut (volume faible : une ligne par énoncé) ; STT_VOICE_LOG=0 pour couper.
_VOICE_LOG = os.environ.get("STT_VOICE_LOG", "1") not in ("0", "false", "")


def _log(msg: str) -> None:
    if _VOICE_LOG:
        print(f"[stt.voice] {msg}", file=sys.stderr, flush=True)

SAMPLE_RATE = 16000
PRE_ROLL = 15
# Mots de mise en veille (« Asa stop ») — déaccentués, cherchés en sous-chaîne quand
# Asa est éveillée. Surchargeable via [voice].sleep_words.
DEFAULT_SLEEP_WORDS = (
    "stop", "dors", "au revoir", "bonne nuit", "fin", "silence", "tais-toi", "chut",
)
# Variantes du wake word « Asa » tolérées — formes COMPACTES (déaccentuées, sans espace).
# L'ASR (Parakeet FR) découpe et rend « Asa » très diversement : « a so », « à ça », « ah ça »,
# « aza »… On compare donc la jonction des 1-2 premiers mots à ces formes. Surchargeable via
# [voice].wake_aliases ; le wake word principal ([voice].wake_word) s'y ajoute.
DEFAULT_WAKE_ALIASES = (
    "asa", "aza", "assa", "aca", "aco", "aso", "asso", "azo",
    "ahca", "ahsa", "aica", "haza", "hassa", "aha",
)

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

# Kokoro TTS (ONNX) — modèle + pack de voix téléchargés par `stt --install-kokoro`.
KOKORO_DIR = Path.home() / ".local/share/kokoro"
KOKORO_MODEL = KOKORO_DIR / "kokoro-v1.0.onnx"
KOKORO_VOICES = KOKORO_DIR / "voices-v1.0.bin"


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
        # callable renvoyant le dernier contexte assemblé (serveur) → visualiseur HUD.
        # Renseigné après coup par le serveur (app.py) ; None tant qu'absent.
        self.context_provider: Callable[[], dict | None] | None = None
        # routeur d'intentions locales (portail) : (text) -> réponse | None.
        # Renseigné après coup par le serveur (app.py). 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
        # instance Kokoro (chargée à la demande si tts_engine="kokoro") — modèle ONNX ~310 Mo
        self._kokoro = None

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

    # ── chargement modèle ────────────────────────────────────────────────
    def load(self) -> None:
        self._asr.load()
        # Précharge Kokoro (modèle lourd) au démarrage si sélectionné → évite une
        # 1re réponse lente. Repli silencieux si indisponible (le _speak gère).
        if (self.cfg.get("tts_engine") or "").lower() == "kokoro":
            self._load_kokoro()

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

    def _wake_aliases(self) -> set[str]:
        """Ensemble des formes déaccentuées acceptées pour réveiller Asa."""
        base = [self.cfg.get("wake_word", "asa")]
        extra = self.cfg.get("wake_aliases") or DEFAULT_WAKE_ALIASES
        return {_deaccent(w).strip() for w in (*base, *extra) if w}

    def _detect_wake(self, text: str) -> tuple[bool, str]:
        """Repère le wake word et renvoie (trouvé, reste de la phrase).

        L'ASR découpe souvent « Asa » en deux tokens (« a ça », « a so »…). On glisse une
        fenêtre de 1 puis 2 mots, on **joint** (sans espace) et déaccentue, et on compare
        en **égalité exacte** à l'ensemble d'alias compacts (l'exact évite les faux positifs
        type « à cause » → « acause »). La fenêtre de 2 d'abord → capte « a so » avant « a »."""
        aliases = self._wake_aliases()
        words = text.strip().split()
        low = [_deaccent(w).strip(",.!?«»…:;\"'()-") for w in words]
        for i in range(len(low)):
            for n in (2, 1):
                if i + n > len(low):
                    continue
                head = "".join(low[i:i + n])
                if head in aliases:
                    return True, " ".join(words[i + n:]).strip()
        return False, ""

    def _is_sleep_command(self, text: str) -> bool:
        """Vrai si l'énoncé (éveillé) demande la mise en veille (« Asa stop », « stop »…)."""
        low = _deaccent(text)
        words = self.cfg.get("sleep_words") or DEFAULT_SLEEP_WORDS
        return any(w in low for w in words)

    # ── TTS ──────────────────────────────────────────────────────────────
    def _speak(self, text: str) -> None:
        if self.no_tts or self._interrupt.is_set():
            return
        wav = self._synthesize(text)   # WAV temporaire selon le moteur (piper/kokoro)
        if wav is None or self._interrupt.is_set():  # « stop » pressé pendant la synthèse
            if wav is not None:
                wav.unlink(missing_ok=True)
            return
        vol = float(self.cfg.get("tts_volume", 1.0))
        if vol <= 0.01:           # volume à zéro → muet (mais le tour s'est déroulé)
            wav.unlink(missing_ok=True)
            return
        if abs(vol - 1.0) > 0.01:  # applique le gain (commun Piper/Kokoro)
            self._apply_gain(wav, vol)
        try:
            # Popen (pas run) → on garde la main pour tuer la lecture sur « stop »
            self._tts_proc = subprocess.Popen(["aplay", "-q", str(wav)])
            self._tts_proc.wait()
        except FileNotFoundError:
            pass
        finally:
            self._tts_proc = None
            wav.unlink(missing_ok=True)

    @staticmethod
    def _apply_gain(wav: "Path", gain: float) -> None:
        """Multiplie le volume du WAV (PCM 16-bit) par `gain`, en place. Best-effort."""
        try:
            import wave

            import numpy as np
            with wave.open(str(wav), "rb") as w:
                params = w.getparams()
                frames = w.readframes(w.getnframes())
            if params.sampwidth != 2:   # on ne gère que le 16-bit (Piper & Kokoro le sont)
                return
            data = np.frombuffer(frames, dtype="<i2").astype(np.float32) * gain
            data = np.clip(data, -32768, 32767).astype("<i2")
            with wave.open(str(wav), "wb") as w:
                w.setparams(params)
                w.writeframes(data.tobytes())
        except Exception as e:  # noqa: BLE001 — au pire le gain n'est pas appliqué
            _log(f"gain TTS non appliqué ({e}).")

    def _synthesize(self, text: str) -> "Path | None":
        """Synthétise `text` en WAV temporaire selon `[voice].tts_engine`. None si KO."""
        if (self.cfg.get("tts_engine") or "piper").lower() == "kokoro":
            return self._synth_kokoro(text)
        return self._synth_piper(text)

    def _synth_piper(self, text: str) -> "Path | None":
        voice = PIPER_VOICE_DIR / f"{self.cfg['piper_voice']}.onnx"
        if not voice.exists() or not PIPER_BIN.exists():
            return None
        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)},
            )
            return out
        except (FileNotFoundError, subprocess.CalledProcessError):
            out.unlink(missing_ok=True)
            return None

    def _load_kokoro(self):
        """Charge (une fois) l'instance Kokoro ONNX. None si modèle/paquet absent."""
        if self._kokoro is not None:
            return self._kokoro
        if not KOKORO_MODEL.exists() or not KOKORO_VOICES.exists():
            _log("Kokoro: modèle absent → lance `stt --install-kokoro` (repli silencieux).")
            return None
        try:
            from kokoro_onnx import Kokoro
            self._kokoro = Kokoro(str(KOKORO_MODEL), str(KOKORO_VOICES))
            return self._kokoro
        except Exception as e:  # noqa: BLE001 — paquet absent / incompatible
            _log(f"Kokoro indisponible ({e}) — installe-le : pipx inject stt kokoro-onnx")
            return None

    def _synth_kokoro(self, text: str) -> "Path | None":
        k = self._load_kokoro()
        if k is None:
            return None
        try:
            import wave

            import numpy as np
            samples, rate = k.create(
                text,
                voice=self.cfg.get("kokoro_voice", "ff_siwis"),
                speed=float(self.cfg.get("kokoro_speed", 1.0)),
                lang=self.cfg.get("kokoro_lang", "fr-fr"),
            )
            pcm = (np.clip(np.asarray(samples), -1.0, 1.0) * 32767).astype("<i2")
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                out = Path(f.name)
            with wave.open(str(out), "wb") as w:
                w.setnchannels(1)
                w.setsampwidth(2)
                w.setframerate(int(rate))
                w.writeframes(pcm.tobytes())
            return out
        except Exception as e:  # noqa: BLE001 — synthèse KO → silence plutôt que crash
            _log(f"Kokoro: échec de synthèse ({e}).")
            return None

    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})
            # contexte assemblé (serveur) → visualiseur HUD ; pas sur un intent local
            if mode != "portail" and self.context_provider is not None:
                try:
                    ctx = self.context_provider()
                except Exception:  # noqa: BLE001
                    ctx = None
                if ctx:
                    self.emit({"type": "context", "context": ctx})
            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 + machine à états veille/éveil) ───────────
    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)

        # Transcription partielle : par défaut active seulement avec l'ASR onnx
        # (Parakeet ~11× temps réel) ; whisper large-v3 CPU est trop lent pour du live.
        engine_name = (self.cfg.get("asr_engine") or "whisper").lower()
        partial_enabled = bool(
            self.cfg.get("partial_transcription", engine_name in ("onnx", "parakeet"))
        )
        partial_interval = float(self.cfg.get("partial_interval_sec", 0.5))
        partial_min = float(self.cfg.get("partial_min_sec", 0.4))
        partial_max = float(self.cfg.get("partial_max_sec", 8.0))

        audio_q: queue.Queue = queue.Queue()
        pre_roll: deque = deque(maxlen=PRE_ROLL)
        asleep = True          # démarre en veille : attend le wake word « Asa »
        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(prefix, emit_partial: bool) -> "np.ndarray | None":
            """Capture jusqu'au silence en partant de `prefix` (pré-roll). Renvoie l'audio
            complet (pré-roll + énoncé) ou None si trop court. Si `emit_partial`, re-transcrit
            le buffer périodiquement et pousse le texte partiel au HUD."""
            captured = list(prefix)
            silent = 0
            last_partial = time.time()
            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 (emit_partial and partial_enabled
                        and time.time() - last_partial >= partial_interval):
                    dur = sum(len(c) for c in captured) / SAMPLE_RATE
                    if partial_min <= dur <= partial_max:
                        snap = np.concatenate(captured)
                        txt = self._transcribe(snap)
                        if txt:
                            self.emit({"type": "partial", "text": txt})
                    last_partial = time.time()
                if silent >= required_silent:
                    break
            if len(captured) <= len(prefix):
                return None
            audio = np.concatenate(captured)
            return audio if len(audio) / SAMPLE_RATE >= min_speech else None

        def go_asleep():
            nonlocal asleep
            asleep = True
            self.emit({"type": "partial", "text": ""})  # efface un partiel résiduel
            self.emit({"type": "state", "state": "asleep"})

        go_asleep()
        with sd.InputStream(
            samplerate=SAMPLE_RATE, channels=1, dtype="int16",
            blocksize=frame_samples, callback=callback,
        ):
            while not self._stop:
                # timeout d'inactivité en éveil → retour veille
                if not asleep and time.time() - last_activity > chat_timeout:
                    go_asleep()

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

                # parole détectée. En veille on enregistre en silence (juste pour
                # repérer le wake word) ; en éveil on montre l'écoute + les partiels.
                if not asleep:
                    self.emit({"type": "state", "state": "listening"})
                audio = record_until_silence(list(pre_roll), emit_partial=not asleep)
                if audio is None:
                    self.emit({"type": "state", "state": "asleep" if asleep else "listening"})
                    continue
                text = self._transcribe(audio)
                _log(f"({'veille' if asleep else 'eveil'}) entendu: {text!r}")
                if not text:
                    self.emit({"type": "state", "state": "asleep" if asleep else "listening"})
                    continue

                if asleep:
                    found, command = self._detect_wake(text)
                    if not found:
                        _log(f"  pas de wake word (alias={sorted(self._wake_aliases())})")
                        self.emit({"type": "state", "state": "asleep"})
                        continue
                    _log(f"  RÉVEIL (commande={command!r})")
                    asleep = False
                    last_activity = time.time()
                    self.emit({"type": "state", "state": "listening"})
                    if command:               # « Asa, quelle heure est-il ? »
                        self._respond(command)
                    self.emit({"type": "state", "state": "listening"})
                else:
                    if self._is_sleep_command(text):   # « Asa stop »
                        self.emit({"type": "user", "text": text})
                        self._speak("Je me mets en veille.")
                        go_asleep()
                        continue
                    last_activity = time.time()
                    self._respond(text)
                    self.emit({"type": "state", "state": "listening"})

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