"""
wivexx_wake_profile.py — personalized, per-language wake-word profiles (v1.58)
═══════════════════════════════════════════════════════════════════════════════
The coined wake word "Wivexx" is rendered differently by the tiny wake model
for every voice and accent ("why mix", "y-bix", "wii bix", …). A guided
enrollment ("tune the wake word") records the OWNER saying the wake word ~5
times, keeps what the wake model ACTUALLY heard, and stores those renderings
in a per-language profile. The wake matcher then treats the owner's own
renderings as first-class wake variants — recognition tuned to THIS voice.

Layout (all inside ./wake_profile/ — gitignored + excluded from releases,
it contains personal voice data):
    wake_profile/profile.json      {"en": {"renderings": [...], "updated": ts},
                                    "es": {...}}
    wake_profile/en_1699999999_1.wav …   raw samples (future custom-model
                                         training data — see
                                         docs/wake_word_training.md)

Public API (all fail-safe — errors return empty/False, never raise):
    enrolled(lang) -> bool
    languages() -> list[str]
    renderings() -> set[str]      # union across languages, normalized
    refs() -> tuple[str, ...]     # glued fuzzy references (lead-ins stripped)
    add_sample(lang, rendering, audio, rate) -> bool
    clear(lang=None) -> None
"""
from __future__ import annotations

import json
import os
import re
import threading
import time
import wave

_HERE = os.path.dirname(os.path.abspath(__file__))
_DIR = os.path.join(_HERE, "wake_profile")
_PATH = os.path.join(_DIR, "profile.json")
_lock = threading.Lock()
_cache: dict | None = None
_cache_mtime: float = -1.0
# v1.58 review fix: renderings()/refs() are called on the ALWAYS-ON wake path —
# memoize their results alongside the parsed-JSON cache so an unchanged profile
# costs one getmtime, not a re-normalization + re-glue + sort per call.
_derived: dict | None = None
_MAX_RENDERINGS = 40    # per language; adaptive learning ages older ones out

_LEADINS = ("hey", "ok", "okay", "hi", "yo", "oye", "oe", "eh", "a", "e")
# v1.58 review fix: store renderings DE-ACCENTED — the wake matcher compares in
# plain ASCII (wivexx_api._wake_tokens de-accents its tokens), so an accented
# stored rendering ('güi bex') could never match anything.
_DEACCENT = str.maketrans("áéíóúüñ", "aeiouun")


def _invalidate() -> None:
    global _cache, _cache_mtime, _derived
    _cache, _cache_mtime, _derived = None, -1.0, None


def _load() -> dict:
    """Profile dict, reloaded when the file changes on disk."""
    global _cache, _cache_mtime, _derived
    try:
        mt = os.path.getmtime(_PATH)
    except OSError:
        _cache, _cache_mtime, _derived = {}, -1.0, None
        return {}
    if _cache is None or mt != _cache_mtime:
        try:
            with open(_PATH, encoding="utf-8") as f:
                data = json.load(f)
            _cache = data if isinstance(data, dict) else {}
        except Exception:
            # a corrupt/truncated profile must be VISIBLE, not silently empty —
            # the user would just notice wake recall regressed with no clue why
            try:
                import logging
                logging.getLogger("wivexx").warning(
                    "wake profile unreadable (%s) — starting empty", _PATH)
            except Exception:
                pass
            _cache = {}
        _cache_mtime = mt
        _derived = None
    return _cache


def _norm(text: str) -> str:
    """Normalize a rendering: lowercase, de-accented, letters+spaces only."""
    words = re.findall(r"[a-záéíóúüñ]+", (text or "").lower())
    return " ".join(words).translate(_DEACCENT)


def enrolled(lang: str) -> bool:
    # v1.58 review fix: judge by ACCEPTED SAMPLES, not unique renderings — a
    # consistent speaker whose six takes all transcribe identically is fully
    # enrolled with a single (excellent!) rendering.
    with _lock:
        entry = _load().get((lang or "").lower(), {})
    return (entry.get("samples", 0) >= 3
            or len(entry.get("renderings", [])) >= 3)


def languages() -> list:
    with _lock:
        return [k for k, v in _load().items()
                if isinstance(v, dict) and v.get("renderings")]


def _derive() -> dict:
    """Memoized (renderings set, refs tuple) — recomputed only when the parsed
    profile cache is refreshed (mtime change / add_sample / clear)."""
    global _derived
    with _lock:
        prof = _load()
        if _derived is not None:
            return _derived
        rends = set()
        for entry in prof.values():
            if isinstance(entry, dict):
                for r in entry.get("renderings", []):
                    n = _norm(r)
                    if not n:
                        continue
                    toks = n.split()
                    # a single token shorter than 4 letters ('mix') is too
                    # loose to be a literal variant
                    if len(toks) == 1 and len(toks[0]) < 4:
                        continue
                    rends.add(n)
        ref_list = []
        for r in sorted(rends):
            words = r.split()
            while len(words) > 1 and words[0] in _LEADINS:
                words = words[1:]
            glued = re.sub(r"(.)\1+", r"\1", "".join(words))
            # min 4: a 3-letter ref ('mix') would fuzzy-match far too loosely
            if 4 <= len(glued) <= 12 and glued not in ref_list:
                ref_list.append(glued)
        _derived = {"renderings": rends, "refs": tuple(ref_list)}
        return _derived


def renderings() -> set:
    """All enrolled renderings across every language (the wake word sounds the
    same regardless of the interface language — match them all, always)."""
    return _derive()["renderings"]


def refs() -> tuple:
    """Glued fuzzy references from the renderings, greeting lead-ins stripped:
    'hey why mix' -> 'whymix', 'y bix' -> 'ybix'. Used by the opener/bare
    fuzzy matcher alongside the built-in reference spellings."""
    return _derive()["refs"]


def add_sample(lang: str, rendering: str, audio=None, rate: int = 16000) -> bool:
    """Store one enrollment sample: the normalized rendering into the profile,
    and (when audio is given) the raw clip as a WAV for future model training."""
    lang = (lang or "en").lower()
    n = _norm(rendering)
    if not n:
        return False
    try:
        with _lock:
            os.makedirs(_DIR, exist_ok=True)
            prof = dict(_load())
            entry = dict(prof.get(lang, {}))
            rends = list(entry.get("renderings", []))
            if n in rends:
                rends.remove(n)          # move-to-end so dups refresh recency
            rends.append(n)
            # cap: keep the most recent N (adaptive learning appends over time —
            # a stale/bad rendering ages out instead of accumulating forever)
            if len(rends) > _MAX_RENDERINGS:
                rends = rends[-_MAX_RENDERINGS:]
            entry["renderings"] = rends
            entry["samples"] = int(entry.get("samples", 0)) + 1
            entry["updated"] = time.time()
            prof[lang] = entry
            # v1.58 review fix: ATOMIC write (tmp + replace) — add_sample runs on
            # every learned wake; a crash mid-dump used to truncate the file and
            # silently wipe every enrolled rendering on the next load.
            tmp = _PATH + ".tmp"
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(prof, f, ensure_ascii=False, indent=2)
            os.replace(tmp, _PATH)
            _invalidate()                # one idiom: next read re-derives
        if audio is not None:
            _save_wav(lang, audio, rate)
        return True
    except Exception:
        return False


def _save_wav(lang: str, audio, rate: int) -> None:
    try:
        import numpy as np
        pcm = np.asarray(audio).flatten()
        if pcm.dtype != np.int16:
            pcm = np.clip(pcm, -32768, 32767).astype(np.int16)
        n = 1
        while True:
            path = os.path.join(_DIR, "%s_%d_%d.wav" % (lang, int(time.time()), n))
            if not os.path.exists(path):
                break
            n += 1
        with wave.open(path, "wb") as w:
            w.setnchannels(1)
            w.setsampwidth(2)
            w.setframerate(int(rate))
            w.writeframes(pcm.tobytes())
    except Exception:
        pass


def clear(lang: str | None = None) -> None:
    try:
        with _lock:
            if lang is None:
                if os.path.exists(_PATH):
                    os.remove(_PATH)
            else:
                prof = dict(_load())
                prof.pop((lang or "").lower(), None)
                os.makedirs(_DIR, exist_ok=True)
                tmp = _PATH + ".tmp"
                with open(tmp, "w", encoding="utf-8") as f:
                    json.dump(prof, f, ensure_ascii=False, indent=2)
                os.replace(tmp, _PATH)
            _invalidate()
    except Exception:
        pass


if __name__ == "__main__":
    # pure self-test in a temp dir — NEVER touches the real profile
    import tempfile
    _DIR = tempfile.mkdtemp(prefix="wakeprof_test_")
    _PATH = os.path.join(_DIR, "profile.json")
    _cache, _cache_mtime = None, -1.0
    ok = True
    ok &= add_sample("en", "Hey, why mix?")
    ok &= add_sample("en", "Y-Bix.")
    ok &= add_sample("en", "wii bix")
    ok &= add_sample("es", "oye güi bex")
    print("renderings:", sorted(renderings()))
    print("refs:", refs())
    print("enrolled(en):", enrolled("en"), " enrolled(es):", enrolled("es"))
    assert ok
    assert "hey why mix" in renderings() and "y bix" in renderings()
    assert "whymix" in refs() and "ybix" in refs() and "wibix" in refs()
    assert enrolled("en") and not enrolled("es")
    clear()
    print("SELF-TEST PASS")
