#!/usr/bin/env python3
"""surface-columns — does each orbit's controls live in that orbit's COLUMN?

The LCXL is a grid: eight columns, each a channel strip.

      col      1    2    3    4    5    6    7    8
  A knob     13   14   15   16   17   18   19   20
  B knob     29   30   31   32   33   34   35   36
  C knob     49   50   51   52   53   54   55   56
  button1    41   42   43   44   57   58   59   60
  button2    73   74   75   76   89   90   91   92
  D fader    77   78   79   80   81   82   83   84

#46 makes fader D_N control orbit N. That only pays off if the knobs ABOVE
each fader shape the SAME orbit -- otherwise the bass fader is in column 4
while the bass filter is in column 5, and the surface is a lie you have to
remember your way around.

PLN put it exactly right while mapping (2026-07-29):

    "we need the coverage of the effects moving in the tracks, e.g. bass from
     81 to 80 means effects on 53 now move to 52"

So the Ardour re-learn is only HALF the remap. This tool measures the other
half: per track, per orbit, which columns does that orbit actually reach into,
and what would have to move for column == orbit.

Two classes of control, and the distinction is the whole point:

  TRACK controls -- a raw "^NN" written in the .tidal file. These are free to
      move. Renumbering them is a text edit.

  HELPER controls -- CCs baked into BootTidal.hs (gF1/gF2/gF3 on 49/50/51,
      gMask on 41, gMute1-3 on 73/74/75, gPanic on 93...). A track cannot move
      these by editing itself; they are global by construction, and they will
      always look "misaligned" against a per-column model. Reporting them as
      work to do would be noise, so they are counted separately.

Usage:
    tools/surface-columns.py                  # the OPAL setlist
    tools/surface-columns.py --all            # every .tidal under live/
    tools/surface-columns.py TRACK.tidal ...  # named tracks
    tools/surface-columns.py --plan TRACK     # the exact ^NN -> ^NN moves

Exit 0 always: this is a survey, not a gate. Alignment is a design choice.
"""
from __future__ import annotations

import argparse
import re
import sys
from collections import defaultdict
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from pvlint.core import Track, load, strip_comment  # noqa: E402

ROOT = Path(__file__).resolve().parent.parent
BOOT = ROOT / "BootTidal.hs"
SETLIST = ROOT / "armada" / "setlist_opal2026.txt"

# cc -> (column, row-label), DERIVED from the single authored grid (#97). This
# used to be hardcoded here, and separately in migrate-columns, lcxl-leds, pvlint
# and the HUD — five copies, of which the HUD's silently went stale after the
# 2026-07-29 remap and showed d9 on A8. Row labels stay this tool's own vocabulary
# ("fader"/"btn1"/"btn2") because they appear in output PLN reads; lcxl_grid
# carries the aliases so nothing has to be renamed.
import lcxl_grid  # noqa: E402

_ROW_LABEL = {"A": "A", "B": "B", "C": "C", "D": "fader", "E": "btn1", "F": "btn2"}
GRID: dict[int, tuple[int, str]] = {
    cc: (col, _ROW_LABEL[row]) for cc, (row, col) in lcxl_grid.CC_TO_CELL.items()
}

CC_REF = re.compile(r'"\^(\d+)"')

# Faders are Ardour's, never Tidal's -- seeding or reading them from here is the
# CC77-goes-to-silence footgun. A track referencing one is a CONFLICT, not a
# column question, so flag rather than plan a move.
ARDOUR_CC = set(range(77, 85))


def helper_ccs(boot: Path = BOOT) -> set[int]:
    """CCs hard-wired into BootTidal's helpers -- not movable by a track edit."""
    if not boot.exists():
        return set()
    text = boot.read_text()
    # Only the helper let-block, not the #55 seed list (which mentions every CC).
    i = text.find("let -- DPV specific parameters")
    if i >= 0:
        j = text.find("\n:}", i)
        text = text[i:j if j > 0 else len(text)]
    # STRIP COMMENTS. Without this the scan reported 11 helper CCs including 18,
    # 34 and 77 -- all three of which appear ONLY inside `--` comments
    # (`midiOn ("^34" - "^18")` documenting perfect.tidal, and a note about a
    # retired midiGGlobal that used to read "^77"). BootTidal claims none of
    # them. An inflated helper set is the dangerous direction of wrong: it would
    # have told us three usable controls were unavailable, and quietly shrunk
    # the design space for #92. A parser miss must never look like a data fact.
    text = "\n".join(strip_comment(l) for l in text.splitlines())
    return {int(m) for m in CC_REF.findall(text)}


def orbit_ccs(track: Track) -> dict[int, list[tuple[int, int]]]:
    """orbit number -> [(cc, 1-indexed line)] for every ^NN it references."""
    out: dict[int, list[tuple[int, int]]] = defaultdict(list)
    for orb in track.orbits():
        for off, raw in enumerate(orb.lines):
            for m in CC_REF.finditer(strip_comment(raw)):
                out[orb.number].append((int(m.group(1)), orb.start + off))
    return out


def analyse(track: Track, helpers: set[int]) -> dict:
    """Per-orbit column alignment for one track."""
    rows = []
    for orbit, refs in sorted(orbit_ccs(track).items()):
        own, held, ardour = [], [], []
        for cc, line in refs:
            if cc in ARDOUR_CC:
                ardour.append((cc, line))
            elif cc in helpers:
                held.append((cc, line))
            else:
                own.append((cc, line))
        cols = {GRID[cc][0] for cc, _ in own if cc in GRID}
        offgrid = sorted({cc for cc, _ in own if cc not in GRID})
        # An orbit only *has* a column if it lives in 1..8 -- d9..d12 are the
        # knob-row orbits under #46 and have no fader column of their own.
        target = orbit if 1 <= orbit <= 8 else None
        aligned = target is not None and cols == {target}
        rows.append(dict(orbit=orbit, target=target, cols=sorted(cols),
                         aligned=aligned, own=own, held=held,
                         ardour=ardour, offgrid=offgrid))
    return dict(track=track, rows=rows)


def moves(row: dict) -> list[tuple[int, int, int]]:
    """(cc, new_cc, line) to bring one orbit's own controls into its column."""
    t = row["target"]
    if t is None:
        return []
    out = []
    for cc, line in row["own"]:
        if cc not in GRID:
            continue
        col, kind = GRID[cc]
        if col == t:
            continue
        new = {"A": 12, "B": 28, "C": 48}.get(kind)
        if new is None:            # a button or a fader -- different geometry
            if kind == "btn1":
                new_cc = (40 + t) if t <= 4 else (52 + t)
            elif kind == "btn2":
                new_cc = (72 + t) if t <= 4 else (84 + t)
            else:
                continue
        else:
            new_cc = new + t
        out.append((cc, new_cc, line))
    return out


def resolve(names: list[str]) -> list[Path]:
    if not names:
        if not SETLIST.exists():
            raise SystemExit(f"surface-columns: no setlist at {SETLIST}")
        names = [l.strip() for l in SETLIST.read_text().splitlines()
                 if l.strip() and not l.startswith("#")]
    out = []
    for n in names:
        p = Path(n)
        if p.exists():
            out.append(p)
            continue
        hits = sorted(ROOT.glob(f"live/**/{Path(n).stem}.tidal"))
        if hits:
            out.append(hits[0])
        else:
            print(f"  ?? no .tidal found for {n}", file=sys.stderr)
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("tracks", nargs="*", help="tracks (default: OPAL setlist)")
    ap.add_argument("--all", action="store_true", help="every .tidal under live/")
    ap.add_argument("--plan", action="store_true",
                    help="print the exact ^NN -> ^NN moves per track")
    ap.add_argument("--knobs", action="store_true",
                    help="KNOB rows only (A/B/C) — the phase-1 scope. Buttons "
                         "(gates/mutes) are phase 2 and would otherwise inflate "
                         "the verdict with work that was deliberately deferred.")
    a = ap.parse_args()
    if a.knobs:
        # Narrow the grid itself, so every downstream count -- cols, aligned,
        # moves -- speaks about phase 1 only. A gate that reports undone phase-2
        # work as a phase-1 failure is a gate you learn to ignore, which is how
        # a real regression gets through.
        for cc, (col, row) in list(GRID.items()):
            if row not in ("A", "B", "C"):
                del GRID[cc]

    paths = (sorted(ROOT.glob("live/**/*.tidal")) if a.all else resolve(a.tracks))
    helpers = helper_ccs()
    print(f"surface-columns: {len(paths)} track(s) | "
          f"{len(helpers)} helper CCs held by BootTidal: "
          f"{','.join(str(c) for c in sorted(helpers))}\n")

    tot_orb = tot_ok = tot_moves = 0
    per_track = []
    for p in paths:
        try:
            tr = load(p)
        except Exception as e:                      # noqa: BLE001
            print(f"  !! {p.name}: {e}")
            continue
        res = analyse(tr, helpers)
        n_moves = sum(len(moves(r)) for r in res["rows"])
        d1_8 = [r for r in res["rows"] if r["target"] is not None]
        ok = sum(1 for r in d1_8 if r["aligned"] or not r["own"])
        tot_orb += len(d1_8); tot_ok += ok; tot_moves += n_moves
        per_track.append((p, res, n_moves, ok, len(d1_8)))

    for p, res, n_moves, ok, n in per_track:
        flag = "OK " if n_moves == 0 else f"{n_moves:2d} moves"
        print(f"== {p.name:34} d1-d8 aligned {ok}/{n:<2}  {flag}")
        for r in res["rows"]:
            if not r["own"] and not r["ardour"] and not r["offgrid"]:
                continue
            own = ",".join(f"^{cc}" for cc, _ in r["own"]) or "-"
            cols = ",".join(str(c) for c in r["cols"]) or "-"
            mark = "==" if r["aligned"] else ("  " if r["target"] is None else "!=")
            print(f"     d{r['orbit']:<2} {mark} cols[{cols:9}] own {own}")
            if r["ardour"]:
                ccs = ",".join(f"^{cc}@L{l}" for cc, l in r["ardour"])
                print(f"          !! ARDOUR-OWNED CC in a track: {ccs}")
            if r["offgrid"]:
                print(f"          ?? off-grid CC: "
                      f"{','.join(f'^{c}' for c in r['offgrid'])}")
            if a.plan:
                for cc, new, line in moves(r):
                    print(f"          plan  ^{cc} -> ^{new}   (line {line})")
        print()

    print("=" * 66)
    print(f" d1-d8 orbits already column-aligned: {tot_ok}/{tot_orb}")
    print(f" total ^NN renumbers for full alignment: {tot_moves}")
    print("=" * 66)
    return 0


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