"""
wivexx_i18n.py — language state + spoken-phrase localization  (v1.26)
═══════════════════════════════════════════════════════════════════════════
One place that answers "what language is Wivexx in right now?" and helps every
subsystem localize:

  • current_language()  -> "en" | "es"   (INTERFACE language only; runtime UI
                           override wins. A legacy stored "auto" migrates → "en".)
  • stt_language()      -> None ALWAYS (v1.51) — the SPOKEN language is auto-
                           detected every utterance, independent of the interface.
  • whisper_models()    -> (full, lite) model names for the chosen language
  • tts_voice()         -> the edge-tts neural voice for the chosen language
  • system_instruction()-> a line appended to Claude's system prompt so it
                           replies in the user's language
  • say(text)           -> localize a FIXED English spoken line (dictionary +
                           templates); conversational replies come from Claude
                           already in-language, so those are passed through.

The language is stored in wivexx_settings.json ("language") — set from the
control room's Settings → Language — falling back to cfg.LANGUAGE, then "en".
"""
import re

SUPPORTED = ("en", "es")            # UI + voice languages we fully ship
# v1.51: the language MENU is interface-only (en/es). "auto" is gone — the spoken
# language is ALWAYS auto-detected (see stt_language). A stored legacy "auto" is
# not in _ALLOWED, so current_language() migrates it → "en".
_ALLOWED = SUPPORTED

# Male neural voices matching the poised-butler persona.
_VOICES = {
    "en": "en-US-GuyNeural",
    "es": "es-ES-AlvaroNeural",
}
# Multilingual Whisper models (NOT the .en english-only ones) for non-English /
# auto-detect. English keeps the faster .en models (no regression).
_ML_FULL = "medium"
_ML_LITE = "small"
_EN_FULL = "medium.en"
_EN_LITE = "small.en"
# v1.38: a dedicated TINY model just for wake-word detection — ~5-8x faster than
# 'small', so the wake word is recognised almost instantly (the full/lite model
# still transcribes the actual command for accuracy).
_ML_WAKE = "tiny"
_EN_WAKE = "tiny.en"

_LANG_NAMES = {"en": "English", "es": "Spanish"}


def _cfg():
    try:
        import wivexx_config as c
        return c
    except Exception:
        return None


def current_language() -> str:
    """'en' | 'es' — the INTERFACE language. Runtime override (Settings) wins over
    cfg.LANGUAGE. A legacy stored "auto" is not in _ALLOWED → migrates to "en"."""
    v = None
    try:
        import wivexx_settings as _ws
        v = _ws.get("language", None)
    except Exception:
        v = None
    if not v:
        c = _cfg()
        v = getattr(c, "LANGUAGE", "en") if c else "en"
    v = (v or "en").lower()
    return v if v in _ALLOWED else "en"


# ── Per-turn language (v1.34) ──────────────────────────────────────────────────
# In "auto" mode the fixed confirmations ("Opening Steam, sir.") used to stay
# ENGLISH even when the user spoke Spanish, because only Claude's replies
# followed the speaker. The app now detects each utterance's language and sets
# it here; say()/system_instruction() localize to THIS turn's language.
# (Turns are serialized by the app's _TURN_LOCK, so a plain global is safe.)
_TURN_LANG = "en"

# Spanish signals. Command verbs weigh heavier — "Abre Steam." has one token
# and no accents, yet must detect as Spanish.
_ES_CMD_VERBS = {
    "abre", "abrir", "cierra", "cerrar", "pon", "reproduce", "sube", "baja",
    "apaga", "enciende", "toma", "haz", "busca", "recuerda", "recuérdame",
    "silencia", "traduce", "muestra", "dime", "guarda", "olvida", "cancela",
    "activa", "desactiva", "cambia", "quita", "empieza", "detén", "para",
    "bloquea", "bloquear",   # lock the workstation (Spanish-only, safe to detect)
    "duerme", "suspende", "hiberna", "hibernar",   # v1.53 sleep/hibernate the PC
    "tira", "avienta", "arroja", "échame", "volado",   # coin flip / dice (Spanish-only)
    # Mexican clitic-imperatives (Spanish-only; keep weak-signal commands like
    # "ponle play" / "dale pausa" from mis-detecting as English via the loanword)
    "ponle", "dale", "ponlo", "quítale", "sáltate", "tírame", "avísame", "tómame",
    # v1.37: verbs for the rest of the command set (some are weak on their own,
    # so they weigh into detection to keep a bare Spanish command → 'es').
    # NOTE: excludes English homographs (resume, transcribe, agenda, import) —
    # those would misdetect English commands as Spanish; their Spanish phrasings
    # still detect via other Spanish tokens (mi, esta, un, mañana, accents…).
    "sigue", "seguir", "tengo", "anota", "anotar", "mueve", "mover", "minimiza",
    "minimizar", "importa", "importar", "libera", "liberar", "optimiza",
    "optimizar", "registra", "registrar", "numera", "numerar",
    "transcribir", "agendar", "entrega", "entregar", "completa",
    "completar", "resumir", "reanuda", "silénciate", "añade", "añadir",
    "crea", "inicia", "detente", "reinicia", "convierte", "convierta", "convertir",
    "apunta", "apuntar", "marca", "marcar", "agéndame", "agendame",
    # v1.37: command synonyms (Spanish-only spellings — NOT English homographs
    # like run/launch/reduce/import, which are excluded to protect EN detection)
    "ejecuta", "ejecutar", "lanza", "lanzar", "arranca", "arrancar", "corre",
    "silencio", "pantallazo", "aumenta", "incrementa", "disminuye", "bájale",
    "súbele", "cuéntame", "cierre", "quita", "entra", "dale", "mata", "termina",
}
_ES_STOPWORDS = {
    "el", "la", "los", "las", "un", "una", "de", "del", "que", "qué", "cómo",
    "como", "por", "para", "con", "está", "estás", "hola", "gracias", "señor",
    "tiempo", "hora", "hoy", "mañana", "adiós", "sí", "cuál", "dónde", "cuándo",
    "quiero", "puedes", "hazlo", "favor", "buenos", "buenas", "días", "noches",
    "temporizador", "captura", "pantalla", "volumen", "clima", "y", "es", "mi",
    # v1.58: frequent casual-chat words (none are English words) so unaccented
    # small talk ("cuentame algo interesante") still reads as Spanish
    "algo", "pero", "porque", "todo", "bien", "muy", "nada", "ahora",
    "también", "tambien", "aquí", "aqui", "entonces", "oye", "dime", "eso",
}
_EN_STOPWORDS = {
    "the", "a", "an", "is", "are", "you", "my", "me", "i", "what", "how",
    "open", "close", "set", "take", "play", "turn", "to", "on", "off", "for",
    "please", "and", "of", "in", "it", "this", "that", "do", "can",
}
_ES_CHARS = re.compile(r"[¿¡ñáéíóúü]")

# v1.58: accent-insensitive Spanish detection. Whisper transcripts (and fast
# typing) often drop accents — "cuentame algo interesante" used to score ZERO
# Spanish signal and misdetect as English, so the reply came back in English.
# Every accented vocab word also matches its bare-vowel form (ñ is kept — it
# survives transcription and is a strong Spanish signal on its own).
_DEACCENT = str.maketrans("áéíóúü", "aeiouu")
_ES_CMD_VERBS |= {w.translate(_DEACCENT) for w in _ES_CMD_VERBS}
_ES_STOPWORDS |= {w.translate(_DEACCENT) for w in _ES_STOPWORDS}


def detect_lang(text: str) -> str:
    """Cheap per-utterance language detection: 'es', 'en' or 'xx' (other).
    Tuned for precision on English (the daily path must never mis-localize)."""
    t = (text or "").strip()
    if not t:
        return "en"
    low = t.lower()
    toks = set(re.findall(r"[a-záéíóúüñ']+", low))
    es = len(toks & _ES_STOPWORDS) + 2 * len(toks & _ES_CMD_VERBS)
    if _ES_CHARS.search(low):
        es += 2
    en = len(toks & _EN_STOPWORDS)
    if es and es > en:
        return "es"
    if en:
        return "en"
    # no signal either way — non-Latin scripts etc. count as "other"
    if re.search(r"[^\x00-\x7f]", t):
        return "xx"
    return "en"


def set_turn_language(lang: str) -> None:
    global _TURN_LANG
    _TURN_LANG = lang if lang in ("en", "es", "xx") else "en"


def turn_language() -> str:
    return _TURN_LANG


def stt_language():
    """Always None (v1.51). Wivexx auto-detects the SPOKEN language on every
    utterance, independent of the interface language — so you can run the app in
    English and still be understood when you speak Spanish (or vice-versa). The
    multilingual Whisper model (see whisper_models) is what makes this work."""
    return None


def whisper_models():
    """(full, lite) Whisper model names. v1.51: ALWAYS the multilingual models so the
    spoken language can be auto-detected regardless of the interface language — the
    fast english-only .en models can't detect or transcribe other languages, so
    forcing them would defeat auto-detection."""
    c = _cfg()
    return (getattr(c, "WHISPER_ML_FULL", _ML_FULL) if c else _ML_FULL,
            getattr(c, "WHISPER_ML_LITE", _ML_LITE) if c else _ML_LITE)


def whisper_wake_model() -> str:
    """The TINY model name used ONLY for fast wake-word detection (v1.38).
    v1.57: ALWAYS the MULTILINGUAL 'tiny' (never tiny.en), regardless of interface
    language — mirrors whisper_models(). The wake decode must auto-detect the SPOKEN
    language too; an English-only model forced onto Spanish can't transcribe it and
    instead HALLUCINATES the coined wake word ('vives'→'wivex'), the #1 cause of
    false wakes during casual Spanish talk. The old tiny.en branch is retired."""
    c = _cfg()
    return getattr(c, "WHISPER_ML_WAKE", _ML_WAKE) if c else _ML_WAKE


def tts_voice() -> str:
    """The edge-tts neural voice. v1.51: follows the DETECTED spoken/turn language
    when it's a non-English supported language, so a Spanish reply (even on an
    English interface) is spoken in the Spanish voice; otherwise the interface
    language's voice. Mirrors _target() exactly, so voice and text never disagree."""
    turn = turn_language()
    lang = turn if (turn in SUPPORTED and turn != "en") else current_language()
    if lang not in SUPPORTED:
        lang = "en"
    c = _cfg()
    voices = getattr(c, "VOICES", None) if c else None
    if isinstance(voices, dict) and voices.get(lang):
        return voices[lang]
    return _VOICES.get(lang, _VOICES["en"])


# Names must survive any language: "Steam" once came back as "El vapor".
_NAMES_RULE = ("Never translate proper names, app names, brand names, file names "
               "or usernames — keep them EXACTLY as written (Steam, Discord, "
               "Spotify, Lightroom…).")

# Memories are stored as (mostly English) notes. Injected into the prompt they
# used to DRAG the reply into English — a Spanish "¿recuerdas algo?" got an
# English answer (v1.35 bug). Every language hint now carries this rule.
_MEMORY_RULE = ("Stored memories/notes in your context may be written in a "
                "different language than the conversation — translate them "
                "naturally into YOUR REPLY's language; never switch languages "
                "because of them.")

_ES_HINT = ("\n\nLANGUAGE: The user's CURRENT message is in Spanish. "
            "Reply in Spanish, keeping the brief, poised butler tone "
            "(address them as 'señor'). " + _MEMORY_RULE + " " + _NAMES_RULE)
_XX_HINT = ("\n\nLANGUAGE: Reply in the EXACT language of the user's "
            "current message, keeping the brief, poised butler tone. "
            + _MEMORY_RULE + " " + _NAMES_RULE)


def system_instruction() -> str:
    """A line to append to Claude's system prompt so replies match the language."""
    lang = current_language()
    if lang == "auto":
        # v1.34: per-turn hint — the generic "detect it yourself" line was
        # ignored often enough that short Spanish turns got English replies.
        turn = turn_language()
        if turn == "es":
            return _ES_HINT
        if turn == "xx":
            return _XX_HINT
        return ("\n\nLANGUAGE: Detect the language the user writes/speaks in and "
                "ALWAYS reply in that exact same language. " + _MEMORY_RULE + " "
                + _NAMES_RULE)
    if lang == "en":
        # v1.35: "English" is the DEFAULT setting, not a handcuff. When the
        # user's current message is another language, mirror it — before this,
        # 'en' mode sent NO language rule, so the English memory block pulled
        # Spanish questions into English answers (the Harry Potter recall bug).
        turn = turn_language()
        if turn == "es":
            return _ES_HINT
        if turn == "xx":
            return _XX_HINT
        return ""
    name = _LANG_NAMES.get(lang, lang)
    return (f"\n\nLANGUAGE: ALWAYS reply in {name}, no matter what language the "
            f"user uses. Keep the same brief, poised butler tone in {name}. "
            + _MEMORY_RULE + " " + _NAMES_RULE)


# ── Spanish → English command canonicalization (v1.34) ─────────────────────────
# The command waterfall is English; instead of duplicating ~75 regexes per
# language, a non-English utterance is translated to English BEFORE matching.
# This dictionary covers the most common commands OFFLINE and with zero latency;
# anything it can't handle falls back to the app's cached Claude translation.
# Group placeholders keep names untouched ("abre Steam" → "open Steam").
# Each tuple: (spanish regex, english output[, english fallback if no groups]).
# The english output MUST match the matching command's English trigger in
# wivexx_api.py — verified by the Spanish-command test. ORDER MATTERS: specific
# patterns first; the GENERIC open/close catch-alls are LAST so e.g. "cierra
# todas las apps" hits close-all, not "close todas las apps".
_ES2EN = [
    # ── strip a leading wake/filler so the patterns below see the bare command ──
    # (handled inline per-pattern via optional prefixes)
    # ── media ──
    (re.compile(r"^pausa(?:r)?(?:\s+(?:la\s+)?(?:m[uú]sica|canci[oó]n|reproducci[oó]n))?$", re.I), "pause the music"),
    (re.compile(r"^(?:reanuda|contin[uú]a)(?:\s+(?:la\s+)?m[uú]sica)?$", re.I), "play the music"),
    (re.compile(r"^(?:siguiente|salta)(?:\s+(?:a\s+la\s+)?(?:canci[oó]n|pista))?$", re.I), "next song"),
    (re.compile(r"^(?:canci[oó]n\s+anterior|pista\s+anterior|anterior|regresa\s+(?:una\s+)?(?:canci[oó]n|pista))$", re.I), "previous song"),
    (re.compile(r"^(?:para|det[eé]n|detente|quita|apaga)\s+(?:la\s+)?(?:m[uú]sica|reproducci[oó]n)$", re.I), "stop the music"),
    (re.compile(r"^(?:ponle|dale)\s+(?:al?\s+)?(?:play|reproducir)$", re.I), "play the music"),
    (re.compile(r"^(?:ponle|dale)\s+(?:a\s+)?(?:pausa|pause)$", re.I), "pause the music"),
    (re.compile(r"^s[aá]ltate\s+(?:esta\s+|la\s+)?(?:canci[oó]n|pista|rola)$", re.I), "next song"),
    (re.compile(r"^regresa\s+(?:la\s+|una\s+)?(?:canci[oó]n|pista|rola)$", re.I), "previous song"),
    # ── coin flip / dice roll (before generic open/play — Mexican: volado/águila) ──
    (re.compile(r"^(?:lanza|tira|avienta|arroja|[eé]chame|t[ií]rame|avi[eé]ntame|arr[oó]jame|hazme|echa)\s+(?:una\s+|la\s+|un\s+)?(?:moneda|volado)(?:\s+(?:porfa|porfis|por\s+favor))?$|^(?:un\s+)?volado(?:\s+(?:porfa|porfis|por\s+favor))?$|^cara\s+o\s+(?:cruz|[aá]guila|sol)$|^vuelta\s+de\s+moneda$", re.I), "flip a coin"),
    (re.compile(r"^(?:lanza|tira|avienta|arroja|rueda|echa|[eé]chame|t[ií]rame|avi[eé]ntame|arr[oó]jame)\s+(?:un\s+|el\s+|los\s+|unos\s+|\d+\s+)?dados?$", re.I), "roll a dice"),
    # ── youtube (before generic open/play) ──
    (re.compile(r"^(?:pon|reproduce)\s+(.+?)\s+en\s+youtube$", re.I), "play {0} on youtube"),
    (re.compile(r"^busca\s+(.+?)\s+en\s+youtube$", re.I), "search youtube for {0}"),
    # ── generic "play music" (no track / no youtube) — after the youtube rule ──
    (re.compile(r"^(?:pon|ponme|reproduce)\s+(?:algo\s+de\s+|un\s+poco\s+de\s+|una\s+|la\s+|el\s+)?(?:m[uú]sica|canci[oó]n)(?:\s+(?:otra\s+vez|de\s+nuevo|ya))?$", re.I), "play the music"),
    # ── volume ──
    (re.compile(r"^(?:pon|sube|ajusta|cambia|deja)\s+(?:el\s+)?volumen\s+(?:al?\s+|a\s+)?(?:m[aá]ximo|tope)$", re.I), "set volume to 100 percent"),
    (re.compile(r"^(?:pon|baja|ajusta|cambia|deja)\s+(?:el\s+)?volumen\s+(?:al?\s+)?m[ií]nimo$", re.I), "set volume to 0 percent"),
    (re.compile(r"^(?:pon|cambia|ajusta|sube|baja|d[eé]jalo)\s+(?:el\s+)?volumen\s+(?:al?\s+|en\s+|a\s+un\s+)?(\d+)(?:\s*(?:%|por\s*ciento))?$", re.I), "set volume to {0} percent"),
    (re.compile(r"^(?:sube|aumenta|incrementa|s[uú]bele)\s+(?:el\s+|la\s+)?(?:volumen|sonido|audio|m[uú]sica)$", re.I), "volume up"),
    (re.compile(r"^(?:baja|reduce|disminuye|b[aá]jale)\s+(?:el\s+|la\s+)?(?:volumen|sonido|audio|m[uú]sica)$|^b[aá]jale\s+al?\s+(?:el\s+)?(?:volumen|sonido|audio)$", re.I), "volume down"),
    (re.compile(r"^m[aá]s\s+(?:fuerte|alto|volumen)$|^s[uú]bele$|^ponlo\s+m[aá]s\s+(?:fuerte|alto)$", re.I), "volume up"),
    (re.compile(r"^m[aá]s\s+bajo$|^b[aá]jale$|^menos\s+(?:volumen|sonido|audio)$|^ponlo\s+m[aá]s\s+(?:bajo|quedito)$", re.I), "volume down"),
    (re.compile(r"^silencio$|^sil[eé]nci(?:ate|a(?:te)?)$|^sil[eé]ncia\s+(?:el\s+|la\s+)?(?:sonido|audio|volumen|m[uú]sica)$|^sil[eé]ncia(?:lo)?\s+todo$|^mute$|^qu[ií]ta(?:le)?\s+(?:el\s+)?(?:sonido|audio|volumen)$|^sin\s+sonido$|^corta(?:le)?\s+(?:el\s+|la\s+)?(?:sonido|audio|volumen|m[uú]sica)$", re.I), "mute the sound"),  # v1.53: + "corta el sonido"
    # ── wake-word tuning (v1.58 personalized wake profile; review fix: accept
    #    natural lead-ins/modals ("quiero ajustar…", "oye, ajusta…") and tails
    #    ("… otra vez") — the anchored bare imperative was the ONLY phrasing
    #    that worked, everything else silently fell through to chat) ──
    (re.compile(r"^(?:oye[, ]+)?(?:quiero\s+|puedes\s+|podr[ií]as\s+|vamos\s+a\s+)?(?:ajustar?|calibrar?|entrenar?|mejorar?|arreglar?|afinar?|configurar?)\s+(?:la\s+|mi\s+)?palabra\s+de\s+activaci[oó]n(?:\s+(?:otra\s+vez|de\s+nuevo))?$|^entrenamiento\s+de\s+(?:la\s+)?palabra\s+de\s+activaci[oó]n$", re.I), "tune the wake word"),
    # ── screenshot / timer ──
    (re.compile(r"^(?:toma|t[oó]mame|haz|hazme|saca|s[aá]came|captura|c[aá]pturame)\s+(?:una\s+|la\s+)?(?:captura|foto|imagen|pantalla)(?:\s+de\s+.+)?$|^pantallazo$|^captura\s+(?:de|la)\s+pantalla$", re.I), "take a screenshot"),
    (re.compile(r"^(?:pon|crea|inicia|programa|configura|echa)\s+(?:un\s+)?temporizador\s+(?:de|para)\s+(.+)$", re.I), "set a timer for {0}"),
    (re.compile(r"^(?:pon|ponme|crea|programa|prong?rama)\s+(?:una\s+|un\s+)?(?:alarma|recordatorio)\s+(?:de|para|en)\s+(.+)$", re.I), "set a timer for {0}"),
    (re.compile(r"^av[ií]same\s+(?:en|dentro\s+de)\s+(.+)$", re.I), "remind me in {0}"),
    (re.compile(r"^(?:cu[aá]nto\s+tiempo\s+(?:queda|falta)|tiempo\s+restante)(?:\s+en\s+(?:el|mi)\s+temporizador)?\??$", re.I), "how much time is left on my timer"),
    (re.compile(r"^(?:tengo|hay)\s+(?:alg[uú]n\s+)?temporizador(?:es)?\s+(?:activo|activos|en\s+marcha|corriendo)\??$", re.I), "do i have any timers running"),
    # NOTE: no "alarma a las 7" (clock-time) pattern — the engine only supports
    # DURATION timers/alarms (_parse_timer_command), so an absolute-time alarm has
    # nothing to route to. Clock-time alarms are a missing FEATURE (both langs).
    # ── time / date / weather ──
    (re.compile(r"^¿?qu[eé]\s+hora\s+(?:es|son|tienes|marca)\??$", re.I), "what time is it"),
    (re.compile(r"^¿?qu[eé]\s+(?:d[ií]a|fecha)\s+es(?:\s+hoy)?\??$|^¿?a\s+qu[eé]\s+(?:d[ií]a|fecha)\s+estamos\??$", re.I), "what day is it today"),
    (re.compile(r"^¿?(?:qu[eé]\s+tiempo\s+hace|c[oó]mo\s+(?:est[aá]|va\s+a\s+estar)\s+el\s+clima|qu[eé]\s+clima\s+(?:hace|va\s+a\s+hacer|habr[aá])|va\s+a\s+llover|qu[eé]\s+temperatura\s+(?:hace|hay|va\s+a\s+hacer))(?:\s+hoy|\s+mañana)?\??$", re.I), "what's the weather like today"),
    # ── version / update / changelog ──
    (re.compile(r"^¿?(?:qu[eé]|cu[aá]l)\s+versi[oó]n\s+(?:eres|tienes|usas|es\s+esta)\??$", re.I), "which version are you running"),
    (re.compile(r"^¿?qu[eé]\s+hay\s+de\s+nuevo\??$", re.I), "what's new"),
    (re.compile(r"^¿?qu[eé]\s+(?:hay\s+de\s+nuevo\s+en\s+la\s+[uú]ltima\s+(?:versi[oó]n|actualizaci[oó]n))\??$", re.I), "what's new in the latest update"),
    (re.compile(r"^(?:mu[eé]strame|dame|ver)\s+(?:el\s+)?(?:registro\s+de\s+cambios|historial\s+de\s+cambios|changelog)$", re.I), "show me the changelog"),
    # ── battery ──
    (re.compile(r"^(?:estado\s+de\s+la\s+bater[ií]a|cu[aá]nta\s+bater[ií]a\s+(?:queda|tengo|me\s+queda))\??$", re.I), "battery status"),
    # ── lock the workstation ──
    (re.compile(r"^(?:bloquea|bloquear)\s+(?:mi\s+|la\s+|el\s+|esta\s+)?(?:pc|computadora|compu|ordenador|equipo|pantalla|sesi[oó]n|laptop|m[aá]quina)$", re.I), "lock the pc"),
    # v1.53: sleep / suspend / hibernate the PC. "pon…" REQUIRES "a dormir" (bare
    # "pon la pc" is incomplete); "duerme/suspende" already mean it.
    (re.compile(r"^(?:(?:pon|ponga|manda|mande)\s+(?:mi\s+|la\s+|el\s+|esta\s+)?(?:pc|computadora|compu|ordenador|equipo|laptop|m[aá]quina)\s+a\s+dormir|(?:duerme|suspende|suspender)\s+(?:mi\s+|la\s+|el\s+|esta\s+)?(?:pc|computadora|compu|ordenador|equipo|laptop|m[aá]quina))$", re.I), "sleep the pc"),
    (re.compile(r"^(?:hiberna|hibernar)(?:\s+(?:mi\s+|la\s+|el\s+|esta\s+)?(?:pc|computadora|compu|ordenador|equipo|laptop|m[aá]quina))?$", re.I), "hibernate the pc"),
    # ── call mode (v1.0: Spanish voice toggle — there was NO mapping at all, so
    #    "activa el modo llamada" fell through to Claude, which faked it) ──
    (re.compile(r"^(?:activa|enciende|pon|inicia|entra\s+(?:en|a)|cambia\s+a|activar|poner)\s+(?:el\s+)?modo\s+(?:de\s+)?llamadas?$|^modo\s+(?:de\s+)?llamadas?$", re.I), "turn on call mode"),
    (re.compile(r"^(?:desactiva|apaga|quita|termina|cierra|sal\s+de|desactivar)\s+(?:el\s+)?modo\s+(?:de\s+)?llamadas?$", re.I), "turn off call mode"),
    # ── work mode ──
    (re.compile(r"^(?:activa|enciende|pon|inicia|entra\s+(?:en|a)|cambia\s+a)\s+(?:el\s+)?modo\s+trabajo$|^modo\s+trabajo$", re.I), "turn on work mode"),
    (re.compile(r"^(?:desactiva|apaga|quita|termina|sal\s+de)\s+(?:el\s+)?modo\s+trabajo$", re.I), "turn off work mode"),
    # ── gaming / performance mode ──
    (re.compile(r"^(?:activa|enciende|pon|inicia|entra\s+(?:en|a)|cambia\s+a)\s+(?:el\s+)?modo\s+(?:juego|gaming|gamer|rendimiento)$|^modo\s+(?:juego|gamer|rendimiento)$", re.I), "turn on gaming mode"),
    (re.compile(r"^(?:desactiva|apaga|quita|termina|sal\s+de)\s+(?:el\s+)?modo\s+(?:juego|gaming|rendimiento)$", re.I), "turn off gaming mode"),
    # ── memory ──
    (re.compile(r"^recu[eé]rdame\s+(.+)$", re.I), "remind me {0}"),
    (re.compile(r"^recuerda\s+que\s+(.+)$", re.I), "remember that {0}"),
    (re.compile(r"^olvida\s+(?:lo\s+que\s+te\s+dije\s+(?:sobre|de|del|acerca\s+de)\s+)(.+)$", re.I), "forget what i told you about {0}"),
    (re.compile(r"^olvida\s+(mi\s+.+)$", re.I), "forget {0}"),
    (re.compile(r"^olvida\s+(.+)$", re.I), "forget what i told you about {0}"),
    (re.compile(r"^¿?qu[eé]\s+(?:sabes|recuerdas)\s+(?:de|sobre|acerca\s+de)\s+m[ií]\??$", re.I), "what do you know about me"),
    (re.compile(r"^¿?qu[eé]\s+recuerdas\s+(?:de|sobre|acerca\s+de)\s+(.+?)\??$", re.I), "what do you remember about {0}"),
    # ── goals ──
    (re.compile(r"^(?:a[ñn]ade|agrega|crea|p[oó]n(?:me)?)\s+(?:un\s+|una\s+)?(?:objetivo|meta)\s+(?:de\s+|para\s+)?(.+)$", re.I), "add a goal to {0}"),
    (re.compile(r"^(?:completa|termina|marca)\s+(?:mi\s+|el\s+|la\s+)?primer(?:a)?\s+(?:objetivo|meta)(?:\s+como\s+(?:completad[oa]|hech[oa]|terminad[oa]|list[oa]))?$", re.I), "complete my first goal"),
    (re.compile(r"^(?:completa|termina|marca)\s+(?:mi\s+|el\s+|la\s+)?segund[oa]\s+(?:objetivo|meta)$", re.I), "complete my second goal"),
    (re.compile(r"^(?:completa|termina|marca)\s+(?:mi\s+|el\s+|la\s+)?tercer(?:a)?\s+(?:objetivo|meta)$", re.I), "complete my third goal"),
    (re.compile(r"^(?:completa|termina|marca)\s+(?:mi\s+|el\s+|la\s+)?[uú]ltim[oa]\s+(?:objetivo|meta)$", re.I), "complete my last goal"),
    (re.compile(r"^(?:completa|termina|marca)\s+(?:mi\s+|la\s+)?(?:objetivo|meta)(?:\s+como\s+(?:completad[oa]|hech[oa]|terminad[oa]|list[oa]))?$", re.I), "complete my first goal"),
    (re.compile(r"^¿?(?:cu[aá]les\s+son\s+mis\s+(?:objetivos|metas)|mis\s+(?:objetivos|metas)|lista\s+de\s+(?:objetivos|metas))\??$", re.I), "list my goals"),
    # ── social ──
    (re.compile(r"^sigue\s+(?:a\s+)?@?([\w.]+)\s+en\s+instagram$", re.I), "track {0} on instagram"),
    (re.compile(r"^(?:deja\s+de\s+seguir|no\s+sigas)\s+(?:a\s+)?@?([\w.]+)$", re.I), "stop tracking {0}"),
    (re.compile(r"^(?:dame\s+mi|mu[eé]strame\s+mi)?\s*informe\s+de\s+redes(?:\s+sociales)?$|^informe\s+de\s+redes(?:\s+sociales)?$", re.I), "social media report"),
    # ── captions / delivery ──
    (re.compile(r"^(?:crea|haz|genera)\s+(?:un\s+)?subt[ií]tulo\s+para\s+(.+)$", re.I), "create a caption for {0}"),
    (re.compile(r"^subtitula\s+(.+)$", re.I), "create a caption for {0}"),
    (re.compile(r"^entrega\s+(?:las\s+)?fotos\s+(?:al|a\s+mi)\s+cliente$", re.I), "deliver the photos to my client"),
    # ── disk / pending ──
    (re.compile(r"^¿?qu[eé]\s+(?:est[aá]\s+llenando|ocupa)\s+(?:mi\s+)?disco\??$|^analiza\s+(?:mi\s+)?disco$", re.I), "analyze my disk"),
    (re.compile(r"^¿?(?:tengo|hay)\s+(?:alguna\s+)?tareas?\s+pendientes?\??$|^tareas\s+pendientes\??$", re.I), "any pending tasks"),
    # ── shutdown ──
    (re.compile(r"^apaga\s+(?:el\s+|la\s+)?(?:pc|ordenador|computadora|compu|equipo|m[aá]quina|sistema)(?:\s+en\s+(.+))?$", re.I), "shut down the pc in {0}", "shut down the pc"),
    (re.compile(r"^cancela\s+el\s+apagado$", re.I), "cancel the shutdown"),
    # ── joke ──
    (re.compile(r"^cu[eé]ntame\s+(?:un\s+)?(?:otro\s+)?chiste$|^(?:otro\s+)?chiste$", re.I), "tell me a joke"),
    # ── notes ──
    (re.compile(r"^(?:toma|haz|crea)\s+(?:una\s+)?nota[:\s]+(.+)$", re.I), "take a note: {0}"),
    (re.compile(r"^(?:anota|apunta)\s+que\s+(.+)$", re.I), "note that {0}"),
    # "apunta" also means "aim/point" ("apunta bien el balón") — require a colon
    # for the direct form so only a clear note ("apunta: comprar leche") matches.
    (re.compile(r"^(?:anota[:\s]+|apunta\s*:\s*)(.+)$", re.I), "take a note: {0}"),
    # ── audio output ──
    (re.compile(r"^(?:cambia|pon)\s+(?:la\s+)?salida\s+(?:de\s+audio\s+)?(?:a|al)\s+(.+)$", re.I), "change output to {0}"),
    # ── clipboard ──
    (re.compile(r"^(?:resume|resumir)\s+(?:mi\s+)?portapapeles$", re.I), "summarize my clipboard"),
    (re.compile(r"^traduce\s+(?:el\s+|mi\s+)?portapapeles\s+(?:a|al)\s+(.+)$", re.I), "translate the clipboard to {0}"),
    (re.compile(r"^reescribe\s+(?:mi\s+)?portapapeles$", re.I), "rewrite my clipboard"),
    # ── window control ──
    (re.compile(r"^mueve\s+(?:esta\s+)?ventana\s+al\s+otro\s+monitor$", re.I), "move this window to the other monitor"),
    (re.compile(r"^minimiza\s+todo\s+(?:excepto|menos)\s+(.+)$", re.I), "minimize everything except {0}"),
    # ── calendar ──
    (re.compile(r"^ag[eé]nda(?:me)?\s+(.+)$", re.I), "schedule a meeting {0}"),
    (re.compile(r"^¿?qu[eé]\s+tengo\s+(?:en\s+(?:el|mi)\s+calendario|agendad[oa]|programad[oa])(?:\s+.+)?\??$", re.I), "what's on my calendar tomorrow"),
    # ── lightroom / overlays / speedtest / transcribe / optimize ──
    (re.compile(r"^importa\s+(?:a|en)\s+lightroom$", re.I), "import to lightroom"),
    (re.compile(r"^muestra\s+(?:la\s+)?superposici[oó]n(?:\s+de\s+rendimiento)?$|^muestra\s+el\s+(?:hud|panel\s+de\s+rendimiento)$", re.I), "show the performance hud"),
    (re.compile(r"^(?:muestra|activa)\s+(?:la\s+)?superposici[oó]n\s+(?:de|del)\s+juego$", re.I), "show the game overlay"),
    (re.compile(r"^(?:haz\s+una\s+|corre\s+una\s+|prueba\s+la\s+)?prueba\s+de\s+velocidad$|^¿?qu[eé]\s+tan\s+r[aá]pida\s+es\s+mi\s+internet\??$", re.I), "run a speed test"),
    (re.compile(r"^transcribe\s+(?:esta\s+|esa\s+)?(?:grabaci[oó]n|audio|video)$", re.I), "transcribe this recording"),
    (re.compile(r"^(?:libera|liberar)\s+(?:memoria|ram)$|^optimiza\s+(?:mi\s+)?(?:pc|ordenador|computadora)$", re.I), "free up memory"),
    # ── voice enroll / screen numbering / click ──
    (re.compile(r"^registra\s+mi\s+voz$", re.I), "enroll my voice"),
    (re.compile(r"^numera\s+(?:la\s+)?pantalla$", re.I), "number the screen"),
    (re.compile(r"^(?:haz\s+)?clic\s+(?:en\s+(?:el\s+)?(?:n[uú]mero\s+)?)?(\d+)$", re.I), "click {0}"),
    # ── usage / add app / save conversation ──
    (re.compile(r"^¿?cu[aá]ntos\s+tokens\s+(?:he\s+usado|llevo)(?:\s+hoy)?\??$|^uso\s+de\s+tokens$", re.I), "how many tokens have i used today"),
    (re.compile(r"^(?:a[ñn]ade|agrega)\s+(?:la\s+)?app\s+(.+)$", re.I), "add app {0}"),
    (re.compile(r"^guarda\s+(?:la\s+|esta\s+)?conversaci[oó]n$", re.I), "save this conversation"),
    # ── calculator / conversions (math only — factual "cuánto es X" that isn't
    #    math still falls to conversation, which uses the ORIGINAL Spanish) ──
    (re.compile(r"^¿?cu[aá]nto(?:s)?\s+(?:es|son)\s+(.+?)\s+por\s+ciento\s+de\s+(.+)$", re.I), "what is {0} percent of {1}"),
    (re.compile(r"^¿?convierte\s+(.+)$", re.I), "convert {0}"),
    (re.compile(r"^¿?cu[aá]nto(?:s)?\s+(?:es|son)\s+((?:.*\d.*)|.*\b(?:m[aá]s|menos|por|entre|dividido|ra[ií]z|elevado)\b.*)$", re.I), "what is {0}"),
    # ── dismiss / misc ──
    (re.compile(r"^(?:adi[oó]s|hasta\s+luego)(?:[,\s]+wivexx)?$", re.I), "goodbye wivexx"),
    (re.compile(r"^(?:eso\s+es\s+todo|nada\s+m[aá]s|descansa)(?:[,\s]+wivexx)?$", re.I), "that's all"),
    (re.compile(r"^rep[ií]te(?:lo)?$|^repite\s+eso$", re.I), "repeat that"),
    (re.compile(r"^¿?qu[eé]\s+puedes\s+hacer\??$", re.I), "what can you do"),
    # ── pronoun close/open (before the generic app rules) so a follow-up like
    #    "abre discord" … "ciérralo" / "cierra eso" resolves to the last app via
    #    _resolve_app_pronoun ("it"/"that"). Clitic -lo/-la + eso/esa demonstratives. ──
    (re.compile(r"^(?:ci[eé]rra(?:lo|la|los|las)|ci[eé]rra\s+(?:eso|esa|esto|esta|esa\s+(?:app|aplicaci[oó]n|ventana)))$", re.I), "close it"),
    (re.compile(r"^(?:[aá]bre(?:lo|la|los|las)|[aá]bre\s+(?:eso|esa|esto|esta))$", re.I), "open it"),
    # ── GENERIC open / close (MUST be last; many spoken variants, like the EN
    #    open/launch/start/run set). open/close only ACT on a registered app, so
    #    a loose "abre la puerta" resolves to no app and falls to conversation. ──
    (re.compile(r"^(?:cierra|cierre|quita|mata|termina|finaliza)\s+(?:todas?\s+las?\s+|todos?\s+los?\s+)(?:apps?|aplicaciones|programas|ventanas)$", re.I), "close all apps"),
    (re.compile(r"^(?:ci[eé]rra(?:me)?|cierre|quita|mata|termina|finaliza)\s+todo$", re.I), "close all apps"),
    # (?!…moneda/dado) — "lanza"/"tira" double as coin/dice verbs; don't let the
    # generic open rule greedily eat "lanza una moneda, <chat>" (→ "open una…").
    # Blocked here, it falls to the coin/dice rules or the ES inline extractor.
    (re.compile(r"^(?:oye\s+|ok\s+)?(?:wivexx[,.\s]+)?(?:[aá]bre(?:me)?|abrir|ejecuta|ejecutar|lanza|lanzar|arranca|arrancar|inicia|iniciar|corre|pon\s+en\s+marcha|dale\s+a|entra\s+(?:a|en))\s+(?!(?:una?\s+|los?\s+|unos\s+)?(?:moneda|volado|dados?)\b)(.+)$", re.I), "open {0}"),
    (re.compile(r"^(?:wivexx[,.\s]+)?(?:cierra|cierre|cerrar|quita|mata|matar|termina|terminar|finaliza|sal\s+de|s[aá]came\s+de)\s+(?:la\s+|el\s+)?(.+)$", re.I), "close {0}"),
]


# Spanish measurement units → the English tokens wivexx_calc understands, so a
# Spanish conversion ("convierte 5 millas a kilómetros") routes to the
# deterministic converter instead of falling to the brain (which fabricates bad
# math, e.g. "5 millas = diez kilómetros"). Kilograms BEFORE grams so
# "kilogramos" isn't half-matched by the "gramos" rule.
_ES_UNIT_MAP = [
    # speed compounds first (so "millas por hora" → mph, not "miles" + leftover)
    (r"\bmillas?\s+por\s+hora\b", "mph"),
    (r"\bkil[oó]metros?\s+por\s+hora\b", "kmh"),
    (r"\bmillas?\b", "miles"),
    (r"\bkil[oó]metros?\b", "kilometers"),
    (r"\bcent[ií]metros?\b", "centimeters"),
    (r"\bmil[ií]metros?\b", "millimeters"),
    (r"\bmetros?\b", "meters"),
    (r"\bpies\b", "feet"), (r"\bpie\b", "foot"),
    (r"\bpulgadas?\b", "inches"),
    (r"\byardas?\b", "yards"),
    (r"\blibras?\b", "pounds"),
    (r"\bmiligramos?\b", "mg"),
    (r"\bk(?:ilo)?gramos?\b|\bkilos?\b", "kilograms"),
    (r"\bgramos?\b", "grams"),
    (r"\bonzas?\b", "ounces"),
    (r"\btoneladas?\b", "tonnes"),
    (r"\bmililitros?\b", "milliliters"),
    (r"\blitros?\b", "liters"),
    (r"\bgalones?\b|\bgal[oó]n\b", "gallons"),
    (r"\btazas?\b", "cups"),
    (r"\bgrados?\s+(?:celsius|cent[ií]grados?)\b", "celsius"),
    (r"\bgrados?\s+fahrenheit\b", "fahrenheit"),
]


def _es_convert_units(tail: str) -> str:
    """Translate Spanish units + connector into the English tokens the local
    calculator parses ("5 millas a kilómetros" -> "5 miles to kilometers")."""
    s = tail
    for pat, en in _ES_UNIT_MAP:
        s = re.sub(pat, en, s, flags=re.IGNORECASE)
    s = re.sub(r"\s+(?:a|en|hacia)\s+", " to ", s)   # connector: a/en/hacia → to
    return s


def _es_math(tail: str) -> str:
    """Translate Spanish arithmetic operators into the English words the local
    calculator parses ("10 menos 3" -> "10 minus 3"). "por ciento" (percent) is
    protected before "por" (times), and "N coma M" becomes the decimal N.M."""
    s = re.sub(r"\bpor\s+ciento\b", "percent", tail, flags=re.IGNORECASE)
    s = re.sub(r"\b(?:la\s+)?ra[ií]z\s+(?:cuadrada\s+)?de\b", "square root of", s, flags=re.IGNORECASE)
    s = re.sub(r"\b(?:elevado\s+a|a\s+la\s+potencia\s+de)\b", "to the power of", s, flags=re.IGNORECASE)
    s = re.sub(r"\bdividido\s+(?:por|entre)\b|\bentre\b", "divided by", s, flags=re.IGNORECASE)
    s = re.sub(r"\bm[aá]s\b", "plus", s, flags=re.IGNORECASE)
    s = re.sub(r"\bmenos\b", "minus", s, flags=re.IGNORECASE)
    s = re.sub(r"\bpor\b", "times", s, flags=re.IGNORECASE)
    s = re.sub(r"(\d)\s+coma\s+(\d)", r"\1.\2", s, flags=re.IGNORECASE)   # decimal
    return s


# Whisper transcribes spoken numbers as WORDS, so cardinals must become digits
# ("cinco" → 5) or the volume-percent / timer / click parsers (which need \d+)
# never fire. Spanish time units then become English ("10 minutos" → None in the
# timer parser, silently breaking every Spanish timer) — but ONLY when a digit
# precedes them, so the question-words in "qué hora es" / "qué día es" survive.
# NOTE: "cien/ciento" are excluded so "por ciento" (percent) is never mangled.
_ES_ONES = {"uno": 1, "dos": 2, "tres": 3, "cuatro": 4, "cinco": 5, "seis": 6,
            "siete": 7, "ocho": 8, "nueve": 9}
_ES_TENS = {"treinta": 30, "cuarenta": 40, "cincuenta": 50, "sesenta": 60,
            "setenta": 70, "ochenta": 80, "noventa": 90}
_ES_CARDINALS = {
    "cero": 0, "diez": 10, "once": 11, "doce": 12, "trece": 13, "catorce": 14,
    "quince": 15, "dieciseis": 16, "dieciséis": 16, "diecisiete": 17,
    "dieciocho": 18, "diecinueve": 19, "veinte": 20, "veintiuno": 21,
    "veintiuna": 21, "veintidos": 22, "veintidós": 22, "veintitres": 23,
    "veintitrés": 23, "veinticuatro": 24, "veinticinco": 25, "veintiseis": 26,
    "veintiséis": 26, "veintisiete": 27, "veintiocho": 28, "veintinueve": 29,
    **_ES_ONES, **_ES_TENS,
}
_ES_TENS_RE = re.compile(r"\b(" + "|".join(_ES_TENS) + r")\s+y\s+(" +
                         "|".join(_ES_ONES) + r")\b", re.IGNORECASE)
_ES_CARD_RE = re.compile(r"\b(" + "|".join(sorted(_ES_CARDINALS, key=len,
                         reverse=True)) + r")\b", re.IGNORECASE)


def _es_unit_en(num: str, unit: str) -> str:
    unit = unit.lower()
    base = ("minute" if unit.startswith("minuto") else
            "second" if unit.startswith("segundo") else
            "hour" if unit.startswith("hora") else "day")
    return f"{num} {base}" + ("" if num == "1" else "s")


def _es_num_units(text: str) -> str:
    """Normalize Spanish cardinals → digits and QUANTIFIED time units → English
    so the deterministic timer/volume/click parsers (which need digits + English
    units) fire. Leaves "por ciento", app names, articles and the bare question
    words in "qué hora es"/"qué día es hoy" intact."""
    # Spanish FRACTIONAL time → explicit minutes/seconds (the timer parser knows
    # "half an hour" in English but not "media hora"). Do these BEFORE cardinals;
    # "tres cuartos" must precede the single-"cuarto" rule. Only "… de hora" /
    # "hora y …" forms match, so a bare "cuarto" (room) / "media" (average) is safe.
    s = re.sub(r"\bmedi[ao]\s+hora\b", "30 minutes", text, flags=re.IGNORECASE)
    s = re.sub(r"\bmedi[ao]\s+minuto\b", "30 seconds", s, flags=re.IGNORECASE)
    s = re.sub(r"\btres\s+cuartos\s+de\s+hora\b", "45 minutes", s, flags=re.IGNORECASE)
    s = re.sub(r"\b(?:un\s+)?cuartos?\s+de\s+hora\b", "15 minutes", s, flags=re.IGNORECASE)
    s = re.sub(r"\b(?:una\s+)?hora\s+y\s+media\b", "90 minutes", s, flags=re.IGNORECASE)
    s = re.sub(r"\b(?:una\s+)?hora\s+y\s+cuarto\b", "75 minutes", s, flags=re.IGNORECASE)
    s = _ES_TENS_RE.sub(lambda m: str(_ES_TENS[m.group(1).lower()]
                                      + _ES_ONES[m.group(2).lower()]), s)
    s = _ES_CARD_RE.sub(lambda m: str(_ES_CARDINALS[m.group(1).lower()]), s)
    # "N horas y media / y cuarto" (number now a digit) → explicit minutes
    s = re.sub(r"\b(\d+)\s+horas?\s+y\s+media\b",
               lambda m: f"{int(m.group(1)) * 60 + 30} minutes", s, flags=re.IGNORECASE)
    s = re.sub(r"\b(\d+)\s+horas?\s+y\s+cuarto\b",
               lambda m: f"{int(m.group(1)) * 60 + 15} minutes", s, flags=re.IGNORECASE)
    # "un/una/uno" → 1 ONLY right before a Spanish time unit, so the article in
    # "un temporizador" / "una alarma" / "una nota" is untouched.
    s = re.sub(r"\b(?:un|una|uno)\s+(?=(?:minutos?|segundos?|horas?|d[ií]as?)\b)",
               "1 ", s, flags=re.IGNORECASE)
    # translate a time unit to English ONLY when a digit precedes it (a quantity)
    s = re.sub(r"\b(\d+)\s+(minutos?|segundos?|horas?|d[ií]as?)\b",
               lambda m: _es_unit_en(m.group(1), m.group(2)), s, flags=re.IGNORECASE)
    return s


def quick_english(text: str):
    """Offline Spanish→English canonicalization for the common commands.
    Returns the English command (names preserved verbatim) or None."""
    t = (text or "").strip().strip(".!?").strip()
    # v1.46: strip leading/trailing politeness (incl. English "please" a bilingual
    # user tacks on) so "sube el volumen please" / "por favor pon la música" still
    # match the anchored command rules. No command starts/ends with these words.
    t = re.sub(r"^(?:por\s+favor|porfa|porfis|please|plis)[\s,]+", "", t, flags=re.I)
    t = re.sub(r"[\s,]+(?:por\s+favor|porfa|porfis|please|plis|porfavor)\s*$", "", t, flags=re.I)
    # v1.50: a leading discourse "no,"/"nel," is a correction ("no, cierra la app" =
    # "no wait, close the app"), not the command itself. COMMA-anchored on purpose so
    # the Spanish negative imperative "no cierres" (no comma) is left untouched → None.
    t = re.sub(r"^(?:no|nel|nop|nope)[,]\s+", "", t, flags=re.I)
    t = _es_num_units(t)
    for entry in _ES2EN:
        rx, fmt = entry[0], entry[1]
        m = rx.match(t)
        if not m:
            continue
        groups = [g for g in m.groups() if g is not None]
        if len(entry) > 2 and not groups:      # optional-group variant
            return entry[2]
        try:
            out = fmt.format(*[g.strip(" .,!?") for g in groups])
        except Exception:
            continue
        if out.startswith("convert "):         # localize units so it routes to calc
            out = "convert " + _es_convert_units(out[len("convert "):])
        elif out.startswith("what is ") and " percent of " not in out:
            out = "what is " + _es_math(out[len("what is "):])   # ES math operators
        return out
    return None


# ── Fixed string localization (spoken confirmations + the tk fallback UI) ─────
# Conversational answers come from Claude already in-language. These cover the
# hard-coded tool CONFIRMATIONS ("Opening Chrome, sir.") and the tk sidecar's
# labels. Strategy: exact dict → templates → persistent cache → (optional)
# Claude translator, cached. English or a failure → the text is returned as-is.
import json as _json
import os as _os
import threading as _threading

_HERE = _os.path.dirname(_os.path.abspath(__file__))
_CACHE_PATH = _os.path.join(_HERE, "wivexx_i18n_cache.json")
_cache = None
_cache_lock = _threading.Lock()
_translator = None          # fn(text, target_lang_code) -> translated str (set by the app)


def set_translator(fn):
    """The app registers a Claude-backed translator so ANY string (spoken line or
    tk label) can be localized on demand and cached — guaranteeing full coverage
    beyond the hand dictionary. Without it, unknown strings stay English."""
    global _translator
    _translator = fn


def _load_cache() -> dict:
    global _cache
    if _cache is None:
        try:
            with open(_CACHE_PATH, encoding="utf-8") as f:
                data = _json.load(f)
            _cache = data if isinstance(data, dict) else {}
        except Exception:
            _cache = {}
    return _cache


def _cache_put(lang: str, text: str, tr: str) -> None:
    with _cache_lock:
        c = _load_cache()
        c.setdefault(lang, {})[text] = tr
        try:
            with open(_CACHE_PATH, "w", encoding="utf-8") as f:
                _json.dump(c, f, ensure_ascii=False, indent=0)
        except Exception:
            pass


_EXACT = {
    "es": {
        # spoken confirmations
        "Yes, sir?": "¿Sí, señor?",
        "Sir?": "¿Señor?",
        "At your service, sir.": "A su servicio, señor.",
        "How can I help, sir?": "¿En qué puedo ayudar, señor?",
        "Standing by, sir.": "En espera, señor.",
        "Done, sir.": "Hecho, señor.",
        "It's done, sir.": "Está hecho, señor.",
        "Right away, sir.": "Enseguida, señor.",
        "Of course, sir.": "Por supuesto, señor.",
        "As you wish, sir.": "Como desee, señor.",
        "Goodbye, sir.": "Hasta luego, señor.",
        "I'm here, sir.": "Aquí estoy, señor.",
        "I'm listening, sir.": "Le escucho, señor.",
        "Working on it, sir.": "Trabajando en ello, señor.",
        "One moment, sir.": "Un momento, señor.",
        "Anything else, sir?": "¿Algo más, señor?",
        "I didn't catch that, sir.": "No entendí eso, señor.",
        "I couldn't understand that, sir.": "No pude entender eso, señor.",
        "I couldn't do that, sir.": "No pude hacer eso, señor.",
        "Something went wrong, sir.": "Algo salió mal, señor.",
        "Muting.": "Silenciando.",
        "Unmuting.": "Reactivando el sonido.",
        # memory command confirmations
        "I have nothing recorded about that, sir.": "No tengo nada registrado sobre eso, señor.",
        "I couldn't read my memory, sir.": "No pude leer mi memoria, señor.",
        "I couldn't store that, sir.": "No pude guardar eso, señor.",
        "I couldn't update my memory, sir.": "No pude actualizar mi memoria, señor.",
        "I have no stored memory matching that, sir. You can review everything in the Memory manager.":
            "No tengo ningún recuerdo guardado que coincida, señor. Puede revisarlo todo en el gestor de memoria.",
        # tk sidecar labels / sections
        "Settings": "Configuración",
        "Brightness": "Brillo",
        "Audio Outputs": "Salidas de audio",
        "Environments": "Entornos",
        "Facts about you": "Datos sobre ti",
        "Open/Close Commands": "Comandos de abrir/cerrar",
        "Storage Import": "Importación de almacenamiento",
        "Topic summaries": "Resúmenes de temas",
        "Low-power mode (automatic)": "Modo de bajo consumo (automático)",
        "Quit": "Salir",
        "Close": "Cerrar",
        "Save": "Guardar",
        "Cancel": "Cancelar",
        "Add": "Añadir",
        "Delete": "Eliminar",
        "Memory": "Memoria",
        "Goals": "Objetivos",
        "Shortcuts": "Atajos",
        "Commands": "Comandos",
        # ── spoken command answers (v1.35) — offline so they're instant + exact ──
        "Authentication error, sir. Please verify the API key.": "Error de autenticación, señor. Por favor verifique la clave API.",
        "Back to the previous track, sir.": "Vuelvo a la pista anterior, señor.",
        "Calendar support isn't available, sir.": "El soporte de calendario no está disponible, señor.",
        "Call mode is already off, sir.": "El modo llamada ya está desactivado, señor.",
        "Call mode is already on, sir.": "El modo llamada ya está activado, señor.",
        "Cleared your clipboard, sir.": "He vaciado su portapapeles, señor.",
        "Clipboard control only works on the PC, sir.": "El control del portapapeles solo funciona en la PC, señor.",
        "Conversation memory cleared, sir.": "Memoria de conversación borrada, señor.",
        "Conversation saved, sir.": "Conversación guardada, señor.",
        "Couldn't play that, sir.": "No pude reproducir eso, señor.",
        "Couldn't start recording, sir.": "No pude iniciar la grabación, señor.",
        "Give me the topic or attach the post itself, sir.": "Deme el tema o adjunte la publicación, señor.",
        "Gaming overlay hidden, sir.": "Superposición de juego oculta, señor.",
        "Gaming overlay is up, sir — transparent and click-through.": "Superposición de juego activa, señor — transparente y con clics traspasables.",
        "I asked the server for a joke. It said 404: humor not found, sir.": "Le pedí un chiste al servidor. Respondió 404: humor no encontrado, señor.",
        "I can't analyse those file types yet, sir — images, PDFs and text files work.": "Aún no puedo analizar esos tipos de archivo, señor — funcionan imágenes, PDF y archivos de texto.",
        "I can't confirm it's your voice, sir, so I won't change what I remember. Say 'enroll my voice' to re-register.": "No puedo confirmar que sea su voz, señor, así que no cambiaré lo que recuerdo. Diga 'registra mi voz' para volver a registrarla.",
        "I can't confirm it's your voice, sir, so I won't shut the PC down. Say 'enroll my voice' to re-register.": "No puedo confirmar que sea su voz, señor, así que no apagaré la PC. Diga 'registra mi voz' para volver a registrarla.",
        "I couldn't add that goal, sir.": "No pude añadir ese objetivo, señor.",
        "I couldn't adjust the volume, sir.": "No pude ajustar el volumen, señor.",
        "I couldn't clear the clipboard, sir.": "No pude vaciar el portapapeles, señor.",
        "I couldn't create that event, sir.": "No pude crear ese evento, señor.",
        "I couldn't explain that, sir.": "No pude explicar eso, señor.",
        "I couldn't find a matching open goal, sir.": "No encontré un objetivo abierto que coincida, señor.",
        "I couldn't find anything clickable on the active window, sir.": "No encontré nada en lo que hacer clic en la ventana activa, señor.",
        "I couldn't find that environment, sir.": "No encontré ese entorno, señor.",
        "I couldn't issue the shutdown command, sir.": "No pude emitir el comando de apagado, señor.",
        "I couldn't launch Lightroom Classic, sir.": "No pude abrir Lightroom Classic, señor.",
        "I couldn't load performance mode, sir.": "No pude cargar el modo rendimiento, señor.",
        "I couldn't optimize just now, sir.": "No pude optimizar en este momento, señor.",
        "I couldn't reach that output device, sir. Make sure it is connected.": "No pude alcanzar ese dispositivo de salida, señor. Asegúrese de que esté conectado.",
        "I couldn't reach the audio device, sir.": "No pude alcanzar el dispositivo de audio, señor.",
        "I couldn't reach the media controls, sir.": "No pude alcanzar los controles multimedia, señor.",
        "I couldn't reach the speed-test server, sir.": "No pude alcanzar el servidor de prueba de velocidad, señor.",
        "I couldn't reach your calendar, sir.": "No pude acceder a su calendario, señor.",
        "I couldn't read battery status, sir.": "No pude leer el estado de la batería, señor.",
        "I couldn't read that file, sir.": "No pude leer ese archivo, señor.",
        "I couldn't read the goal list, sir.": "No pude leer la lista de objetivos, señor.",
        "I couldn't read the network adapter details, sir.": "No pude leer los detalles del adaptador de red, señor.",
        "I couldn't read the social trackers, sir.": "No pude leer los rastreadores sociales, señor.",
        "I couldn't read those files, sir.": "No pude leer esos archivos, señor.",
        "I couldn't retrieve live results right now, sir.": "No pude obtener resultados en vivo ahora mismo, señor.",
        "I couldn't save that app, sir.": "No pude guardar esa app, señor.",
        "I couldn't save the note, sir.": "No pude guardar la nota, señor.",
        "I couldn't summarise that, sir.": "No pude resumir eso, señor.",
        "I couldn't tell which output device you meant, sir.": "No supe a qué dispositivo de salida se refería, señor.",
        "I couldn't toggle the gaming overlay, sir.": "No pude alternar la superposición de juego, señor.",
        "I couldn't toggle the overlay, sir.": "No pude alternar la superposición, señor.",
        "I couldn't toggle the performance overlay, sir.": "No pude alternar la superposición de rendimiento, señor.",
        "I couldn't update that goal, sir.": "No pude actualizar ese objetivo, señor.",
        "I couldn't update the trackers, sir.": "No pude actualizar los rastreadores, señor.",
        "I didn't catch anything to note, sir.": "No capté nada para anotar, señor.",
        "I didn't catch enough clear audio, sir — try enrolling again somewhere quiet.": "No capté suficiente audio claro, señor — intente registrarse de nuevo en un lugar silencioso.",
        "I didn't catch the path, sir. Please try again.": "No capté la ruta, señor. Por favor inténtelo de nuevo.",
        "I don't have that routine loaded, sir.": "No tengo esa rutina cargada, señor.",
        "I encountered an error analysing the files, sir.": "Encontré un error analizando los archivos, señor.",
        "I encountered an error analysing those files, sir.": "Encontré un error analizando esos archivos, señor.",
        "I encountered an error processing that, sir.": "Encontré un error procesando eso, señor.",
        "I encountered an error, sir.": "Encontré un error, señor.",
        "I had trouble with the clipboard, sir.": "Tuve problemas con el portapapeles, señor.",
        "I need a time, sir — try 'schedule a call tomorrow at 3pm for an hour'.": "Necesito una hora, señor — pruebe 'agenda una llamada mañana a las 3pm por una hora'.",
        "I need an Anthropic API key for that, sir — the two-minute setup guide is in the chat.": "Necesito una clave API de Anthropic para eso, señor — la guía de configuración de dos minutos está en el chat.",
        "I was unable to save the conversation, sir.": "No pude guardar la conversación, señor.",
        "I would tell you a UDP joke, but you might not get it, sir.": "Le contaría un chiste sobre UDP, pero quizá no le llegue, señor.",
        "I'm afraid that function isn't in my current build, sir.": "Me temo que esa función no está en mi versión actual, señor.",
        "I'm afraid that isn't something I can action from here, sir.": "Me temo que eso no es algo que pueda hacer desde aquí, señor.",
        "I'm still listening, sir.": "Sigo escuchando, señor.",
        "Enrolling your voice has to be done at the PC with the mic, sir — say 'enroll my voice' there.": "Registrar su voz debe hacerse en la PC con el micrófono, señor — diga 'registra mi voz' allí.",
        "Client delivery runs on the PC, sir — that's where the RAW files live and where I develop and upload them. Say it again at the workstation and I'll open the delivery window.": "La entrega a clientes funciona en la PC, señor — ahí están los archivos RAW y ahí los revelo y subo. Dígalo de nuevo en la estación de trabajo y abriré la ventana de entrega.",
        "Opening client delivery, sir — point me at the shoot folder and I'll develop the RAWs, upload them to a new Drive gallery, and hand you the share link.": "Abriendo la entrega a clientes, señor — indíqueme la carpeta de la sesión y revelaré los RAW, los subiré a una nueva galería de Drive y le daré el enlace para compartir.",
        "Opening the caption studio, sir — drop in the video or photo, pick the platforms, and I'll handle the rest.": "Abriendo el estudio de subtítulos, señor — suelte el video o la foto, elija las plataformas y yo me encargo del resto.",
        "Done — I rewrote it and copied the new version to your clipboard, sir.": "Hecho — lo reescribí y copié la nueva versión a su portapapeles, señor.",
        "Mic calibration cleared, sir. Restart Wivexx to re-calibrate.": "Calibración del micrófono borrada, señor. Reinicie Wivexx para recalibrar.",
        "Microphone unmuted, sir.": "Micrófono reactivado, señor.",
        "My apologies, sir. I was unable to retrieve an answer.": "Mis disculpas, señor. No pude obtener una respuesta.",
        "My database fell in love — it had too many relationships, sir.": "Mi base de datos se enamoró — tenía demasiadas relaciones, señor.",
        "No applications appear to be running, sir.": "No parece haber aplicaciones en ejecución, señor.",
        "No battery detected — you appear to be on a desktop system, sir.": "No se detectó batería — parece estar en un equipo de escritorio, señor.",
        "No connection to the API, sir.": "Sin conexión con la API, señor.",
        "No connection, sir.": "Sin conexión, señor.",
        "No file picked, sir — transcription cancelled.": "No se eligió archivo, señor — transcripción cancelada.",
        "Note saved, sir.": "Nota guardada, señor.",
        "Nothing is pending at the moment, sir. All tasks are clear.": "No hay nada pendiente por ahora, señor. Todas las tareas están al día.",
        "Nothing was recorded, sir.": "No se grabó nada, señor.",
        "Of course, sir. Standing by.": "Por supuesto, señor. En espera.",
        "On it, sir — optimizing now.": "Enseguida, señor — optimizando ahora.",
        "Pardon, sir?": "¿Perdón, señor?",
        "Paused, sir.": "En pausa, señor.",
        "Performance mode hit a snag, sir.": "El modo rendimiento tuvo un problema, señor.",
        "Performance overlay hidden, sir.": "Superposición de rendimiento oculta, señor.",
        "Performance overlay is up, sir.": "Superposición de rendimiento activa, señor.",
        "Playing, sir.": "Reproduciendo, señor.",
        "Rate limit reached, sir.": "Límite de solicitudes alcanzado, señor.",
        "Ready for your request, sir.": "Listo para su solicitud, señor.",
        "Request logged, sir.": "Solicitud registrada, señor.",
        "Running a speed test, sir — one moment.": "Ejecutando una prueba de velocidad, señor — un momento.",
        "Screen control isn't available, sir.": "El control de pantalla no está disponible, señor.",
        "Shutdown cancelled, sir.": "Apagado cancelado, señor.",
        "Shutting down all systems. Goodbye, sir.": "Apagando todos los sistemas. Hasta luego, señor.",
        "Shutting down the system now, sir. Goodbye.": "Apagando el sistema ahora, señor. Hasta luego.",
        "Shutting down. Goodbye, sir.": "Apagando. Hasta luego, señor.",
        "Skipping to the next track, sir.": "Paso a la siguiente pista, señor.",
        "Stable directory not found, sir.": "No se encontró el directorio estable, señor.",
        "Starting the drive analysis now, sir. I'll note the results in the Analytics folder and tell you here when it's done.": "Comenzando el análisis de disco ahora, señor. Anotaré los resultados en la carpeta Analytics y le avisaré aquí cuando termine.",
        "Analysing your drives now, sir. I'll show the progress and let you know the moment it's done.": "Analizando sus discos ahora, señor. Le mostraré el progreso y le avisaré en cuanto termine.",
        "Status overlay hidden, sir.": "Superposición de estado oculta, señor.",
        "Status overlay is up, sir.": "Superposición de estado activa, señor.",
        "Stopping playback, sir.": "Deteniendo la reproducción, señor.",
        "Sure, sir — pick the audio or video file.": "Claro, señor — elija el archivo de audio o video.",
        "That capability hasn't been programmed into my systems yet, sir.": "Esa capacidad aún no está programada en mis sistemas, señor.",
        "That doesn't look like an audio or video file I can transcribe, sir.": "Eso no parece un archivo de audio o video que pueda transcribir, señor.",
        "That falls outside my operational parameters at the moment, sir.": "Eso queda fuera de mis parámetros operativos por el momento, señor.",
        "That file had no speech I could make out, sir.": "Ese archivo no tenía voz que pudiera distinguir, señor.",
        "That needs the uiautomation library, sir — install it with pip install uiautomation.": "Eso necesita la librería uiautomation, señor — instálela con pip install uiautomation.",
        "That particular capability isn't available in my current configuration, sir.": "Esa capacidad en particular no está disponible en mi configuración actual, señor.",
        "That rewrite failed, sir.": "Esa reescritura falló, señor.",
        "That subroutine doesn't exist in my current configuration, sir.": "Esa subrutina no existe en mi configuración actual, señor.",
        "That's not part of my skill set at the moment, sir.": "Eso no forma parte de mis habilidades por el momento, señor.",
        "The audio device isn't responding, sir. Please set a working playback device as the Windows default.": "El dispositivo de audio no responde, señor. Por favor establezca un dispositivo de reproducción funcional como predeterminado de Windows.",
        "The caption engine ran into a problem, sir.": "El motor de subtítulos tuvo un problema, señor.",
        "The clipboard module isn't available, sir.": "El módulo del portapapeles no está disponible, señor.",
        "The drive analysis ran into a problem, sir.": "El análisis de disco tuvo un problema, señor.",
        "The gaming overlay only runs on the PC, sir.": "La superposición de juego solo funciona en la PC, señor.",
        "The performance overlay only runs on the PC, sir.": "La superposición de rendimiento solo funciona en la PC, señor.",
        "The scheduled shutdown has been cancelled, sir.": "El apagado programado ha sido cancelado, señor.",
        "The social tracker ran into a problem, sir. Check the log.": "El rastreador social tuvo un problema, señor. Revise el registro.",
        "The speed test failed, sir.": "La prueba de velocidad falló, señor.",
        "The status overlay only runs on the PC, sir.": "La superposición de estado solo funciona en la PC, señor.",
        "The transcription failed, sir.": "La transcripción falló, señor.",
        "The translation failed, sir.": "La traducción falló, señor.",
        "There is no conversation to save yet, sir.": "Aún no hay conversación para guardar, señor.",
        "There is no conversation to save, sir.": "No hay conversación para guardar, señor.",
        "There is no shutdown scheduled, sir.": "No hay ningún apagado programado, señor.",
        "Voice ID isn't available, sir.": "La identificación por voz no está disponible, señor.",
        "Voice ID needs the resemblyzer library, sir — install it with pip install resemblyzer.": "La identificación por voz necesita la librería resemblyzer, señor — instálela con pip install resemblyzer.",
        "What would you like me to note down, sir?": "¿Qué le gustaría que anote, señor?",
        "Why did the AI go to therapy? Too many unresolved issues, sir.": "¿Por qué la IA fue a terapia? Demasiados asuntos sin resolver, señor.",
        "Why do programmers prefer dark mode? Because light attracts bugs, sir.": "¿Por qué los programadores prefieren el modo oscuro? Porque la luz atrae a los bichos, señor.",
        "Window control only works on the PC, sir.": "El control de ventanas solo funciona en la PC, señor.",
        "Wivexx shutting the system down, sir.": "Wivexx apagando el sistema, señor.",
        "Work mode disengaged, sir.": "Modo trabajo desactivado, señor.",
        "Work mode engaged, sir. I'll extract every new zip in Downloads automatically — and the Lightroom shortcut imports the newest folder.": "Modo trabajo activado, señor. Extraeré cada nuevo zip en Descargas automáticamente — y el atajo de Lightroom importa la carpeta más reciente.",
        "Work mode isn't available, sir.": "El modo trabajo no está disponible, señor.",
        "You haven't used the API yet today, sir.": "Aún no ha usado la API hoy, señor.",
        "Your clipboard is empty, sir — copy something first.": "Su portapapeles está vacío, señor — copie algo primero.",
        "psutil is not available, so I cannot read battery status, sir.": "psutil no está disponible, así que no puedo leer el estado de la batería, señor.",
        "What is the post about, sir? Say, for example, 'create a caption for my sunset drone video'.": "¿De qué trata la publicación, señor? Diga, por ejemplo, 'crea un subtítulo para mi video con dron del atardecer'.",
        "My apologies, sir — something went wrong with that. I've noted it in the log.": "Mis disculpas, señor — algo salió mal con eso. Lo he anotado en el registro.",
    },
}
# templates: (compiled english regex, spanish format string). Group 1 → {0}.
# The {0} placeholder passes NAMES through untouched — the remote translator
# once turned "Steam" into "El vapor", so every canned line that carries a
# name should have a template here (names never reach the translator).
_TEMPLATES = {
    "es": [
        (re.compile(r"^Opening (.+?), sir\.$"),        "Abriendo {0}, señor."),
        (re.compile(r"^Opening (.+?) in your browser, sir\.$"), "Abriendo {0} en su navegador, señor."),
        (re.compile(r"^Closing (.+?), sir\.$"),        "Cerrando {0}, señor."),
        (re.compile(r"^I couldn't open (.+?), sir\.$"), "No pude abrir {0}, señor."),
        (re.compile(r"^I couldn't close (.+?), sir\.$"), "No pude cerrar {0}, señor."),
        (re.compile(r"^Timer set for (.+?), sir\.$"),  "Temporizador puesto para {0}, señor."),
        (re.compile(r"^Timer set for (.+?), sir\. I'll let you know when time is up\.$"),
         "Temporizador puesto para {0}, señor. Le avisaré cuando acabe el tiempo."),
        (re.compile(r"^Setting a timer for (.+?)\.$"), "Poniendo un temporizador para {0}."),
        (re.compile(r"^Volume (.+?), sir\.$"),         "Volumen {0}, señor."),
        (re.compile(r"^Taking a screenshot, sir\.$"),  "Tomando una captura de pantalla, señor."),
        (re.compile(r"^Searching for (.+?)\.$"),       "Buscando {0}."),
        (re.compile(r"^(.+?) has been closed, sir\.$"), "{0} ha sido cerrado, señor."),
        (re.compile(r"^(.+?) have been closed, sir\.$"), "{0} han sido cerrados, señor."),
        (re.compile(r"^(.+?) doesn't appear to be running, sir\.$"), "{0} no parece estar en ejecución, señor."),
        (re.compile(r"^Audio output switched to (.+?), sir\.$"), "Salida de audio cambiada a {0}, señor."),
        (re.compile(r"^Searching YouTube for (.+?), sir\.$"), "Buscando {0} en YouTube, señor."),
        (re.compile(r"^Committed to memory, sir — (.+?)\.$"), "Guardado en memoria, señor — {0}."),
        (re.compile(r"^Here is what I have on record, sir — (.+?)\.$"), "Esto es lo que tengo registrado, señor — {0}."),
        (re.compile(r"^Forgotten, sir — I removed (\d+) memories: (.+?)\.$"), "Olvidado, señor — eliminé {0} recuerdos: {1}."),
        (re.compile(r"^Forgotten, sir — I removed (\d+) memory: (.+?)\.$"), "Olvidado, señor — eliminé {0} recuerdo: {1}."),
        (re.compile(r"^You have no timers running, sir\.$"), "No tiene temporizadores activos, señor."),
        (re.compile(r"^Well done, sir — '(.+?)' is complete\. That's (\d+) this week\.$"),
         "Bien hecho, señor — '{0}' está completado. Van {1} esta semana."),
        # ── parameterized command answers (v1.35) ──
        (re.compile(r"^Closed (\d+) applications, sir\.$"), "Cerré {0} aplicaciones, señor."),
        (re.compile(r"^Closed (\d+) (.+?) apps?, sir\.$"), "Cerré {0} apps de {1}, señor."),
        (re.compile(r"^No (.+?) apps appear to be running, sir\.$"), "No parece haber apps de {0} en ejecución, señor."),
        (re.compile(r"^Version (.+?), sir\.$"), "Versión {0}, señor."),
        (re.compile(r"^I'm running Wivexx version (.+?), sir\.$"), "Estoy en la versión {0} de Wivexx, señor."),
        (re.compile(r"^I'm running version (.+?), sir\. Check CHANGES\.md for details\.$"), "Estoy en la versión {0}, señor. El registro completo está en CHANGES.md."),
        (re.compile(r"^I'm on version (.+?), sir\. The full changelog is in CHANGES\.md\.$"), "Estoy en la versión {0}, señor. El registro de cambios completo está en CHANGES.md."),
        (re.compile(r"^Clicked (.+?), sir\.$"), "Hice clic en {0}, señor."),
        (re.compile(r"^The time is (.+?), sir\.$"), "Son las {0}, señor."),
        (re.compile(r"^Today is (.+?), sir\.$"), "Hoy es {0}, señor."),
        (re.compile(r"^Moved (.+?) to the (.+?) monitor, sir\.$"), "Moví {0} al monitor {1}, señor."),
        (re.compile(r"^Understood, sir\. The system will shut down in (.+?)\. Say 'cancel shutdown' to stop it\.$"),
         "Entendido, señor. El sistema se apagará en {0}. Diga 'cancela el apagado' para detenerlo."),
        (re.compile(r"^Are you sure you want to shut down the PC (.+?), sir\? Reply 'confirm shutdown' to proceed, or 'cancel shutdown' to stop\.$"),
         "¿Está seguro de que desea apagar la PC {0}, señor? Responda 'confirmar apagado' para continuar, o 'cancela el apagado' para detener."),
        (re.compile(r"^Goal added, sir: (.+?)\. Manage it from the Goals menu\.$"), "Objetivo añadido, señor: {0}. Gestiónelo desde el menú de Objetivos."),
        (re.compile(r"^Tracking @(.+?) on Instagram now, sir\. One moment for the first reading\.$"),
         "Siguiendo @{0} en Instagram ahora, señor. Un momento para la primera lectura."),
        (re.compile(r"^Understood, sir — I've stopped tracking @(.+?) and cleared its history\.$"),
         "Entendido, señor — dejé de seguir @{0} y borré su historial."),
        (re.compile(r"^I wasn't tracking @(.+?), sir\. Say 'social media report' to hear what I do track\.$"),
         "No estaba siguiendo @{0}, señor. Diga 'informe de redes sociales' para oír qué sí sigo."),
        (re.compile(r"^An update to version (.+?) is ready, sir — installing it now\. Back in a moment\.$"),
         "Una actualización a la versión {0} está lista, señor — instalándola ahora. Vuelvo en un momento."),
        (re.compile(r"^Translated to (.+?) and copied it back to your clipboard, sir\.$"), "Traducido a {0} y copiado de vuelta a su portapapeles, señor."),
        (re.compile(r"^Added (.+?) to the app registry, sir\.$"), "Añadí {0} al registro de apps, señor."),
        (re.compile(r"^Minimised (.+?) windows?, sir\.$"), "Minimicé {0} ventanas, señor."),
        (re.compile(r"^Stable version updated with (\d+) files, sir\.$"), "Versión estable actualizada con {0} archivos, señor."),
        (re.compile(r"^(.+?) environment initialized, sir\. Everything is ready\.$"), "Entorno {0} inicializado, señor. Todo está listo."),
        (re.compile(r"^(.+?) environment initialized, sir — (\d+) of (\d+) items launched\.$"), "Entorno {0} inicializado, señor — {1} de {2} elementos abiertos."),
        (re.compile(r"^(.+?) environment closed, sir — (\d+) applications? terminated\.$"), "Entorno {0} cerrado, señor — {1} aplicaciones cerradas."),
        (re.compile(r"^The (.+?) environment has nothing configured to open, sir\.$"), "El entorno {0} no tiene nada configurado para abrir, señor."),
        (re.compile(r"^Nothing from the (.+?) environment appears to be running, sir\.$"), "Nada del entorno {0} parece estar en ejecución, señor."),
        (re.compile(r"^What is the executable path for (.+?), sir\?$"), "¿Cuál es la ruta del ejecutable de {0}, señor?"),
        (re.compile(r"^I couldn't find a window for (.+?), sir\.$"), "No encontré una ventana para {0}, señor."),
        (re.compile(r"^I couldn't find an output device matching (.+?), sir\.$"), "No encontré un dispositivo de salida que coincida con {0}, señor."),
        (re.compile(r"^I couldn't retrieve the weather for (.+?), sir\.$"), "No pude obtener el clima de {0}, señor."),
        (re.compile(r"^(.+?) doesn't seem to be installed, or I couldn't find it, sir\.$"), "{0} no parece estar instalado, o no pude encontrarlo, señor."),
        (re.compile(r"^Here is the current weather, sir: (.+)$"), "Este es el clima actual, señor: {0}"),
        (re.compile(r"^Here is what I can do, sir: (.+)$"), "Esto es lo que puedo hacer, señor: {0}"),
        (re.compile(r"^Here's the gist, sir: (.+)$"), "Aquí va lo esencial, señor: {0}"),
        (re.compile(r"^Here it is, sir: (.+)$"), "Aquí está, señor: {0}"),
        (re.compile(r"^Here is what's pending, sir: (.+)$"), "Esto es lo que está pendiente, señor: {0}"),
        (re.compile(r"^Here is what I have on record, sir — (.+)$"), "Esto es lo que tengo registrado, señor — {0}"),
        (re.compile(r"^Are you sure you want to shut down the PC (.+?), sir\?$"), "¿Está seguro de que desea apagar la PC {0}, señor?"),
        (re.compile(r"^Battery is at (.+?) percent and (.+?), sir\.$"), "La batería está al {0} por ciento y {1}, señor."),
        (re.compile(r"^Booked '(.+?)' (.+?) at (.+?) for (.+?), sir\.$"), "Agendé '{0}' {1} a las {2} por {3}, señor."),
        (re.compile(r"^By the way, sir — your (.+?) timer has finished\.$"), "Por cierto, señor — su temporizador de {0} ha terminado."),
        (re.compile(r"^Enrollment didn't take, sir — (.+?)\.$"), "El registro no funcionó, señor — {0}."),
        (re.compile(r"^First reading complete, sir: @(.+?) has (.+?) followers and (.+?) posts\. Growth figures will appear once I have a few days of history\. I'll brief you once per session, or ask me any time\.$"),
         "Primera lectura completa, señor: @{0} tiene {1} seguidores y {2} publicaciones. Las cifras de crecimiento aparecerán cuando tenga unos días de historial. Le informaré una vez por sesión, o pregúnteme cuando quiera."),
        (re.compile(r"^I can't analyse those file types, sir — use 'Save to PC' instead\. \(Unsupported: (.+?)\)$"),
         "No puedo analizar esos tipos de archivo, señor — use 'Guardar en la PC' en su lugar. (No admitido: {0})"),
        (re.compile(r"^I couldn't add that tracker, sir — (.+?)\.$"), "No pude añadir ese rastreador, señor — {0}."),
        (re.compile(r"^I couldn't click (.+?), sir\.$"), "No pude hacer clic en {0}, señor."),
        (re.compile(r"^I couldn't find (.+?), sir, so I left your windows as they were\.$"), "No encontré {0}, señor, así que dejé sus ventanas como estaban."),
        (re.compile(r"^I don't have a number (.+?) on screen, sir — say 'number the screen' first\.$"), "No tengo un número {0} en pantalla, señor — diga 'numera la pantalla' primero."),
        (re.compile(r"^I found (.+?) but couldn't move it, sir\.$"), "Encontré {0} pero no pude moverlo, señor."),
        (re.compile(r"^I translated it but couldn't write it back, sir\. Here it is: (.+)$"), "Lo traduje pero no pude escribirlo de vuelta, señor. Aquí está: {0}"),
        (re.compile(r"^Minimised everything except (.+?), sir — (.+?) windows tucked away\.$"), "Minimicé todo excepto {0}, señor — {1} ventanas guardadas."),
        (re.compile(r"^Version (.+?), sir\. No TL;DR section found\.$"), "Versión {0}, señor. No se encontró sección de resumen."),
        (re.compile(r"^Wivexx version (.+?) online\. All systems operational, sir\.$"), "Wivexx versión {0} en línea. Todos los sistemas operativos, señor."),
        (re.compile(r"^(.+?) clickable items numbered, sir — say 'click' and the number\.$"), "{0} elementos clicables numerados, señor — diga 'clic' y el número."),
        (re.compile(r"^You have (.+?), sir\.$"), "Tiene {0}, señor."),
        (re.compile(r"^Your voiceprint is saved, sir — (.+?) samples\. Shutdown and memory-wipe commands now need your voice\.$"),
         "Su huella de voz está guardada, señor — {0} muestras. Los comandos de apagado y borrado de memoria ahora requieren su voz."),
        (re.compile(r"^@(.+?) is a private account, sir — I can see the follower count \((.+?)\) but not the posts, so video and comment stats won't be available\.$"),
         "@{0} es una cuenta privada, señor — puedo ver el número de seguidores ({1}) pero no las publicaciones, así que no habrá estadísticas de video ni comentarios."),
    ],
}


def _target() -> str:
    """The language to localize FIXED strings into. In 'auto' AND 'en' (default)
    mode this follows the CURRENT TURN's detected language (v1.34/v1.35) — fixed
    confirmations used to stay English when the user spoke Spanish. A forced
    non-English setting ('es') always localizes; pure-English turns never do."""
    lang = current_language()
    if lang in ("auto", "en"):
        turn = turn_language()
        return turn if turn in SUPPORTED and turn != "en" else ""
    return lang


def _translate(text: str, allow_remote: bool = True) -> str:
    """Localize a fixed English string (spoken line OR tk label): exact → template
    → cache → Claude translator (cached). Returns the text unchanged on en/auto,
    empty input, unknown-with-no-translator, or any error."""
    try:
        target = _target()
        if not target:
            return text
        t = (text or "").strip()
        if not t:
            return text
        exact = _EXACT.get(target, {})
        if t in exact:
            return exact[t]
        for rx, fmt in _TEMPLATES.get(target, []):
            m = rx.match(t)
            if m:
                return fmt.format(*m.groups())
        # v1.58: NEVER "translate" text that is ALREADY in the target language.
        # Claude's conversational replies arrive in-language (the system hint
        # works); shipping an already-Spanish reply to the remote translator
        # made it BACK-TRANSLATE to English — and cache the poison — which is
        # exactly the "I speak Spanish and it answers in English" bug.
        # (Review fix: the guard runs AFTER the hand-written exact/template
        # lookups — an ENGLISH fixed line whose {0} payload is Spanish ("I
        # translated it … Here it is: hola, ¿cómo estás?") can misdetect as
        # Spanish, and must still win its hand-written template first.)
        if detect_lang(t) == target:
            return t
        cached = _load_cache().get(target, {}).get(t)
        if cached is not None:
            return cached
        if allow_remote and _translator is not None:
            try:
                tr = _translator(t, target)
                if tr and tr.strip() and tr.strip().lower() != t.lower():
                    tr = tr.strip()
                    _cache_put(target, t, tr)
                    return tr
            except Exception:
                pass
        return text
    except Exception:
        return text


def cmd_cache_get(text: str):
    """Cached English canonicalization of a non-English command utterance."""
    return _load_cache().get("cmd_en", {}).get((text or "").strip().lower())


def cmd_cache_put(text: str, en: str) -> None:
    _cache_put("cmd_en", (text or "").strip().lower(), en)


def say(text: str) -> str:
    """Localize a fixed English SPOKEN line to the current language."""
    return _translate(text, allow_remote=True)


def ui(text: str, allow_remote: bool = True) -> str:
    """Localize a fixed English UI label (tk sidecar) to the current language.
    Pass allow_remote=False on the UI thread (dict/cache only — never blocks on a
    network call); a background pre-warm uses allow_remote=True to fill the cache."""
    return _translate(text, allow_remote=allow_remote)


def prewarm(texts) -> None:
    """Translate + cache a batch of fixed strings (call from a background thread).
    Populates the cache so the next UI-thread ui(..., allow_remote=False) is instant."""
    for t in list(texts or []):
        try:
            _translate(t, allow_remote=True)
        except Exception:
            pass


if __name__ == "__main__":
    # self-test: turn-language detection, offline command canonicalization, and
    # name-preserving localization. Pure offline — no settings writes, no network.
    fails = []
    def _chk(name, ok, extra=""):
        print(("PASS" if ok else "FAIL"), name, extra)
        if not ok:
            fails.append(name)

    _chk("es: Abre Steam", detect_lang("Abre Steam.") == "es")
    _chk("es: qué hora es", detect_lang("¿Qué hora es?") == "es")
    _chk("es: cierra discord", detect_lang("cierra Discord") == "es")
    _chk("es: hola", detect_lang("hola") == "es")
    _chk("en: open steam", detect_lang("open steam") == "en")
    _chk("en: la la land trap", detect_lang("close la la land") == "en")
    _chk("en: steam closed", detect_lang("Steam has been closed") == "en")
    _chk("en: what time", detect_lang("what time is it") == "en")

    _chk("cmd: abre", quick_english("Abre Steam.") == "open Steam", quick_english("Abre Steam."))
    _chk("cmd: cierra", quick_english("Cierra Steam") == "close Steam", quick_english("Cierra Steam"))
    _chk("cmd: volumen", quick_english("sube el volumen") == "volume up")
    _chk("cmd: captura", quick_english("toma una captura de pantalla") == "take a screenshot")
    _chk("cmd: temporizador", quick_english("pon un temporizador de 10 minutos") == "set a timer for 10 minutes", quick_english("pon un temporizador de 10 minutos"))
    _chk("cmd: temporizador palabra", quick_english("pon un temporizador de cinco minutos") == "set a timer for 5 minutes", quick_english("pon un temporizador de cinco minutos"))
    _chk("cmd: volumen palabra", quick_english("pon el volumen al cincuenta por ciento") == "set volume to 50 percent", quick_english("pon el volumen al cincuenta por ciento"))
    _chk("cmd: hora", quick_english("¿Qué hora es?") == "what time is it")
    _chk("cmd: youtube", quick_english("pon lofi beats en youtube") == "play lofi beats on youtube")
    _chk("cmd: salida audio", quick_english("cambia la salida a primary") == "change output to primary")
    _chk("cmd: chat is None", quick_english("me encanta la pizza con piña") is None)
    # v1.45: ES pronoun follow-ups → "close it"/"open it" (resolve to last app)
    _chk("cmd: ciérralo", quick_english("ciérralo") == "close it", quick_english("ciérralo"))
    _chk("cmd: cierra eso", quick_english("cierra eso") == "close it")
    _chk("cmd: ábrelo", quick_english("ábrelo") == "open it")
    _chk("cmd: coin volado", quick_english("échame un volado") == "flip a coin")
    _chk("cmd: dado", quick_english("tira un dado") == "roll a dice")
    _chk("cmd: compu lock", quick_english("bloquea la compu") == "lock the pc")
    # v1.53: sleep / hibernate the PC (Spanish)
    _chk("cmd: pon a dormir", quick_english("pon la pc a dormir") == "sleep the pc", quick_english("pon la pc a dormir"))
    _chk("cmd: duerme compu", quick_english("duerme la computadora") == "sleep the pc", quick_english("duerme la computadora"))
    _chk("cmd: suspende pc", quick_english("suspende la pc") == "sleep the pc")
    _chk("cmd: hiberna pc", quick_english("hiberna la pc") == "hibernate the pc", quick_english("hiberna la pc"))
    _chk("cmd: dormir chat None", quick_english("me voy a dormir") is None)
    _chk("cmd: corta el sonido", quick_english("corta el sonido") == "mute the sound", quick_english("corta el sonido"))
    # v1.50: leading discourse "no,"/"nel," is a correction → the command still fires,
    # but the negative imperative "no cierres" (no comma) stays None (never fires).
    _chk("cmd: no, cierra", quick_english("no, cierra la app") == "close app", quick_english("no, cierra la app"))
    _chk("cmd: nel, pon", quick_english("nel, pon música") == "play the music")
    _chk("cmd: no cierres stays None", quick_english("no cierres la app") is None)
    _chk("cmd: no me silencies None", quick_english("no me silencies") is None)

    # v1.51: spoken-language detection is ALWAYS automatic + decoupled from the
    # interface language; the "auto" menu option is retired (migrates → en).
    _chk("stt: always auto-detect (None)", stt_language() is None)
    _chk("whisper: multilingual (not english-only)",
         not whisper_models()[0].endswith(".en") and not whisper_models()[1].endswith(".en"),
         whisper_models())
    _chk("interface: 'auto' retired from the menu", "auto" not in _ALLOWED)
    _chk("interface: current_language is en/es only", current_language() in SUPPORTED,
         current_language())

    # the Harry Potter recall bug's exact question must detect as Spanish
    _chk("es: harry potter recall", detect_lang(
        "Bueno, cuéntame, ¿qué sabes sobre Harry Potter que te hablaba yo en el "
        "pasado? ¿Recuerdas algo?") == "es")

    # name-preserving localization: force auto mode + a Spanish turn
    _cur = current_language
    globals()["current_language"] = lambda: "auto"
    try:
        set_turn_language("es")
        tr = say("Opening Steam, sir.")
        _chk("say: template + name kept", tr == "Abriendo Steam, señor.", tr)
        tr = say("Steam has been closed, sir.")
        _chk("say: closed template", tr == "Steam ha sido cerrado, señor.", tr)
        tr = say("Audio output switched to Arctis Nova Pro, sir.")
        _chk("say: audio template", tr == "Salida de audio cambiada a Arctis Nova Pro, señor.", tr)
        set_turn_language("en")
        tr = say("Opening Steam, sir.")
        _chk("say: english turn stays english", tr == "Opening Steam, sir.", tr)
        set_turn_language("es")
        ins = system_instruction()
        _chk("instruction: spanish turn", "Spanish" in ins and "Never translate" in ins)
        _chk("instruction: memory rule present", "memories" in ins)
    finally:
        globals()["current_language"] = _cur
        set_turn_language("en")

    # v1.35: 'en' (the DEFAULT) now mirrors a non-English turn — the recall bug
    globals()["current_language"] = lambda: "en"
    try:
        set_turn_language("es")
        ins = system_instruction()
        _chk("en-mode: spanish turn gets spanish hint", "Spanish" in ins and "memories" in ins)
        tr = say("Here is what I have on record, sir — hp: visited the park.")
        _chk("en-mode: recall line localizes", tr == "Esto es lo que tengo registrado, señor — hp: visited the park.", tr)
        tr = say("I have nothing recorded about that, sir.")
        _chk("en-mode: empty recall localizes", tr == "No tengo nada registrado sobre eso, señor.", tr)
        set_turn_language("xx")
        ins = system_instruction()
        _chk("en-mode: other-language turn gets exact-language hint", "EXACT language" in ins)
        set_turn_language("en")
        ins = system_instruction()
        _chk("en-mode: english turn stays ruleless", ins == "")
        tr = say("Opening Steam, sir.")
        _chk("en-mode: english turn line stays english", tr == "Opening Steam, sir.", tr)
        # v1.51: TTS voice follows the SPOKEN language even on an English interface —
        # a Spanish reply is spoken in the Spanish voice, matching _target()'s text.
        set_turn_language("en"); _v_en = tts_voice()
        set_turn_language("es"); _v_es = tts_voice()
        _chk("en-mode: tts voice follows spoken language",
             _v_es != _v_en and _v_es == _VOICES["es"], (_v_en, _v_es))
    finally:
        globals()["current_language"] = _cur
        set_turn_language("en")

    print("RESULT:", "PASS" if not fails else "FAIL: %s" % fails)
    raise SystemExit(1 if fails else 0)
