#!/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 reconcile(verbose: bool = False) -> bool:
    """Wire our virtual port into SuperCollider's MIDI input, idempotently."""
    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((p for p, n in _clients("-i") if "SuperCollider" in n), None)
    if not src or not dst:
        if verbose:
            print(f"  reconcile: src={src} dst={dst} — nothing to do yet")
        return False
    graph = _aconnect_pairs()
    if f"{dst}" in graph and src.split(":")[0] in graph:
        pass  # cheap pre-check only; aconnect is idempotent anyway
    try:
        subprocess.run(["aconnect", src, dst], capture_output=True, timeout=3)
        if verbose:
            print(f"  reconcile: {src} -> {dst}")
        return True
    except Exception:
        return False


# -------------------------------------------------------------------- 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)

    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)
                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("--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.show_map:
        describe_map(build_map())
        return 0
    return Driver(args).run()


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