#!/usr/bin/env python3
"""surface — the control surface as ONE piece of always-on state.

The cockpit's foundation (#155). Before this, four separate tools each spawned
their own `aseqdump`: gig-log, lcxl-leds, the Pulsar HUD, and whatever PLN had
left in a terminal. On 2026-08-14 three of the four were bound to ALSA client
24 (an Arturia KeyStep) instead of 20 (the Launch Control XL), because a replug
had renumbered the clients and every one of them resolves its port ONCE, at
start. Silent, of course: the LEDs stayed dark and the log recorded a surface
nobody was touching.

So this module exists to be the ONLY subscriber, and to fix the three things
that made the old shape fragile:

  1. RESOLVE BY NAME, EVERY RESPAWN.  A port address (`20:0`) is not an
     identity, it is a lease. `Launch Control XL` is the identity. Re-resolved
     on every (re)spawn, so a replug costs one backoff interval, not a silent
     session.
  2. STAY UP.  midistream.MidiStream is deliberately lazy — it opens the port on
     first subscribe and closes it when the last watcher leaves. Correct for a
     monitor tab, wrong for a cockpit: the surface's VALUES are the state, and
     state you only collect while someone is looking is not state. The
     supervisor here never exits.
  3. REMEMBER.  Keep the last value of every CC, so a view that connects
     mid-set paints the real board immediately instead of a blank one.

That third point carries the day's hardest-won lesson. `_ctrl` holds ONLY the
controls that have actually been seen, and "absent" is a distinct, meaningful
answer — NOT the same as zero. An untouched control is exactly what silences a
Tidal stream (a bare "^NN" with no value yields no events at all), so a cockpit
that renders untouched-as-0 would hide the single failure it exists to show.
Callers get `seen: false` and must decide what to do about it.

Pure functions (`resolve_port`, `load_grid`, `Surface.apply`) are unit-testable
without ALSA. But green tests on those prove nothing about the seam — the tap
must also be watched against a real surface. See tests/ and the README.
"""
from __future__ import annotations

import json
import subprocess
import threading
import time
from pathlib import Path

import midimon
import midistream

# The surface we care about. Matched as a case-insensitive substring against the
# CLIENT name from `aseqdump -l`, never against a port address.
SURFACE_NAME = "Launch Control XL"

# tools/lcxl_grid.json — CC → {row, column, label, role, owner}. Loaded once and
# joined onto every event, so subscribers never have to know the mapping. This
# is the first join of the shared model: the surface's PHYSICS (what moved) and
# its MEANING (which orbit that knob owns) arrive together or not at all.
GRID_PATH = Path(__file__).resolve().parent.parent / "lcxl_grid.json"

_BACKOFF_MIN = 0.5
_BACKOFF_MAX = 5.0


def resolve_port(ports: list[dict], name: str = SURFACE_NAME) -> str | None:
    """Pick the surface's address out of `midistream.parse_ports` output.

    Pure. Prefers the LOWEST port number on the matching client: the LCXL
    exposes two ports (`…Launch Contro` and `…HUI`) and only the first carries
    the knobs and buttons. Returns None when the surface is not plugged in,
    which is a normal state, not an error.
    """
    hits = [p for p in ports if name.lower() in (p.get("client") or "").lower()]
    if not hits:
        return None

    def portnum(p):
        try:
            return int(str(p["addr"]).split(":")[1])
        except (KeyError, IndexError, ValueError):
            return 9999

    return sorted(hits, key=portnum)[0]["addr"]


def load_grid(path: Path = GRID_PATH) -> dict[int, dict]:
    """CC → metadata from lcxl_grid.json, tolerant of where it sits in the file.

    Walks for any dict whose values look like control entries (they carry a
    `row`), so a future reshuffle of the file's top level does not silently
    yield an empty map — the failure mode would be a cockpit that renders every
    control as unmapped, which reads as a hardware fault.
    """
    out: dict[int, dict] = {}
    try:
        doc = json.loads(Path(path).read_text())
    except (OSError, ValueError):
        return out

    def walk(node):
        if not isinstance(node, dict):
            return
        for k, v in node.items():
            if isinstance(v, dict) and "row" in v:
                try:
                    out[int(k)] = v
                except (TypeError, ValueError):
                    pass
            else:
                walk(v)

    walk(doc)
    return out


class Surface:
    """Always-on, self-healing tap on the control surface, plus its live values."""

    def __init__(self, name: str = SURFACE_NAME, grid_path: Path = GRID_PATH,
                 autostart: bool = True):
        self.name = name
        self.grid = load_grid(grid_path)
        self._lock = threading.Lock()
        self._ctrl: dict[int, dict] = {}      # cc -> last value; ABSENT means never seen
        self._subs: set = set()
        self._port: str | None = None
        self._state = "starting"              # starting | ok | searching | no-tool
        self._respawns = -1                   # first spawn is not a respawn
        self._started_at = time.time()
        self._last_event = None
        self._proc = None
        self._stop = threading.Event()
        if autostart:
            threading.Thread(target=self._supervise, daemon=True).start()

    # ── public ──────────────────────────────────────────────────────────
    def snapshot(self) -> dict:
        """Everything a freshly-connected view needs to paint the board once."""
        with self._lock:
            controls = []
            for cc, meta in sorted(self.grid.items()):
                live = self._ctrl.get(cc)
                controls.append({
                    "cc": cc,
                    "label": meta.get("label"),
                    "row": meta.get("row"),
                    "column": meta.get("column"),
                    "role": meta.get("role"),
                    "owner": meta.get("owner"),
                    # `seen` is the point. False means this control has never
                    # been moved since the tap came up, which for a bare "^NN"
                    # in a pattern means that stream emits NOTHING.
                    "seen": live is not None,
                    "value": (live or {}).get("value"),
                    "norm": (live or {}).get("norm"),
                    "at": (live or {}).get("at"),
                })
            # CCs seen on the wire that the grid does not know about — an
            # external keyboard, or a grid that has drifted from the hardware.
            unmapped = sorted(cc for cc in self._ctrl if cc not in self.grid)
            return {
                "port": self._port,
                "state": self._state,
                "respawns": max(0, self._respawns),
                "uptime_s": round(time.time() - self._started_at, 1),
                "last_event_s": (round(time.time() - self._last_event, 1)
                                 if self._last_event else None),
                "seen_count": len(self._ctrl),
                "grid_count": len(self.grid),
                "unmapped": unmapped,
                "controls": controls,
            }

    def subscribe(self):
        import queue
        q: "queue.Queue" = queue.Queue(maxsize=512)
        with self._lock:
            self._subs.add(q)
        return q

    def unsubscribe(self, q) -> None:
        with self._lock:
            self._subs.discard(q)

    def apply(self, ev: dict) -> dict:
        """Fold one enriched event into the value map; return it, grid-annotated.

        Separated from the reader thread so it can be tested without ALSA.
        Only control-change events carry state; notes and clock pass through
        untouched (a note you played is history, a knob position is truth).
        """
        cc = ev.get("controller")
        if cc is not None and (ev.get("event") or "").lower().startswith("control"):
            val = ev.get("value")
            if val is not None:
                now = time.time()
                with self._lock:
                    self._ctrl[cc] = {"value": val, "norm": round(val / 127.0, 4),
                                      "at": now}
                    self._last_event = now
                meta = self.grid.get(cc)
                if meta:
                    ev = dict(ev, label=meta.get("label"), row=meta.get("row"),
                              column=meta.get("column"), role=meta.get("role"),
                              owner=meta.get("owner"))
                ev = dict(ev, norm=round(val / 127.0, 4))
        return ev

    def stop(self) -> None:
        """Stop supervising AND kill the tap. Both halves matter: setting the
        event alone leaves _read blocked on the child's stdout, so the process
        lingers until the next MIDI byte arrives — which on an idle surface is
        never."""
        self._stop.set()
        proc, self._proc = self._proc, None
        if proc is not None:
            try:
                proc.terminate()
            except OSError:
                pass

    # ── supervisor ──────────────────────────────────────────────────────
    def _supervise(self) -> None:
        """Resolve → spawn → read → backoff → repeat, forever.

        The re-resolve at the TOP of the loop is the whole fix: every respawn
        looks the surface up by name again, so a replug that renumbers the ALSA
        client is absorbed automatically instead of leaving a tap wired to
        whatever device inherited the old address.
        """
        backoff = _BACKOFF_MIN
        while not self._stop.is_set():
            ports = self._list_ports()
            addr = resolve_port(ports, self.name)
            if addr is None:
                with self._lock:
                    self._state = "searching"
                    self._port = None
                self._stop.wait(backoff)
                backoff = min(backoff * 2, _BACKOFF_MAX)
                continue

            proc = self._spawn(addr)
            self._proc = proc
            if proc is None:
                with self._lock:
                    self._state = "no-tool"
                self._stop.wait(_BACKOFF_MAX)
                continue

            with self._lock:
                self._state = "ok"
                self._port = addr
                self._respawns += 1
            backoff = _BACKOFF_MIN
            self._read(proc)                      # blocks until the tap dies

            with self._lock:
                self._state = "searching"
            try:
                proc.terminate()
            except OSError:
                pass
            self._stop.wait(backoff)

    def _list_ports(self) -> list[dict]:
        try:
            r = subprocess.run(["aseqdump", "-l"], capture_output=True,
                               text=True, timeout=5)
        except (OSError, subprocess.SubprocessError):
            return []
        return midistream.parse_ports(r.stdout)

    def _spawn(self, addr: str):
        """Spawn the tap so the KERNEL kills it when we die.

        Earned the hard way on 2026-08-14: three smoke-test runs of this very
        module each left an `aseqdump` behind, reparented to pid 1 — the exact
        litter PLN had just complained about ("i see 4 aseqdumps... you had one
        job"). try/finally is not enough, because the parent does not always get
        to run its cleanup: a SIGKILL, an OOM, or `timeout` leaves the child
        orphaned and holding a MIDI port.

        PR_SET_PDEATHSIG (prctl option 1) moves the guarantee into the kernel:
        the child is signalled when its parent thread exits, however it exits.
        Belt-and-braces with the terminate() in _supervise and the atexit hook.
        """
        def _die_with_parent():
            try:
                import ctypes
                import signal
                ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, signal.SIGTERM)
            except Exception:
                pass          # non-Linux or no libc — fall back to try/finally

        try:
            return subprocess.Popen(["aseqdump", "-p", addr],
                                    stdout=subprocess.PIPE, text=True, bufsize=1,
                                    preexec_fn=_die_with_parent)
        except OSError:
            return None

    def _read(self, proc) -> None:
        for line in proc.stdout or []:
            if self._stop.is_set():
                return
            ev = midimon.parse_line(line)
            if not ev:
                continue
            self._fanout(self.apply(midimon.enrich(ev)))

    def _fanout(self, ev: dict) -> None:
        """Drop the OLDEST on a full queue — for surface state the newest IS truth.

        Same policy and same reason as midistream._fanout: a subscriber that
        stalls once must not be pinned to stale values forever.
        """
        import queue
        with self._lock:
            for q in self._subs:
                try:
                    q.put_nowait(ev)
                except queue.Full:
                    try:
                        q.get_nowait()
                        q.put_nowait(ev)
                    except (queue.Empty, queue.Full):
                        pass


if __name__ == "__main__":
    # Manual smoke: watch the real surface. Green unit tests on resolve_port
    # prove nothing about whether the tap actually binds — this is the seam.
    import argparse
    ap = argparse.ArgumentParser()
    ap.add_argument("--seconds", type=float, default=20)
    a = ap.parse_args()
    import atexit
    s = Surface()
    atexit.register(s.stop)
    t0 = time.time()
    while time.time() - t0 < a.seconds:
        snap = s.snapshot()
        print(f"  {snap['state']:<10} port={snap['port']}  "
              f"seen {snap['seen_count']}/{snap['grid_count']}  "
              f"respawns={snap['respawns']}  "
              f"last_event={snap['last_event_s']}s", flush=True)
        time.sleep(2)
