"""button_roles — the GATE/GESTURE judgement three tools depend on.

If this drifts, migrate-columns assigns the wrong cell, fix-button-roles
"repairs" it to the wrong cell, and PV010 blesses it. So it is tested here rather
than only through its consumers.
"""

import pathlib
import sys

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

import button_roles as br  # noqa: E402


# ------------------------------------------------------------------ classify

def test_a_mask_is_a_gate_and_a_ply_is_a_gesture():
    assert br.classify('(mask "t(4,8,1)")') == br.GATE_ROLE
    assert br.classify('(ply "1 <2!3 4>")') == br.GESTURE_ROLE


def test_a_gate_composed_with_a_gesture_is_a_gate():
    """`mask "t(8,16,1)" . chop 16` is a latched break-gate that chops while open.

    Calling this ambiguous is what hid the_revolution_will_be_sampled's d8
    inversion from the first scan — the very pair PLN heard as "way too fast".
    """
    assert br.classify('(mask "t(8,16,1)" . chop 16)') == br.GATE_ROLE
    assert br.classify('(chop 16 . mask "t(8,16,1)")') == br.GATE_ROLE


def test_an_unknown_body_stays_unknown():
    """Never guess. A wrong guess gets ACTED on; None makes the caller fall back."""
    assert br.classify('(# n "102")') is None
    assert br.classify("(") is None


# ----------------------------------------------------------------- site_body

def test_body_follows_a_gesture_opened_on_the_next_line():
    """The real you_my_sunshine d7 shape: the paren opens, then a comment, then
    the function. A same-line regex sees `(` and says "unknown" — and an unknown
    control gets a positional fallback, which can re-create the inversion."""
    lines = ['  $ midiOn "^91" ( -- SLICE!',
             '    slice 4 "0 1 2 3"',
             '    )']
    sites = list(br.sites(lines))
    assert len(sites) == 1
    assert sites[0][1] == 91
    assert sites[0][4] == br.GESTURE_ROLE


def test_body_stops_at_the_closing_paren_and_does_not_bleed():
    """A gate on one line must not absorb the NEXT line's gesture."""
    lines = ['  $ midiOff "^60" (mask "t(4,8,1)")',
             '  $ midiOn "^92" (ply 4)']
    roles = {cc: role for _i, cc, _c, _e, role in br.sites(lines)}
    assert roles == {60: br.GATE_ROLE, 92: br.GESTURE_ROLE}


def test_a_runaway_body_is_capped():
    """An unbalanced paren must not swallow the rest of the file."""
    lines = ['  $ midiOn "^92" ('] + ['    x'] * 50 + ['    mask "t"']
    sites = list(br.sites(lines))
    assert sites[0][4] is None      # the mask is past the cap, so: unknown


# --------------------------------------------------------------------- sites

def test_arithmetic_forms_are_not_reported():
    """`midiOn ("^89" - "^53")` combines two controls; its role is ambiguous and
    it must never be moved."""
    assert list(br.sites(['  $ midiOn ("^89" - "^53") (ply 2)'])) == []


def test_comments_are_not_code():
    assert list(br.sites(['  -- $ midiOn "^60" (ply 4)'])) == []


def test_the_pool_filters_and_the_span_bounds_only_the_literal():
    lines = ['  $ midiOn "^60" (ply 4)']
    assert list(br.sites(lines, {92})) == []
    i, cc, col, end, role = next(iter(br.sites(lines, {60})))
    assert lines[i][col:end] == '"^60"'
    assert (cc, role) == (60, br.GESTURE_ROLE)


def test_role_names_are_the_grid_role_names():
    """They index lcxl_grid.orbit_home() directly, so they must match it."""
    import lcxl_grid

    home = lcxl_grid.orbit_home(8)
    assert home[br.GATE_ROLE] == 60
    assert home[br.GESTURE_ROLE] == 92


# ----------------------------------------------------------------- top_level

def test_a_gate_word_nested_inside_a_gesture_does_not_win():
    """The real phunk d6 shape. `superimpose (struct ... . arpeggiate ...)` is a
    held flourish that adds an arpeggiated layer; the `struct` builds that layer's
    rhythm two levels down and says nothing about the button's role.

    Before top_level(), this classified as a GATE and the repair tool moved it to
    the latching row — the inversion, reintroduced one level down.
    """
    body = ('(superimpose (\n struct "t . t(3,8)"\n . arpeggiate\n'
            ' . (|+ note 12)\n . (# cut 51)\n))')
    assert br.classify(body) == br.GESTURE_ROLE


def test_a_gate_at_the_top_of_the_chain_still_wins():
    assert br.classify('(mask "t(8,16,1)" . chop 16)') == br.GATE_ROLE


def test_parens_inside_a_string_do_not_break_depth_tracking():
    """`mask "t(4,8,1)"` must not read as two extra open parens — that would hide
    the rest of the chain and silently return "unknown"."""
    assert br.classify('(mask "t(4,8,1)")') == br.GATE_ROLE
    assert br.top_level('(mask "t(4,8,1)" . chop 16)').count("(") == 0
    assert "chop" in br.top_level('(mask "t(4,8,1)" . chop 16)')


def test_top_level_drops_nested_arguments_only():
    chain = br.top_level('(ply 2 . (# pan "0.85 0.15") . (|+ note 12))')
    assert "ply" in chain
    assert "pan" not in chain and "note" not in chain
