"""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


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):
        # Wayland: no portable focus — report rather than duplicate.
        return {"ok": True, "status": "already-running",
                "msg": f"{spec['name']} is already running"}
    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}
