#!/usr/bin/env python3
r"""lcxl3 — the Launch Control XL 3 bench.

A place to *look at* the new surface before we commit the rig to it: enumerate its
two interfaces, decode everything it sends, paint its LEDs, draw on its screen, and
answer the one question that decides the architecture (see `keystone`).

WHY THIS EXISTS SEPARATELY FROM `lcxl-leds.py`
    v2 and v3 are different protocols, not different constants. v2 LEDs are a
    template/palette SysEx with 3-colour buttons; v3 has true RGB per control, a
    128x64 OLED, endless encoders, and a *fixed* CC map that only exists in DAW
    mode. Folding v3 into the v2 tool would produce a file that lies about both.
    When the migration lands, `lcxl_grid.py` stays the ONE authored table
    (feedback_one_parser_per_concept) and this module becomes its transport.

TRANSPORT — and why not aseqsend
    `lcxl-leds.py` shells out to `aseqsend`, which on this system takes `-s` as a
    FILENAME, not a hex string: `aseqsend -p 20:0 -s "B0 25 05"` prints
    "cannot open B0 25 05" and exits 0. Silent no-op. So we use python-rtmidi via
    mido, which also gets us real input callbacks instead of parsing aseqdump.

    Install:  sudo pacman -S python-mido python-rtmidi
    Deliberately system packages, not a venv: the rig's systemd --user units run
    system python (reference_rig_reconciler_units).

THE TWO INTERFACES (programmer's reference, "MIDI on Launch Control XL 3")
    MIDI In/Out  — Custom Modes; the surface's own output in standalone.
    DAW In/Out   — everything in this file that paints or draws.
    To DIN Out 1/2 — host -> the unit's DIN jacks. Irrelevant here: this machine
    has no DIN *input* at all, so DIN buys the rig nothing today.

ALL BYTE SEQUENCES BELOW ARE QUOTED FROM the official programmer's reference guide
v1.0, not recalled:
    https://fael-downloads-prod.focusrite.com/customer/prod/downloads/launch_control_xl_3_programmer_s_reference_guide-pdf_en.pdf
"""

from __future__ import annotations

import argparse
import sys
import time

# ---------------------------------------------------------------- protocol ----

HEADER = (0xF0, 0x00, 0x20, 0x29, 0x02, 0x15)

CMD_COLOUR_RGB = 0x01  # then 0x53 <index> <R> <G> <B>
CMD_DAW_MODE = 0x02  # then 0x7F on / 0x00 off
CMD_CONFIG_DISPLAY = 0x04  # then <target> <config>
CMD_SET_TEXT = 0x06  # then <target> <field> <ascii...>
CMD_BITMAP = 0x09  # then <target> <1216 bytes>, terminated 0x7F

# DAW-mode control indices. These double as the LED addresses ("The Control Change
# indices listed are also used for sending colour to the corresponding LEDs").
FADERS = range(5, 13)  # 05h-0Ch
ENCODERS = range(13, 37)  # 0Dh-24h, three rows of 8
ENC_ROWS = (range(13, 21), range(21, 29), range(29, 37))
BUTTONS = range(37, 53)  # two rows of 8
ALL_CONTROLS = list(FADERS) + list(ENCODERS) + list(BUTTONS)

# Feature controls: DAW *in* port, channel 7 (status B6h); query on channel 8.
FEAT_MODE_SELECT = 0x1E  # 30  - reports AND sets surface mode
FEAT_SHIFT = 0x3F  # 63
FEAT_REL_ROW = {1: 0x45, 2: 0x48, 3: 0x49}  # 69 / 72 / 73
FEAT_FADER_PICKUP = 0x46  # 70
FEAT_TOUCH = 0x47  # 71
FEAT_LED_BRIGHT = 0x6F  # 111, non-volatile
FEAT_SCREEN_BRIGHT = 0x70  # 112, non-volatile
FEAT_DISPLAY_TIMEOUT = 0x71  # 113, 1/10 s units
FEAT_ENCODER_CURVE = 0x79  # 121, 0 slow / 1 med / 2 fast

# Display targets ("Configure displays")
TGT_STATIONARY = 0x35  # 53 - permanent display
TGT_TEMPORARY = 0x36  # 54 - overlay/temporary
TGT_BITMAP_STATIONARY = 0x20  # 32 - bitmap only accepts 32 or 33
TGT_BITMAP_TEMPORARY = 0x21  # 33

# Surface modes, for FEAT_MODE_SELECT
MODES = {"mixer": 0x01, "control": 0x02}
for _n in range(1, 5):  # Custom 1-4 -> 06h-09h
    MODES[f"custom{_n}"] = 0x05 + _n
for _n in range(5, 9):  # Custom 5-8 -> 12h-15h
    MODES[f"custom{_n}"] = 0x12 + (_n - 5)
for _n in range(9, 17):  # Custom 8-16 -> 16h-1Dh (guide's own numbering)
    MODES[f"custom{_n}"] = 0x16 + (_n - 9)

# Channels are 1-based in the guide, 0-based in mido.
CH_BUTTONS = 0  # channel 1
CH_FEATURE = 6  # channel 7
CH_FEATURE_QUERY = 7  # channel 8
CH_TOUCH = 14  # channel 15
CH_ANALOGUE = 15  # channel 16 - encoders and faders


def control_name(index: int) -> str:
    """Human label for a DAW-mode control index."""
    if index in FADERS:
        return f"fader {index - 4}"
    for row, rng in enumerate(ENC_ROWS, start=1):
        if index in rng:
            return f"encoder r{row}c{index - rng.start + 1}"
    if index in BUTTONS:
        row = "E" if index < 45 else "F"
        col = (index - 37) % 8 + 1
        return f"button {row}{col}"
    return f"cc {index}"


# --------------------------------------------------------------- transport ----


def _mido():
    try:
        import mido  # noqa: PLC0415
    except ImportError:
        sys.exit(
            "lcxl3: python-mido / python-rtmidi are not installed.\n"
            "  sudo pacman -S python-mido python-rtmidi\n"
            "(system packages on purpose — the rig's systemd --user units run "
            "system python, not a venv.)"
        )
    return mido


def find_ports(kind: str = "daw") -> tuple[str | None, str | None]:
    """(input_name, output_name) for the LCXL3's MIDI or DAW interface.

    Matched on the *substring* the device actually advertises. The v2 tools match
    "Launch Control XL", which does NOT match v3's "LCXL3 1" — that stale string is
    still live in tools/bridge/surface.py.
    """
    mido = _mido()
    want = "DAW" if kind == "daw" else "MIDI"
    ins = [p for p in mido.get_input_names() if "LCXL3" in p]
    outs = [p for p in mido.get_output_names() if "LCXL3" in p]

    def pick(ports: list[str]) -> str | None:
        exact = [p for p in ports if want in p]
        if exact:
            return exact[0]
        # The MIDI interface is the one WITHOUT "DAW" in its name.
        if want == "MIDI":
            plain = [p for p in ports if "DAW" not in p]
            if plain:
                return plain[0]
        return None

    return pick(ins), pick(outs)


class Surface:
    """An open connection to one of the LCXL3's interfaces."""

    def __init__(self, kind: str = "daw", need_input: bool = False):
        mido = _mido()
        self.kind = kind
        in_name, out_name = find_ports(kind)
        if out_name is None:
            sys.exit(
                f"lcxl3: no LCXL3 {kind.upper()} output port found.\n"
                "  Run `lcxl3.py ports` to see what the system advertises."
            )
        self.out = mido.open_output(out_name)
        self.out_name = out_name
        self.inp = None
        self.in_name = in_name
        if need_input:
            if in_name is None:
                sys.exit(f"lcxl3: no LCXL3 {kind.upper()} input port found.")
            self.inp = mido.open_input(in_name)

    def close(self) -> None:
        try:
            self.out.close()
            if self.inp:
                self.inp.close()
        except Exception:
            pass

    # -- raw sends ----------------------------------------------------------

    def sysex(self, *payload: int) -> None:
        """Send HEADER + payload (do NOT include F0/F7)."""
        mido = _mido()
        self.out.send(mido.Message("sysex", data=list(HEADER[1:]) + list(payload)))

    def cc(self, control: int, value: int, channel: int = CH_BUTTONS) -> None:
        mido = _mido()
        self.out.send(
            mido.Message("control_change", channel=channel, control=control, value=value)
        )

    def note(self, note: int, velocity: int, channel: int = 15) -> None:
        mido = _mido()
        self.out.send(
            mido.Message("note_on", channel=channel, note=note, velocity=velocity)
        )

    # -- documented operations ---------------------------------------------

    def daw_mode(self, on: bool) -> None:
        """9Fh 0Ch 7Fh/00h, or the SysEx equivalent. We send the SysEx."""
        self.sysex(CMD_DAW_MODE, 0x7F if on else 0x00)

    def feature_controls(self, on: bool) -> None:
        """9Fh 0Bh 7Fh/00h — needed to reach feature CCs in standalone."""
        self.note(0x0B, 0x7F if on else 0x00, channel=15)

    def feature(self, cc: int, value: int) -> None:
        self.cc(cc, value, channel=CH_FEATURE)

    def rgb(self, index: int, r: int, g: int, b: int) -> None:
        """F0 .. 01h 53h <index> <R> <G> <B> F7. R/G/B are 7-bit (0-127)."""
        self.sysex(CMD_COLOUR_RGB, 0x53, index, r & 0x7F, g & 0x7F, b & 0x7F)

    def palette(self, index: int, colour: int) -> None:
        """B0h <index> <colour index> — the 128-entry palette path."""
        self.cc(index, colour & 0x7F, channel=CH_BUTTONS)

    def relative(self, row: int, on: bool) -> None:
        self.feature(FEAT_REL_ROW[row], 0x7F if on else 0x00)

    def configure_display(self, target: int, config: int) -> None:
        self.sysex(CMD_CONFIG_DISPLAY, target, config & 0x7F)

    def set_text(self, target: int, field: int, text: str) -> None:
        """ASCII 20h-7Eh only; 1Bh/1Ch/1Dh/1Eh are box/box/flat/heart."""
        data = [ord(c) & 0x7F for c in text if 0x20 <= ord(c) <= 0x7E]
        self.sysex(CMD_SET_TEXT, target, field, *data)

    def bitmap(self, rows: list[int], target: int = TGT_BITMAP_STATIONARY) -> None:
        """1216 bytes: 19 per row x 64 rows, 7 pixels per byte, MSB leftmost.

        NOTE the terminator: the guide ends this message with 7Fh, not F7h.
        """
        if len(rows) != 1216:
            raise ValueError(f"bitmap needs exactly 1216 bytes, got {len(rows)}")
        self.sysex(CMD_BITMAP, target, *rows, 0x7F)


# ------------------------------------------------------------ bitmap helper ----

WIDTH, HEIGHT, ROW_BYTES = 128, 64, 19


def pack_bitmap(pixels: list[list[int]]) -> list[int]:
    """[64][128] of 0/1 -> the 1216-byte payload.

    7 pixels per byte, highest bit leftmost, 19 bytes covering 128 px (five unused
    bits in the last byte). 19*7 = 133 >= 128.
    """
    out: list[int] = []
    for y in range(HEIGHT):
        row = pixels[y]
        for bidx in range(ROW_BYTES):
            byte = 0
            for bit in range(7):
                x = bidx * 7 + bit
                on = row[x] if x < WIDTH else 0
                if on:
                    byte |= 1 << (6 - bit)
            out.append(byte & 0x7F)
    return out


def blank() -> list[list[int]]:
    return [[0] * WIDTH for _ in range(HEIGHT)]


def demo_pixels(phase: int = 0) -> list[list[int]]:
    """Something unmistakably OURS, so 'did it work' needs no interpretation:
    a border, a diagonal, and a moving filled bar."""
    px = blank()
    for x in range(WIDTH):
        px[0][x] = px[HEIGHT - 1][x] = 1
    for y in range(HEIGHT):
        px[y][0] = px[y][WIDTH - 1] = 1
    for i in range(min(WIDTH, HEIGHT)):
        px[i][i] = 1
    bar_w = 30
    start = phase % (WIDTH - bar_w - 4) + 2
    for y in range(HEIGHT // 2 - 6, HEIGHT // 2 + 6):
        for x in range(start, start + bar_w):
            px[y][x] = 1
    return px


# ------------------------------------------------------------------ commands ----


def cmd_ports(_args) -> int:
    mido = _mido()
    print("inputs:")
    for p in mido.get_input_names():
        print(f"   {'*' if 'LCXL3' in p else ' '} {p}")
    print("outputs:")
    for p in mido.get_output_names():
        print(f"   {'*' if 'LCXL3' in p else ' '} {p}")
    for kind in ("midi", "daw"):
        i, o = find_ports(kind)
        print(f"resolved {kind.upper():4}: in={i!r} out={o!r}")
    return 0


def _describe(msg) -> str:
    if msg.type == "control_change":
        ch = msg.channel + 1
        tag = ""
        if ch == 16:
            tag = f"  <- {control_name(msg.control)}"
        elif ch == 7:
            names = {
                FEAT_MODE_SELECT: "MODE SELECT",
                FEAT_SHIFT: "SHIFT",
                FEAT_LED_BRIGHT: "led brightness",
                FEAT_SCREEN_BRIGHT: "screen brightness",
            }
            tag = f"  <- feature: {names.get(msg.control, f'cc{msg.control}')}"
        elif ch == 1:
            tag = f"  <- {control_name(msg.control)}"
        elif ch == 15:
            tag = f"  <- TOUCH {control_name(msg.control)}"
        return f"cc  ch{ch:<2} #{msg.control:<3} = {msg.value:<3}{tag}"
    if msg.type == "sysex":
        d = list(msg.data)
        head = " ".join(f"{b:02X}" for b in d[:6])
        rest = " ".join(f"{b:02X}" for b in d[6:14])
        more = f" ... (+{len(d) - 14} bytes)" if len(d) > 14 else ""
        return f"sysex {head} | {rest}{more}"
    return str(msg)


def cmd_listen(args) -> int:
    s = Surface(args.port, need_input=True)
    print(f"listening on {s.in_name!r} for {args.secs}s — touch the surface (ctrl-c to stop)")
    end = time.time() + args.secs
    seen = 0
    try:
        while time.time() < end:
            for msg in s.inp.iter_pending():
                seen += 1
                print(f"  {_describe(msg)}")
            time.sleep(0.002)
    except KeyboardInterrupt:
        print()
    finally:
        s.close()
    print(f"{seen} message(s)")
    return 0


def cmd_daw(args) -> int:
    s = Surface("daw")
    s.daw_mode(args.state == "on")
    print(f"DAW mode -> {args.state}  (sent on {s.out_name!r})")
    s.close()
    return 0


def cmd_mode(args) -> int:
    if args.name not in MODES:
        sys.exit(f"lcxl3: unknown mode {args.name!r}. one of: {', '.join(MODES)}")
    s = Surface("daw")
    s.feature(FEAT_MODE_SELECT, MODES[args.name])
    print(f"mode -> {args.name} (0x{MODES[args.name]:02X})")
    s.close()
    return 0


def cmd_paint(args) -> int:
    s = Surface(args.port)
    if args.rgb:
        r, g, b = args.rgb
        s.rgb(args.index, r, g, b)
        print(f"rgb {control_name(args.index)} (#{args.index}) -> ({r},{g},{b})")
    else:
        s.palette(args.index, args.colour)
        print(f"palette {control_name(args.index)} (#{args.index}) -> {args.colour}")
    s.close()
    return 0


def cmd_sweep(args) -> int:
    """Light every control in turn. This is how we SEE the index map, rather than
    trusting a table read off a low-resolution diagram in the PDF."""
    s = Surface(args.port)
    print(f"sweeping {len(ALL_CONTROLS)} indices on {s.out_name!r}, {args.delay}s apart")
    print("watch the surface and say what lights, in order:")
    try:
        for i in ALL_CONTROLS:
            if args.rgb:
                s.rgb(i, 0x7F, 0x00, 0x00)
            else:
                s.palette(i, 0x05)
            print(f"  #{i:<3} {control_name(i)}")
            time.sleep(args.delay)
            if args.clear:
                s.rgb(i, 0, 0, 0) if args.rgb else s.palette(i, 0)
    except KeyboardInterrupt:
        print("\ninterrupted")
    finally:
        s.close()
    return 0


def cmd_bright(args) -> int:
    s = Surface("daw")
    cc = FEAT_SCREEN_BRIGHT if args.screen else FEAT_LED_BRIGHT
    s.feature(cc, args.value)
    what = "screen" if args.screen else "LED"
    print(f"{what} brightness -> {args.value} (CC {cc}, non-volatile)")
    s.close()
    return 0


def cmd_relative(args) -> int:
    s = Surface("daw")
    rows = [args.row] if args.row else [1, 2, 3]
    for r in rows:
        s.relative(r, args.state == "on")
        print(f"encoder row {r} relative -> {args.state} (CC {FEAT_REL_ROW[r]})")
    s.close()
    return 0


def cmd_text(args) -> int:
    s = Surface("daw")
    target = TGT_TEMPORARY if args.temporary else TGT_STATIONARY
    # config: bit0-4 = arrangement. 2 = "Title, Parameter Name and Text Value".
    s.configure_display(target, args.arrangement)
    for field, line in enumerate(args.lines):
        s.set_text(target, field, line)
    s.configure_display(target, 0x7F)  # 7Fh = bring up with current contents
    print(f"text -> target 0x{target:02X}, arrangement {args.arrangement}: {args.lines}")
    s.close()
    return 0


def cmd_bitmap(args) -> int:
    s = Surface("daw")
    target = TGT_BITMAP_TEMPORARY if args.temporary else TGT_BITMAP_STATIONARY
    frames = args.frames
    print(f"sending {frames} frame(s) to target 0x{target:02X} ({len(pack_bitmap(blank()))} bytes each)")
    for n in range(frames):
        s.bitmap(pack_bitmap(demo_pixels(n * 3)), target)
        time.sleep(args.delay)
    print("done — the guide says the firmware holds ONE bitmap at a time,")
    print("and ACKs each frame (F0 00 20 29 02 15 09 7F) for animation pacing.")
    s.close()
    return 0


def cmd_keystone(args) -> int:
    """THE question: is paint really DAW-mode-only, as the guide implies?

    The guide puts 'Colouring the surface' and 'Controlling the screen' inside the
    DAW mode chapter, prefaced "only available once DAW mode is enabled". If that
    is merely how the document is organised and not a hardware restriction, we can
    paint in standalone — and then the corpus keeps its v2 CC numbering and NOTHING
    has to be renumbered. That is a 169-file difference, so it gets tested.
    """
    print(__doc__.strip().splitlines()[0])
    print()
    print("=" * 68)
    print("STEP 1 — STANDALONE. Nothing has enabled DAW mode in this run.")
    print("=" * 68)
    s = Surface("midi")
    print(f"  painting via {s.out_name!r} (the MIDI interface)")
    for i in BUTTONS:
        s.palette(i, 0x05)  # red
    time.sleep(0.3)
    for i in ENCODERS:
        s.rgb(i, 0x00, 0x7F, 0x00)  # green rings
    s.close()
    print("  sent: 16 palette CCs to buttons, 24 RGB SysEx to encoder rings")
    print()
    input("  LOOK AT THE SURFACE. Anything lit? press enter to continue... ")

    print("=" * 68)
    print("STEP 2 — same paint, but on the DAW interface WITHOUT DAW mode on.")
    print("=" * 68)
    d = Surface("daw")
    for i in BUTTONS:
        d.palette(i, 0x2D)  # a blue-ish palette entry
    time.sleep(0.3)
    print("  sent: 16 palette CCs to buttons on the DAW port, DAW mode still OFF")
    print()
    input("  LOOK AGAIN. press enter to continue... ")

    print("=" * 68)
    print("STEP 3 — enable DAW mode, then paint.")
    print("=" * 68)
    d.daw_mode(True)
    time.sleep(0.2)
    for i in BUTTONS:
        d.palette(i, 0x15)  # yellow-ish
    time.sleep(0.3)
    for i in ENCODERS:
        d.rgb(i, 0x7F, 0x00, 0x7F)  # magenta rings
    print("  sent: DAW mode ON, then 16 palette + 24 RGB")
    print()
    input("  LOOK AGAIN. press enter to finish... ")

    if not args.stay:
        d.daw_mode(False)
        print("  DAW mode -> OFF (the guide says a host should exit DAW mode on quit)")
    d.close()

    print()
    print("Report which STEP lit the surface. That answers:")
    print("  step 1 lights  -> paint works in standalone. The corpus keeps v2")
    print("                    numbering, b5ad8b6 merges on its own merits, done.")
    print("  only step 3    -> paint needs DAW mode, whose CC map partly overlaps")
    print("                    v2's with different meanings. Then we want the")
    print("                    surface-driver adapter, not a second renumber.")
    return 0


# ---------------------------------------------------------------------- cli ----


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="lcxl3", description=__doc__.splitlines()[0])
    sub = p.add_subparsers(dest="cmd", required=True)

    sub.add_parser("ports", help="list MIDI ports and resolve the two interfaces").set_defaults(
        func=cmd_ports
    )

    q = sub.add_parser("listen", help="decode everything the surface sends")
    q.add_argument("--port", choices=("midi", "daw"), default="daw")
    q.add_argument("--secs", type=float, default=20.0)
    q.set_defaults(func=cmd_listen)

    q = sub.add_parser("daw", help="enable/disable DAW mode")
    q.add_argument("state", choices=("on", "off"))
    q.set_defaults(func=cmd_daw)

    q = sub.add_parser("mode", help="select a surface mode (mixer|control|custom1..16)")
    q.add_argument("name")
    q.set_defaults(func=cmd_mode)

    q = sub.add_parser("paint", help="colour one control")
    q.add_argument("index", type=int)
    q.add_argument("--rgb", nargs=3, type=int, metavar=("R", "G", "B"))
    q.add_argument("--colour", type=int, default=5, help="palette index (0-127)")
    q.add_argument("--port", choices=("midi", "daw"), default="daw")
    q.set_defaults(func=cmd_paint)

    q = sub.add_parser("sweep", help="light every index in turn, to SEE the map")
    q.add_argument("--delay", type=float, default=0.35)
    q.add_argument("--rgb", action="store_true", help="use RGB SysEx instead of palette")
    q.add_argument("--clear", action="store_true", help="unlight each after showing it")
    q.add_argument("--port", choices=("midi", "daw"), default="daw")
    q.set_defaults(func=cmd_sweep)

    q = sub.add_parser("bright", help="LED (or --screen) brightness, non-volatile")
    q.add_argument("value", type=int)
    q.add_argument("--screen", action="store_true")
    q.set_defaults(func=cmd_bright)

    q = sub.add_parser("relative", help="switch encoder rows to relative (endless) output")
    q.add_argument("state", choices=("on", "off"))
    q.add_argument("--row", type=int, choices=(1, 2, 3))
    q.set_defaults(func=cmd_relative)

    q = sub.add_parser("text", help="write text lines to the OLED")
    q.add_argument("lines", nargs="+")
    q.add_argument("--arrangement", type=int, default=2)
    q.add_argument("--temporary", action="store_true")
    q.set_defaults(func=cmd_text)

    q = sub.add_parser("bitmap", help="draw a demo bitmap (optionally animated)")
    q.add_argument("--frames", type=int, default=1)
    q.add_argument("--delay", type=float, default=0.12)
    q.add_argument("--temporary", action="store_true")
    q.set_defaults(func=cmd_bitmap)

    q = sub.add_parser("keystone", help="does paint need DAW mode? the 169-file question")
    q.add_argument("--stay", action="store_true", help="leave DAW mode on at the end")
    q.set_defaults(func=cmd_keystone)

    args = p.parse_args(argv)
    return args.func(args)


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