"""Regression tests for the LCXL colour convention.

These exist because the LED work was previously validated only against a FAKE
aseqdump stream — written, wired into boot, and never once seen to light an LED.
A colour rule is a pure function, so it can and must be tested.
"""
import importlib.util, sys
from pathlib import Path

TOOLS = Path(__file__).resolve().parents[2]

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

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


def test_colour_byte_decodes_to_two_bits_each():
    assert mock.decode_colour(13) == (1, 0)    # dim red
    assert mock.decode_colour(15) == (3, 0)    # bright red
    assert mock.decode_colour(60) == (0, 3)    # bright green
    assert mock.decode_colour(63) == (3, 3)    # bright amber


def test_value_ramp_is_pln_six_steps_in_order():
    """dim red -> bright red -> dim amber -> bright amber -> dim green -> bright green."""
    seq = []
    for v in range(128):
        c = leds.value_ramp(v)
        if not seq or seq[-1] != c:
            seq.append(c)
    assert seq == [13, 15, 29, 31, 28, 60]


def test_value_ramp_is_monotonic_in_perceived_progress():
    """Never goes backwards: each step is >= the previous in (green, red) order."""
    def rank(c):
        r, g = mock.decode_colour(c)
        return (g, r)
    vals = [leds.value_ramp(v) for v in range(128)]
    ranks = [rank(c) for c in vals]
    assert ranks == sorted(ranks) or len(set(ranks)) == 6


def test_value_ramp_bottom_is_dim_not_off():
    """A mapped control at zero must still be visible, or you forget it exists."""
    assert leds.value_ramp(0) != leds.OFF


def test_dj_filter_is_bipolar_and_brightest_at_centre():
    """Centre 64 is bypass. Both ends are extremes — a monotonic ramp would lie."""
    assert leds.filter_colour(64) == 63
    assert leds.filter_colour(0) != leds.filter_colour(127)
    assert leds.filter_colour(64) not in (leds.filter_colour(0), leds.filter_colour(127))


def test_dj_filter_is_never_dark():
    """A filter you cannot see is a filter you forget is closed."""
    assert all(leds.filter_colour(v) != leds.OFF for v in range(128))


# --------------------------------------------------------------------------- #
# #11a — the DJ filter is THREE states now, not seven
# --------------------------------------------------------------------------- #

def test_dj_filter_has_exactly_three_states():
    """PLN, 2026-07-29: "sunset the dimorange and just have red in lows green in
    highs for clarity" + "clarity > resolution". The old seven-step version spent
    four steps on shades either side of centre, so the row read as a wash of
    oranges and the one thing you need — which way is it cutting — was hardest to
    see."""
    assert len({leds.filter_colour(v) for v in range(128)}) == 3


def test_dj_filter_is_red_low_amber_centre_green_high():
    assert leds.filter_colour(0) == 15      # red   — LPF, lows only
    assert leds.filter_colour(30) == 15
    assert leds.filter_colour(64) == 63     # bright amber — bypass
    assert leds.filter_colour(100) == 60    # green — HPF, highs only
    assert leds.filter_colour(127) == 60


def test_no_dim_amber_survives_in_the_filter_ramp():
    """The specific colour PLN asked to retire (29 = dim amber / 'dimorange')."""
    assert 29 not in {leds.filter_colour(v) for v in range(128)}


def test_the_filter_centre_band_is_wide_enough_for_the_detent_and_no_wider():
    """The knob's centre detent lands on 63/64. Too narrow and bypass flickers red;
    too wide and a real filter move shows as bypass."""
    amber = [v for v in range(128) if leds.filter_colour(v) == 63]
    assert 63 in amber and 64 in amber
    assert len(amber) <= 12
    assert amber == list(range(min(amber), max(amber) + 1))   # contiguous


def test_the_filter_ramp_is_symmetric_about_the_centre_band():
    """Neither direction may be privileged — 'am I cutting up or down' has to read
    the same amount at both ends."""
    lo = sum(1 for v in range(128) if leds.filter_colour(v) == 15)
    hi = sum(1 for v in range(128) if leds.filter_colour(v) == 60)
    assert abs(lo - hi) <= 2


# --------------------------------------------------------------------------- #
# #11b — value_ramp is actually WIRED IN now
# --------------------------------------------------------------------------- #

def test_knobs_use_the_six_step_ramp_not_three_states():
    """The bug PLN caught by eye: "I See only two states, dim and not dim, at 0 and
    not 0 atm on the knobs". value_ramp was written, unit-tested, and never called —
    control_colour had exactly three outcomes, so 30% and 90% were the same colour.
    """
    seen = {leds.control_colour(13, "bass", v, True) for v in range(128)}
    assert len(seen) == 6
    assert seen == {13, 15, 29, 31, 28, 60}


def test_a_knob_at_30_percent_and_at_90_percent_are_different_colours():
    a = leds.control_colour(13, "bass", 38, True)
    b = leds.control_colour(13, "bass", 115, True)
    assert a != b


def test_every_knob_row_uses_the_ramp():
    """Rows A, B and C are all 0..127 ranges — no row keeps the old rule."""
    for cc in (13, 29, 40):    # A1, B1, B-mid... all knob rows
        assert leds.control_colour(cc, "bass", 100, True) == leds.value_ramp(100)


def test_the_ramp_overrides_role_hue_on_knobs_deliberately():
    """The trade this makes, asserted so it cannot regress silently: hue now carries
    VALUE, so two knobs at the same value look the same whatever their role. Role is
    still encoded by POSITION (the lane convention), and a knob's setting has no
    other channel at all — the pot's pointer is invisible on a dark stage."""
    assert leds.control_colour(13, "bass", 100, True) \
        == leds.control_colour(13, "rhythm", 100, True)


def test_the_dj_filters_are_exempt_from_the_unipolar_ramp():
    """They are bipolar: centre is bypass, so a monotonic ramp would paint bypass as
    mid-amber and the two opposite musical extremes as the same colour."""
    for cc in leds.DJ_FILTERS:
        assert leds.control_colour(cc, "fx", 64, True) == 63
        assert leds.control_colour(cc, "fx", 64, True) != leds.value_ramp(64)


def test_an_unbound_control_is_still_dark_after_the_ramp_change():
    """'dark means this control does nothing here' is the distinction that matters;
    the ramp must not have lit the whole board."""
    frame = leds.build_frame({13: "bass"}, {13: 100}, {13})
    assert frame[leds.cc_to_index(13)] == leds.value_ramp(100)
    assert frame[leds.cc_to_index(14)] == leds.OFF


# --------------------------------------------------------------------------- #
# #76 — panic is a STATE (^93), not a membership
# --------------------------------------------------------------------------- #

def test_the_three_mutes_are_identical_at_rest():
    """PLN by eye: "why are [mutes] 1/2/3 resp red red green? these 3 buttons have
    same roles, why diff colours?". gMute1=73 and gMute2=74 are panic-CHORD members
    and the old rule tested membership BEFORE role, so they sat dim red while
    gMute3=75 sat by role. The panic identity does not exist at rest."""
    cols = [leds.control_colour(cc, "rhythm", 0, False, False) for cc in (73, 74, 75)]
    assert len(set(cols)) == 1


def test_the_three_mutes_are_identical_when_engaged_too():
    cols = [leds.control_colour(cc, "rhythm", 127, True, False) for cc in (73, 74, 75)]
    assert len(set(cols)) == 1


def test_panic_unarmed_leaves_all_four_chord_members_painted_by_role():
    for cc in leds.PANIC_CHORD:
        assert leds.control_colour(cc, "rhythm", 0, False, False) \
            == leds.HUE["green"]["dim"]


def test_arming_panic_flashes_red_on_all_four_chord_members():
    """^93 armed means every gPanic'd stream is silent. This is the highest-value
    LED in the rig: it answers "why is there no sound?"."""
    for cc in leds.PANIC_CHORD:
        assert leds.control_colour(cc, "rhythm", 0, False, True) \
            == leds.HUE["red"]["flash"]


def test_arming_panic_does_not_repaint_unrelated_buttons():
    assert leds.control_colour(75, "rhythm", 0, False, True) \
        == leds.control_colour(75, "rhythm", 0, False, False)


def test_panic_defaults_to_unarmed_so_an_untouched_93_behaves_as_zero():
    """"^93" UNTOUCHED must read as 0, not as missing — the #55 lesson. Omitting the
    argument entirely has to mean 'not armed'."""
    assert leds.control_colour(73, "rhythm", 0, False) \
        == leds.control_colour(73, "rhythm", 0, False, False)


def test_build_frame_reads_the_panic_state_out_of_the_model():
    """The daemon must not need a special code path: ^93 lives in `values` like any
    other control, so any repaint reproduces the armed board."""
    bindings = {cc: "rhythm" for cc in leds.PANIC_CHORD}
    calm = leds.build_frame(bindings, {}, set())
    armed = leds.build_frame(bindings, {leds.PANIC_CC: 1}, {leds.PANIC_CC})
    for cc in leds.PANIC_CHORD:
        i = leds.cc_to_index(cc)
        assert calm[i] != armed[i]
        assert armed[i] == leds.HUE["red"]["flash"]


def test_the_chord_members_are_not_force_bound_as_fx():
    """The OTHER half of #76, found by rendering the mock: parse_track used to
    setdefault all four chord members to role "fx" (red hue), so on a track that
    binds gMute3 but not gMute1/2 the row read literally "R R G" — PLN's exact
    observation. A chord is not a binding."""
    b = leds.parse_track.__doc__ is not None   # sanity: function exists
    assert b
    src = (TOOLS / "lcxl-leds.py").read_text()
    seg = src[src.index("def parse_track"):src.index("def convention_bindings")]
    assert "for cc in DJ_FILTERS:" in seg
    assert "DJ_FILTERS + PANIC_CHORD" not in seg


def test_panic_overlay_lights_chord_members_even_when_the_track_binds_none_of_them():
    """Painted as an overlay, so an unbound (dark) chord member still flashes when
    armed. Otherwise the one state that explains silence could be invisible."""
    armed = leds.build_frame({1: "rhythm"}, {leds.PANIC_CC: 1}, {leds.PANIC_CC})
    for cc in leds.PANIC_CHORD:
        assert armed[leds.cc_to_index(cc)] == leds.HUE["red"]["flash"]


def test_the_panic_overlay_wins_over_a_bound_role():
    armed = leds.build_frame({73: "rhythm"}, {73: 127, leds.PANIC_CC: 1},
                             {73, leds.PANIC_CC})
    assert armed[leds.cc_to_index(73)] == leds.HUE["red"]["flash"]


def test_an_unarmed_board_leaves_unbound_chord_members_dark():
    calm = leds.build_frame({1: "rhythm"}, {}, set())
    for cc in leds.PANIC_CHORD:
        assert calm[leds.cc_to_index(cc)] == leds.OFF


def test_cc93_owns_no_led_of_its_own():
    """Which is WHY the four chord members have to carry the armed state: CC93 sits
    outside row F's 89-92, so there is nowhere else to show it."""
    assert leds.cc_to_index(leds.PANIC_CC) is None
    assert leds.resolve_paint(leds.PANIC_CC, 1, {leds.PANIC_CC: "fx"}) is None


def test_resolve_paint_threads_the_panic_state_through():
    b = {73: "rhythm"}
    assert leds.resolve_paint(73, 0, b, panic=True)[1] == leds.HUE["red"]["flash"]
    assert leds.resolve_paint(73, 0, b, panic=False)[1] != leds.HUE["red"]["flash"]


def test_sysex_roundtrip_through_the_mock_surface():
    frame = leds.sysex_pairs([(0x10, 13), (0x11, 60), (0x20, 15)])
    state = mock.parse_frames(frame)
    assert state == {0x10: 13, 0x11: 60, 0x20: 15}


def test_mock_never_reports_a_forbidden_fader_led():
    """CC77-84 are faders: no LEDs at all. They must not appear in a frame."""
    assert "D" not in leds.ROW_BASE


# --------------------------------------------------------------------------- #
# #78 — the watcher must paint the LOADED track, not the convention board
#
# "dark = this control does nothing on this track" outranks every other use of
# colour here, because a lit dead knob gets reached for mid-set. The watcher ran
# with no track, so it painted the convention board — all 40 lit by lane role,
# regardless of what was loaded. build_frame() was always correct; the LIE was in
# which bindings it was handed.
# --------------------------------------------------------------------------- #

REPO = TOOLS.parent


def _lit(bindings):
    return sum(1 for c in leds.build_frame(bindings, {}, set()) if c != leds.OFF)


def test_the_convention_board_lights_EVERYTHING_which_is_the_bug():
    assert _lit(leds.load_bindings(None, quiet=True)) == leds.LIT_INDICES


def test_a_real_track_leaves_a_lot_of_the_board_dark():
    lit = _lit(leds.load_bindings(str(REPO / "live/techno/do_it_right.tidal"),
                                  quiet=True))
    # measured 19/40 the day this landed; assert the SHAPE, not the exact number,
    # so an edit to the track is not a test failure.
    assert 10 <= lit <= 30, lit
    assert lit < leds.LIT_INDICES


def test_current_track_round_trips(tmp_path, monkeypatch):
    monkeypatch.setattr(leds, "TRACK_FILE", tmp_path / "current-track")
    assert leds.read_current_track() is None
    leds.write_current_track("live/techno/do_it_right.tidal")
    assert leds.read_current_track() == "live/techno/do_it_right.tidal"


def test_an_empty_published_track_reads_as_none(tmp_path, monkeypatch):
    """A truncated/blank cache file must mean 'nobody said', not a track named ''."""
    f = tmp_path / "current-track"
    f.write_text("\n")
    monkeypatch.setattr(leds, "TRACK_FILE", f)
    assert leds.read_current_track() is None


def test_publishing_never_raises_on_an_unwritable_path(tmp_path, monkeypatch):
    """LEDs are feedback, not sound: a broken cache must not break a boot."""
    (tmp_path / "nope").write_text("i am a file, not a directory")
    monkeypatch.setattr(leds, "TRACK_FILE", tmp_path / "nope" / "current-track")
    leds.write_current_track("whatever")          # must not raise
    assert leds.read_current_track() is None
