#!/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]]:
    """[(client:port, name)] from `aconnect -i|-o`."""
    try:
        out = subprocess.run(
            ["aconnect", direction], capture_output=True, text=True, timeout=3
        ).stdout
    except Exception:
        return []
    res, cur = [], None
    for line in out.splitlines():
        if line.startswith("client "):
            parts = line.split("'")
            cur = (line.split()[1].rstrip(":"), parts[1].strip() if len(parts) > 1 else "")
        elif cur and line.strip() and line[0] in " \t":
            tok = line.strip().split()
            if tok and tok[0].isdigit():
                res.append((f"{cur[0]}:{tok[0]}", cur[1]))
    return res


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

    THE DIRECTION TRAP, and it bit the original reconcile() silently.
        `aconnect -i` lists ports you can read FROM (sources); `aconnect -o` lists
        ports you can write TO (destinations). SuperCollider appears in BOTH: its
        in0..inN are destinations, its out0..outN are sources. Asking `-i` for
        "SuperCollider" therefore returns out0 (130:5 here), and
        `aconnect <virt> 130:5` writes into an output-only port. It fails — and
        because the original code discarded the CompletedProcess it then printed
        "reconcile: src -> dst" as if it had worked. The driver had never
        connected anything to SC in its life.
    """
    return [p for p, n in _clients("-o") if "SuperCollider" in n]


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

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


# Clients whose traffic must NOT reach SuperCollider while we translate.
# These deliver 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 both ^52 (correct) and ^32 (row B's cell).
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 itself, which
# reads from the stage as "the button does nothing".
THRU_CLIENTS = ("Midi Through",)


def prune(verbose: bool = False, cut_thru: bool = True) -> list[str]:
    """Cut every path into SuperCollider that is not our translated port.

    WHY THIS EXISTS — found against a LIVE SuperCollider, 2026-08-29
        `MIDIIn.connectAll` subscribes SC to EVERY source present at boot. On
        this rig that was in2=Midi Through, in3=LCXL3 MIDI In, in4=LCXL3 DAW In.
        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. This is the half that only a live SC could show.
    """
    cut = []
    bad = RAW_CLIENTS + (THRU_CLIENTS if cut_thru else ())
    for dst in _sc_input_ports():
        for src, name in _sources_feeding(dst):
            if VIRTUAL_PORT_NAME in name:
                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, and cut the raw ones."""
    src = next((p for p, n in _clients("-i") if VIRTUAL_PORT_NAME in n), None)
    if src is None:
        src = next((p for p, n in _clients("-o") if VIRTUAL_PORT_NAME in n), None)
    dst = next(iter(_sc_input_ports()), None)      # -o, NOT -i: see the note there
    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()
    ok = r.returncode == 0 or "already subscribed" in err.lower()
    if not ok:
        # 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" in err.lower() else ""))
    prune(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())
