#!/usr/bin/env python3
"""What can be checked about a screen without looking at it.

The display protocol has NO readback — `configure_display` and `set_text` are
write-only, so nothing here proves a pixel. What it does prove is the layer where
a mistake would be silent AND invisible: the bytes on the wire, and the text we
chose to put in them. The evening this fixes was lost to a wrong TARGET, not a
wrong byte, and a wrong target looks exactly like a working driver from the host
side — hence a test that pins the target numbers against the guide's own table.

Run: python3 tools/tests/test_lcxl3_display.py   (silence + exit 0 = pass)
"""
import importlib.util
import pathlib
import sys

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

import lcxl3 as L                     # noqa: E402
import lcxl3_language as lang         # noqa: E402
import lcxl_grid as grid              # noqa: E402

_spec = importlib.util.spec_from_file_location("drv", TOOLS / "lcxl3-driver.py")
drv = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(drv)

FAILED = []


def check(cond, msg):
    if not cond:
        FAILED.append(msg)


class FakeOut:
    """Enough mido surface to record what a Surface would have sent."""

    def __init__(self):
        self.sent = []

    def send(self, msg):
        self.sent.append(msg)

    def close(self):
        pass


def fake_surface():
    s = L.Surface.__new__(L.Surface)     # no port, no device, no DAW mode
    s.out = FakeOut()
    s.inp = None
    return s


# ---- the guide's target table, quoted in lcxl3.py, pinned here --------------
# "05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
#  indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)"
check(L.TGT_CONTROL_FIRST == 0x05, "per-control target range must start at 05h")
check(L.TGT_CONTROL_LAST == 0x24, "per-control target range must end at 24h")
check(all(L.is_analogue(i) for i in L.FADERS), "all 8 faders own a display target")
check(all(L.is_analogue(i) for i in L.ENCODERS), "all 24 encoders own one")
check(not any(L.is_analogue(i) for i in L.BUTTONS),
      "buttons own NO per-control target — that is why they never lost the screen")
check(L.TGT_CONTROL_LAST + 1 == min(L.BUTTONS),
      "the analogue targets must end exactly where the buttons begin")

# ---- config byte: bits 5+6 are the native display's on-switch ---------------
# "Bit 6: Allow ... automatically on Change (default: Set).
#  Bit 5: Allow ... automatically on Touch (default: Set)."
check(L.display_config(4, True, True) == 0x64, "arr 4 + both auto bits = 0x64")
check(L.display_config(4, False, False) == 0x04, "arr 4, natives off = 0x04")
check(L.display_config(2, False, False) == 0x02,
      "0x02 is what the OLD code sent to 0x36: arrangement 2, autos irrelevant "
      "there — it never touched the per-control targets at all")
check(L.display_config(1, True, False) == 0x41, "bit 6 only = 0x41")
# The arrangement must survive as bits 0-4 and never collide with the auto bits.
check(all(L.display_config(a) & 0x1F == a for a in range(32)),
      "arrangement occupies bits 0-4 intact")

# ---- label_control puts the right bytes on the wire ------------------------
s = fake_surface()
s.label_control(0x18, "d4 crushbus")          # encoder r2c4
data = [list(m.data) for m in s.out.sent]
check(len(data) == 2, "label_control sends exactly configure + set_text")
check(data[0] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x04, 0x18, 0x64],
      f"configure bytes wrong: {data[0]}")
check(data[1][:8] == [0x00, 0x20, 0x29, 0x02, 0x15, 0x06, 0x18, 0x00],
      f"set_text header wrong (target 0x18, field 0): {data[1][:8]}")
check(bytes(data[1][8:]).decode() == "d4 crushbus", "the name must arrive verbatim")

# The DJF path: same target, autos CLEARED so our own overlay wins uncontested.
s = fake_surface()
s.label_control(0x1D, "DJF percs", native=False)
check(list(s.out.sent[0].data)[-1] == 0x04,
      "native=False must clear bits 5+6 (config 0x04, arrangement still non-zero "
      "because 0 would CANCEL the display)")

# A button has no target and must be refused loudly, not sent into the void.
try:
    fake_surface().label_control(0x25, "gate")
    FAILED.append("label_control(button) must raise, not silently no-op")
except ValueError:
    pass

# ---- set_text must stop eating the reassigned control codes ----------------
s = fake_surface()
s.set_text(L.TGT_TEMPORARY, 2, lang.ZERO_FRAMES[0])   # hearts around a zero
body = bytes(list(s.out.sent[0].data)[8:])
check(body.count(0x1E) == 2,
      f"the two hearts (1Eh) must survive the ASCII filter, got {body!r}")

# ---- the labels themselves -------------------------------------------------
# Faders carry no ^NN at all — they are Ardour-learned levels — so their label
# has to come from the grid's role table, not from a track's context.
d1 = grid.CELL_TO_CC[("D", 1)]
check(lang.control_label(d1, None) == "d1 level [ard]",
      f"fader label from CC_ROLE, marked Ardour-owned: {lang.control_label(d1, None)!r}")
check(lang.control_label(d1, None, mapped=False) == "D1 unmapped",
      "an unmapped control must SAY so — the screen may not contradict a dark LED")
c1 = grid.CELL_TO_CC[("C", 1)]
check(lang.control_label(c1, None) == "DJF percs", "family filter names its bloc")
f1 = grid.CELL_TO_CC[("F", 1)]
check(lang.control_label(f1, None) == "MUTE kick",
      "gMute groups by ROLE, not bloc — kick alone is family 1")
b4 = grid.CELL_TO_CC[("B", 4)]
check(lang.control_label(b4, "# crushbus") == "d4 # crushbus",
      "orbit first, then what the control does in this track")
check(len(lang.control_label(b4, "# a_very_long_effect_name")) <= 16,
      "labels must fit the panel")

# ---- parse_context: the trailing stage note beats the Haskell ---------------
# This line is real (piment_bresilien.tidal:87) and used to yield `# n "23")) --`.
ctx = drv.parse_context('  $ midiOn "^56" ((# n "23")) -- Raise COMEON!\n')
check(ctx.get(56) == "Raise COMEON!",
      f"PLN's own words must win over the Haskell body: {ctx.get(56)!r}")
# With no stage note, the useful noun is the sample, not the combinator.
ctx = drv.parse_context('  $ midiOn "^28" (loopAt 1 . (# "break:18"))\n')
check(ctx.get(28) == "break:18",
      f"no comment -> first quoted string, not `loopAt 1 . (#`: {ctx.get(28)!r}")
ctx = drv.parse_context('  $ midiOn "^25" ((# n "6"))\n')
check(ctx.get(25) == "n 6",
      f"a numeric string keeps its identifier for meaning: {ctx.get(25)!r}")
# A commented-out line must still never label a control.
check(drv.parse_context('-- $ midiOn "^56" (id) -- nope\n') == {},
      "commented-out code labels nothing")

# ---- label_table: every analogue control, and only those -------------------
m = drv.build_map()
rows = drv.label_table(m, {}, set(m.values()), lang, grid)
check(len(rows) == 32, f"32 analogue controls own a display, got {len(rows)}")
check(sorted(r[0] for r in rows) == list(range(0x05, 0x25)),
      "the table must cover targets 05h-24h exactly")
check(all(r[2] for r in rows), "no control may be left nameless")
djf = [r for r in rows if grid.CC_ROLE.get(r[1], ("", 0))[0] == "family_filter"]
check(len(djf) == 3 and not any(r[3] for r in djf),
      "the 3 DJF knobs keep their rich readout: drawn by US, natives suppressed")
check(all(r[3] for r in rows if r not in djf),
      "everything else is drawn by the firmware, from our name")
# The fallback flag must hand the whole surface back to our own overlay.
rows = drv.label_table(m, {}, set(m.values()), lang, grid, overlay_only=True)
check(not any(r[3] for r in rows), "--oled-overlay suppresses every native display")

if FAILED:
    print(f"{len(FAILED)} FAILED:")
    for f in FAILED:
        print("  -", f)
    sys.exit(1)
