"""launchers — the fleet's app launchers, shared by the web Bridge and the tray.

An allow-listed registry (never arbitrary commands): each entry knows how to be
found, whether it's running, and how to spawn detached. Both faces — the web hub
(`/api/launch`) and the perf-tray menu — drive the same `launch(key)`.

"Open if running, else launch": on Wayland there's no portable window-focus, so
a running app reports `already-running` (the user can click again knowingly)
rather than spawning a duplicate.
"""
from __future__ import annotations

import glob
import os
import re
import shutil
import socket
import subprocess
from pathlib import Path

HERE = Path(__file__).resolve().parent
TIDAL = Path.home() / "Work" / "Sound" / "Tidal"
# "Tidal Live" is THE performing session (records every orbit as a stem). "Tidal
# Multi" is the older ARCHIVE session holding the historical per-orbit recordings —
# it was wired in here, so the tray's Ardour button opened the archive instead of the
# rig, and any faders touched there would have been the wrong ones. Auditing that
# same archive as if it were live already produced a confidently wrong fader report
# on 2026-07-28; this is the same mixup, one layer down.
ARDOUR_SESSION = Path.home() / "Work" / "Sound" / "Ardour" / "Tidal Live" / "Tidal Live.ardour"
MIDIMON = HERE / "midimon.py"

# Terminal emulators we know how to wrap, best first.
TERMINALS = ("konsole", "kitty", "alacritty", "gnome-terminal", "x-terminal-emulator")


def which_first(candidates):
    for c in candidates:
        p = shutil.which(c)
        if p:
            return p
    return None


def _term_argv(inner):
    """Wrap an argv to run inside the first available terminal emulator."""
    term = which_first(TERMINALS)
    if not term:
        return None
    base = os.path.basename(term)
    if base == "gnome-terminal":
        return [term, "--", *inner]
    if base == "kitty":
        return [term, *inner]
    return [term, "-e", *inner]            # konsole, alacritty, x-terminal-emulator


# key -> spec. `candidates` = binaries (first found wins); `args` appended;
# `pgrep` = running-check pattern; `terminal` = run inside a terminal emulator.
LAUNCHERS = [
    {"key": "pulsar", "name": "Pulsar", "blurb": "editor — open the Tidal folder",
     "candidates": ["pulsar"], "args": [str(TIDAL)], "exe": r"pulsar$"},
    # `ardour9` was missing from the candidates, so this entry could never actually
    # start the installed Ardour (9.2.0) — it reported "unavailable" while Ardour was
    # running. Newest first, and the version-suffixed real binary (`ardour-9.2.0`) is
    # what the exe check has to match.
    {"key": "ardour", "name": "Ardour", "blurb": "DAW — the Tidal Live session (records the stems)",
     "candidates": ["ardour9", "ardour8", "ardour7", "ardour6", "ardour"],
     "args": ([str(ARDOUR_SESSION)] if ARDOUR_SESSION.exists() else []),
     "exe": r"[Aa]rdour[-\d.]*$"},
    {"key": "qjackctl", "name": "QjackCtl", "blurb": "JACK / audio graph control",
     "candidates": ["qjackctl"], "args": [], "exe": r"qjackctl$"},
    {"key": "midimon", "name": "MIDI Monitor", "blurb": "live, parsed MIDI seq (aseqdump done right)",
     "candidates": ["python3"], "args": [str(MIDIMON)], "pgrep": "midimon.py", "terminal": True},
]

# Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
# the UI opens `url`. (Fixes dead hub links to not-yet-started servers.)
WEB = [
    {"key": "foundry", "name": "The Foundry", "blurb": "URL → audio → stems → loops",
     "url": "http://127.0.0.1:8765/", "port": 8765,
     "cmd": ["python3", str(TIDAL / "tools/foundry/server.py"), "--port", "8765"]},
    # Serve tide-table (the catalog viz lives there) and open triangle.html —
    # exactly what `tide.py serve` does. Serving armada/ root = a dir listing.
    {"key": "armada", "name": "L'Armada", "blurb": "catalog · triangle viz · gigs",
     "url": "http://127.0.0.1:8731/triangle.html", "port": 8731,
     "cmd": ["python3", str(TIDAL / "armada/serve.py"),
             "--dir", str(TIDAL / "armada/tide-table"), "--port", "8731"]},
]

BY_KEY = {l["key"]: l for l in (*LAUNCHERS, *WEB)}


def _port_open(port, host="127.0.0.1"):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.25)
        return s.connect_ex((host, port)) == 0


# ---------------------------------------------------------------------------
# Window focus on Wayland
#
# The docstring above used to say "on Wayland there's no portable window-focus",
# and `launch()` therefore answered a click on a running app with a text status.
# That is true of the X11 toolbox — xdotool, wmctrl and kdotool are all absent
# here and the first two could not work under Wayland anyway — but it is not
# true of THIS desktop. Plasma exposes KWin's scripting engine on the session
# bus, and a KWin script runs inside the compositor, where activating a window
# is simply allowed.
#
# So: register a tiny script, run it, unregister it. The script matches on
# resourceClass (the app id), which for Pulsar is stable across projects and
# window titles — matching on the title would break the moment PLN opens a file
# whose name does not contain "Tidal".
#
# Everything here is best-effort: if qdbus is missing, if KWin refuses, if the
# script registers but matches nothing, we fall back to reporting. A focus
# button that silently does nothing is a papercut; one that throws during a gig
# is not acceptable.
_KWIN_SCRIPT = """
var re = new RegExp(%s, "i");
var wins = (workspace.windowList ? workspace.windowList()
                                 : workspace.clientList());
for (var i = 0; i < wins.length; i++) {
    var w = wins[i];
    var id = (w.resourceClass || "") + " " + (w.resourceName || "");
    if (re.test(id)) {
        w.minimized = false;
        if (workspace.currentDesktop && w.desktops && w.desktops.length)
            workspace.currentDesktop = w.desktops[0];
        workspace.activeWindow = w;      // Plasma 6
        workspace.activeClient = w;      // Plasma 5 fallback; harmless if absent
        break;
    }
}
"""


def _qdbus():
    return which_first(("qdbus6", "qdbus", "qdbus-qt6"))


def focus_window(pattern):
    """Raise+activate the first window whose app id matches `pattern` (regex).

    Returns True only when the whole round trip succeeded. Never raises.
    """
    qd = _qdbus()
    if not qd:
        return False
    tmp = None
    try:
        import json as _json
        import tempfile
        body = _KWIN_SCRIPT % _json.dumps(pattern)
        with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as f:
            f.write(body)
            tmp = f.name
        name = "pv-focus-%d" % os.getpid()
        r = subprocess.run([qd, "org.kde.KWin", "/Scripting", "loadScript", tmp, name],
                           capture_output=True, text=True, timeout=5)
        sid = (r.stdout or "").strip()
        if r.returncode != 0 or not sid.lstrip("-").isdigit():
            return False
        # loadScript returns the script id; the object path is derived from it.
        # A script that is already registered returns its EXISTING id, which is
        # why the name carries our pid — two tray clicks must not collide.
        ok = subprocess.run([qd, "org.kde.KWin", "/Scripting/Script%s" % sid, "run"],
                            capture_output=True, text=True, timeout=5).returncode == 0
        subprocess.run([qd, "org.kde.KWin", "/Scripting", "unloadScript", name],
                       capture_output=True, text=True, timeout=5)
        return ok
    except Exception:
        return False
    finally:
        if tmp:
            try:
                os.unlink(tmp)
            except OSError:
                pass


def binary(spec):
    return which_first(spec["candidates"])


def available(spec):
    if not binary(spec):
        return False
    if spec.get("terminal") and not which_first(TERMINALS):
        return False
    return True


def _cmdlines():
    """[(argv0 basename, full command line)] for every process, with NO forks.

    This used to be one `pgrep -f` per launcher, so a single snapshot() forked N
    processes — and snapshot() is called by the web Bridge's poll AND (now) by the
    tray menu. A fork per item per refresh is the shape of bug that made the LED
    daemon lag; on an audio rig it is not worth paying for a boolean.
    """
    out = []
    for p in glob.glob("/proc/[0-9]*/cmdline"):
        if p.split("/")[2] == str(os.getpid()):
            continue
        try:
            with open(p, "rb") as f:
                raw = f.read()
        except OSError:
            continue          # the pid exited between glob and open; ordinary
        if not raw:
            continue
        line = raw.replace(b"\x00", b" ").decode("utf-8", "replace").strip()
        argv0 = line.split(" ", 1)[0].rsplit("/", 1)[-1] if line else ""
        out.append((argv0, line))
    return out


def is_running(spec, lines=None):
    """True if a matching process is running.

    Matches the EXECUTABLE NAME (`exe`) rather than the whole command line wherever
    possible, because the whole line is full of false positives: `pgrep -f ardour`
    matched the `tidal-ardour-autoroute.sh` helper AND any shell whose command line
    merely mentioned the word — so the tray could report Ardour up when it was down,
    which is worse than reporting nothing. Full-line matching (`pgrep`) is kept only
    for interpreted tools, where argv[0] is `python3` and the real identity is the
    script path.

    `lines` lets a caller scan /proc ONCE for a whole snapshot instead of per item.
    """
    exe = spec.get("exe")
    if exe:
        try:
            rx = re.compile(exe)
        except re.error:
            return False
        return any(rx.match(a) for a, _ in (lines if lines is not None else _cmdlines()))
    pat = spec.get("pgrep")
    if not pat:
        return False
    try:
        rx = re.compile(pat)
    except re.error:
        return False
    return any(rx.search(ln) for _, ln in (lines if lines is not None else _cmdlines()))


def _argv(spec):
    inner = [binary(spec), *spec.get("args", [])]
    return _term_argv(inner) if spec.get("terminal") else inner


def launch(key):
    """Spawn the launcher detached. Returns {ok, status, msg, url?}."""
    spec = BY_KEY.get(key)
    if not spec:
        return {"ok": False, "status": "unknown", "msg": f"unknown launcher {key!r}"}

    if "port" in spec:                                   # web service: start-or-open
        if _port_open(spec["port"]):
            return {"ok": True, "status": "already-running", "url": spec["url"],
                    "msg": f"{spec['name']} is up"}
        try:
            subprocess.Popen(spec["cmd"], cwd=str(TIDAL), start_new_session=True,
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except OSError as e:
            return {"ok": False, "status": "error", "msg": str(e)}
        return {"ok": True, "status": "launched", "url": spec["url"],
                "msg": f"starting {spec['name']}…"}

    if not available(spec):
        return {"ok": False, "status": "unavailable",
                "msg": f"{spec['name']} not installed ({'/'.join(spec['candidates'])})"}
    if not spec.get("terminal") and is_running(spec):
        # Already up: FOCUS it rather than reporting at it. Spawning a duplicate
        # would be worse than useless during a set — a second Pulsar means a
        # second GHCi and a fight over port 6010.
        if focus_window(spec.get("focus") or spec["key"]):
            return {"ok": True, "status": "focused",
                    "msg": f"{spec['name']} brought to front"}
        return {"ok": True, "status": "already-running",
                "msg": f"{spec['name']} is already running (could not focus)"}
    argv = _argv(spec)
    if not argv:
        return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
    try:
        subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except OSError as e:
        return {"ok": False, "status": "error", "msg": str(e)}
    return {"ok": True, "status": "launched", "msg": f"launched {spec['name']}"}


def snapshot():
    """Apps + web tools with live state for the UI / tray."""
    lines = _cmdlines()          # scan /proc ONCE for the whole snapshot
    apps = [{"key": s["key"], "name": s["name"], "blurb": s["blurb"], "kind": "app",
             "available": available(s), "running": is_running(s, lines),
             "terminal": bool(s.get("terminal"))} for s in LAUNCHERS]
    web = [{"key": s["key"], "name": s["name"], "blurb": s["blurb"], "kind": "web",
            "url": s["url"], "available": True, "running": _port_open(s["port"])}
           for s in WEB]
    return {"web": web, "apps": apps}
