"""
wivexx_appindex.py — auto-discover installed apps from the Start Menu  (v1.24)
═══════════════════════════════════════════════════════════════════════════
So "open Spotify" / "close Spotify" work on ANY PC without the user
pre-registering every app. We index the Start Menu shortcuts (the same source
the Windows search box uses) into {normalised name -> .lnk path}, then fuzzy-
match a spoken app name against it.

Robust to the env-stripped silent launcher: the Start Menu folders are resolved
via the Windows Known-Folder API (no %APPDATA% / %PROGRAMDATA% env needed), with
env-based fallbacks.

Public API:
    find_app(name) -> dict | None   # {"name", "lnk", "target"} best match, or None
    rebuild()                       # force a re-scan (else cached ~5 min)
"""
import difflib
import os
import re
import threading
import time

_lock = threading.Lock()
_index: dict = {}          # {norm_name: (display_name, lnk_path)}
_built_at = 0.0
_TTL = 300.0               # rebuild the index at most every 5 minutes

# shortcuts that are never an "app to open" — skip them so they don't shadow apps
_SKIP_RE = re.compile(
    r"\b(uninstall|readme|read me|help|documentation|release notes|changelog|"
    r"website|home ?page|on the web|manual|license|licence|support|"
    r"command prompt for|setup|reset preferences|reset|safe mode|troubleshoot|"
    r"cache files|repair|modify install)\b", re.IGNORECASE)


def _norm(s: str) -> str:
    """lowercase, drop bracketed bits + non-alphanumerics → 'visual studio code'."""
    s = re.sub(r"\(.*?\)|\[.*?\]", " ", s or "")
    s = re.sub(r"[^a-z0-9]+", " ", s.lower())
    return re.sub(r"\s+", " ", s).strip()


def _known_folder(guid_parts) -> str:
    """SHGetKnownFolderPath → path, env-independent (works under the VBS launch)."""
    if os.name != "nt":
        return ""
    try:
        import ctypes
        from ctypes import wintypes

        class _GUID(ctypes.Structure):
            _fields_ = [("Data1", wintypes.DWORD), ("Data2", wintypes.WORD),
                        ("Data3", wintypes.WORD), ("Data4", ctypes.c_ubyte * 8)]

        d1, d2, d3, d4 = guid_parts
        fid = _GUID(d1, d2, d3, (ctypes.c_ubyte * 8)(*d4))
        out = ctypes.c_wchar_p()
        shell32 = ctypes.windll.shell32
        shell32.SHGetKnownFolderPath.argtypes = [
            ctypes.POINTER(_GUID), wintypes.DWORD, wintypes.HANDLE,
            ctypes.POINTER(ctypes.c_wchar_p)]
        if shell32.SHGetKnownFolderPath(ctypes.byref(fid), 0, None,
                                        ctypes.byref(out)) == 0 and out.value:
            path = out.value
            try:
                ctypes.windll.ole32.CoTaskMemFree(out)
            except Exception:
                pass
            return path
    except Exception:
        pass
    return ""


def _start_menu_dirs() -> list:
    dirs = []
    # FOLDERID_Programs (this user) + FOLDERID_CommonPrograms (all users)
    dirs.append(_known_folder((0xA77F5D77, 0x2E2B, 0x44C3,
                               (0xA6, 0xA2, 0xAB, 0xA6, 0x01, 0x05, 0x4A, 0x51))))
    dirs.append(_known_folder((0x0139D44E, 0x6AFE, 0x49F2,
                               (0x86, 0x90, 0x3D, 0xAF, 0xCA, 0xE6, 0xFF, 0xB8))))
    # env fallbacks (in case the known-folder call ever fails)
    appdata = os.environ.get("APPDATA")
    progdata = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
    if appdata:
        dirs.append(os.path.join(appdata, r"Microsoft\Windows\Start Menu\Programs"))
    dirs.append(os.path.join(progdata, r"Microsoft\Windows\Start Menu\Programs"))
    # de-dup, keep existing
    seen, out = set(), []
    for d in dirs:
        if d and d.lower() not in seen and os.path.isdir(d):
            seen.add(d.lower())
            out.append(d)
    return out


def _build() -> dict:
    global _index, _built_at
    idx: dict = {}
    for root in _start_menu_dirs():
        for dp, _dn, fn in os.walk(root):
            for f in fn:
                if not f.lower().endswith(".lnk"):
                    continue
                stem = f[:-4]
                if _SKIP_RE.search(stem):
                    continue
                norm = _norm(stem)
                if not norm:
                    continue
                # first one wins, but a shorter (cleaner) display name is preferred
                if norm not in idx or len(stem) < len(idx[norm][0]):
                    idx[norm] = (stem, os.path.join(dp, f))
    _index = idx
    _built_at = time.time()
    return idx


def _get_index() -> dict:
    with _lock:
        if not _index or (time.time() - _built_at) > _TTL:
            try:
                return _build()
            except Exception:
                return _index or {}
        return _index


def rebuild() -> int:
    with _lock:
        try:
            return len(_build())
        except Exception:
            return 0


def _resolve_target(lnk: str) -> str:
    """Best-effort: read a .lnk's target exe (for 'close <app>'). Empty on failure."""
    try:
        import comtypes.client
        shell = comtypes.client.CreateObject("WScript.Shell")
        sc = shell.CreateShortcut(lnk)
        tgt = str(getattr(sc, "TargetPath", "") or "")
        if tgt.lower().endswith(".exe"):
            return tgt
    except Exception:
        pass
    return ""


def find_app(name: str) -> dict:
    """Best Start-Menu match for a spoken app name, or None.
    Returns {"name": display, "lnk": path, "target": exe_or_''}."""
    needle = _norm(name)
    if not needle:
        return None
    idx = _get_index()
    if not idx:
        return None

    # 1) exact normalised match
    if needle in idx:
        disp, lnk = idx[needle]
        return {"name": disp, "lnk": lnk, "target": _resolve_target(lnk)}

    # 2) score every stem: containment is a strong signal (the cleaner/closer the
    # length, the better, so "vlc" → "vlc media player", not a longer sibling),
    # otherwise fall back to fuzzy spelling similarity.
    best, best_score = None, 0.0
    for stem, (disp, lnk) in idx.items():
        if needle in stem:
            score = 0.80 + 0.20 * (len(needle) / max(len(stem), 1))
        elif stem in needle:
            score = 0.80 + 0.20 * (len(stem) / max(len(needle), 1))
        else:
            score = difflib.SequenceMatcher(None, needle, stem).ratio()
        if score > best_score:
            best, best_score = (disp, lnk), score
    if best and best_score >= 0.72:
        disp, lnk = best
        return {"name": disp, "lnk": lnk, "target": _resolve_target(lnk)}
    return None
