"""
wivexx_wakeword.py — dedicated on-device wake-word detector  (v1.57, Tier 3)
═══════════════════════════════════════════════════════════════════════════════
A best-in-class ACOUSTIC keyword spotter (openWakeWord) — the same class of
technology Alexa/Google use: a tiny always-on DNN scores the SOUND of the wake
phrase, tuned to a low false-accepts-per-hour operating point, instead of
transcribing open-vocabulary audio and string-matching it (which is what makes
the Whisper path hallucinate the coined name out of ordinary/Spanish speech).

It runs on the SAME clip the wake loop already captures: the audio is fed to the
model in 80 ms frames and the peak score per wake model is taken. A hit still
passes through the Tier-2 `_wake_confirm` gate in wivexx_api (accurate re-decode
+ optional speaker verification), so this is purely an accuracy upgrade.

Design rules (this guards a daily-driver assistant — safety first):
  • FULLY OPTIONAL + fail-safe. If openWakeWord (or its runtime) isn't installed,
    or the models can't be fetched, `available()`/`ready()` return False and the
    caller falls back to the improved Whisper wake path — nothing ever breaks and
    boot is never blocked.
  • Pretrained "hey_jarvis" ships working out of the box (Wivexx's hidden alias).
  • The coined brand phrase "hey wivexx" needs a CUSTOM model (openWakeWord can't
    ship one for a made-up word). Drop a trained `hey_wivexx.onnx` into the
    project's `wake_models/` folder and it's auto-loaded next launch. Until then
    "hey wivexx" keeps working via the Whisper path. See docs/wake_word_training.md.

Public API (used by wivexx_api.py):
    available() -> bool           # openwakeword importable
    ready() -> bool               # a model is loaded
    load(threshold=..) -> bool    # download (if needed) + instantiate; idempotent
    scan_clip(audio, rate) -> (name|None, score)   # peak detection over a clip
    reset() ; configure(threshold=..) ; set_logger(logger) ; info() -> dict
    model_names() -> list[str]
"""

import glob
import os
import threading
from typing import Optional, Tuple

import numpy as np

_HERE = os.path.dirname(os.path.abspath(__file__))
# custom user/brand models (e.g. hey_wivexx.onnx) live here; auto-loaded if present
_CUSTOM_DIR = os.path.join(_HERE, "wake_models")

_logger = None
_threshold = 0.5            # openWakeWord's recommended default operating point
_model = None               # the loaded openwakeword.model.Model (or None)
_load_lock = threading.Lock()
_load_tried = False
_names: list = []


def set_logger(logger) -> None:
    global _logger
    _logger = logger


def _log(level: str, msg: str, *a) -> None:
    if _logger:
        getattr(_logger, level, _logger.info)(msg, *a)


def configure(threshold: Optional[float] = None) -> None:
    global _threshold
    if threshold is not None:
        try:
            _threshold = max(0.05, min(0.99, float(threshold)))
        except Exception:
            pass


def available() -> bool:
    """True when the openWakeWord package (+ an ONNX runtime) is importable."""
    try:
        import openwakeword  # noqa: F401
        import onnxruntime   # noqa: F401
        return True
    except Exception:
        return False


def ready() -> bool:
    return _model is not None


def model_names() -> list:
    return list(_names)


def _custom_model_paths() -> list:
    try:
        return sorted(glob.glob(os.path.join(_CUSTOM_DIR, "*.onnx")))
    except Exception:
        return []


def load(threshold: Optional[float] = None) -> bool:
    """Download the pretrained models (once, if missing) and instantiate the model
    with 'hey_jarvis' + any custom *.onnx in wake_models/. Idempotent + fail-safe:
    returns False (and leaves the caller on the Whisper path) on ANY problem."""
    global _model, _load_tried, _names
    if threshold is not None:
        configure(threshold)
    if _model is not None:
        return True
    if not available():
        return False
    with _load_lock:
        if _model is not None:
            return True
        _load_tried = True
        try:
            import openwakeword
            from openwakeword.model import Model
            # pretrained ONNX (melspectrogram + embedding + hey_jarvis, etc.) —
            # fetched to the package's resources dir on first use. Network only
            # the first time; a failure here is non-fatal (we just won't load).
            try:
                from openwakeword import utils as _owwu
                _owwu.download_models()
            except Exception as e:
                _log("debug", "openWakeWord model download skipped/failed: %s", e)
            custom = _custom_model_paths()
            wake_models = list(custom) + ["hey_jarvis"]   # custom first, alias always
            _model = Model(wakeword_models=wake_models, inference_framework="onnx")
            _names = list(getattr(_model, "models", {}).keys())
            _log("info", "openWakeWord ready — models: %s (threshold %.2f)",
                 ", ".join(_names) or "?", _threshold)
            return True
        except Exception as e:
            _model = None
            _names = []
            _log("warning", "openWakeWord load failed (%s) — using Whisper wake path.", e)
            return False


def _to_16k_int16(audio: np.ndarray, rate: int) -> Optional[np.ndarray]:
    """Wake models want 16 kHz mono int16. Resample (scipy poly) + clamp."""
    try:
        a = np.asarray(audio).flatten()
        if a.dtype.kind == "f":                       # float [-1,1] → int16 scale
            a = a * 32768.0
        a = a.astype(np.float32)
        if int(rate) != 16000:
            from scipy.signal import resample_poly
            from math import gcd
            g = gcd(int(rate), 16000)
            a = resample_poly(a, 16000 // g, int(rate) // g)
        return np.clip(a, -32768, 32767).astype(np.int16)
    except Exception as e:
        _log("debug", "wake resample failed: %s", e)
        return None


def scan_clip(audio: np.ndarray, rate: int) -> Tuple[Optional[str], float]:
    """Feed a captured clip through the acoustic model in 80 ms frames and return
    (best_model_name, peak_score) if any model crossed the threshold, else
    (None, peak_score). Fail-safe: any error → (None, 0.0) so the caller falls
    back to the Whisper path."""
    if _model is None:
        return None, 0.0
    pcm = _to_16k_int16(audio, rate)
    if pcm is None or pcm.size < 1280:
        return None, 0.0
    peak: dict = {}
    try:
        frame = 1280                                   # 80 ms @ 16 kHz (oww's native step)
        for i in range(0, pcm.size - frame + 1, frame):
            scores = _model.predict(pcm[i:i + frame])
            for k, v in scores.items():
                fv = float(v)
                if fv > peak.get(k, 0.0):
                    peak[k] = fv
        self_reset()
    except Exception as e:
        _log("debug", "openWakeWord predict failed: %s", e)
        self_reset()
        return None, 0.0
    if not peak:
        return None, 0.0
    best_name = max(peak, key=peak.get)
    best_score = peak[best_name]
    if best_score >= _threshold:
        return best_name, best_score
    return None, best_score


def self_reset() -> None:
    """Clear the model's streaming state between clips (each capture is independent)."""
    global _model
    try:
        if _model is not None and hasattr(_model, "reset"):
            _model.reset()
    except Exception:
        pass


# public alias
reset = self_reset


def info() -> dict:
    return {
        "available": available(),
        "ready": ready(),
        "threshold": _threshold,
        "models": model_names(),
        "custom_dir": _CUSTOM_DIR,
        "custom_models": [os.path.basename(p) for p in _custom_model_paths()],
    }
