#!/usr/bin/env python3
"""Remove local `let gMask/gM*/gMute*/gF*/gPanic` shadows so BootTidal.hs is the
ONLY definition of the global helpers.

WHY THIS EXISTS
---------------
For years every track opened with a copy-pasted stanza:

    let gMask  = (midiOn "^41" (mask "t . <f t f <f t>> <t f f <t f>>"))
    let gMute1 = (midiOn "^73" (mask "f*16"))
    let gM1    = gMask . gMute
    let gF1    = (# djfbus 1 (range 0.05 0.95 "^49"))

A local `let` SHADOWS the boot definition, so every fix made centrally in
BootTidal.hs stopped at the door of 254 files. Two live consequences, both real:

  * `gMask` was "retired" in BootTidal on 2026-07-29 (`gMask = id`) — and still
    chopped an eighth out of every bar on the 237 tracks that redefined it. PLN
    caught it by loading bombe_dj and seeing ^41 still masking.
  * `gF1/2/3` were rewritten to the two-sided `gDJF` (centre = bypass, left =
    lpf, right = hpf). 336 local defs kept the OLD one-directional
    `(# djfbus N (range 0.05 0.95 "^49"))`, so the fix was invisible in
    performance. See memory reference_djf_bypass_is_centre.

CLASSIFICATION — the safety property
------------------------------------
A local def is deleted ONLY if its body positively matches a known boilerplate
pattern (BOILERPLATE below). Anything unrecognised is COMMENTED OUT with a
marker instead of deleted — so an unknown body can never be silently lost, and
the failure mode of a bad regex is "left a comment", not "deleted PLN's music".

Some of those bodies ARE music, not boilerplate:

    let gF2 = (whenmod 64 48 (# djf (slow 16 $ range 0.5 0.2 saw))) -- Parts DJF
    let gF1 = (someCyclesBy "<f!10 t!6 f!112>" (# djfbus 1 ...))    -- Intro DJF

Those keep working today only because they shadow. Commenting them out is a
deliberate behaviour change (they become the global filter) with the original
one line above, re-enableable by deleting two dashes.

NOT TOUCHED
-----------
  * `gDJF` — local is `let gDJF = …` (0 args); BootTidal's is `gDJF ch` (1 arg).
    Removing the shadow is a TYPE ERROR, not a global. 65 files. Own pass (#96).
  * `gMute`, `gM`, `gF`, `gM'`, `gDJF1/2`, `gMaskEnd16`, … — names BootTidal does
    not define at all. Deleting them breaks the build. Left alone; a leftover
    unused `let` is harmless.
  * Anything already inside a comment.
  * Definitions that take arguments, when boot's arity differs.

USAGE
    python3 tools/deshadow-helpers.py --plan          # corpus-wide report
    python3 tools/deshadow-helpers.py --plan --set    # OPAL setlist only
    python3 tools/deshadow-helpers.py --apply
"""

from __future__ import annotations

import argparse
import pathlib
import re
import sys
from collections import Counter

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

NAME = r"[a-zA-Z_][A-Za-z0-9_']*"

# Zero-arg helpers BootTidal defines and that a track may therefore shadow.
# gDJF is deliberately ABSENT: boot's takes a channel argument (see docstring).
SHADOWABLE = {
    "gMask",
    "gM1", "gM2", "gM3",
    "gMute1", "gMute2", "gMute3",
    "gF1", "gF2", "gF3",
    "gPanic",
    "midiGGlobal",
}

LET = re.compile(rf"^(\s*)let\s+({NAME})\s*=(?!=)\s*(.*?)\s*$")

# --- BOILERPLATE: the copy-pasted stanza, safe to delete outright -------------
# Each entry is (helper-name regex, body regex). Bodies are matched against the
# code only -- a trailing `--` comment is stripped first.
BOILERPLATE: list[tuple[re.Pattern, re.Pattern]] = [
    # gMask = (midiOn "^NN" (mask "<anything>"))   -- the retired global
    (re.compile(r"^gMask$"),
     re.compile(r'^\(?\s*midiOn\s+"\^\d+"\s+\(mask\s+"[^"]*"\)\s*\)?$')),
    # gMute<N> = (midiOn "^7N" (mask "f*16"))
    (re.compile(r"^gMute[123]$"),
     re.compile(r'^\(?\s*midiOn\s+"\^\d+"\s+\(mask\s+"f\*16"\)\s*\)?$')),
    # gM<N> = gMask . gMute / gMute<N> / gMute<N>   (gMask is `id` now)
    (re.compile(r"^gM[123]$"),
     re.compile(r"^(gMask\s*\.\s*)?gMute[123]?$")),
    # gM<N> = (midiOn "^7N" (mask "f*16"))  -- inlined mute
    (re.compile(r"^gM[123]$"),
     re.compile(r'^\(?\s*midiOn\s+"\^\d+"\s+\(mask\s+"f\*16"\)\s*\)?$')),
    # gF<N> = (# djfbus N (range a b "^NN"))  -- the OLD one-directional filter
    (re.compile(r"^gF[123]$"),
     re.compile(r'^\(?\s*#\s*djfbus\s+\d+\s+\(range\s+[\d.]+\s+[\d.]+\s+"\^\d+"\)\s*\)?$')),
    # a plain passthrough of any helper
    (re.compile(r"^(gMask|gM[123]|gMute[123]|gF[123]|gPanic)$"),
     re.compile(r"^id$")),
    # gPanic / midiGGlobal restated exactly as boot has them
    (re.compile(r"^gPanic$"),
     re.compile(r'^\(?\s*#\s*gain\s+\(range\s+1\s+0\s+.*\)\s*\)?$')),
    (re.compile(r"^midiGGlobal$"),
     re.compile(r"^[\d.]+$")),
]

MARKER = "-- DESHADOW(#96): now global in BootTidal.hs; body kept in case it was music"


def strip_comment(body: str) -> str:
    """Drop a trailing Haskell `--` comment. Never inside a string literal."""
    out, in_str = [], False
    i = 0
    while i < len(body):
        c = body[i]
        if c == '"' and (i == 0 or body[i - 1] != "\\"):
            in_str = not in_str
        if not in_str and body.startswith("--", i):
            break
        out.append(c)
        i += 1
    return "".join(out).strip()


def classify(name: str, body: str) -> str:
    """'delete' | 'comment' | 'skip'."""
    if name not in SHADOWABLE:
        return "skip"
    code = strip_comment(body)
    if not code:
        return "skip"
    for npat, bpat in BOILERPLATE:
        if npat.match(name) and bpat.match(code):
            return "delete"
    return "comment"


HELPERISH = re.compile(r"^(gM|gF|gDJ|gPanic|gMask|gMute|midiG|gSel)")


def orphans(lines: list[str], skip: set[int]) -> list[tuple[int, str, str, str]]:
    """Local helper defs that nothing in the file references any more.

    Pass 1 deletes `let gM1 = gMask . gMute`, which is often the ONLY user of a
    track-local `let gMute = …`. BootTidal has no `gMute`, so pass 1 cannot touch
    it — but leaving it behind is half a stanza and reads like it still does
    something. A def referenced nowhere else in the file is dead by definition,
    so deleting it cannot change a single event.
    """
    out = []
    defs: dict[str, int] = {}
    for i, line in enumerate(lines):
        if i in skip or line.lstrip().startswith("--"):
            continue
        m = LET.match(line)
        if m and HELPERISH.match(m.group(2)):  # group 1 is the indent, not the name
            defs[m.group(2)] = i
    for name, di in defs.items():
        pat = re.compile(rf"(?<![A-Za-z0-9_']){re.escape(name)}(?![A-Za-z0-9_'])")
        used = any(
            pat.search(strip_comment(ln))
            for j, ln in enumerate(lines)
            if j != di and j not in skip and not ln.lstrip().startswith("--")
        )
        if not used:
            out.append((di, "delete-orphan", name, lines[di]))
    return out


def plan_file(path: pathlib.Path) -> list[tuple[int, str, str, str]]:
    """-> [(line_index, action, name, original_line)] for one file."""
    out = []
    lines = path.read_text(errors="replace").splitlines()
    for i, line in enumerate(lines):
        if line.lstrip().startswith("--"):
            continue
        m = LET.match(line)
        if not m:
            continue
        _indent, name, body = m.groups()
        action = classify(name, body)
        if action != "skip":
            out.append((i, action, name, line))
    # Pass 2 runs against the file AS IF pass 1 had landed: a def is only an
    # orphan once its pass-1 users are gone.
    gone = {i for i, act, _n, _l in out if act == "delete"}
    out.extend(orphans(lines, gone))
    return sorted(out)


def rewrite(path: pathlib.Path, actions: list[tuple[int, str, str, str]]) -> None:
    lines = path.read_text(errors="replace").splitlines(keepends=True)
    by_index = {i: (act, name) for i, act, name, _ in actions}
    out: list[str] = []
    for i, line in enumerate(lines):
        if i not in by_index:
            out.append(line)
            continue
        act, _name = by_index[i]
        if act in ("delete", "delete-orphan"):
            continue  # the whole point: boot's definition takes over
        nl = "\n" if line.endswith("\n") else ""
        stripped = line.rstrip("\n")
        indent = stripped[: len(stripped) - len(stripped.lstrip())]
        out.append(f"{indent}{MARKER}{nl}")
        out.append(f"{indent}-- {stripped.lstrip()}{nl}")

    # Never leave a run of blank lines where a whole stanza was removed: a blank
    # line is Tidal's BLOCK SEPARATOR (reference_boot_block_seam), so doubling
    # them changes how Pulsar chunks the file.
    collapsed: list[str] = []
    for line in out:
        if not line.strip() and collapsed and not collapsed[-1].strip():
            continue
        collapsed.append(line)
    path.write_text("".join(collapsed))


def targets(only_set: bool) -> list[pathlib.Path]:
    if only_set:
        names = [l.split("#")[0].strip() for l in SETLIST.read_text().splitlines()]
        return [ROOT / n for n in names if n]
    return sorted(p for p in ROOT.rglob("*.tidal") if ".git" not in p.parts)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--plan", action="store_true")
    ap.add_argument("--apply", action="store_true")
    ap.add_argument("--set", action="store_true", help="OPAL setlist only")
    ap.add_argument("--verbose", action="store_true")
    args = ap.parse_args()
    if not (args.plan or args.apply):
        ap.error("need --plan or --apply")

    files = targets(args.set)
    buckets = {"delete": Counter(), "delete-orphan": Counter(), "comment": Counter()}
    touched: list[tuple[pathlib.Path, list]] = []
    for p in files:
        acts = plan_file(p)
        if not acts:
            continue
        touched.append((p, acts))
        for _i, act, name, line in acts:
            buckets[act][name] += 1
            if act == "comment" and args.verbose:
                print(f"   COMMENT  {p.relative_to(ROOT)}: {line.strip()[:110]}")

    print(f"scanned {len(files)} .tidal file(s); {len(touched)} carry a local shadow")
    for act, title in (
        ("delete", "DELETE — exact boilerplate, boot's definition takes over"),
        ("delete-orphan", "DELETE — orphaned by pass 1, referenced nowhere (0 events change)"),
        ("comment", "COMMENT OUT — body not recognised as boilerplate, so preserved"),
    ):
        c = buckets[act]
        print(f"\n{title}   total {sum(c.values())}")
        for n, k in c.most_common():
            print(f"   {k:5d}  {n}")
    if not args.verbose and buckets["comment"]:
        print("   (re-run with --verbose to see each one)")

    if args.apply:
        for p, acts in touched:
            rewrite(p, acts)
        print(f"\napplied to {len(touched)} file(s)")
    else:
        print("\n(plan only — nothing written)")
    return 0


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