"""
wivexx_audio_devices.py — Windows default audio output switching  (v1.6.2)
═══════════════════════════════════════════════════════════════════════════
Lets WIVEXX change the system's default playback device ("change to output 1"
→ Arctis Nova Pro, etc.).

Windows has no public API for setting the default endpoint; like every audio
switcher out there (AudioSwitcher, SoundSwitch, nircmd) this uses the
undocumented-but-stable IPolicyConfig COM interface.  Only SetDefaultEndpoint
is ever called — the earlier vtable slots are declared as placeholders purely
to keep the method offsets correct.

Public API:
    list_output_devices() -> list[dict]      # [{"id": ..., "name": ...}, ...]
    set_default_output(device_id) -> bool
    resolve_device(name_or_id) -> Optional[dict]   # fuzzy match by name, or id
"""

import difflib
import re
import threading
from ctypes import POINTER, c_void_p
from ctypes.wintypes import DWORD, LPCWSTR
from typing import Optional

_lock = threading.Lock()


def _norm_name(s: str) -> str:
    """Normalise a device name for matching: lowercase and drop the "2- " /
    "3- " enumeration prefixes Windows adds to re-paired devices, so a phrase
    saved against "Headphones (2- Arctis Nova Pro)" still finds the currently
    active "Headphones (Arctis Nova Pro)"."""
    return re.sub(r"\b\d+-\s*", "", (s or "").lower()).strip()


# ── Voice-phrase matching (v1.34) ───────────────────────────────────────────────
# The old matcher required the configured phrase as a SUBSTRING (or a whole-
# sentence fuzzy ≥0.85), so natural word-order changes and Whisper's
# self-corrections missed: "switch to output secondary", "switch to, change to
# output to primary" never matched "change output to secondary/primary" — the
# turn fell through to Claude, which then PRETENDED to switch. These matchers
# are ORDER-FREE: a phrase matches when all its distinctive words appear in the
# utterance together with a switch verb.
_SWITCH_VERBS = {"switch", "change", "set", "move", "put", "swap"}
_GLUE_WORDS = {"to", "the", "my", "a", "an", "please", "mode", "now", "device",
               "and", "then", "back", "over", "on", "into", "again", "for", "me"}
_SPOKEN_NUM = {"one": "1", "two": "2", "three": "3", "four": "4", "five": "5",
               "six": "6", "seven": "7", "eight": "8", "nine": "9", "ten": "10"}
_AUDIO_CONTEXT = {"output", "audio", "sound", "speaker", "speakers",
                  "headphones", "headset", "playback"}


def _cmd_tokens(s: str) -> set:
    toks = re.findall(r"[a-z0-9]+", (s or "").lower())
    return {_SPOKEN_NUM.get(t, t) for t in toks}


def phrase_matches(utterance: str, phrase: str) -> bool:
    """Order-free match of a configured output-switch phrase against speech.
    All the phrase's distinctive words must appear in the utterance, plus a
    switch verb — so "switch to, change to output to primary" matches
    "change output to primary", while "the secondary output on my amp died"
    (no switch verb) does not."""
    ut = _cmd_tokens(utterance)
    target = _cmd_tokens(phrase) - _GLUE_WORDS - _SWITCH_VERBS
    if not target:
        return False
    return target <= ut and bool(ut & _SWITCH_VERBS)


_VOLUME_WORDS = {"volume", "percent", "louder", "quieter", "up", "down",
                 "higher", "lower", "mute", "unmute"}


def wants_output_switch(utterance: str) -> bool:
    """True when the utterance clearly asks to change where sound plays —
    used to answer honestly ("which device?") instead of letting a missed
    match fall through to the LLM, which would fake compliance. Volume-style
    phrasings ("change the sound to 50 percent") are excluded — those belong
    to the volume command that runs later in the waterfall."""
    ut = _cmd_tokens(utterance)
    if ut & _VOLUME_WORDS:
        return False
    if ut & _SWITCH_VERBS and ut & _AUDIO_CONTEXT:
        return True
    return bool(re.search(r"\bplay\s+(?:audio|sound|music|everything)\s+"
                          r"(?:through|on|to)\b", (utterance or "").lower()))


if __name__ == "__main__":
    # matcher self-test (the bug that motivated v1.34: reorderings + Whisper
    # self-corrections must match a configured phrase; traps must not)
    _P = "change output to primary"
    for s in ("switch to output primary", "switch to, change to output to primary",
              "switch to primary output mode", "put the output on primary"):
        assert phrase_matches(s, _P), s
    for s in ("the primary output on my amp died", "i changed my audio setup",
              "set volume to 50 percent"):
        assert not phrase_matches(s, _P), s
    assert wants_output_switch("switch the audio output")
    assert not wants_output_switch("turn the volume down")
    print("wivexx_audio_devices matcher self-test: PASS")


# ── IPolicyConfig (undocumented; stable since Vista) ───────────────────────────
def _get_policy_config():
    """Build and return an IPolicyConfig instance. Raises on failure."""
    import comtypes
    from comtypes import COMMETHOD, GUID, HRESULT, IUnknown

    class IPolicyConfig(IUnknown):
        _iid_ = GUID("{f8679f50-850a-41cf-9c72-430f290290c8}")
        # Only SetDefaultEndpoint (slot 11) is called; slots 1-10 and 12 are
        # placeholders that exist solely to keep the vtable offsets right.
        _methods_ = (
            COMMETHOD([], HRESULT, "GetMixFormat",
                      (["in"], LPCWSTR, "dev"), (["out"], POINTER(c_void_p), "fmt")),
            COMMETHOD([], HRESULT, "GetDeviceFormat",
                      (["in"], LPCWSTR, "dev"), (["in"], DWORD, "default"),
                      (["out"], POINTER(c_void_p), "fmt")),
            COMMETHOD([], HRESULT, "ResetDeviceFormat",
                      (["in"], LPCWSTR, "dev")),
            COMMETHOD([], HRESULT, "SetDeviceFormat",
                      (["in"], LPCWSTR, "dev"), (["in"], c_void_p, "endpFmt"),
                      (["in"], c_void_p, "mixFmt")),
            COMMETHOD([], HRESULT, "GetProcessingPeriod",
                      (["in"], LPCWSTR, "dev"), (["in"], DWORD, "default"),
                      (["out"], POINTER(c_void_p), "defPeriod"),
                      (["out"], POINTER(c_void_p), "minPeriod")),
            COMMETHOD([], HRESULT, "SetProcessingPeriod",
                      (["in"], LPCWSTR, "dev"), (["in"], c_void_p, "period")),
            COMMETHOD([], HRESULT, "GetShareMode",
                      (["in"], LPCWSTR, "dev"), (["out"], POINTER(c_void_p), "mode")),
            COMMETHOD([], HRESULT, "SetShareMode",
                      (["in"], LPCWSTR, "dev"), (["in"], c_void_p, "mode")),
            COMMETHOD([], HRESULT, "GetPropertyValue",
                      (["in"], LPCWSTR, "dev"), (["in"], DWORD, "fxStore"),
                      (["in"], c_void_p, "key"), (["out"], POINTER(c_void_p), "pv")),
            COMMETHOD([], HRESULT, "SetPropertyValue",
                      (["in"], LPCWSTR, "dev"), (["in"], DWORD, "fxStore"),
                      (["in"], c_void_p, "key"), (["in"], c_void_p, "pv")),
            COMMETHOD([], HRESULT, "SetDefaultEndpoint",
                      (["in"], LPCWSTR, "wszDeviceId"), (["in"], DWORD, "role")),
            COMMETHOD([], HRESULT, "SetEndpointVisibility",
                      (["in"], LPCWSTR, "dev"), (["in"], DWORD, "visible")),
        )

    CLSID_PolicyConfigClient = GUID("{870af99c-171d-4f9e-af0d-e63df40c2bc9}")
    return comtypes.CoCreateInstance(
        CLSID_PolicyConfigClient, interface=IPolicyConfig,
        clsctx=comtypes.CLSCTX_ALL,
    )


def _co_init() -> None:
    try:
        import comtypes
        comtypes.CoInitialize()
    except Exception:
        pass   # already initialised on this thread


# ── Public API ─────────────────────────────────────────────────────────────────
def _is_active_state(state) -> bool:
    """True only for AudioDeviceState.Active.  pycaw's state is a plain Enum
    (NOT an IntEnum — int(state) raises), so check .value / .name.  Windows
    keeps NotPresent "ghost" registrations of re-paired devices (e.g.
    "Headphones (2- Arctis Nova Pro)"); switching to those silently fails,
    so they must never reach the device list."""
    try:
        if getattr(state, "name", "") == "Active":
            return True
        return getattr(state, "value", state) == 1
    except Exception:
        return False


def list_output_devices() -> list[dict]:
    """ACTIVE playback (render) endpoints: [{"id": ..., "name": ...}, ...].
    Render endpoint IDs always start with "{0.0.0." — capture ones with
    "{0.0.1." — which is how the two are told apart."""
    _co_init()
    out: list[dict] = []
    try:
        from pycaw.pycaw import AudioUtilities
        for d in AudioUtilities.GetAllDevices():
            try:
                dev_id = str(d.id or "")
                if not dev_id.startswith("{0.0.0."):
                    continue            # not a playback endpoint
                if not _is_active_state(getattr(d, "state", None)):
                    continue            # disabled / unplugged / ghost endpoint
                name = str(getattr(d, "FriendlyName", "") or dev_id)
                out.append({"id": dev_id, "name": name})
            except Exception:
                continue
    except Exception:
        return []
    return out


def set_default_output(device_id: str) -> bool:
    """Make *device_id* the default playback device for all three Windows
    roles (console, multimedia, communications)."""
    if not device_id:
        return False
    with _lock:
        _co_init()
        try:
            pc = _get_policy_config()
            for role in (0, 1, 2):
                pc.SetDefaultEndpoint(device_id, role)
            return True
        except Exception:
            return False


def resolve_device(name_or_id: str) -> Optional[dict]:
    """Find an ACTIVE playback device by endpoint id, name substring, or fuzzy
    name (names are normalised so "2- " ghost prefixes don't break matching)."""
    needle = (name_or_id or "").strip()
    if not needle:
        return None
    devices = list_output_devices()
    for d in devices:
        if d["id"] == needle:
            return d
    low = _norm_name(needle)
    if not low:
        return None
    for d in devices:
        norm = _norm_name(d["name"])
        if low in norm or norm in low:
            return d
    best, best_ratio = None, 0.0
    for d in devices:
        r = difflib.SequenceMatcher(None, low, _norm_name(d["name"])).ratio()
        if r > best_ratio:
            best, best_ratio = d, r
    return best if best_ratio >= 0.55 else None
