#!/usr/bin/env python3
r"""lcxl-leds — paint the LaunchControl XL, from the hardware side, with OUR colour coding.

The problem this exists to kill
-------------------------------
The surface went dark. Not "wrong colours" — DARK, with only knob A1 green from a
hand-sent test SysEx. PLN's words, three times over: *"why no button lights, still
see only A1 green? recover that asap i see no feedback anymore not even yellow
lifeline"*, then *"back from 'all yellow' to 'our coding, but touch-reactive and
persistent post touches'"*.

Three failure modes stacked up:

  1. **Nothing painted at boot.** The LED painter lived in the Pulsar HUD
     (`lib/lcxl-leds.js`), so the surface was only ever lit if Pulsar was open, had
     activated the package, and its frame pipeline worked. #57 says its `lastFrame`
     dedupe latches an early empty frame — so even with Pulsar open, one bad first
     frame means a permanently dark board and no error anywhere.
  2. **Factory yellow winning.** When anything DID light, it snapped back to a flat
     amber/yellow — that was a real Midi-Through feedback loop echoing velocity 127
     back as an LED colour byte (see the `reference_lcxl_midi_feedback_loop` memory),
     not the device's idea of a default.
  3. **No persistence.** The LEDs are **write-only — there is no readback** — so
     software must OWN the LED state and re-assert it. Nothing did.

So this is a standalone, stdlib-only CLI/daemon that works with Pulsar closed, is
callable from `gig-up.sh` and `tidal-remote.py`, and keeps an in-process model of
every control so a repaint is always available from truth rather than from whatever
the last MIDI event happened to be.

What it is NOT
--------------
It **never sends a CC**. Not one. It is SysEx-out only. Sending CCs would move live
audio parameters (and CC 77-84 are MIDI-learned to Ardour track gains — CC77 down is
total silence). Input is consumed strictly read-only, by watching `aseqdump` as a
child process, which does not steal MIDI from SuperCollider/Tidal.

Protocol (verified on hardware 2026-07-27, not inferred)
--------------------------------------------------------
    F0 00 20 29 02 11 78 <template> <index> <value> [<index> <value> ...] F7
      template 00 = User 1 (PLN is always User 1)
      index 00-07 knob row A · 08-0F row B · 10-17 row C
            18-1F top button row · 20-27 bottom button row
            28-2B Device/Mute/Solo/RecArm · 2C-2F arrows
    colour = (16 * green) + red + flags, red/green each 0-3; flags 12 normal, 8 flash

Row D (faders) has **no LEDs at all**. Many pairs fit in one message, so a full
40-LED repaint is ONE write.

**Dim vs full reads poorly on this hardware** — established by having PLN read a
ramp back. So FLASH, never brightness, carries anything that must be unmissable.

State language (settled with PLN 2026-07-29 — clarity over resolution)
---------------------------------------------------------------------
    dark = this control does nothing on this track   <- the biggest cognitive win

KNOBS (rows A/B/C) — hue carries the VALUE.
    unipolar (rows A/B):  dim red -> red -> dim amber -> amber -> dim green -> green
                          i.e. `value_ramp`, PLN's own six-step spec
    bipolar  (row C, the DJ filters): red = cutting the highs (LPF)
                          BRIGHT AMBER = the centre detent, true bypass
                          green = cutting the lows (HPF)
    Role is NOT in the hue on the knobs. It does not need to be: the lane
    convention fixes role by POSITION, whereas a knob's setting has no other
    channel at all — the pot's pointer is invisible on a dark stage.

BUTTONS (rows E/F) — hue carries the ROLE, brightness the on/off state.
    dim/full = off/on, hue = green rhythm · amber bass+lead+pad · red riser/FX
    (Colouring these by FUNCTION instead of lane-role is #49, still open.)

GLOBAL OVERLAY — "^93" armed flashes RED on all four panic-chord buttons, over
    whatever else they were showing, because it answers "why is there no sound?".

**Dim vs full reads poorly on this hardware** — established by having PLN read a
ramp back — so FLASH, never brightness alone, carries anything unmissable.

Usage
-----
    tools/lcxl-leds.py --map                 # convention paint (no track) — the BOOT paint
    tools/lcxl-leds.py --map TRACK.tidal     # paint by what that track actually binds
    tools/lcxl-leds.py --test                # walk every index; a dead LED is visible
    tools/lcxl-leds.py --all green
    tools/lcxl-leds.py --off
    tools/lcxl-leds.py --watch [TRACK.tidal] # touch-reactive, persistent daemon
    ... any of the above with --dry-run      # print the hex, send nothing

Port resolution
---------------
Resolved by matching the name "Launch Control XL" in `amidi -l` / `aconnect`, behind
a 2-second cache that is **dropped on any send failure**. The port has already moved
(20:0 -> 24:0) on a replug, and a binding resolved once at boot then silently
invalidated by a device event is this rig's single most expensive recurring bug class
-- so the cache is deliberately shorter than a human notices and cannot survive the
event that would make it wrong. See `invalidate_ports`.

If sends start failing while input still arrives, that is a USB OUT endpoint stall
(urb -32) and **only a replug fixes it** — this tool says so rather than looping.
"""

from __future__ import annotations

import argparse
import os
import re
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import lcxl_grid  # noqa: E402  — the one authored grid (#97)

REPO = Path(__file__).resolve().parent.parent

TEMPLATE = 0x00  # User 1

# --- colour palette (same numbers as ~lcxlCol in start_and_midi.scd) ---
OFF = 12
COLOURS = {
    "off": 12,
    "redlo": 13, "red": 15,
    "amberlo": 29, "amber": 63,
    "yellow": 62,
    "greenlo": 28, "green": 60,
    "redflash": 11, "amberflash": 59, "yellowflash": 58, "greenflash": 56,
}
HUE = {
    "green": {"dim": 28, "full": 60, "flash": 56},
    "amber": {"dim": 29, "full": 63, "flash": 59},
    "red":   {"dim": 13, "full": 15, "flash": 11},
}
ROLE_HUE = {"rhythm": "green", "bass": "amber", "lead": "amber",
            "pad": "amber", "riser": "red", "fx": "red"}

# --- CC layout (source of truth: reference_controller_map) ---
ROW_SEGMENTS = [
    ("A", 13, [1, 2, 3, 4, 5, 6, 7, 8]),
    ("B", 29, [1, 2, 3, 4, 5, 6, 7, 8]),
    ("C", 49, [1, 2, 3, 4, 5, 6, 7, 8]),
    ("D", 77, [1, 2, 3, 4, 5, 6, 7, 8]),   # faders — no LEDs
    ("E", 41, [1, 2, 3, 4]), ("E", 57, [5, 6, 7, 8]),
    ("F", 73, [1, 2, 3, 4]), ("F", 89, [5, 6, 7, 8]),
]
ROW_BASE = {"A": 0x00, "B": 0x08, "C": 0x10, "E": 0x18, "F": 0x20}
LIT_INDICES = 40  # 24 knobs + 16 buttons. Side/arrow LEDs left to the device.

DJ_FILTERS = [49, 50, 51]          # gF1/gF2/gF3, centre 64 = bypass
PANIC_CHORD = [73, 74, 91, 92]     # the four-button panic chord
PANIC_CC = 93

# ParVagues channel convention: lane -> role. Used for the CONVENTION paint (no
# track given) so a boot always produces a meaningful board, not a guess-free dark one.
LANE_ROLE = {1: "rhythm", 2: "rhythm", 3: "rhythm", 4: "bass",
             5: "lead", 6: "lead", 7: "rhythm", 8: "pad"}

# Orbit -> role fallback, mirroring the HUD's role-classifier.
ORBIT_ROLE = {1: "rhythm", 2: "rhythm", 3: "rhythm", 4: "bass", 5: "lead",
              6: "rhythm", 7: "rhythm", 8: "rhythm", 9: "pad", 10: "riser",
              11: "lead", 12: "lead"}

# Sound-name role patterns, ported from the HUD's role-classifier.js. Order
# matters: riser before rhythm so "risers:N" isn't eaten by the drum wordlist.
SOUND_PATTERNS = [
    ("riser",  re.compile(r"(risers?|sweep|whoosh|uplifter|fx_uplift)", re.I)),
    ("rhythm", re.compile(r"(kick|bd\b|:bd|808bd|808bb|snare|\bsn\b|\bhh\b|\boh\b|"
                          r"\bch\b|hat|breaks?|drums?|808|clap|cymbal|tom|perc|rim|"
                          r"cowbell|electro|jungle|gretsch|\bcp\b)", re.I)),
    ("bass",   re.compile(r"(bass|sub|cbow|cpluck|moog|acid|\bcb\b)", re.I)),
    ("pad",    re.compile(r"(pad|voice|vox|choir|noise|wind|rain|ambient|drone|"
                          r"ghost|foul|suns|birds|crow|atmosph|air)", re.I)),
    ("lead",   re.compile(r"(lead|synth|gameboy|arpy|piano|keys|guitar|fpiano|"
                          r"qstab|pluck|melody)", re.I)),
]


# --------------------------------------------------------------------------- #
# CC <-> LED index
# --------------------------------------------------------------------------- #
def decode_cc(cc: int) -> tuple[str, int | None]:
    for row, start, lanes in ROW_SEGMENTS:
        i = cc - start
        if 0 <= i < len(lanes):
            return row, lanes[i]
    return "?", None


def cc_to_index(cc: int) -> int | None:
    """LED index for a CC, or None for faders and unknown CCs (no LED exists)."""
    row, lane = decode_cc(cc)
    base = ROW_BASE.get(row)
    if base is None or lane is None:
        return None
    idx = base + (lane - 1)
    return idx if idx < LIT_INDICES else None


ALL_LIT_CCS = [cc for cc in range(128) if cc_to_index(cc) is not None]


# --------------------------------------------------------------------------- #
# value -> colour
# --------------------------------------------------------------------------- #
DJF_CENTRE_LO, DJF_CENTRE_HI = 61, 67   # the detent band that means "bypass"


def filter_colour(value: int) -> int:
    """DJ-filter knob: THREE states. red = lows, bright amber = bypass, green = highs.

    The three DJF knobs are the one place where a control's VALUE matters more than
    whether it has been touched: mid-set you need "am I filtering, and which way?".
    A fully-bright amber row therefore means "all three filters are out of the way".

    Settled by PLN 2026-07-29, and it REPLACES a seven-step ramp:
      "now maybe we sunset the dimorange and just have red in lows green in highs
       for clarity" / "i agree on clarity > resolution".
    The old version spent four of its seven steps on dim-amber and mid shades either
    side of centre, so the row read as a wash of oranges at a glance and the one
    thing you actually need — WHICH WAY is this filter cutting — was the hardest bit
    to see. Three unambiguous, full-brightness states beat seven shades nobody can
    resolve on a dark stage.

    Direction, in his words: red is low (the LPF end, toward subbass), green is high
    (the HPF end). 0.5 / CC 64 is bypass, NOT zero — `djf` 0.05 is a ~26 Hz low-pass,
    i.e. silence, which is the #48 footgun this colour is here to keep visible.
    """
    v = max(0, min(127, int(value)))
    if v < DJF_CENTRE_LO:   return 15   # red          — LPF, cutting the highs away
    if v <= DJF_CENTRE_HI:  return 63   # BRIGHT amber — centre detent == true bypass
    return 60                           # green        — HPF, cutting the lows away


def value_ramp(value: int) -> int:
    """PLN's six-step UNIPOLAR ramp, for any control that is a 0..max range.

    His spec, verbatim (2026-07-28): "from dim red nothing through bright red
    dim orange bright orange dim green bright green at max".

    The LCXL is bicolor (2 bits red x 2 bits green), so the whole palette is 16
    states and only ~6 read reliably on a dim stage. That is exactly six steps,
    so this ramp uses the entire usable budget and nothing is wasted on shades
    nobody can tell apart. See tools/mock-lcxl.py --palette.

    NOT for the DJ filters: those are bipolar (centre = bypass), and a monotonic
    ramp would paint bypass as mid-orange and the two opposite musical extremes
    as the same colour. They keep `filter_colour`.
    """
    v = max(0, min(127, int(value)))
    if v < 8:    return 13   # dim red      — effectively nothing
    if v < 32:   return 15   # bright red   — just coming in
    if v < 56:   return 29   # dim amber
    if v < 80:   return 31   # bright amber — half
    if v < 112:  return 28   # dim green
    return 60                # bright green — at max


def control_colour(cc: int, role: str, value: int | None, touched: bool,
                   panic: bool = False) -> int:
    """The one colour rule for the whole surface. Persistent by construction:
    it is a pure function of the MODEL (role + value + touched + panic), never of
    an event.

    `panic` is the live state of "^93" — armed means every stream wrapped in gPanic
    is silent. It is passed in rather than inferred from `cc`, and that is the whole
    of fix #76 (see below).
    """
    if cc in DJ_FILTERS:
        # Never dark: a filter you cannot see is a filter you forget is closed.
        return filter_colour(64 if value is None else value)

    hue = HUE[ROLE_HUE.get(role, "red")]
    row, _ = decode_cc(cc)

    if row in ("E", "F"):  # buttons: on/off state
        on = bool(value)
        # #76 — PANIC IS A STATE, NOT A MEMBERSHIP.
        # The panic chord is an OVERLOAD: hold 73+74+91+92 together and the SC
        # bridge edge-detects the chord and flips the persistent "^93" toggle. Those
        # buttons individually are gMute1 / gMute2 / (91,92) — they have day jobs.
        # The old rule tested `cc in PANIC_CHORD` BEFORE role, so 73 and 74 sat dim
        # RED at rest while 75 (gMute3, same job, not a chord member) sat green, and
        # PLN spotted it immediately: "why are [mutes] 1/2/3 resp red red green?
        # these 3 buttons have same roles, why diff colours?". The panic identity
        # simply does not exist at rest, so it must not be painted at rest.
        #
        # ^93 has no LED of its own (CC93 is outside row F's 89-92), so these four
        # buttons are the only place the armed state can be shown — and it is the
        # highest-value LED in the rig, because it answers "why is there no sound?".
        if panic and cc in PANIC_CHORD:
            return HUE["red"]["flash"]      # ARMED: everything gPanic'd is silent
        return hue["full"] if on else hue["dim"]

    # knobs (rows A/B/C): hue carries the VALUE, via PLN's six-step ramp.
    #
    # #11 — this ramp existed, was unit-tested, and was never wired in. PLN caught
    # that too: "wait 'brightness value 6 steps ramp'? I See only two states, dim
    # and not dim, at 0 and not 0 atm on the knobs". It was true — the old rule had
    # exactly three outcomes (dim / full / flash), so a knob at 30% and a knob at
    # 90% were the same colour.
    #
    # The cost, stated plainly: the ramp spends all three hues on VALUE, so hue can
    # no longer carry ROLE on rows A/B/C. That is the right trade, because role is
    # already encoded by POSITION (the lane convention is fixed, LANE_ROLE), while
    # a knob's setting has no other channel at all — the pot's pointer is invisible
    # on a dark stage. Unbound controls still go DARK, which is the distinction that
    # actually matters. The touched/untouched nuance moves to the HUD, which has
    # unlimited colours; trying to keep it here collided with the ramp (dim green is
    # both "rhythm, untouched" and "value ~3/4").
    if value is None:
        # Pre-seed only: boot seeding (#61) means this is effectively unreachable.
        return value_ramp(0)
    return value_ramp(value)


# --------------------------------------------------------------------------- #
# .tidal parsing — which controls does THIS track bind, and to what role
# --------------------------------------------------------------------------- #
BLOCK_ORBIT = re.compile(r"^\s*(?:d|p\s*)(\d+)\b")
CC_REF = re.compile(r"\^(\d+)")
SOUND_REF = re.compile(r"\b(?:sound|s)\s+\"([^\"]+)\"")


def classify_sound(sound: str | None, orbit: int | None) -> str:
    if sound:
        for role, rx in SOUND_PATTERNS:
            if rx.search(sound):
                return role
    if orbit and orbit in ORBIT_ROLE:
        return ORBIT_ROLE[orbit]
    return "fx"


def parse_track(path: Path) -> dict[int, str]:
    """cc -> role for every `^NN` the file references.

    Segmentation, and why it is NOT just "split on blank lines": a blank line is
    Tidal's block separator, but a `dN` pattern often runs many lines with full-line
    `-- comments` inside it, and stripping those comments manufactures blank lines
    that would cut a block in half. So segment boundaries are **original** blank
    lines *plus* every line that starts a new `dN`, and comments are stripped only
    for the CC scan. Verified against a real track: the naive version attributed 9/9
    bindings to the "fx" fallback because no segment head ever matched `dN`.

    A segment with no `dN` head (a helper definition like `gF = ...`) is
    cross-cutting FX.
    """
    raw = path.read_text(errors="replace").splitlines()
    bindings: dict[int, str] = {}

    segments: list[list[str]] = []
    cur: list[str] = []
    for ln in raw:
        if ln.strip() == "":
            if cur:
                segments.append(cur)
            cur = []
            continue
        if BLOCK_ORBIT.match(ln) and cur:
            segments.append(cur)
            cur = []
        cur.append(ln)
    if cur:
        segments.append(cur)

    for seg in segments:
        # Comments stripped here only — a commented-out binding must not light a knob.
        body = "\n".join(re.sub(r"--.*$", "", ln) for ln in seg)
        ccs = [int(m) for m in CC_REF.findall(body)]
        if not ccs:
            continue
        om = BLOCK_ORBIT.match(seg[0])
        orbit = int(om.group(1)) if om else None
        # `sound "x"` / `s "x"` when present; otherwise ParVagues writes the sample
        # name as a bare mini-notation string (`d2 $ gF1 $ "~ c . <...>"`), so fall
        # back to the first quoted string whose CONTENT matches a role pattern —
        # a test, not a guess, which keeps mini-notation like "t f!7" from scoring.
        sm = SOUND_REF.search(body)
        sound = sm.group(1) if sm else None
        if sound is None:
            for cand in re.findall(r"\"([^\"]+)\"", body):
                if any(rx.search(cand) for _, rx in SOUND_PATTERNS):
                    sound = cand
                    break
        role = classify_sound(sound, orbit)
        for cc in ccs:
            # An orbit/sound-derived role beats the bare "fx" fallback, so a CC
            # first seen in a helper block can still be upgraded by its real orbit.
            if cc not in bindings or (bindings[cc] == "fx" and role != "fx"):
                bindings[cc] = role

    # The three DJ filters are GLOBAL and must never be dark, whether or not this
    # particular file names them: a closed filter you cannot see is the single
    # easiest way to lose a track on stage. Their VALUE is meaningful even unbound,
    # so a default role is the right call for them.
    #
    # The panic chord used to be force-bound here too, and that was the OTHER half
    # of #76: it lit 73/74/91/92 as "fx" (red) for no reason other than chord
    # membership, so on a track binding gMute3 but not gMute1/2 the row read
    # "R R G" — three buttons with the same job, three different colours. A chord
    # is not a binding. Panic is painted by build_frame as a global OVERLAY when
    # "^93" is armed, and is invisible otherwise.
    for cc in DJ_FILTERS:
        bindings.setdefault(cc, "fx")

    # HELPERS INVOKED BY NAME. A `^NN` scan cannot see them, and they are the most
    # frequently used controls on the board.
    #
    # Every ParVagues orbit is written `dN $ gF2 $ gM3 $ ...`. Those names resolve,
    # in BootTidal.hs, to real CCs:
    #     gF1/gF2/gF3 -> ^49/^50/^51        (handled above, force-lit)
    #     gMask       -> ^41
    #     gMute1/2/3  -> ^73/^74/^75
    #     gM1/gM2/gM3 =  gMask . gMute{1,2,3}   -- so ONE name arms TWO controls
    #
    # PLN, on loading bombe_dj (2026-07-29): "i expect filters and mutes on all and
    # the ^41 as the mask control no?" He was right and the board was lying: the
    # three mutes were live on every orbit of that track and all three sat dark,
    # because the file never writes "^73" anywhere — it writes gM1. Dark means "not
    # mapped here" ([[feedback_dark_means_unmapped_outranks_all]]), so three of the
    # most-used buttons were claiming to do nothing.
    #
    # Same family as [[feedback_boot_helpers_are_invisible]]: a .tidal-only scan is
    # blind to every filter and mute, so PARSE the helper names too.
    #
    # Role is deliberately the neutral "fx" rather than the invoking orbit's role:
    # these are SHARED (bombe_dj drives gMute3 from d4, d5 and d7), so an
    # orbit-derived role would just be whichever orbit the parser saw last — a
    # stable colour beats an arbitrary one. Refining the button colour language is
    # #49/#51.
    whole = "\n".join(re.sub(r"--.*$", "", ln) for ln in raw)
    for name in set(re.findall(r"\b(gMute[123]|gM[123])\b", whole)):
        bindings.setdefault(72 + int(name[-1]), "fx")
    # gMask is retired to `id` in BootTidal (2026-07-29) and no longer owns ^41,
    # which now belongs to d1's gate. Naming it must not light anything.

    # A1-A4 = the LEVEL of d9..d12, MIDI-learned to Ardour (#46). No .tidal ever
    # writes ^13-^16 — Ardour owns them — so a "^NN" scan leaves them dark, and
    # dark means "not mapped here". PLN, on bombe_dj: "i see no led under d9 while
    # d9 has a sound a FMRhodes". The board was denying the existence of a control
    # that works.
    #
    # Light A_N exactly when the track DECLARES d(8+N), so the row answers the
    # question actually being asked mid-set: which extra orbits does this track
    # have, and where is their volume? A track with no d11 still gets a dark A3,
    # because there is nothing there to level.
    #
    # Role is the orbit's own, so the colour matches that orbit's family — these
    # are per-orbit by construction, unlike the shared filters and mutes.
    # The level CC comes from the authored grid (#97), not from `12 + (orbit - 8)`
    # arithmetic repeated here. That arithmetic IS the convention, and every copy
    # of it is a place the board can start disagreeing with the surface — which is
    # exactly how the HUD ended up drawing d9 on A8.
    declared = {int(m.group(1)) for ln in raw
                if (m := BLOCK_ORBIT.match(ln)) is not None}
    for orbit in declared:
        if 9 <= orbit <= 12:
            cc = lcxl_grid.orbit_home(orbit).get("level")
            if cc is not None:
                bindings.setdefault(cc, classify_sound(None, orbit))
    return bindings


def convention_bindings() -> dict[int, str]:
    """The no-track paint: every seeded control lit by its LANE's conventional role.

    This is what boot uses. It is honest about being a convention rather than a
    reading of a file, and it guarantees the surface is never dark after a boot —
    the "yellow lifeline", except in our colours.
    """
    out: dict[int, str] = {}
    for cc in ALL_LIT_CCS:
        _, lane = decode_cc(cc)
        out[cc] = LANE_ROLE.get(lane or 0, "fx")
    return out


# --------------------------------------------------------------------------- #
# frame building + SysEx
# --------------------------------------------------------------------------- #
def build_frame(bindings: dict[int, str], values: dict[int, int],
                touched: set[int]) -> list[int]:
    """index -> colour for the whole lit surface. Unbound controls go DARK, which is
    the point: the board becomes a map of the file in front of you."""
    frame = [OFF] * LIT_INDICES
    panic = bool(values.get(PANIC_CC))     # ^93: armed == gPanic'd streams silent
    for cc, role in bindings.items():
        idx = cc_to_index(cc)
        if idx is None:
            continue
        frame[idx] = control_colour(cc, role, values.get(cc), cc in touched, panic)
    if panic:
        # Panic is a global OVERLAY, painted last and over everything — including
        # controls this track does not bind, which would otherwise be dark. It has to
        # win: when it is armed, "why is there no sound?" is the only question on the
        # board, and CC93 owns no LED of its own to answer it with.
        for cc in PANIC_CHORD:
            idx = cc_to_index(cc)
            if idx is not None:
                frame[idx] = HUE["red"]["flash"]
    return frame


def sysex_pairs(pairs: list[tuple[int, int]]) -> str:
    body = "".join(f" {i:02X} {v:02X}" for i, v in pairs)
    return f"F0 00 20 29 02 11 78 {TEMPLATE:02X}{body} F7"


def sysex_frame(frame: list[int]) -> str:
    return sysex_pairs(list(enumerate(frame)))


# --------------------------------------------------------------------------- #
# port resolution + send
# --------------------------------------------------------------------------- #
NAME_RE = re.compile(r"launch\s*control\s*xl", re.I)

# Port lookups are `aconnect -o` / `amidi -l` subprocesses, ~1.7 ms each measured on
# this machine. The original code ran BOTH on every single LED write, so one lit knob
# cost three forks (~5 ms) -- and at 400 CC/s from eight moving faders that is 2000 ms
# of work per second of input. The queue could only grow; PLN saw it as "the animation
# lags a second or two behind". Cache the answer for a beat, and throw the cache away
# the instant a write fails, which is exactly when the answer might have changed.
PORT_TTL = 2.0
_PORT_CACHE: dict[str, tuple[float, str | None]] = {}
_PORT_LOCK = threading.Lock()


def invalidate_ports() -> None:
    """Forget every resolved port. Called on send failure and before `--ports`."""
    with _PORT_LOCK:
        _PORT_CACHE.clear()


def _cached_port(key: str, fn) -> str | None:
    now = time.monotonic()
    with _PORT_LOCK:
        hit = _PORT_CACHE.get(key)
        if hit is not None and now - hit[0] < PORT_TTL:
            return hit[1]
    val = fn()
    with _PORT_LOCK:
        _PORT_CACHE[key] = (time.monotonic(), val)
    return val


def _find_hw_port() -> str | None:
    """The `amidi` raw port (hw:C,D,S), resolved BY NAME (see `find_hw_port`).

    Excludes the HUI port: the LCXL exposes two, and only the first speaks the
    Launchpad SysEx dialect.
    """
    if not shutil.which("amidi"):
        return None
    try:
        out = subprocess.run(["amidi", "-l"], capture_output=True, text=True,
                             timeout=5).stdout
    except Exception:
        return None
    for line in out.splitlines():
        if not NAME_RE.search(line) or re.search(r"\bHUI\b", line):
            continue
        m = re.search(r"(hw:\d+,\d+(?:,\d+)?)", line)
        if m:
            return m.group(1)
    return None


def find_hw_port() -> str | None:
    return _cached_port("hw", _find_hw_port)


def _find_seq_port(direction: str = "-i") -> str | None:
    """The ALSA-sequencer client:port for the LCXL.

    `-i` = readable (source) side, for `aseqdump` — a READ-ONLY subscription that
    does not steal MIDI from SuperCollider. `-o` = writable (destination) side, for
    `aseqsend` — which is how we push SysEx.
    """
    if not shutil.which("aconnect"):
        return None
    try:
        out = subprocess.run(["aconnect", direction], capture_output=True, text=True,
                             timeout=5).stdout
    except Exception:
        return None
    client = None
    for line in out.splitlines():
        m = re.match(r"^client (\d+): '([^']*)'", line)
        if m:
            client = m.group(1) if NAME_RE.search(m.group(2)) else None
            continue
        if client:
            pm = re.match(r"^\s+(\d+) '(.*?)\s*'", line)
            if pm and not re.search(r"\bHUI\b", pm.group(2)):
                return f"{client}:{pm.group(1)}"
    return None


def find_seq_port(direction: str = "-i") -> str | None:
    return _cached_port(f"seq{direction}", lambda: _find_seq_port(direction))


class Sender:
    """SysEx-only writer with stall detection.

    TRANSPORT — and this cost the first live attempt. `amidi -p hw:2,0,0 -S ...` is
    what lit knob A1 by hand, but it only worked because nothing else held the raw
    device. Once SuperCollider is up, the ALSA **sequencer** layer owns the rawmidi
    substream and `amidi` dies with *"cannot open port hw:2,0,0: Device or resource
    busy"* — i.e. the tool that works on a cold rig fails on a LIVE one, which is
    the only rig that matters. So the primary transport is `aseqsend` to the LCXL's
    writable **sequencer** port, which coexists with SuperCollider; `amidi` stays as
    the fallback for when no seq client exists.

    Failures are counted, not ignored. If writes keep failing we say USB-OUT-STALL
    and tell PLN to replug, because that is the only thing that fixes urb -32 — and
    a tool that retries forever on a stalled endpoint just hides a dark board.
    """

    STALL_AFTER = 5

    def __init__(self, dry_run: bool = False, verbose: bool = True):
        self.dry_run = dry_run
        self.verbose = verbose
        self.fails = 0
        self.sends = 0
        self.stalled = False

    def send(self, hexmsg: str) -> bool:
        if self.dry_run:
            print(hexmsg)
            self.sends += 1
            return True

        # Port resolution is cached for PORT_TTL and dropped on failure (see
        # `invalidate_ports`), so a replug -- which already moved this device once,
        # 20:0 -> 24:0 -- is recovered from within one write, while the steady state
        # costs one fork instead of three.
        attempts: list[tuple[str, list[str]]] = []
        seq = find_seq_port("-o")
        if seq and shutil.which("aseqsend"):
            attempts.append(("aseqsend", ["aseqsend", "-p", seq, hexmsg]))
        hw = find_hw_port()
        if hw and shutil.which("amidi"):
            attempts.append(("amidi", ["amidi", "-p", hw, "-S", hexmsg]))

        if not attempts:
            self._fail("no 'Launch Control XL' port found (aconnect -o / amidi -l) "
                       "— unplugged?")
            return False

        errs = []
        for name, cmd in attempts:
            try:
                r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
            except Exception as e:
                errs.append(f"{name} raised: {e}")
                continue
            if r.returncode == 0:
                self.fails = 0
                self.sends += 1
                return True
            errs.append(f"{name} exit {r.returncode}: "
                        f"{(r.stderr or r.stdout).strip()}")
        self._fail("; ".join(errs))
        return False

    def _fail(self, why: str) -> None:
        # A failed write is the one moment we KNOW the cached port may be stale, so
        # this is where the cache dies -- not on a timer, on evidence.
        invalidate_ports()
        self.fails += 1
        if self.verbose:
            print(f"lcxl-leds: send FAILED — {why}", file=sys.stderr)
        if self.fails >= self.STALL_AFTER and not self.stalled:
            self.stalled = True
            print("lcxl-leds: USB OUT ENDPOINT STALL suspected "
                  f"({self.fails} consecutive failures).\n"
                  "  If input still works but LEDs are dark, only a REPLUG fixes this\n"
                  "  (urb -32). Nothing in software will recover it — see the\n"
                  "  reference_lcxl_led_stall memory.", file=sys.stderr)


class Painter:
    """A coalescing LED writer. The MIDI reader hands it colours and never blocks.

    Why (2026-07-28): PLN moved several faders at once and watched the board catch up
    a second or two later. The cause was structural, not a slow device -- the reader
    loop did its own I/O, so `for line in proc.stdout` could not advance while a write
    was in flight. aseqdump's pipe filled, and because a pipe QUEUES rather than drops,
    every event was faithfully rendered... late. Latency grew without bound for as long
    as the input rate exceeded the write rate.

    Three compounding reductions, in payoff order:

    1. DEDUPE ON COLOUR, not on value. This is the big one and it was the actual bug:
       the old loop repainted whenever the CC *value* changed, but colour is a step
       function of value (six steps in `value_ramp`, seven in `filter_colour`). Sweeping
       one fader 0 -> 127 emits ~128 events and needs at most 6 wire messages. ~20x.
    2. COALESCE. Only the LAST colour per index within a frame reaches the wire, so a
       burst of a thousand events costs one message per index per frame, not a thousand.
    3. BATCH. All dirty indices go out in ONE SysEx, because the Launchpad dialect takes
       (index, colour) pairs -- so a whole-surface repaint is one write, not forty.

    The rate limit is a floor on the interval BETWEEN writes, not a fixed tick: an
    isolated button press goes out immediately (no added latency), and only sustained
    input gets batched. Bounded wire rate, bounded latency, no polling when idle.

    Correctness invariant: the model is still the truth and `control_colour` is still a
    pure function of it, so whatever the coalescer folds away, the frame that lands is
    the frame a full recomputation would have produced. `test_lcxl_latency` asserts
    exactly that.
    """

    def __init__(self, sender: Sender, fps: float = 60.0, track_latency: bool = True):
        self.sender = sender
        self.min_interval = (1.0 / fps) if fps > 0 else 0.0
        self._dirty: dict[int, int] = {}
        self._stamp: dict[int, float] = {}
        self._sent: dict[int, int] = {}
        self._cv = threading.Condition()
        self._stop = False
        self._thread: threading.Thread | None = None
        self.track_latency = track_latency
        self.latencies: list[float] = []
        self.frames = 0          # wire messages actually sent
        self.updates = 0         # index-updates that reached the wire
        self.deduped = 0         # dropped: the colour was already correct
        self.coalesced = 0       # folded into a pending frame

    # -- producer side (called from the reader thread; must never do I/O) --
    def set(self, idx: int, colour: int) -> bool:
        """Queue one LED. Returns True if this actually changes the board."""
        with self._cv:
            pending = self._dirty.get(idx, self._sent.get(idx))
            if pending == colour:
                self.deduped += 1
                return False
            if idx in self._dirty:
                self.coalesced += 1
            elif self.track_latency:
                self._stamp[idx] = time.monotonic()
            self._dirty[idx] = colour
            self._cv.notify()
            return True

    def set_frame(self, frame: list[int]) -> None:
        """Queue a full re-assert. Unconditional: the point is to overrule the device."""
        with self._cv:
            now = time.monotonic()
            for i, c in enumerate(frame):
                if i not in self._dirty and self.track_latency:
                    self._stamp[i] = now
                self._dirty[i] = c
            self._cv.notify()

    # -- consumer side --
    def _drain(self) -> tuple[list[tuple[int, int]], list[float]]:
        with self._cv:
            pairs = sorted(self._dirty.items())
            stamps = [self._stamp.pop(i, None) for i, _ in pairs]
            self._dirty.clear()
            self._sent.update(pairs)
        now = time.monotonic()
        return pairs, [now - s for s in stamps if s is not None]

    def _run(self) -> None:
        next_ok = 0.0
        while True:
            with self._cv:
                while not self._dirty and not self._stop:
                    self._cv.wait(0.25)
                if self._stop and not self._dirty:
                    return
            gap = next_ok - time.monotonic()
            if gap > 0:
                time.sleep(gap)      # the coalescing window: late arrivals join us
            pairs, lat = self._drain()
            if not pairs:
                continue
            self.frames += 1
            self.updates += len(pairs)
            if self.track_latency:
                self.latencies.extend(lat)
            self.sender.send(sysex_pairs(pairs))
            next_ok = time.monotonic() + self.min_interval

    def start(self) -> Painter:
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()
        return self

    def stop(self, timeout: float = 1.0) -> None:
        with self._cv:
            self._stop = True
            self._cv.notify()
        if self._thread:
            self._thread.join(timeout)

    def flush(self, timeout: float = 2.0) -> None:
        """Block until the wire has caught up with the model (tests, shutdown)."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            with self._cv:
                if not self._dirty:
                    return
            time.sleep(0.005)

    def stats(self) -> dict[str, float]:
        lat = sorted(self.latencies)
        def pct(p: float) -> float:
            return lat[min(len(lat) - 1, int(p * len(lat)))] * 1000 if lat else 0.0
        return {"frames": self.frames, "updates": self.updates,
                "deduped": self.deduped, "coalesced": self.coalesced,
                "p50_ms": pct(0.50), "p99_ms": pct(0.99),
                "max_ms": (lat[-1] * 1000) if lat else 0.0}


def resolve_paint(cc: int, val: int, bindings: dict[int, str],
                  ack_unbound: bool = False,
                  panic: bool = False) -> tuple[int, int] | None:
    """(led index, colour) for one incoming control event, or None if not a lit index.

    Pulled out of the read loop so the bench and the tests exercise the SAME function
    the live daemon does -- a benchmark of a re-implementation measures nothing.

    `panic` is the current ^93 state from the model. Note that CC93 itself has no LED,
    so an event ON 93 resolves to None here — arming panic has to repaint the four
    chord members, which is why the watch loop rebuilds the frame instead of painting
    one index (see cmd_watch).
    """
    idx = cc_to_index(cc)
    if idx is None:
        return None
    role = bindings.get(cc)
    if role is None:
        # Unbound on this track. Default is to STAY DARK: "dark means this control does
        # nothing here" is the strongest signal on the board, and lighting it on touch
        # would undermine the one thing PLN reads at a glance. The touch is still
        # recorded in the model, so --ack-unbound can surface it when debugging.
        return idx, (HUE["red"]["dim"] if (ack_unbound and val) else OFF)
    return idx, control_colour(cc, role, val, True, panic)


# --------------------------------------------------------------------------- #
# commands
# --------------------------------------------------------------------------- #
def cmd_off(s: Sender) -> int:
    return 0 if s.send(sysex_frame([OFF] * LIT_INDICES)) else 1


def cmd_all(s: Sender, name: str) -> int:
    col = COLOURS.get(name.lower())
    if col is None:
        print(f"lcxl-leds: unknown colour '{name}'. Known: "
              f"{', '.join(sorted(COLOURS))}", file=sys.stderr)
        return 2
    return 0 if s.send(sysex_frame([col] * LIT_INDICES)) else 1


def cmd_test(s: Sender, delay: float = 0.06) -> int:
    """Walk every index in each hue, then paint a row-identifying pattern.

    Purpose is diagnostic: a DEAD LED shows up as the one that never lights during
    the walk, which you cannot see from a static frame.
    """
    ok = True
    for col in (HUE["red"]["full"], HUE["amber"]["full"], HUE["green"]["full"]):
        for i in range(LIT_INDICES):
            ok &= s.send(sysex_pairs([(i, col)]))
            if not s.dry_run:
                time.sleep(delay)
        if not s.dry_run:
            time.sleep(0.15)
    # Land on a row-legible pattern so the walk ends in something readable.
    frame = [OFF] * LIT_INDICES
    for row, base in ROW_BASE.items():
        hue = {"A": "green", "B": "amber", "C": "red", "E": "green", "F": "amber"}[row]
        for lane in range(8):
            if base + lane < LIT_INDICES:
                frame[base + lane] = HUE[hue]["full" if lane % 2 == 0 else "dim"]
    ok &= s.send(sysex_frame(frame))
    return 0 if ok else 1


def resolve_track(raw: str) -> Path:
    p = Path(raw)
    if p.exists():
        return p
    cand = REPO / raw
    if cand.exists():
        return cand
    if not raw.endswith(".tidal"):
        raw += ".tidal"
    hits = sorted((REPO / "live").rglob(raw)) + sorted((REPO / "copycat").rglob(raw))
    if len(hits) == 1:
        return hits[0]
    print(f"lcxl-leds: cannot resolve track '{raw}' ({len(hits)} matches)",
          file=sys.stderr)
    raise SystemExit(2)


# Where the currently-loaded track is published, so the LED watcher can paint what
# is ACTUALLY mapped instead of the convention board. One line, one path.
TRACK_FILE = Path(os.path.expanduser("~/.cache/parvagues/current-track"))


def read_current_track() -> str | None:
    """The track the rig is playing, or None if nobody has said."""
    try:
        t = TRACK_FILE.read_text().strip()
    except OSError:
        return None
    return t or None


def write_current_track(track: str) -> None:
    """Publish the loaded track. Best-effort: this must never break a boot."""
    try:
        TRACK_FILE.parent.mkdir(parents=True, exist_ok=True)
        TRACK_FILE.write_text(str(track) + "\n")
    except OSError:
        pass


def load_bindings(track: str | None, quiet: bool = False) -> dict[int, str]:
    if not track:
        b = convention_bindings()
        if not quiet:
            # This paint is a LIE and must say so. It lights all 40 lane-role
            # controls regardless of what is loaded, so a knob that does nothing
            # on this track still glows — and PLN reaches for it mid-set. "Dark =
            # not mapped here" outranks every other use of colour on this board.
            print(f"lcxl-leds: no track given → CONVENTION paint "
                  f"({len(b)} controls lit by lane role) — WARNING: this lights "
                  f"controls that may do NOTHING on the loaded track (#78)")
        return b
    path = resolve_track(track)
    b = parse_track(path)
    if not quiet:
        roles = {}
        for r in b.values():
            roles[r] = roles.get(r, 0) + 1
        lit = sum(1 for cc in b if cc_to_index(cc) is not None)
        print(f"lcxl-leds: {path.name} binds {len(b)} controls "
              f"({lit} with LEDs); roles: "
              + ", ".join(f"{k}={v}" for k, v in sorted(roles.items())))
        nolights = sorted(cc for cc in b if cc_to_index(cc) is None)
        if nolights:
            print(f"  (bound but no LED exists: {nolights} — row D faders have none)")
    return b


def cmd_map(s: Sender, track: str | None, values: dict[int, int] | None = None,
            quiet: bool = False) -> int:
    # "Paint for this track" also PUBLISHES it, so the persistent --watch daemon
    # follows along. Without this, a one-shot --map at boot showed the right board
    # for a moment and the watcher's next re-assert (30s) overwrote it with the
    # convention paint — the same board, lying again. One writer, one meaning.
    if track:
        write_current_track(track)
    bindings = load_bindings(track, quiet=quiet)
    values = values or {}
    frame = build_frame(bindings, values, set(values))
    lit = sum(1 for c in frame if c != OFF)
    if not quiet:
        print(f"lcxl-leds: painting {lit}/{LIT_INDICES} LEDs "
              f"({LIT_INDICES - lit} dark = unbound on this track)")
    return 0 if s.send(sysex_frame(frame)) else 1


# --------------------------------------------------------------------------- #
# --watch : the touch-reactive, persistent daemon
# --------------------------------------------------------------------------- #
# aseqdump's ACTUAL output format, taken from the binary's own format strings
# rather than guessed:
#   Control change         %2d, controller %d, value %d
#   Note on                %2d, note %d, velocity %s
# The first version of these regexes assumed bare space-separated numbers and would
# have matched NOTHING — a daemon that runs clean, logs nothing, and paints nothing.
# Green code proves nothing about whether the data reaches it; verify the seam.
ASEQ_CC = re.compile(r"Control change\s+(\d+),\s*controller\s+(\d+),\s*value\s+(\d+)")
ASEQ_NOTE = re.compile(r"Note (on|off)\s+(\d+),\s*note\s+(\d+),\s*velocity\s+(\d+)")


def cmd_watch(s: Sender, track: str | None, reassert: float = 30.0,
              verbose: bool = True, ack_unbound: bool = False,
              fps: float = 60.0) -> int:
    """Own the LED state; repaint the touched control from the model on every change.

    Persistence is not a feature bolted on — it is the data model. `values` is the
    truth, `control_colour` is a pure function of it, so any repaint (a touch, the
    periodic re-assert, a reconnect) reproduces exactly the same board. The device's
    own defaults never get a say because we re-assert on a timer and the device has
    no readback to argue with.

    READ-ONLY input: `aseqdump` subscribes to the LCXL port without stealing it, so
    SuperCollider keeps receiving every CC. We never write a CC anywhere.
    """
    if not shutil.which("aseqdump"):
        print("lcxl-leds: FAIL — aseqdump not found (alsa-utils)", file=sys.stderr)
        return 2

    # Follow the loaded track when the caller did not pin one. Without this the
    # watcher paints the CONVENTION board forever — 40 controls lit by lane role,
    # most of which are unmapped on any given track. That is #78: a lit dead knob
    # is worse than no colour, because it gets touched mid-set.
    follow = track is None
    current = track or read_current_track()
    bindings = load_bindings(current, quiet=not verbose)
    values: dict[int, int] = {}
    touched: set[int] = set()
    # bindings is read by two other threads; hand them a box, not a rebindable name.
    state = {"bindings": bindings, "track": current}

    # ONE thread owns the wire. The reader below only ever touches the model and hands
    # colours to the painter, so a slow write can no longer stall the read of the next
    # MIDI event -- which is what made the board lag behind PLN's hands (#71).
    painter = Painter(s, fps=fps).start()
    painter.set_frame(build_frame(state["bindings"], values, touched))
    if verbose:
        print("lcxl-leds --watch: initial frame painted; listening (Ctrl-C to stop)")

    backoff = 1.0
    events = 0

    # The re-assert runs on its OWN thread, deliberately. The obvious placement —
    # inside the aseqdump read loop — only fires when an event arrives, so during the
    # long silences that make up most of a set (exactly when a device hiccup would go
    # unnoticed) it would never run at all. A timer thread makes persistence hold
    # while nobody is touching anything, which is the whole point of "persistent post
    # touches". It queues through the painter like everything else, so a full repaint
    # can never interleave with a single-LED one.
    def reassert_loop() -> None:
        while reassert:
            time.sleep(reassert)
            painter.set_frame(build_frame(state["bindings"], dict(values),
                                          set(touched)))

    if reassert:
        threading.Thread(target=reassert_loop, daemon=True).start()

    def _mtime(t) -> float:
        try:
            return Path(t).stat().st_mtime if t else 0.0
        except OSError:
            return 0.0

    def follow_loop() -> None:
        """Repaint when the loaded track changes OR when its file is edited.

        Polled, not watched: a 1s poll of two small stats costs nothing measurable,
        and inotify would add a dependency plus a whole class of "the watch died
        and nobody noticed" failure — which is precisely this rig's signature bug
        (a binding resolved once and never rechecked).

        The MTIME half is not a nicety. Following only track CHANGES means the
        board reflects the file as it was the moment it was opened — and this is
        livecoding, so the file changes constantly. On 2026-07-29 the column
        migration renumbered bombe_dj's knobs while the watcher had held a parse
        from 66 minutes earlier; PLN saw C5+C6 lit and C4 dark, which was a
        faithful picture of a file that no longer existed, with nothing anywhere
        reporting a problem. Same family as [[feedback_stale_binding_pattern]]:
        resolved once, invalidated by an event, no error. Re-stat and re-parse.
        """
        while True:
            time.sleep(1.0)
            t = read_current_track()
            mt = _mtime(t)
            if t == state["track"] and mt == state.get("mtime"):
                continue
            same_track = (t == state["track"])
            try:
                state["bindings"] = load_bindings(t, quiet=not verbose)
            except Exception as e:                 # a bad path must not kill LEDs
                print(f"lcxl-leds: cannot bind {t}: {e}", file=sys.stderr)
                state["mtime"] = mt                # don't re-parse a broken file
                continue
            state["track"] = t
            state["mtime"] = mt
            # Touched-state is per-track: carrying it across would claim you had
            # already worked controls on a track you just opened. Values persist —
            # the knobs did not physically move. But an EDIT to the track already
            # loaded is not a new track: keep what the hands have touched, or a
            # ctrl+S mid-set would wipe the one thing this display is for.
            if not same_track:
                touched.clear()
            painter.set_frame(build_frame(state["bindings"], dict(values),
                                          set(touched)))

    if follow:
        threading.Thread(target=follow_loop, daemon=True).start()

    while True:
        port = find_seq_port()      # re-resolved on every (re)spawn, never cached
        if port is None:
            print("lcxl-leds --watch: no LCXL sequencer port; retrying in "
                  f"{backoff:.0f}s", file=sys.stderr)
            time.sleep(backoff)
            backoff = min(backoff * 2, 30.0)
            continue

        if verbose:
            print(f"lcxl-leds --watch: aseqdump -p {port}")
        try:
            proc = subprocess.Popen(["aseqdump", "-p", port],
                                    stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                                    text=True, bufsize=1)
        except Exception as e:
            print(f"lcxl-leds --watch: aseqdump spawn failed: {e}", file=sys.stderr)
            time.sleep(backoff); backoff = min(backoff * 2, 30.0)
            continue

        started = time.time()
        try:
            for line in proc.stdout:                      # type: ignore[union-attr]
                cc = val = None
                m = ASEQ_CC.search(line)
                if m:
                    cc, val = int(m.group(2)), int(m.group(3))
                else:
                    n = ASEQ_NOTE.search(line)
                    if n:
                        # Row F sends Notes, not CCs — and the note NUMBER equals
                        # the CC number in our map (73-76 / 89-92), so both event
                        # kinds fold into one model keyed by control number.
                        cc = int(n.group(3))
                        val = 0 if n.group(1) == "off" else int(n.group(4))
                if cc is not None and val is not None:
                    events += 1
                    was_panic = bool(values.get(PANIC_CC))
                    values[cc] = val
                    touched.add(cc)
                    if cc == PANIC_CC and bool(val) != was_panic:
                        # ^93 flipped. It owns no LED of its own, and it changes the
                        # colour of FOUR other indices at once, so this is the one
                        # event that cannot be served by a single-index paint. Rebuild
                        # the frame; the painter coalesces it like any other write.
                        painter.set_frame(build_frame(state["bindings"],
                                                      dict(values), set(touched)))
                        if verbose:
                            print(f"  ^93 PANIC {'ARMED — gPanic streams are SILENT'
                                                 if val else 'cleared'}")
                        continue
                    hit = resolve_paint(cc, val, state["bindings"], ack_unbound,
                                        panic=bool(values.get(PANIC_CC)))
                    # Print only when the BOARD changes. Logging every event was itself
                    # a per-event stdout write in the hot loop, and 400 CC/s of "CC77 =
                    # 63" tells nobody anything.
                    if hit and painter.set(*hit) and verbose:
                        row, lane = decode_cc(cc)
                        print(f"  CC{cc:<3d} {row}{lane} = {val:<3d} "
                              f"-> idx {hit[0]:02X} colour {hit[1]}"
                              f"{'' if cc in state['bindings'] else '  [unbound]'}")
        except KeyboardInterrupt:
            proc.terminate()
            painter.flush()
            painter.stop()
            st = painter.stats()
            print(f"\nlcxl-leds --watch: stopped after {events} events, "
                  f"{s.sends} sends, {len(touched)} controls touched")
            print(f"  coalescer: {int(st['frames'])} wire frames, "
                  f"{int(st['deduped'])} no-op events dropped, "
                  f"{int(st['coalesced'])} folded; "
                  f"latency p50 {st['p50_ms']:.1f} ms p99 {st['p99_ms']:.1f} ms")
            return 0
        except Exception as e:
            print(f"lcxl-leds --watch: read loop error: {e}", file=sys.stderr)
        finally:
            try:
                proc.terminate()
            except Exception:
                pass

        # Respawn with backoff — but reset it if we had a healthy long run, so a
        # one-off device blip doesn't leave us in a 30s-poll rut all gig.
        if time.time() - started > 30:
            backoff = 1.0
        print(f"lcxl-leds --watch: aseqdump exited; respawning in {backoff:.0f}s",
              file=sys.stderr)
        time.sleep(backoff)
        backoff = min(backoff * 2, 30.0)


# --------------------------------------------------------------------------- #
# --bench : prove the speed, don't claim it
# --------------------------------------------------------------------------- #
class BenchSender(Sender):
    """A Sender that writes nothing but costs what a real write costs.

    `aseqsend` measured ~1.7 ms of fork+exec on this machine and the old code paid it
    three times per LED (aconnect -o, amidi -l, aseqsend). Modelling that cost is the
    whole point: a bench against an instant sender would show a beautiful number and
    predict nothing about the rig.
    """

    def __init__(self, send_ms: float = 5.0):
        super().__init__(dry_run=False, verbose=False)
        self.cost = send_ms / 1000.0
        self.messages = 0
        self.pairs = 0

    def send(self, hexmsg: str) -> bool:
        self.messages += 1
        self.pairs += max(0, (len(hexmsg.split()) - 8) // 2)
        time.sleep(self.cost)
        self.sends += 1
        return True


def sweep_events(seconds: float, rate: float):
    """The worst realistic case: a hand dragging across the whole surface at once.

    Eight row-A knobs (CC 13-20, which HAVE LEDs) and the eight faders (CC 77-84, which
    do NOT) swept together, full travel. The faders matter even though they light
    nothing: their events still have to be read and parsed, and half the load of PLN's
    "i move all fders quickly" is exactly that. Nothing is ever WRITTEN to 77-84 --
    those are Ardour's track gains and this tool only ever listens.
    """
    ccs = [13 + i for i in range(8)] + [77 + i for i in range(8)]
    for k in range(int(seconds * rate)):
        lane = k % len(ccs)
        phase = (k // len(ccs)) * 4 + lane * 16
        yield ccs[lane], abs((phase % 254) - 127)


def cmd_bench(seconds: float = 3.0, rate: float = 500.0, fps: float = 60.0,
              send_ms: float = 5.0) -> int:
    bindings = convention_bindings()
    events = list(sweep_events(seconds, rate))
    period = 1.0 / rate

    def run(coalesced: bool) -> tuple[BenchSender, dict, float]:
        # Legacy paid the write cost THREE times per LED: `aconnect -o` and `amidi -l`
        # ran inside every send() to re-resolve the port, then aseqsend actually wrote.
        s = BenchSender(send_ms if coalesced else send_ms * 3)
        p = Painter(s, fps=fps) if coalesced else None
        if p:
            p.start()
        last_val: dict[int, int] = {}
        t0 = time.monotonic()
        for k, (cc, val) in enumerate(events):
            hit = resolve_paint(cc, val, bindings)
            if hit:
                if p:
                    p.set(*hit)
                elif last_val.get(cc) != val:
                    # The OLD behaviour, faithfully: dedupe on the CC *value*, then one
                    # blocking write per event, inline in the read loop. Colour is a
                    # step function of value, so this repainted a knob ~20x more often
                    # than the board could possibly change. That was the bug.
                    last_val[cc] = val
                    s.send(sysex_pairs([hit]))
            # Real MIDI arrives on a wire at a fixed rate. If we fall behind, the
            # events queue -- they are NOT dropped, which is exactly why the old
            # version lagged instead of skipping.
            due = t0 + (k + 1) * period
            slack = due - time.monotonic()
            if slack > 0:
                time.sleep(slack)
        drain = time.monotonic()
        if p:
            p.flush(timeout=10.0)
            p.stop()
        wall = time.monotonic() - t0
        st = p.stats() if p else {"p50_ms": 0.0, "p99_ms": 0.0, "max_ms": 0.0}
        st["drain_s"] = time.monotonic() - drain
        return s, st, wall

    print(f"lcxl-leds --bench: {len(events)} events over {seconds:.0f}s "
          f"({rate:.0f}/s, 8 knobs + 8 faders swept together), "
          f"write cost {send_ms:.1f} ms (legacy paid it 3x -- port re-resolution), "
          f"coalescer {fps:.0f} fps\n")
    rows = []
    for label, coalesced in (("legacy (write per LED)", False), ("coalesced", True)):
        s, st, wall = run(coalesced)
        overrun = wall - seconds
        rows.append((label, s.messages, s.pairs, wall, overrun, st))
        print(f"  {label:<24s} {s.messages:6d} wire msgs  {s.pairs:6d} LED updates  "
              f"wall {wall:6.2f}s  overrun {overrun:+6.2f}s  "
              f"drain {st['drain_s']:5.2f}s")
        if coalesced:
            print(f"  {'':<24s} latency p50 {st['p50_ms']:.1f} ms  "
                  f"p99 {st['p99_ms']:.1f} ms  max {st['max_ms']:.1f} ms")

    (_, m0, _, _, o0, _), (_, m1, _, _, o1, _) = (rows[0][:6], rows[1][:6])
    print(f"\n  wire traffic  {m0} -> {m1}  ({m0 / max(1, m1):.1f}x fewer messages)")
    print(f"  overrun       {o0:+.2f}s -> {o1:+.2f}s   "
          f"(overrun IS the visible lag: the board finishes that far behind the hands)")
    return 0


# --------------------------------------------------------------------------- #
def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--map", nargs="?", const="", metavar="TRACK.tidal",
                   help="paint by role; no argument = the channel-convention paint")
    g.add_argument("--watch", nargs="?", const="", metavar="TRACK.tidal",
                   help="touch-reactive persistent daemon")
    g.add_argument("--test", action="store_true", help="walk every LED index")
    g.add_argument("--all", metavar="COLOUR", help="paint everything one colour")
    g.add_argument("--off", action="store_true", help="all LEDs off")
    g.add_argument("--ports", action="store_true", help="show resolved ports and exit")
    g.add_argument("--bench", action="store_true",
                   help="measure the paint pipeline against the legacy one (no hardware)")
    ap.add_argument("--dry-run", action="store_true", help="print hex, send nothing")
    ap.add_argument("--ack-unbound", action="store_true",
                    help="--watch: faintly light controls this track does NOT bind "
                         "when touched (off by default — dark is the signal)")
    ap.add_argument("--reassert", type=float, default=30.0,
                    help="--watch: full re-assert interval in seconds (0 = off)")
    ap.add_argument("--fps", type=float, default=60.0,
                    help="--watch: max LED frames per second (0 = no rate limit)")
    ap.add_argument("--bench-rate", type=float, default=500.0,
                    help="--bench: incoming MIDI events per second")
    ap.add_argument("--bench-seconds", type=float, default=3.0)
    ap.add_argument("--bench-send-ms", type=float, default=5.0,
                    help="--bench: modelled cost of one wire write")
    ap.add_argument("-q", "--quiet", action="store_true")
    args = ap.parse_args()

    if args.bench:
        return cmd_bench(seconds=args.bench_seconds, rate=args.bench_rate,
                         fps=args.fps, send_ms=args.bench_send_ms)

    if args.ports:
        invalidate_ports()
        print(f"aseqsend (LED out, primary): {find_seq_port('-o') or 'NOT FOUND'}")
        print(f"amidi    (LED out, fallback): {find_hw_port() or 'NOT FOUND'}")
        print(f"aseqdump (CC in, read-only) : {find_seq_port('-i') or 'NOT FOUND'}")
        return 0

    s = Sender(dry_run=args.dry_run, verbose=not args.quiet)

    if args.off:
        return cmd_off(s)
    if args.all:
        return cmd_all(s, args.all)
    if args.test:
        return cmd_test(s)
    if args.map is not None:
        return cmd_map(s, args.map or None, quiet=args.quiet)
    if args.watch is not None:
        return cmd_watch(s, args.watch or None, reassert=args.reassert,
                         verbose=not args.quiet, ack_unbound=args.ack_unbound,
                         fps=args.fps)
    return 2


if __name__ == "__main__":
    sys.exit(main())
