#!/usr/bin/env python3
"""button-column-audit — which tracks still carry the PRE-REMAP button layout?

THE QUESTION THIS ANSWERS
-------------------------
2026-08-15, PLN, mid-session in the cafe tracks:

    "some d1 had still 42 not 41 as button, did we not do all refact?
     doesnt our tools shout at such error snow?"

Both halves are right, and the answer to each is worth writing down.

WHY d1 IS ON ^42
----------------
CC 41 used to be gMask. While it was, an orbit's gate row started one column
late: d1 -> ^42, d2 -> ^43, and so on. Retiring gMask FREED 41, and REMAP PHASE 2
(5910aac, 2026-07-29) column-aligned the board so that column N IS orbit N --
d1's gate became ^41.

That commit touched FOURTEEN FILES. It was the OPAL set, not the corpus. Every
track not in those fourteen still speaks the old layout, and cafe_glace /
cafe_tiede / cafe_bouillant are three of them. So "did we not do all refact?" --
no. The remap was scoped to the set being played, and nothing since has widened
it.

WHY NOTHING SHOUTED
-------------------
pvlint DOES have a button rule: PV008, "one physical button driving two orbits".
It is how the perfect.tidal mis-migration was caught. But PV008 fires on a
COLLISION -- two orbits reaching for one CC. A uniformly shifted file has no
collision: every orbit is off by the same amount, so no two orbits ever meet on
the same button. The whole file slides one column over and stays internally
consistent. PV008 is structurally blind to it, and so is every other rule.

This tool is that missing lens. It is deliberately NOT a pvlint rule -- see the
measurement below for why.

WHY THIS IS NOT A LINT RULE
---------------------------
PV014 set the house standard for encoding a convention: measure it first. It
earned its place at 894/984 = 90.9% conformance. The same measurement for
buttons, across live/:

    button refs on orbits 1-8       2700
    on the orbit's OWN column        963   (35.7%)
    on ANOTHER column               1737   (64.3%)
    files with >=1 off-column ref     306   of 696

35.7% is not a convention, it is a minority. A strict "column N = orbit N" rule
would emit 1737 findings and hold the gig gate shut over the corpus's normal
state. ([[feedback_stated_invariant_is_a_hypothesis]] -- "column N IS orbit N" is
the SETTLED DESIGN, and a hypothesis about the corpus. The corpus says no.)

What IS crisp is the shift SIGNATURE: a track where three or more orbits share
one non-zero column offset. That is a layout generation, not a typo, and it is
one finding per file instead of one per reference.

USAGE
-----
    python3 tools/button-column-audit.py                  # summary + shifted files
    python3 tools/button-column-audit.py --stats          # conformance numbers only
    python3 tools/button-column-audit.py live/midi/...    # specific files
    python3 tools/button-column-audit.py --paths          # bare paths, for xargs

Reports only. Migrating them is tools/migrate-columns.py's job, and it is a
POST-GIG move -- retuning 174 tracks' muscle memory days before a set is how you
arrive on stage reaching for the wrong button.
"""
from __future__ import annotations

import argparse
import collections
import pathlib
import sys

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

from pvlint.core import load, strip_comment                    # noqa: E402
from pvlint.rules import BUTTON_CCS, FAMILY_CCS, CC_IN_CODE    # noqa: E402
import lcxl_grid as grid                                       # noqa: E402

# Column of each button CC, derived from the one authored grid rather than
# hardcoded -- a second copy of the map is how the HUD's went stale (#97).
COLUMN: dict[int, int] = {}
for _row in ("E", "F"):
    for _col, _cc in grid.as_dict()["rows"][_row]["columns"].items():
        COLUMN[int(_cc)] = int(_col)

HOME = {o: grid.orbit_home(o) for o in range(1, 13)}

MIN_ORBITS_FOR_SHIFT = 3   # below this it is a one-off, not a layout


def button_sites(track):
    """(orbit, line, cc) for every non-family button ref on orbits 1-8."""
    for orb in track.orbits():
        if not 1 <= orb.number <= 8:
            continue
        for off, ln in enumerate(orb.lines):
            for m in CC_IN_CODE.finditer(strip_comment(ln)):
                cc = int(m.group(1))
                if cc in BUTTON_CCS and cc not in FAMILY_CCS:
                    yield orb.number, orb.start + off, cc


def audit(path: pathlib.Path):
    """-> (dominant_shift, orbits_shifted, orbits_on_home, refs, off_home)"""
    try:
        track = load(str(path))
    except Exception:
        return None
    deltas: dict[int, set[int]] = collections.defaultdict(set)
    refs = off_home = 0
    for orbit, _line, cc in button_sites(track):
        refs += 1
        home = HOME.get(orbit, {})
        if cc not in (home.get("gate"), home.get("gate2")):
            off_home += 1
        if cc in COLUMN:
            deltas[COLUMN[cc] - orbit].add(orbit)
    shifted = {d: o for d, o in deltas.items()
               if d != 0 and len(o) >= MIN_ORBITS_FOR_SHIFT}
    if not shifted:
        return (0, [], sorted(deltas.get(0, ())), refs, off_home)
    dom = max(shifted, key=lambda d: len(shifted[d]))
    return (dom, sorted(shifted[dom]), sorted(deltas.get(0, ())), refs, off_home)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("paths", nargs="*", help="files (default: all of live/)")
    ap.add_argument("--stats", action="store_true", help="conformance numbers only")
    ap.add_argument("--paths", dest="bare", action="store_true",
                    help="print bare paths of shifted tracks, for xargs")
    args = ap.parse_args()

    files = ([pathlib.Path(p) for p in args.paths] if args.paths
             else sorted(pathlib.Path("live").rglob("*.tidal")))

    refs = off_home = 0
    shifted, by_delta = [], collections.Counter()
    for f in files:
        got = audit(f)
        if got is None:
            continue
        dom, orbs, home_orbs, r, oh = got
        refs += r
        off_home += oh
        if dom:
            shifted.append((f, dom, orbs, home_orbs))
            by_delta[dom] += 1

    if args.bare:
        for f, *_ in shifted:
            print(f)
        return 0

    if refs:
        print(f"button refs on orbits 1-8   {refs}")
        print(f"  on the orbit's OWN column {refs - off_home:5d}  "
              f"({100 * (refs - off_home) / refs:.1f}%)")
        print(f"  on ANOTHER column         {off_home:5d}  "
              f"({100 * off_home / refs:.1f}%)")
    print(f"files scanned               {len(files)}")
    print(f"files with a LAYOUT SHIFT   {len(shifted)}  "
          f"(>={MIN_ORBITS_FOR_SHIFT} orbits sharing one offset)")
    for d, n in sorted(by_delta.items()):
        print(f"  shift {d:+d}: {n} files"
              + ("   <- the pre-remap layout (gMask still held CC 41)"
                 if d == 1 else ""))

    if not args.stats and shifted:
        print("\nshifted tracks:")
        for f, dom, orbs, home_orbs in sorted(shifted, key=lambda x: -len(x[2])):
            home = f"  on-home: d{home_orbs}" if home_orbs else ""
            print(f"  {dom:+d}  d{orbs}{home}\n      {f}")
    return 0


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