#!/usr/bin/env python3
"""fix-mute-roles — put each orbit's MUTE on the family button its role belongs to.

WHY THIS EXISTS
---------------
PLN, 2026-07-30, closing #105:

  *"I want the kick, now on d1/fader1, to have its mute on F1 botrow button.
    I want the C1 knob to djf all percs, C2 only bass djf, C3 melodies DJF.
    however i want the mutes consistent across all all tracks:
    m1 d1 / m2 other percs / m3 all bass+melodics"*

The corpus had drifted into treating gM<N> as a mirror of gF<N> — because it once
was: gM<N> used to be `gMask . gMute<N>`, and gMask has since been retired
(BootTidal.hs). That mirror is wrong for the hand. The DJ FILTERS want the rhythm
section as one bloc (you sweep the drums together), but the MUTES want the kick
split out on its own button (you drop the kick alone). Two different gestures, so
two different groupings — authored in tools/lcxl_grid.py as _FILTER_FAMILY and
_MUTE_FAMILY, which this tool imports rather than restating.

Before, in 12 of 13 setlist tracks:

    d1 $ gF1 $ gMute2    -- kick filters in column 1, mutes in column 2
    d2 $ gF1 $ gM1       -- percs mute in column 1, i.e. ON TOP OF the kick's slot

After:

    d1 $ gF1 $ gM1       -- kick: F1, alone
    d2 $ gF1 $ gM2       -- percs: F2, together

Note the bulk of the work is NOT the kick. It is the 37 perc lines that have to
vacate gM1 so the kick can have it.

SAFETY
------
Every button CC seeds to 0 at boot (BootTidal.hs `_seed`), and gMute<N> is
`midiOn "^7N" (mask "f*16")` — a midiOn whose control reads 0 is never applied.
So permuting WHICH family mute an orbit references cannot change any orbit's
boot behaviour: all three are equally inert until pressed. The edit is
event-neutral at boot BY CONSTRUCTION.

That also means `silent-eval --seeded` CANNOT validate the interesting half of
this change. It proves nothing got silenced at boot and NOTHING about what
happens under a press. Run it anyway as a regression net; the real gate is an
ear, or pv-at (#74).

WHAT IT WILL NOT DO
-------------------
- Touch trailing comments. `d1 $ gF1 $ gMute2 -- KICK: Sub thud, 4otf` keeps its
  comment verbatim; only the token changes.
- Rewrite a commented-out line (`-- $ gF3 $ gMute3 -- COUNT TO SUPERSTARDOM`).
  Note a commented mute means the orbit has NO mute — `--fill` will offer it one.
- Touch gF at all. The filter families are already correct in 13/13 setlist tracks.
- Fill a missing mute anywhere but the setlist unless told to. PLN: *"no mute is
  oversight! ensure we have that consistently in all set, and flag in other
  non-set tracks for me to see."* So `--fill` on the set writes; `--report-missing`
  on the corpus only lists, because outside the set a missing gate may well be a
  composition choice and that judgement is his.

USAGE
    python3 tools/fix-mute-roles.py                  # dry run, OPAL setlist
    python3 tools/fix-mute-roles.py --all            # dry run, whole repo
    python3 tools/fix-mute-roles.py --apply          # rewrite the setlist in place
    python3 tools/fix-mute-roles.py --all --apply    # rewrite the whole repo
    python3 tools/fix-mute-roles.py --fill --apply   # also GIVE missing mutes
    python3 tools/fix-mute-roles.py --check          # exit 1 if any drift remains
    python3 tools/fix-mute-roles.py --all --report-missing docs/report.md
"""

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
from pvlint.core import ORBIT_RE as DN_RE, Track, strip_comment  # noqa: E402

ROOT = pathlib.Path(__file__).resolve().parent.parent
SETLIST = ROOT / "armada" / "setlist_opal2026.txt"

# The orbit-statement regex and the block chunking are pvlint's, deliberately:
# this tool had its OWN `^dN\s+\$` and it silently missed every orbit whose head
# line carries no `$` — e.g. `d5  -- The Voice of Love` with the gates on a
# commented continuation line. pvlint's PV013 caught the gap minutes after being
# written, on the very file the migration had just walked. Two parsers disagreeing
# about what an orbit IS is the same class of bug as two copies of the grid (#97),
# so there is now one parser and the migrator is the consumer.
# The family-mute reference. Both spellings: gM<N> is current, gMute<N> is the
# legacy long form BootTidal still aliases. Word-bounded so gM1 never matches
# inside gM12 or a sample name.
MUTE_RE = re.compile(r"\bg(?:M|Mute)([123])\b")


def setlist_tracks() -> list[pathlib.Path]:
    if not SETLIST.exists():
        raise SystemExit(f"fix-mute-roles: no setlist at {SETLIST}")
    out = []
    for raw in SETLIST.read_text().splitlines():
        line = raw.split("#")[0].strip()
        if line:
            out.append(ROOT / line)
    return out


# Directories that are not PLN's playable tracks: dependency trees, git internals,
# and generated artifacts. Everything else is fair game — the point of --all is that
# opening ANY old track finds the mutes where his fingers now expect them.
_SKIP_DIRS = {".git", "node_modules", "output", "__pycache__", ".venv"}


def corpus_tracks() -> list[pathlib.Path]:
    """Every playable .tidal in the repo, not just live/.

    PLN 2026-07-30: *"not only in the set am i right? did we not do the whole
    refact on the other .tidal files"* — yes. Muscle memory does not know which
    folder a track lives in, so a half-migrated corpus is worse than either
    extreme: it makes the surface unpredictable exactly when he reaches for an
    old track mid-set.
    """
    return sorted(p for p in ROOT.rglob("*.tidal")
                  if not _SKIP_DIRS & set(p.relative_to(ROOT).parts))


def is_commented(line: str, upto: int) -> bool:
    """True if the match at `upto` sits after a Haskell `--` comment opener."""
    return "--" in line[:upto]


def blocks(text: str) -> list[tuple[int, int, list[int]]]:
    """Split into orbit statements, via pvlint's parser.

    Returns (orbit, head_index, [0-based line indices in the block]). A block runs
    from its `dN` head until a blank line or the next head — the same chunking
    Pulsar uses when it evaluates (blank line = statement separator), so what this
    tool calls a block is what the editor calls one eval.
    """
    out: list[tuple[int, int, list[int]]] = []
    for orb in Track(path="<migrate>", text=text).orbits():
        head = orb.start - 1                       # pvlint is 1-indexed
        out.append((orb.number, head,
                    list(range(head, head + len(orb.lines)))))
    return out


def plan_file(path: pathlib.Path) -> tuple[list[tuple[int, int, str, str]], list[tuple[int, int]]]:
    """What would change in one file.

    Returns (edits, ungated) where an edit is
    (line_index, orbit, old_token, new_token).
    """
    text = path.read_text()
    lines = text.splitlines()
    edits: list[tuple[int, int, str, str]] = []
    ungated: list[tuple[int, int]] = []
    for orbit, head, idxs in blocks(text):
        want = lcxl_grid.mute_family(orbit)
        found = False
        for i in idxs:
            for m in MUTE_RE.finditer(lines[i]):
                if is_commented(lines[i], m.start()):
                    continue
                found = True
                old = m.group(0)
                new = f"gM{want}"
                if old != new:
                    edits.append((i, orbit, old, new))
                break                      # first live reference wins
            if found:
                break
        if not found:
            ungated.append((orbit, head + 1))
    return edits, ungated


GF_RE = re.compile(r"\bgF([123])\b")


def fill_file(path: pathlib.Path, ungated: list[tuple[int, int]],
              write: bool) -> list[tuple[int, int, str]]:
    """Give an orbit block that has NO family mute the one its role wants.

    PLN 2026-07-30: *"no mute is oversight! ensure we have that consistently in
    all set"*. An orbit with no gM cannot be muted from the surface at all —
    during a set that is a stem you cannot drop, which is the opposite of the
    affordance the family buttons exist to provide.

    Boot-safe by the same argument as the rewrite: gMute<N> is
    `midiOn "^7N" (mask "f*16")` and every button CC seeds to 0, so a midiOn
    whose control reads 0 is never applied. ADDING a mute cannot change how the
    block sounds until the button is pressed.

    Placement follows the corpus convention `dN $ gF<N> $ gM<M>`: after the last
    gF on the head line if there is one, else immediately after `dN $`. Never
    inside or across a trailing comment.
    """
    lines = path.read_text().splitlines(keepends=True)
    done: list[tuple[int, int, str]] = []
    for orbit, head_1 in ungated:
        i = head_1 - 1
        raw = lines[i]
        nl = "\n" if raw.endswith("\n") else ""
        body = raw[:-len(nl)] if nl else raw
        # Split off a trailing comment so insertion never lands inside it.
        cut = body.find("--")
        code, comment = (body[:cut], body[cut:]) if cut >= 0 else (body, "")
        want = f"gM{lcxl_grid.mute_family(orbit)}"
        gfs = list(GF_RE.finditer(code))
        if gfs:
            at = gfs[-1].end()
            new_code = f"{code[:at]} $ {want}{code[at:]}"
        else:
            m = DN_RE.match(code)
            at = m.end()
            rest = code[at:]
            # `d5 $ foo`  -> `d5 $ gM3 $ foo`   (splice after the existing $)
            # `d5` alone  -> `d5 $ gM3`         (there was no $ to splice after)
            if rest.lstrip().startswith("$"):
                cut_at = at + rest.index("$") + 1
                new_code = f"{code[:cut_at]} {want} ${code[cut_at:]}"
            else:
                new_code = f"{code[:at]} $ {want}{rest}"
        # Tidy trailing whitespace the splice may have left — but NEVER eat the
        # gap before a trailing comment. `")-- 4-bar roots` still compiles and
        # still reads as a comment, so this would have been an invisible
        # cosmetic regression in PLN's own annotations.
        new_code = re.sub(r"[ \t]+$", " " if comment else "", new_code)
        lines[i] = new_code + comment + nl
        done.append((i, orbit, want))
    if write and done:
        path.write_text("".join(lines))
    return done


def apply_file(path: pathlib.Path, edits: list[tuple[int, int, str, str]]) -> None:
    """Rewrite only the matched token on each line. Comments survive verbatim."""
    lines = path.read_text().splitlines(keepends=True)
    for i, _orbit, old, new in edits:
        line = lines[i]
        for m in MUTE_RE.finditer(line):
            if is_commented(line, m.start()):
                continue
            lines[i] = line[:m.start()] + new + line[m.end():]
            break
    path.write_text("".join(lines))


def main() -> int:
    ap = argparse.ArgumentParser(prog="fix-mute-roles")
    ap.add_argument("--apply", action="store_true", help="rewrite in place")
    ap.add_argument("--check", action="store_true",
                    help="exit 1 if any drift remains (for CI / pre-gig gate)")
    ap.add_argument("--all", action="store_true",
                    help="whole repo instead of just the OPAL setlist")
    ap.add_argument("--fill", action="store_true",
                    help="also GIVE a mute to blocks that have none (PLN: 'no mute "
                         "is oversight'). Use with --apply to write.")
    ap.add_argument("--report-missing", metavar="PATH", type=pathlib.Path,
                    help="write the ungated blocks to a markdown report instead of "
                         "filling them — for non-set tracks PLN wants to eyeball")
    ap.add_argument("tracks", nargs="*", type=pathlib.Path)
    a = ap.parse_args()

    tracks = ([p if p.is_absolute() else ROOT / p for p in a.tracks]
              or (corpus_tracks() if a.all else setlist_tracks()))

    print("fix-mute-roles: the authored map (tools/lcxl_grid.py)")
    by_fam: dict[int, list[int]] = collections.defaultdict(list)
    for o in range(1, 13):
        by_fam[lcxl_grid.mute_family(o)].append(o)
    for fam in sorted(by_fam):
        cc = lcxl_grid.CELL_TO_CC[("F", fam)]
        orbits = " ".join(f"d{o}" for o in by_fam[fam])
        print(f"  gM{fam}  (F{fam}, ^{cc})  {orbits}")
    print(f"\n{len(tracks)} track(s)\n")

    shapes: collections.Counter[str] = collections.Counter()
    total = 0
    ungated_total = 0
    filled_total = 0
    missing: list[tuple[pathlib.Path, int, int]] = []
    for path in tracks:
        if not path.exists():
            print(f"  {path}: MISSING")
            continue
        edits, ungated = plan_file(path)
        ungated_total += len(ungated)
        missing += [(path, o, ln) for o, ln in ungated]
        if edits:
            total += len(edits)
            detail = " ".join(f"d{o}:{old}->{new}" for _i, o, old, new in edits)
            print(f"  {path.stem:32} {len(edits):3}  {detail}")
            for _i, _o, old, new in edits:
                shapes[f"{old} -> {new}"] += 1
            if a.apply:
                apply_file(path, edits)
        if a.fill and ungated:
            got = fill_file(path, ungated, write=a.apply)
            filled_total += len(got)
            detail = " ".join(f"d{o}+{w}" for _i, o, w in got)
            print(f"  {path.stem:32} {'':3}  FILL {detail}")

    print(f"\n{'REWROTE' if a.apply else 'WOULD REWRITE'}: {total} line(s)")
    if shapes:
        print("by shape:")
        for k, v in shapes.most_common():
            print(f"  {v:4}  {k}")
    if a.fill:
        print(f"\n{'FILLED' if a.apply else 'WOULD FILL'}: {filled_total} block(s) "
              "that had no family mute at all")
    elif ungated_total:
        print(f"\n{ungated_total} orbit block(s) declare no family mute at all "
              "(--fill to give them one, --report-missing to list them)")

    if a.report_missing:
        by_file: dict[pathlib.Path, list[tuple[int, int]]] = collections.defaultdict(list)
        for p, o, ln in missing:
            by_file[p].append((o, ln))

        # The distinction that makes 2397 findings actionable. A track where SOME
        # orbits carry a family mute and some do not is a genuine oversight — the
        # convention was applied and then missed. A track where NO orbit has one
        # was written before the family mutes existed; that is a style era, not a
        # mistake, and mass-filling it would be a rewrite nobody asked for.
        partial: list[pathlib.Path] = []
        never: list[pathlib.Path] = []
        for p in by_file:
            has_any = any(MUTE_RE.search(strip)
                          for strip in (line.split("--")[0]
                                        for line in p.read_text().splitlines()))
            (partial if has_any else never).append(p)

        def fmt(p: pathlib.Path) -> str:
            got = sorted(by_file[p], key=lambda t: t[1])
            orbits = " ".join(f"d{o}(L{ln}→gM{lcxl_grid.mute_family(o)})"
                              for o, ln in got)
            return f"- `{p.relative_to(ROOT)}` — {len(got)}: {orbits}"

        n_partial = sum(len(by_file[p]) for p in partial)
        n_never = sum(len(by_file[p]) for p in never)
        out = [
            "# Orbits with no family mute — for PLN's eyes",
            "",
            "PLN 2026-07-30: *\"no mute is oversight! ensure we have that "
            "consistently in all set, and flag in other non-set tracks for me "
            "to see.\"*",
            "",
            "The OPAL setlist has been FILLED automatically. Everything below is a "
            "NON-SET track, left exactly as it was. Nothing here was written to.",
            "",
            f"**{len(missing)} blocks across {len(by_file)} files** — but they are "
            "not one problem. Split by whether the track uses family mutes at all:",
            "",
            f"| | files | blocks | what it means |",
            f"|---|---|---|---|",
            f"| **A · partial** | {len(partial)} | {n_partial} | uses family mutes, "
            "but some orbits were missed — **this is the oversight you meant** |",
            f"| **B · never** | {len(never)} | {n_never} | no orbit has one; written "
            "before the convention. A style era, not a mistake |",
            "",
            "Group B is the bulk and mass-filling it would be a rewrite nobody "
            "asked for. Group A is the short, real list.",
            "",
            "To fix one file once you have looked at it:",
            "",
            "```",
            "python3 tools/fix-mute-roles.py --fill --apply <path.tidal>",
            "```",
            "",
            f"## A — partial: the convention was applied then missed ({len(partial)} files)",
            "",
        ]
        out += [fmt(p) for p in sorted(partial, key=lambda q: (-len(by_file[q]), q))]
        out += ["",
                f"## B — never adopted family mutes ({len(never)} files)",
                "",
                "Listed for completeness. Reading one of these is only worth it if "
                "you intend to play it again.",
                ""]
        out += [fmt(p) for p in sorted(never)]
        a.report_missing.write_text("\n".join(out) + "\n")
        print(f"\nreport: {a.report_missing}")
        print(f"  A partial (real oversight): {len(partial)} files, {n_partial} blocks")
        print(f"  B never adopted           : {len(never)} files, {n_never} blocks")

    if a.check and total:
        print("\nfix-mute-roles: DRIFT — run --apply", file=sys.stderr)
        return 1
    if a.apply:
        print("\nNow: re-run to confirm convergence (must report 0), then "
              "`silent-eval --seeded` as a regression net. Remember it CANNOT see "
              "behaviour under a press — that needs an ear or pv-at (#74).")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
