#!/usr/bin/env python3
"""migrate-columns — move each orbit's effect knobs into that orbit's column.

The other half of #46. See tools/surface-columns.py for the survey that prices
the work and explains the grid; this tool performs it.

THE TARGET GRID (settled with PLN 2026-07-29)

      col        1       2       3       4       5       6       7       8
  A 13-20    d9 lvl  d10 lvl d11 lvl d12 lvl d9 fx   d10 fx  d11 fx  d12 fx
  B 29-36    d1 fx   d2 fx   d3 fx   d4 fx   d5 fx   d6 fx   d7 fx   d8 fx
  C 49-56    gF1     gF2     gF3     d4 fx2  d5 fx2  d6 fx2  d7 fx2  d8 fx2
  D 77-84    d1 lvl  d2 lvl  d3 lvl  d4 lvl  d5 lvl  d6 lvl  d7 lvl  d8 lvl

So each of d1-d8 gets its FADER for level and its B knob for one effect;
d4-d8 also get their C knob for a second. d1-d3 do NOT, because C1/C2/C3 are
the three family DJ filters (gF1 drums, gF2 bass, gF3 leads) — measured to be
applied per-FAMILY across the whole corpus, and deliberately left alone.

WHAT THIS TOOL WILL NOT TOUCH

  * faders 77-84 and knobs 13-16 — MIDI-learned to Ardour track gains. Writing
    or renumbering into that range is the CC77-goes-to-silence footgun.
  * the 8 helper CCs (gF1-3, gMask, gMute1-3, gPanic) — BootTidal owns them.
  * buttons (41-44, 57-60, 73-76, 89-92) — gates and mutes are a separate axis
    with their own collisions against gMask/gMute, and retraining a gate is more
    disruptive than retraining a knob. Phase 2, after the gig.

WHY THE PERMUTATION MUST BE APPLIED SIMULTANEOUSLY

Computing the moves one at a time makes the corpus look like a minefield: 34
"destination already taken" clashes. They are phantom. The corpus is uniformly
off by one column, so each orbit's knob wants the slot of the orbit below it,
which is occupied *only until that one also moves*. Build the whole map, then
rewrite in a single pass, and the clashes vanish. Applying moves sequentially
would corrupt the file.

WHY THE REWRITE IS SCOPED PER ORBIT

Two orbits can legitimately share a CC today (desire.tidal had ^19 driving both
d7 and d9) and they migrate to DIFFERENT destinations. A file-wide
search-and-replace would send both to the same place and silently merge two
gestures into one. So the substitution walks orbit by orbit, applying only that
orbit's own map to that orbit's own lines.

OVERFLOW -> gSel

An orbit with three or more knobs has more effects than the grid has slots.
Those are NOT renumbered into a neighbour's column; they are left in place with
a FIXME(#54) pointing at gSel, the clamped knob-selector that folds a multi-knob
stack onto one control. PLN: "if we have two effects, we consolidate via gSel
indeed. mark FIXMEs in code in there".

Usage:
    tools/migrate-columns.py --plan  TRACK [...]    # show, change nothing
    tools/migrate-columns.py --apply TRACK [...]    # rewrite in place
    tools/migrate-columns.py --plan                 # whole OPAL setlist

Always --plan first. Always pvlint + silent-eval after --apply.
"""
from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

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

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

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

KNOB_A = set(range(13, 21))
KNOB_B = set(range(29, 37))
KNOB_C = set(range(49, 57))
KNOBS = KNOB_A | KNOB_B | KNOB_C
# Hands off, for the reasons in the docstring.
ARDOUR = set(range(77, 85)) | {13, 14, 15, 16}
BUTTONS = set(range(41, 45)) | set(range(57, 61)) | set(range(73, 77)) | set(range(89, 93))


def helper_ccs() -> set[int]:
    """CCs BootTidal's helpers own. Comment-stripped — see surface-columns."""
    boot = ROOT / "BootTidal.hs"
    if not boot.exists():
        return set()
    text = boot.read_text()
    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)]
    text = "\n".join(strip_comment(l) for l in text.splitlines())
    return {int(m) for m in CC_REF.findall(text)}


def plan_track(path: Path, helpers: set[int]) -> dict:
    """Per-orbit {src cc -> dest cc}, plus the overflow that needs gSel."""
    track = load(path)
    per_orbit: dict[int, dict[int, int]] = {}
    overflow: list[tuple[int, int, int]] = []      # (orbit, cc, line)
    for orb in track.orbits():
        if not 1 <= orb.number <= 8:
            continue
        # First-appearance order matters: the first effect written is the one
        # most likely to be the primary gesture, so it gets the easy B knob.
        seen: list[tuple[int, int]] = []
        for off, raw in enumerate(orb.lines):
            for m in CC_REF.finditer(strip_comment(raw)):
                cc = int(m.group(1))
                if cc in KNOBS and cc not in helpers and cc not in ARDOUR:
                    if cc not in [c for c, _ in seen]:
                        seen.append((cc, orb.start + off))
        m: dict[int, int] = {}
        for i, (cc, line) in enumerate(seen):
            if i == 0:
                m[cc] = 28 + orb.number                      # its B knob
            elif i == 1 and orb.number >= 4:
                m[cc] = 48 + orb.number                      # its C knob
            else:
                overflow.append((orb.number, cc, line))      # -> gSel
        # Guard: within one orbit two sources must never share a destination.
        if len(set(m.values())) != len(m):
            raise SystemExit(f"migrate-columns: internal collision in "
                             f"{path.name} d{orb.number}: {m}")
        per_orbit[orb.number] = m
    return dict(track=track, per_orbit=per_orbit, overflow=overflow)


def rewrite(path: Path, res: dict) -> tuple[int, list[str]]:
    """Apply the plan. Returns (substitutions made, notes)."""
    lines = path.read_text().splitlines(keepends=True)
    # line number (1-indexed) -> that orbit's map
    line_map: dict[int, dict[int, int]] = {}
    for orb in res["track"].orbits():
        m = res["per_orbit"].get(orb.number)
        if not m:
            continue
        for off in range(len(orb.lines)):
            line_map[orb.start + off] = m

    n = 0
    for idx in range(len(lines)):
        m = line_map.get(idx + 1)
        if not m:
            continue
        code_end = len(lines[idx])
        # Only rewrite live code, never a comment: a commented-out ^NN is an
        # ALTERNATIVE PLN may re-enable, and renumbering it would silently
        # rewire that alternative to a different orbit's knob.
        c = lines[idx].find("--")
        if c >= 0:
            code_end = c

        def sub(mo, _m=m):
            nonlocal n
            cc = int(mo.group(1))
            if mo.start() >= code_end or cc not in _m or _m[cc] == cc:
                return mo.group(0)
            n += 1
            return f'"^{_m[cc]}"'

        lines[idx] = CC_REF.sub(sub, lines[idx])

    notes = []
    # FIXMEs for overflow, inserted bottom-up so earlier line numbers hold.
    for orbit, cc, line in sorted(res["overflow"], reverse=True):
        indent = re.match(r"\s*", lines[line - 1]).group(0)
        lines.insert(line - 1,
                     f"{indent}-- FIXME(#54) d{orbit} 3rd+ effect: the grid gives d{orbit} "
                     f"B{orbit}"
                     + (f" + C{orbit}" if orbit >= 4 else " only (C is a gF filter)")
                     + f". Fold ^{cc} in via gSel rather than borrowing a column.\n")
        notes.append(f"d{orbit} ^{cc} -> gSel FIXME at line {line}")
    path.write_text("".join(lines))
    return n, notes


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


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("tracks", nargs="*")
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--plan", action="store_true")
    g.add_argument("--apply", action="store_true")
    a = ap.parse_args()

    helpers = helper_ccs()
    total_moves = total_over = 0
    for p in resolve(a.tracks):
        res = plan_track(p, helpers)
        moves = [(s, d, o) for o, m in res["per_orbit"].items()
                 for s, d in m.items() if s != d]
        keeps = sum(1 for m in res["per_orbit"].values()
                    for s, d in m.items() if s == d)
        print(f"== {p.name}   {len(moves)} move  {keeps} keep  "
              f"{len(res['overflow'])} overflow")
        for s, d, o in sorted(moves, key=lambda x: x[2]):
            row = "A" if s in KNOB_A else "B" if s in KNOB_B else "C"
            drow = "B" if d in KNOB_B else "C"
            print(f"     d{o:<2} ^{s:<3} {row}{(s-12) if s in KNOB_A else (s-28) if s in KNOB_B else s-48}"
                  f"  ->  ^{d:<3} {drow}{o}")
        for o, cc, line in res["overflow"]:
            print(f"     d{o:<2} ^{cc:<3} line {line}  -> gSel FIXME (no slot)")
        total_moves += len(moves); total_over += len(res["overflow"])
        if a.apply:
            n, notes = rewrite(p, res)
            print(f"     APPLIED: {n} substitution(s), {len(notes)} FIXME(s)")
    print(f"\n  {total_moves} move(s), {total_over} overflow -> gSel")
    if a.apply:
        print("  NOW RUN: cd tools && python3 -m pvlint ...   then  "
              "tools/silent-eval.py --seeded ...")
    return 0


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