#!/usr/bin/env python3
"""mock-lcxl — a virtual LaunchControl XL you can SEE in a terminal.

Why this exists (2026-07-28)
----------------------------
The LED work had a hole in the middle of it: `lcxl-leds.py --watch` was written,
wired into the boot sequence, and validated only against a FAKE `aseqdump`
stream. Nobody — human or machine — had ever seen it light a single LED. PLN's
report was "still seeing static colors", and there was no way for me to check my
own work, because I cannot look at the device.

So: decode the same SysEx the real surface receives, and render it. Now the
colour convention can be designed, reviewed and REGRESSION-TESTED without
hardware, and the only thing left needing PLN's eyes is whether the physical
LEDs match this picture.

Colour bytes
------------
The LCXL is BICOLOR. A colour byte is `0b00GG11RR`: two bits of green, two bits
of red, plus the 0x0C flag bits. That is 4 red levels x 4 green levels = 16
states, of which only about 6 are reliably distinguishable on a dim stage. Any
"smooth ramp" design has to live inside that budget — which is exactly the
constraint worth seeing before committing to a convention.

Usage
-----
    tools/lcxl-leds.py --map TRACK --dry-run | tools/mock-lcxl.py
    tools/mock-lcxl.py --ramp                # show the value->colour ramp
    tools/mock-lcxl.py --ramp --role bass    # ...for a given role family
    tools/mock-lcxl.py --palette             # every reachable colour
"""

from __future__ import annotations

import argparse
import importlib.util
import re
import sys
from pathlib import Path

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


def _load(name: str, path: Path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    sys.modules[name] = mod
    spec.loader.exec_module(mod)
    return mod


leds = _load("lcxl_leds", TOOLS / "lcxl-leds.py")

SYSEX_RE = re.compile(
    r"F0\s+00\s+20\s+29\s+02\s+11\s+78\s+([0-9A-F]{2})\s+(.*?)\s*F7",
    re.IGNORECASE | re.DOTALL)

# Two bits each. Rendered as 24-bit ANSI so the four brightness steps are
# actually distinguishable in a terminal, which they barely are on the device.
RED_LEVELS = [(40, 40, 46), (110, 30, 30), (180, 40, 40), (255, 60, 60)]
GRN_LEVELS = [(40, 40, 46), (30, 110, 40), (40, 180, 60), (70, 255, 90)]


def decode_colour(v: int) -> tuple[int, int]:
    """-> (red 0-3, green 0-3)."""
    return v & 0x03, (v >> 4) & 0x03


def swatch(v: int) -> str:
    r, g = decode_colour(v)
    rr, rg, rb = RED_LEVELS[r]
    gr, gg, gb = GRN_LEVELS[g]
    # Additive, like the physical LED pair.
    R, G, B = min(255, rr + gr - 40), min(255, rg + gg - 40), min(255, rb + gb - 40)
    if r == 0 and g == 0:
        return "\033[38;2;70;70;80m ·· \033[0m"
    return f"\033[48;2;{R};{G};{B}m    \033[0m"


def name_of(v: int) -> str:
    r, g = decode_colour(v)
    if r == 0 and g == 0:
        return "off"
    hue = "red" if g == 0 else ("green" if r == 0 else "amber")
    level = max(r, g)
    return f"{['', 'dim ', 'mid ', 'bright '][level]}{hue}"


def parse_frames(text: str) -> dict[int, int]:
    """Fold every SysEx frame in `text` into a final index -> colour map."""
    state: dict[int, int] = {}
    for _tmpl, body in SYSEX_RE.findall(text):
        vals = [int(x, 16) for x in body.split()]
        for i in range(0, len(vals) - 1, 2):
            state[vals[i]] = vals[i + 1]
    return state


def render(state: dict[int, int]) -> str:
    """Draw the surface: 3 knob rows, 8 faders (no LEDs), 2 button rows."""
    out = ["", "        " + "".join(f"  {c+1:<2d}  " for c in range(8))]
    for row in ("A", "B", "C"):
        base = leds.ROW_BASE[row]
        cells = "".join(" " + swatch(state.get(base + c, leds.OFF)) + " "
                        for c in range(8))
        out.append(f"  knob{row} {cells}")
    out.append("  fader  " + "".join("  --  " for _ in range(8))
               + "   (no LEDs; CC77-84 are Ardour's)")
    for row in ("E", "F"):
        base = leds.ROW_BASE[row]
        cells = "".join(" " + swatch(state.get(base + c, leds.OFF)) + " "
                        for c in range(8))
        out.append(f"  btn{row}  {cells}")
    lit = sum(1 for v in state.values() if v != leds.OFF)
    out.append(f"\n  {lit}/{leds.LIT_INDICES} lit")
    return "\n".join(out)


def show_ramp(role: str) -> str:
    """The value -> colour ramp, as the convention actually resolves it.

    This is the picture to argue with when designing #11: it shows what the
    surface can really express, not what a gradient in your head can.
    """
    out = [f"\n  VALUE -> COLOUR, role={role!r}", ""]

    out.append("  DJ filter (CC49/50/51) — BIPOLAR, centre 64 = bypass:")
    line, labels = "  ", "  "
    for v in range(0, 128, 8):
        c = leds.filter_colour(v)
        line += swatch(c)
        labels += f"{v:<4d}"
    out += [line, labels, ""]
    seen = []
    for v in range(128):
        c = leds.filter_colour(v)
        if not seen or seen[-1][1] != c:
            seen.append((v, c))
    out.append("  breakpoints: " + ", ".join(
        f"{v}={name_of(c)}" for v, c in seen))

    out.append("")
    out.append(f"  ordinary knob, role={role!r} (touched):")
    line, labels = "  ", "  "
    for v in range(0, 128, 8):
        c = leds.control_colour(13, role, v, True)
        line += swatch(c)
        labels += f"{v:<4d}"
    out += [line, labels]
    out.append(f"  unbound on this track: {swatch(leds.OFF)}  = off (dark). Since #11"
               f" the ramp owns the hue, so 'untouched' is no longer a colour —")
    out.append("  it moved to the HUD, which has unlimited colours. Dark still means"
               " 'this control does nothing here'.")

    out += ["", "  buttons (rows E/F) — role hue, off/on; and the ^93 PANIC state:"]
    line, labels = "  ", "  "
    for label, args in (("off", (0, False, False)), ("on", (127, True, False)),
                        ("PANIC", (0, False, True))):
        line += swatch(leds.control_colour(73, role, *args))
        labels += f"{label:<4.4s}"
    out += [line, labels]

    out += ["", "  PLN's six-step unipolar ramp (value_ramp) — volume/effect ranges:"]
    line, labels = "  ", "  "
    for v in range(0, 128, 8):
        line += swatch(leds.value_ramp(v))
        labels += f"{v:<4d}"
    out += [line, labels]
    steps = []
    for v in range(128):
        c = leds.value_ramp(v)
        if not steps or steps[-1][1] != c:
            steps.append((v, c))
    out.append("  steps: " + ", ".join(f"{v}+={name_of(c)}" for v, c in steps))
    return "\n".join(out)


def show_palette() -> str:
    out = ["\n  EVERY REACHABLE COLOUR (4 red x 4 green — the whole budget)", ""]
    for g in range(4):
        line = "  "
        for r in range(4):
            v = 0x0C | r | (g << 4)
            line += swatch(v) + f" {v:<3d} {name_of(v):<14s}"
        out.append(line)
    out.append("\n  Distinguishable on a dim stage: roughly the corners plus the "
               "mid-amber.\n  Design the convention to THAT, not to a smooth gradient.")
    return "\n".join(out)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--ramp", action="store_true", help="show the value->colour ramp")
    ap.add_argument("--palette", action="store_true", help="show every reachable colour")
    ap.add_argument("--role", default="fx", help="role family for --ramp")
    ap.add_argument("file", nargs="?", help="file of SysEx hex (default: stdin)")
    args = ap.parse_args()

    if args.palette:
        print(show_palette())
        return 0
    if args.ramp:
        print(show_ramp(args.role))
        return 0

    text = Path(args.file).read_text() if args.file else sys.stdin.read()
    state = parse_frames(text)
    if not state:
        print("mock-lcxl: no SysEx frames found on input", file=sys.stderr)
        return 1
    print(render(state))
    return 0


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