#!/usr/bin/env python3
"""button_roles — is this control a GATE or a GESTURE? One answer, three consumers.

THE DISTINCTION
---------------
  GATE     state you latch and leave      mask, struct, gMute
  GESTURE  a flourish you hold            ply, fast, stut, chop, slice, arp, ...

PLN's convention, stated twice in one session and confirmed by ear both times:

    *"should ALWAYS be the mask midiOff on ^60 and the ply or other
      multiplier/nassim button effect on the push-release 92"*

    row E (41-44, 57-60), latching   -> GATES
    row F (73-76, 89-92), momentary  -> GESTURES

WHY THIS IS ITS OWN MODULE
--------------------------
Three tools need the same judgement and must never disagree:

    tools/migrate-columns.py    assigns button cells when remapping
    tools/fix-button-roles.py   repairs cells that are already wrong
    tools/pvlint  (PV010)       reports cells that are still wrong

The phase-2 remap (5910aac) shipped 32 inverted pairs because it had no notion of
role at all — it handed out cells in first-appearance order, and tracks
conventionally write the gesture line above the gate line. PLN heard it before any
check did: *"drums in revolution seem way too fast ... yea def an inversion"*. A
`ply` on the latching row stays multiplied after one press; a `mask` on the
momentary row only gates while a finger holds it down. Neither errors, neither is
silent, so only the ear catches it — which is exactly the kind of rule that must
live in ONE place. Three copies of a musical judgement is three chances to drift,
the same lesson as #97 (one generated grid, three consumers).

TWO HARD-WON PARSING RULES
--------------------------
1. **A gate beats a gesture when both appear.** `mask "t(8,16,1)" . chop 16` is a
   latched break-gate that happens to chop while it is open. Treating it as
   ambiguous is what hid the_revolution_will_be_sampled's d8 pair from the first
   scan — the very pair PLN heard.
2. **The body is not the rest of the line.** `midiOn "^91" ( -- SLICE!` puts the
   whole function on the FOLLOWING lines, so a same-line regex sees `(` and
   concludes "no role". That silence is dangerous, not neutral: an unclassified
   control gets a positional fallback, which can re-create the very inversion
   being fixed. `site_body` follows the parentheses.
"""

from __future__ import annotations

import re

GATE = re.compile(r"\b(mask|struct|gMute)\b")
GESTURE = re.compile(
    r"\b(ply|stut|striate|chop|echo|jux|arp|hurry|fast|loopAt|slice|superimpose)\b")

# The simple, rewritable form. Arithmetic (`midiOn ("^89" - "^53")`) combines two
# controls; its role is genuinely ambiguous and callers must leave it alone.
SIMPLE_CALL = re.compile(r'\bmidi(?:On|Off)\s+"\^(\d+)"')

GATE_ROLE = "gate"       # lcxl_grid role name for the latching cell (row E)
GESTURE_ROLE = "gate2"   # lcxl_grid role name for the momentary cell (row F)

MAX_BODY_LINES = 8       # a button gesture that spans more than this is not one


def strip_comment(line: str) -> str:
    """Drop a Haskell `--` comment. Deliberately naive: no `--` inside a string
    literal appears in this corpus, and a false strip can only LOSE a role (safe,
    reported as unknown), never invent one."""
    return line.split("--")[0]


def site_body(lines: list[str], idx: int, after: int) -> str:
    """The text a `midiOn "^NN"` actually applies, following unbalanced parens.

    `lines` is the whole file (or block); `idx` the 0-based line of the call;
    `after` the column just past the `"^NN"` literal. Returns the remainder of
    that line plus as many following lines as are needed to balance the
    parentheses opened on it (capped at MAX_BODY_LINES).
    """
    head = strip_comment(lines[idx])[after:]
    depth = head.count("(") - head.count(")")
    if depth <= 0:
        return head
    out = [head]
    for j in range(idx + 1, min(idx + 1 + MAX_BODY_LINES, len(lines))):
        nxt = strip_comment(lines[j])
        out.append(nxt)
        depth += nxt.count("(") - nxt.count(")")
        if depth <= 0:
            break
    return " ".join(out)


def top_level(body: str) -> str:
    """The body's OUTERMOST composition chain, with nested arguments removed.

    Depth is counted from the `(` that opens the midiOn argument, so the chain
    lives at depth 1 and anything deeper is a nested function's own business.
    String literals are skipped, because `mask "t(4,8,1)"` would otherwise look
    like it opened two parens and hide everything after it.

    WHY THIS IS NOT OPTIONAL: `midiOn "^58" (superimpose (struct "t . t(3,8)" .
    arpeggiate . (|+ note 12)))` is a HELD FLOURISH — it adds an arpeggiated
    layer. A plain substring search sees `struct`, calls it a gate, and moves it
    to the latching row: the exact inversion this module exists to prevent, one
    level down. The role belongs to the head of the chain, not to any word in it.
    """
    out, depth, i, quote = [], 0, 0, False
    while i < len(body):
        c = body[i]
        if quote:
            if c == "\\":
                i += 2
                continue
            if c == '"':
                quote = False
            i += 1
            continue
        if c == '"':
            quote = True
        elif c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
        elif depth <= 1:
            out.append(c)
        i += 1
    return "".join(out)


def classify(body: str) -> str | None:
    """-> "gate" | "gate2" | None (unknown — callers must not guess).

    A GATE beats a GESTURE **at the same level**: `mask "t(8,16,1)" . chop 16` is
    a latched break-gate that happens to chop while it is open. Reading that as
    ambiguous is what hid the_revolution_will_be_sampled's d8 inversion, the very
    pair PLN heard as "drums way too fast".
    """
    chain = top_level(body)
    if GATE.search(chain):
        return GATE_ROLE
    if GESTURE.search(chain):
        return GESTURE_ROLE
    return None


def sites(lines: list[str], pool: set[int] | None = None):
    """Yield (line_idx, cc, col, end, role) for each simple button call.

    `col`/`end` bound the `"^NN"` literal itself, so a caller can rewrite exactly
    that token and nothing else. `role` may be None.
    """
    for i, raw in enumerate(lines):
        code = strip_comment(raw)
        for m in SIMPLE_CALL.finditer(code):
            cc = int(m.group(1))
            if pool is not None and cc not in pool:
                continue
            lit = code.index(f'"^{cc}"', m.start())
            yield i, cc, lit, lit + len(f'"^{cc}"'), classify(site_body(lines, i, m.end()))


def describe(role: str | None) -> str:
    return {GATE_ROLE: "gate", GESTURE_ROLE: "gesture"}.get(role, "unknown")
