"""
wivexx_voiceid.py — speaker verification / voice biometrics  (v1.17)
═══════════════════════════════════════════════════════════════════════
Lets WIVEXX confirm a sensitive command (system shutdown, wiping memory) is
spoken by the enrolled owner before acting on it.

Uses Resemblyzer (neural d-vector speaker embeddings).  Enroll once with a few
voice samples → an averaged 256-d voiceprint is saved to wivexx_voiceprint.npy.
At verification time the captured utterance is embedded and compared by cosine
similarity against that print.

Design rules (safety first — this guards a daily-driver assistant):
  • Everything is OPTIONAL: with Resemblyzer missing or no enrollment, the
    public verify path returns "allow" so nothing is ever locked.
  • It only REFUSES the single sensitive command on a mismatch; it never
    disables anything.

Public API (used by wivexx_api.py):
    available() -> bool          # resemblyzer importable
    enrolled() -> bool           # a voiceprint exists
    enroll(samples) -> dict      # samples: list[(audio_ndarray, rate)]
    verify(audio, rate) -> (ok: bool, score: float)
    configure(threshold=...) ; set_logger(logger) ; info() -> dict
"""

import json
import os
import time
from typing import Optional

import numpy as np

_HERE = os.path.dirname(os.path.abspath(__file__))
_PRINT_PATH = os.path.join(_HERE, "wivexx_voiceprint.npy")
_META_PATH = os.path.join(_HERE, "wivexx_voiceprint.json")

_encoder = None
_threshold = 0.75
_logger = None


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


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


def available() -> bool:
    try:
        import resemblyzer  # noqa: F401
        return True
    except Exception:
        return False


def _get_encoder():
    global _encoder
    if _encoder is None:
        from resemblyzer import VoiceEncoder
        _encoder = VoiceEncoder(verbose=False)
    return _encoder


def _embed(audio, rate: int):
    """256-d unit-normalised embedding for an utterance, or None on failure."""
    from resemblyzer import preprocess_wav
    a = np.asarray(audio)
    if a.dtype.kind in "iu":                  # int16 → float [-1, 1]
        a = a.astype(np.float32) / 32768.0
    else:
        a = a.astype(np.float32)
    if a.size == 0:
        return None
    wav = preprocess_wav(a, source_sr=int(rate))   # resamples to 16k + VAD-trims
    if wav is None or len(wav) < 16000 * 0.3:       # need ~0.3 s of voiced audio
        return None
    emb = _get_encoder().embed_utterance(wav)
    n = np.linalg.norm(emb)
    return (emb / n) if n else emb


def enrolled() -> bool:
    return os.path.exists(_PRINT_PATH)


def enroll(samples) -> dict:
    """samples: list of (audio_ndarray, rate). Averages their embeddings into
    the saved voiceprint.  Returns {ok, samples, error}."""
    if not available():
        return {"ok": False, "samples": 0,
                "error": "Voice ID needs the resemblyzer library "
                         "(pip install resemblyzer)."}
    embs = []
    for audio, rate in samples:
        try:
            e = _embed(audio, rate)
            if e is not None:
                embs.append(e)
        except Exception as ex:
            if _logger:
                _logger.debug("voiceid embed (enroll) failed: %s", ex)
    if len(embs) < 1:
        return {"ok": False, "samples": 0,
                "error": "I couldn't get a clear voice sample, sir."}
    mean = np.mean(np.stack(embs), axis=0)
    n = np.linalg.norm(mean)
    if n:
        mean = mean / n
    try:
        np.save(_PRINT_PATH, mean.astype(np.float32))
        with open(_META_PATH, "w", encoding="utf-8") as f:
            json.dump({"samples": len(embs), "dims": int(mean.shape[0]),
                       "created": time.strftime("%Y-%m-%d %H:%M")}, f, indent=2)
    except Exception as ex:
        return {"ok": False, "samples": len(embs),
                "error": f"Couldn't save the voiceprint: {ex}"}
    return {"ok": True, "samples": len(embs), "error": ""}


def clear_enrollment() -> bool:
    ok = False
    for p in (_PRINT_PATH, _META_PATH):
        try:
            if os.path.exists(p):
                os.remove(p)
                ok = True
        except Exception:
            pass
    return ok


def verify(audio, rate: int):
    """(ok, score). ok=True (score 1.0) when not enrolled or unavailable, so the
    caller decides policy; on a real comparison ok = score >= threshold."""
    if not enrolled() or not available():
        return True, 1.0
    try:
        ref = np.load(_PRINT_PATH)
        emb = _embed(audio, rate)
        if emb is None:
            return False, 0.0
        score = float(np.dot(ref, emb))
        return (score >= _threshold), score
    except Exception as ex:
        if _logger:
            _logger.warning("voiceid verify failed: %s", ex)
        return True, 1.0          # fail-open: never lock out on an internal error


def info() -> dict:
    meta = {}
    try:
        if os.path.exists(_META_PATH):
            with open(_META_PATH, encoding="utf-8") as f:
                meta = json.load(f)
    except Exception:
        meta = {}
    return {"available": available(), "enrolled": enrolled(),
            "threshold": _threshold, **meta}
