#!/usr/bin/env python3
"""fix-button-roles — put GATES back on the latching row and GESTURES on the momentary one.

WHY THIS EXISTS
---------------
The phase-2 surface remap (5910aac, #94) column-aligned every button reference:
each orbit's buttons moved onto its own column. It assigned the two available
slots in **encounter order** — first button reference in the block got row E,
second got row F. That is role-blind, and tracks conventionally write the
gesture line ABOVE the gate line:

    $ midiOn  "^92" (ply "1 <2!3 4>")        -- gesture, momentary
    $ midiOff "^60" (mask "t(4,8,1)")        -- gate, latching

so the remap systematically swapped them. PLN found it by ear on two tracks
within minutes of each other:

  *"techno drum mask is inverted on d8, should ALWAYS be the mask midiOff on ^60
    and the ply or other multiplier/nassim button effect on the push-release 92"*
  *"drums in revolution seem way too fast ... yea def an inversion"*

"Way too fast" is the audible signature: a `ply` gesture landed on the LATCHING
button, so a single press left the drums permanently multiplied. The reverse is
just as bad — a `mask` on the momentary button means the gate only holds while
a finger holds it down.

THE CONVENTION
--------------
  row E (41-44, 57-60), latching   -> GATES    (mask/struct, usually via midiOff)
  row F (73-76, 89-92), momentary  -> GESTURES (ply/fast/stut/chop/slice, midiOn)

A REAL LIMIT OF THE GRID, worth knowing before reading the report: **orbits 1-3
have only ONE button slot.** Their row-F cells (73,74,75) are the per-family
mutes gMute1/2/3, so d1-d3 own a row-E button and nothing else. A gesture on
^41/^42/^43 is therefore not an inversion — it is the only cell that exists.
This tool skips those columns rather than inventing a slot, and says so.

SAFETY
------
Every button CC seeds to 0 at boot (BootTidal.hs `_seed`), so permuting CCs
*between two calls in the same block* cannot change either call's default branch:
`midiOn` stays never-applied and `midiOff` stays always-applied. The edit is
event-neutral at boot BY CONSTRUCTION — verify it anyway with
`tools/silent-eval.py --seeded` before and after and diff the verdicts.

USAGE
    python3 tools/fix-button-roles.py                 # dry run, whole corpus
    python3 tools/fix-button-roles.py --setlist       # dry run, OPAL set only
    python3 tools/fix-button-roles.py --apply         # rewrite in place
"""

from __future__ import annotations

import argparse
import collections
import pathlib
import re
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import lcxl_grid  # noqa: E402
import button_roles  # noqa: E402

ROOT = pathlib.Path(__file__).resolve().parent.parent

# Role classification lives in tools/button_roles.py — ONE judgement, shared with
# migrate-columns.py (which assigns cells) and pvlint PV010 (which reports them).
# Three copies of a musical rule is three chances to drift.
ANY_CC = re.compile(r'"\^(\d+)"')
ORBIT = re.compile(r"^d(\d+)\b")


def blocks(lines):
    """Yield (orbit, start_index, end_index) for each dN block. Column 0 only."""
    starts = [(i, int(m.group(1))) for i, l in enumerate(lines) if (m := ORBIT.match(l))]
    for n, (i, orbit) in enumerate(starts):
        end = starts[n + 1][0] if n + 1 < len(starts) else len(lines)
        yield orbit, i, end


def plan_file(path: pathlib.Path):
    """-> (edits, skips). An edit is (line_idx, col_start, col_end, old_cc, new_cc, why)."""
    lines = path.read_text().splitlines()
    edits, skips = [], []

    for orbit, start, end in blocks(lines):
        home = lcxl_grid.orbit_home(orbit)
        e_cc, f_cc = home.get("gate"), home.get("gate2")
        if e_cc is None or f_cc is None:
            continue  # d1-d3 (and non-button columns): only one slot exists

        block = lines[start:end]
        occupied = set()  # every button CC referenced in this block, movable or not
        for raw in block:
            for m in ANY_CC.finditer(button_roles.strip_comment(raw)):
                occupied.add(int(m.group(1)))

        # Rewritable, role-classified sites. `col`/`end` bound the "^NN" literal
        # itself so the rewrite touches that token and nothing else.
        sites = [{"line": start + i, "cc": cc, "role": role, "col": col, "end": end_}
                 for i, cc, col, end_, role in button_roles.sites(block, {e_cc, f_cc})
                 if role is not None]
        if not sites:
            continue

        gates = [s for s in sites if s["role"] == button_roles.GATE_ROLE]
        gests = [s for s in sites if s["role"] == button_roles.GESTURE_ROLE]
        wrong = [s for s in sites if s["cc"] != home[s["role"]]]
        if not wrong:
            continue

        tag = f"{path.relative_to(ROOT)}:d{orbit}"

        # Case 1 — a clean swap: one gate and one gesture, each on the other's cell.
        if len(gates) == 1 and len(gests) == 1 and gates[0]["cc"] == f_cc and gests[0]["cc"] == e_cc:
            edits.append((gates[0], e_cc, "swap: gate -> latching row E (was F)"))
            edits.append((gests[0], f_cc, "swap: gesture -> momentary row F (was E)"))
            continue

        # Case 2 — a lone control on the wrong cell, with the right cell free.
        if len(sites) == 1:
            s = sites[0]
            target = home[s["role"]]
            noun = button_roles.describe(s["role"])
            if target not in occupied:
                edits.append((s, target, f"move: {noun} -> its own row"))
            else:
                skips.append((tag, f"{noun} on ^{s['cc']} wants ^{target}, which is "
                                   f"taken — migrate-columns.py can permute it"))
            continue

        skips.append((tag, f"{len(gates)} gate(s) + {len(gests)} gesture(s) on "
                           f"{sorted(s['cc'] for s in sites)} — not a clean swap, needs a human"))

    return lines, edits, skips


def apply_edits(lines, edits):
    """Rewrite right-to-left per line so earlier columns stay valid."""
    by_line = collections.defaultdict(list)
    for site, new_cc, _ in edits:
        by_line[site["line"]].append((site, new_cc))
    for i, items in by_line.items():
        for site, new_cc in sorted(items, key=lambda it: -it[0]["col"]):
            l = lines[i]
            lines[i] = l[:site["col"]] + f'"^{new_cc}"' + l[site["end"]:]
    return lines


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--apply", action="store_true", help="rewrite files in place")
    ap.add_argument("--setlist", action="store_true", help="only the OPAL setlist")
    a = ap.parse_args()

    files = sorted((ROOT / "live").rglob("*.tidal"))
    if a.setlist:
        sl = ROOT / "tools" / "setlist.txt"
        if not sl.exists():
            print(f"no setlist at {sl}", file=sys.stderr)
            return 2
        names = {l.strip() for l in sl.read_text().splitlines() if l.strip() and not l.startswith("#")}
        files = [f for f in files if f.name in names or str(f.relative_to(ROOT)) in names]

    total, touched, all_skips = 0, [], []
    for f in files:
        try:
            lines, edits, skips = plan_file(f)
        except Exception as exc:  # a malformed file must not stop the sweep
            print(f"  !! {f.relative_to(ROOT)}: {exc}", file=sys.stderr)
            continue
        all_skips += skips
        if not edits:
            continue
        total += len(edits)
        touched.append(f)
        print(f"\n{f.relative_to(ROOT)}")
        for site, new_cc, why in edits:
            noun = button_roles.describe(site["role"])
            print(f"   L{site['line']+1:>4}  ^{site['cc']} -> ^{new_cc:<3} {noun:<7} {why}")
        if a.apply:
            f.write_text("\n".join(apply_edits(lines, edits)) + "\n")

    print(f"\n=== {total} rewrites in {len(touched)} files "
          f"({'APPLIED' if a.apply else 'dry run'}) ===")
    if all_skips:
        print(f"\n=== {len(all_skips)} sites left for a human ===")
        for tag, why in all_skips:
            print(f"   {tag}: {why}")
    return 0


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