#!/usr/bin/env python3
r"""lcxl3-driver — make the LCXL3 speak the corpus's language.

THE PROBLEM IT SOLVES
    The v3 hardware and the 169-track corpus disagree about what a CC means, and
    only one of them is cheap to change.

    Paint (RGB + the OLED) works ONLY in DAW mode — tested, not assumed: painting
    on the MIDI port in standalone did nothing, painting on the DAW port with DAW
    mode off did nothing, and the same paint after `daw on` lit the whole surface.
    But DAW mode's CC map is FIXED, and it overlaps v2's with different meanings:

        row    v2 CC (lcxl_grid)      v3 DAW CC
        A      13-20                  13-20      <- identical
        B      29-36                  21-28
        C      49-56                  29-36      <- v2's row B numbers!
        D      77-84                  5-12
        E      41-44, 57-60           37-44
        F      73-76, 89-92           45-52

    Renumbering the corpus to match would move controls silently — row C knobs
    would answer to row B's numbers — which is the exact failure class the #94
    migration spent 169 files fixing. So we translate in software instead, and the
    corpus keeps the numbering it already agrees on.

    Also confirmed on the hardware: in DAW mode the surface reports on the DAW
    interface ONLY. Nothing arrives on the MIDI port. That is why the rig went
    deaf when the v3 arrived.

WHY ABSOLUTE MODE, FOR NOW
    The endless encoders CAN send relative deltas, but relative mode is per-row
    opt-in and every row currently reads 0 (queried, not assumed). In absolute
    mode a v3 encoder behaves exactly like a v2 pot, so playability is a pure
    renumbering job. Relative mode needs an integrator that OWNS the value — a
    real improvement (it kills pot-pickup for good) but a separate change, and it
    is where paint-by-value belongs. See `--relative` for the experiment.

WHY IT DERIVES THE MAP RATHER THAN HOLDING ONE
    `lcxl_grid.py` is the one authored table (feedback_one_parser_per_concept).
    This module reads v2 numbers OUT of it positionally, so if the grid changes,
    the translation follows instead of drifting.

WHY A VIRTUAL PORT AND A RECONCILER
    SuperCollider runs `MIDIIn.connectAll` once at boot, so a port that appears
    later is not connected. And a port resolved once is a stale binding waiting to
    happen (feedback_stale_binding_pattern — the rig's most repeated failure). So
    we publish a stable virtual port and re-assert the aconnect link on a timer,
    the same shape as tools/midi-autoconnect.sh and tools/tidal-ardour-autoroute.sh.

    Requires: sudo pacman -S python-mido python-rtmidi
"""

from __future__ import annotations

import argparse
import pathlib
import signal
import subprocess
import sys
import time

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))

import lcxl3 as L  # noqa: E402

try:
    import lcxl_grid as GRID  # noqa: E402
except ImportError:  # pragma: no cover
    GRID = None

VIRTUAL_PORT_NAME = "ParVagues LCXL3"

# v3 DAW-mode indices per physical row, verified against the hardware by moving
# one control per row and reading what arrived (fader1->cc5 ch16, A1->13 ch16,
# B1->21 ch16, C1->29 ch16, E1->37 ch1, F1->45 ch1).
V3_ROWS: dict[str, list[int]] = {
    "A": list(range(13, 21)),
    "B": list(range(21, 29)),
    "C": list(range(29, 37)),
    "D": list(range(5, 13)),
    "E": list(range(37, 45)),
    "F": list(range(45, 53)),
}


def build_map() -> dict[int, int]:
    """v3 DAW CC -> v2 CC, positionally, straight out of the authored grid."""
    if GRID is None:
        sys.exit("lcxl3-driver: cannot import lcxl_grid — run me from the repo's tools/")
    out: dict[int, int] = {}
    for row, v3ccs in V3_ROWS.items():
        v2ccs = GRID.ROW_CCS.get(row)
        if not v2ccs:
            sys.exit(f"lcxl3-driver: lcxl_grid has no row {row!r}")
        if len(v2ccs) != len(v3ccs):
            sys.exit(
                f"lcxl3-driver: row {row} has {len(v2ccs)} v2 cells but "
                f"{len(v3ccs)} v3 indices — the grid and the hardware disagree, "
                "which is exactly what must not be papered over"
            )
        for v3, v2 in zip(v3ccs, v2ccs):
            out[v3] = v2
    return out


def describe_map(m: dict[int, int]) -> None:
    print(f"{'row':<4} {'v3 DAW CC':<22} -> v2 CC")
    for row, v3ccs in V3_ROWS.items():
        v2 = [m[c] for c in v3ccs]
        same = " (unchanged)" if v2 == v3ccs else ""
        print(f"{row:<4} {str(v3ccs[0]) + '-' + str(v3ccs[-1]):<22} -> "
              f"{','.join(str(x) for x in v2)}{same}")
    collisions = sorted(set(m) & set(m.values()) - {c for c in m if m[c] == c})
    if collisions:
        print(f"\nNOTE: {len(collisions)} v3 index/es also exist as v2 numbers with a")
        print("DIFFERENT meaning — translating (not renumbering) is what keeps that safe:")
        for c in collisions:
            print(f"  v3 #{c} means {L.control_name(c)}, but v2 #{c} is a different cell")


# ------------------------------------------------------------- aconnect glue ----


def _aconnect_pairs() -> str:
    try:
        return subprocess.run(
            ["aconnect", "-l"], capture_output=True, text=True, timeout=3
        ).stdout
    except Exception:
        return ""


def _clients(direction: str) -> list[tuple[str, str, str]]:
    """[(port_id, client_name, port_name)] from `aconnect -i|-o`.

    BOTH names, because they are genuinely different and each is load-bearing:
    python-rtmidi registers the CLIENT as 'RtMidiOut Client' and puts our chosen
    name on the PORT. Matching only the client name (the original code) meant the
    driver's own virtual port could never be found — it reported src=None while
    `aconnect -i` listed 'ParVagues LCXL3' plainly.
    """
    try:
        out = subprocess.run(
            ["aconnect", direction], capture_output=True, text=True, timeout=3
        ).stdout
    except Exception:
        return []
    res, cid, cname = [], None, ""
    for line in out.splitlines():
        if line.startswith("client "):
            cid = line.split()[1].rstrip(":")
            parts = line.split("'")
            cname = parts[1].strip() if len(parts) > 1 else ""
        elif cid and line.strip() and line[0] in " \t":
            tok = line.strip().split()
            if tok and tok[0].isdigit():
                parts = line.split("'")
                pname = parts[1].strip() if len(parts) > 1 else ""
                res.append((f"{cid}:{tok[0]}", cname, pname))
    return res


def _find_port(direction: str, needle: str) -> str | None:
    """First port whose CLIENT or PORT name contains needle."""
    for pid, cname, pname in _clients(direction):
        if needle in cname or needle in pname:
            return pid
    return None


def _sc_input_ports() -> list[str]:
    """SuperCollider's MIDI *destination* ports, e.g. ['130:0', ...].

    THE DIRECTION TRAP, which bit the original reconcile() silently.
        `aconnect -i` lists ports you can read FROM; `-o` lists ports you can
        write TO. SuperCollider appears in both: in0..inN are destinations,
        out0..outN are sources. Asking `-i` for "SuperCollider" returns out0
        (130:5 on this rig), and `aconnect <virt> 130:5` writes into an
        output-only port. It fails — and the original code discarded the
        CompletedProcess, then printed "reconcile: src -> dst" as if it had
        worked. Nothing was ever connected.
    """
    return [pid for pid, cname, _ in _clients("-o") if "SuperCollider" in cname]


def _sources_feeding(dst: str) -> list[tuple[str, str]]:
    """[(src_port_id, src_client_name)] currently connected INTO dst.

    Parsed off the SOURCE side ("Connecting To: ..."), the only place
    `aconnect -l` prints an edge with both of its ends.
    """
    res, cid, cname, port = [], None, "", None
    for line in _aconnect_pairs().splitlines():
        if line.startswith("client "):
            cid = line.split()[1].rstrip(":")
            parts = line.split("'")
            cname = parts[1].strip() if len(parts) > 1 else ""
            port = None
        elif cid and line.strip() and line[0] in " \t":
            tok = line.strip().split()
            if tok and tok[0].isdigit():
                port = f"{cid}:{tok[0]}"
            elif "Connecting To:" in line and port:
                for t in line.split("Connecting To:")[1].split(","):
                    if t.strip().split("[")[0].strip() == dst:
                        res.append((port, cname))
    return res


# Clients whose traffic must NOT reach SuperCollider while we translate. These
# carry the surface's RAW v3 numbers, which collide with the corpus's v2 numbers
# on 16 indices with a DIFFERENT meaning — so leaving them connected does not
# merely duplicate, it fires the WRONG control alongside the right one: row C
# knob 4 would drive ^52 (correct) and ^32 (row B's cell) at once.
RAW_CLIENTS = ("LCXL3", "Launch Control XL")
# Midi Through carries whatever midi-autoconnect's rule 1 pushes into it — the
# surface again, i.e. a second copy of the same stream. A doubled CC is
# survivable on a knob and fatal on a latch: the toggle fires twice and cancels,
# which reads from the stage as "the button does nothing".
THRU_CLIENTS = ("Midi Through",)


def prune(protect: set[str], verbose: bool = False,
          cut_thru: bool = True) -> list[str]:
    """Cut every raw path into SuperCollider, protecting `protect` port ids.

    PROTECT BY PORT ID, NOT BY NAME. python-rtmidi calls our client
    'RtMidiOut Client', so a name-based "skip our own port" test does not match
    and prune would cheerfully cut the very link it just made. Identity here is
    the ALSA port id; the names are decoration.

    WHY THIS EXISTS — found against a LIVE SuperCollider, 2026-08-29
        `MIDIIn.connectAll` subscribes SC to EVERY source present at boot: on
        this rig in2=Midi Through, in3=LCXL3 MIDI, in4=LCXL3 DAW. So the moment
        the driver published a correct translated stream, SC heard it ALONGSIDE
        two raw ones. reconcile() only ever ADDED links, so the driver could not
        have been correct here however right its translation was.
    """
    cut = []
    bad = RAW_CLIENTS + (THRU_CLIENTS if cut_thru else ())
    for dst in _sc_input_ports():
        for src, name in _sources_feeding(dst):
            if src in protect:
                continue
            if any(b in name for b in bad):
                subprocess.run(["aconnect", "-d", src, dst],
                               capture_output=True, timeout=3)
                cut.append(f"{src} '{name}' -> {dst}")
                if verbose:
                    print(f"  prune: cut {src} '{name}' -> {dst}")
    return cut


def reconcile(verbose: bool = False, cut_thru: bool = True) -> bool:
    """Wire our virtual port into SC's MIDI input, then cut the raw ones.

    Order matters and is deliberate: we only prune AFTER a confirmed connect.
    Cutting the raw surface while our replacement stream is not actually
    delivering would leave PLN with a dead surface — worse than a wrong one.
    """
    src = _find_port("-i", VIRTUAL_PORT_NAME)
    dst = next(iter(_sc_input_ports()), None)      # -o, NOT -i: see note above
    if not src or not dst:
        if verbose:
            print(f"  reconcile: src={src} dst={dst} — nothing to do yet")
        return False
    r = subprocess.run(["aconnect", src, dst], capture_output=True,
                       text=True, timeout=3)
    err = (r.stderr or "").strip()
    already = "already subscribed" in err.lower()
    if r.returncode != 0 and not already:
        # Report it. A reconciler that cannot say "I failed" is decoration.
        print(f"  reconcile: FAILED {src} -> {dst}: {err or r.returncode}")
        return False
    if verbose:
        print(f"  reconcile: {src} -> {dst}" + (" (already)" if already else ""))
    prune({src}, verbose=verbose, cut_thru=cut_thru)
    return True


# -------------------------------------------------------------------- driver ----


class Driver:
    def __init__(self, args):
        self.args = args
        self.map = build_map()
        self.stats = {"in": 0, "out": 0, "dropped": 0}
        self.unmapped: dict[tuple[int, int], int] = {}
        mido = L._mido()
        self.surface = L.Surface("daw", need_input=True)
        try:
            self.virt = mido.open_output(VIRTUAL_PORT_NAME, virtual=True)
        except Exception as e:
            sys.exit(
                f"lcxl3-driver: could not create the virtual port ({e}).\n"
                "python-rtmidi with the ALSA backend is required."
            )
        self.out_channel = args.channel - 1
        self._stop = False

    def start(self) -> None:
        print(f"enabling DAW mode (the only mode that paints, and the only one")
        print(f"  that reports the fixed map we translate from)")
        self.surface.daw_mode(True)
        time.sleep(0.2)
        if self.args.relative:
            for row in (1, 2, 3):
                self.surface.relative(row, True)
            print("  encoder rows 1-3 -> RELATIVE (deltas; values are ours to hold)")
        print(f"virtual port published: {VIRTUAL_PORT_NAME!r}")
        reconcile(verbose=True, cut_thru=not self.args.keep_thru)

    def stop(self) -> None:
        if self.args.relative:
            for row in (1, 2, 3):
                try:
                    self.surface.relative(row, False)
                except Exception:
                    pass
        if not self.args.stay:
            try:
                self.surface.daw_mode(False)
                print("\nDAW mode -> OFF (the guide says a host should exit on quit)")
            except Exception:
                pass
        try:
            self.virt.close()
        except Exception:
            pass
        self.surface.close()

    def run(self) -> int:
        mido = L._mido()
        self.start()

        def onsig(_s, _f):
            self._stop = True

        signal.signal(signal.SIGINT, onsig)
        signal.signal(signal.SIGTERM, onsig)

        print("\ntranslating — ctrl-c to stop\n")
        last_recon = time.time()
        while not self._stop:
            for msg in self.surface.inp.iter_pending():
                self.stats["in"] += 1
                if msg.type != "control_change":
                    continue
                # Feature-control traffic (ch7) is state, not a musical control.
                if msg.channel == L.CH_FEATURE:
                    if self.args.verbose:
                        print(f"  [feature] {L._describe(msg)}")
                    continue
                v2 = self.map.get(msg.control)
                if v2 is None:
                    key = (msg.channel, msg.control)
                    self.unmapped[key] = self.unmapped.get(key, 0) + 1
                    self.stats["dropped"] += 1
                    if self.args.verbose:
                        print(f"  [unmapped] ch{msg.channel + 1} #{msg.control}")
                    continue
                self.virt.send(
                    mido.Message(
                        "control_change",
                        channel=self.out_channel,
                        control=v2,
                        value=msg.value,
                    )
                )
                self.stats["out"] += 1
                if self.args.verbose:
                    print(
                        f"  {L.control_name(msg.control):<16} "
                        f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}"
                    )
            if time.time() - last_recon > self.args.reconcile_secs:
                reconcile(verbose=self.args.verbose,
                          cut_thru=not self.args.keep_thru)
                last_recon = time.time()
            time.sleep(0.001)

        self.stop()
        print(f"\nin={self.stats['in']} translated={self.stats['out']} "
              f"dropped={self.stats['dropped']}")
        if self.unmapped:
            print("unmapped controls seen (these are the surface's extra buttons —")
            print("Page/Track/Record/Play/Solo/Mute, unused by the corpus today):")
            for (ch, cc), n in sorted(self.unmapped.items(), key=lambda kv: -kv[1]):
                print(f"  ch{ch + 1} #{cc}  x{n}")
        return 0


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="lcxl3-driver", description=__doc__.splitlines()[0])
    p.add_argument("--show-map", action="store_true", help="print the translation and exit")
    p.add_argument("--verbose", "-v", action="store_true", help="log every translation")
    p.add_argument("--channel", type=int, default=1,
                   help="output MIDI channel, 1-based (default 1; SC ignores it, "
                        "Ardour may not)")
    p.add_argument("--relative", action="store_true",
                   help="EXPERIMENT: switch encoder rows to relative. Their CC numbers "
                        "shift +64, so the map below no longer applies — expect drops.")
    p.add_argument("--graph", action="store_true",
                   help="print what currently feeds SuperCollider's MIDI in, and exit")
    p.add_argument("--keep-thru", action="store_true",
                   help="do NOT cut Midi Through -> SuperCollider. Only for when "
                        "something else legitimately feeds SC through it; by default "
                        "we cut it, because it is a second copy of the surface and a "
                        "doubled CC cancels a latching button.")
    p.add_argument("--stay", action="store_true", help="leave DAW mode on at exit")
    p.add_argument("--reconcile-secs", type=float, default=5.0)
    args = p.parse_args(argv)

    if args.graph:
        ports = _sc_input_ports()
        if not ports:
            print("SuperCollider has no ALSA-seq input ports — is sclang running?")
            return 1
        bad = RAW_CLIENTS + THRU_CLIENTS
        for dst in ports:
            for src, name in _sources_feeding(dst):
                tag = ("OK (ours)" if VIRTUAL_PORT_NAME in name
                       else "WOULD CUT" if any(b in name for b in bad) else "left alone")
                print(f"  {src:<8} {name:<24} -> {dst:<8}  {tag}")
        return 0
    if args.show_map:
        describe_map(build_map())
        return 0
    return Driver(args).run()


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