#!/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+)"')

# All DERIVED from the single authored grid (#97) — this file used to hardcode the
# CC ranges AND the destination arithmetic (`28 + orbit`, `48 + orbit`), which is
# exactly the duplication that let the HUD's copy go stale after the remap.
import lcxl_grid  # noqa: E402
import button_roles  # noqa: E402
import setlist  # noqa: E402  — the SET is backlog.md, never the generated .txt

KNOB_A = set(lcxl_grid.ROW_CCS["A"])
KNOB_B = set(lcxl_grid.ROW_CCS["B"])
KNOB_C = set(lcxl_grid.ROW_CCS["C"])
KNOBS = lcxl_grid.KNOB_CCS
ARDOUR = lcxl_grid.ARDOUR_CCS      # hands off, for the reasons in the docstring
BUTTONS = lcxl_grid.BUTTON_CCS


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)}


_ROW_NAME = {"E": "BT", "F": "BL"}      # this tool prints buttons as BT/BL


def label(cc: int) -> str:
    """44 -> 'BT4'. This tool's row vocabulary, from the authored grid."""
    cell = lcxl_grid.CC_TO_CELL.get(int(cc))
    if not cell:
        return f"cc{cc}"
    row, col = cell
    return f"{_ROW_NAME.get(row, row)}{col}"


def knob_slots(orbit: int) -> list[int]:
    """The knob destinations an orbit owns, most reachable first.

    B<col> then C<col> — but C1/C2/C3 are gF1/gF2/gF3, the per-FAMILY DJ filters,
    so d1..d3 own exactly one knob. Derived, not arithmetic: the old
    `28 + orbit` / `48 + orbit` encoded that rule a second time.
    """
    return lcxl_grid.slots(orbit, "knob")


def button_slots(orbit: int) -> list[int]:
    """The button destinations an orbit owns, most reachable first.

    BT is per-orbit across all 8 columns. BL columns 1-3 are gMute1/2/3, the
    per-family mutes, so d1..d3 own exactly ONE button — the structural reason a
    two-button gesture on d1 cannot survive column alignment.
    """
    return lcxl_grid.slots(orbit, "button")


def _seen_ccs(blocks: list, pool: set[int], helpers: set[int]) -> list[tuple[int, int]]:
    """CCs of one kind an orbit drives, in first-appearance order.

    Takes ALL BLOCKS of that orbit, not one. A track routinely declares the same
    orbit several times as section alternates — perfect.tidal has six `d1 $ …`
    blocks. Planning per block and keying by orbit number silently dropped every
    block but the last, which is why perfect.tidal kept ^42 on d1 while d2 also
    owned ^42: d1's move was computed and then overwritten. An orbit's control
    set is the UNION across its blocks.

    First-appearance order matters: the first control written is the one most
    likely to be the primary gesture, so it gets the most reachable slot.
    """
    seen: list[tuple[int, int]] = []
    for orb in blocks:
        for off, raw in enumerate(orb.lines):
            for m in CC_REF.finditer(strip_comment(raw)):
                cc = int(m.group(1))
                if cc in pool 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))
    return seen


def _button_roles(blocks: list) -> dict[int, str]:
    """cc -> "gate" | "gate2", read from what the control DOES (button_roles.py).

    THE BUG THIS EXISTS TO PREVENT (#94, found by ear the same day it shipped):
    slots used to be handed out in first-appearance order. Tracks conventionally
    write the gesture line above the gate line —

        $ midiOn  "^92" (ply "1 <2!3 4>")     -- momentary flourish
        $ midiOff "^60" (mask "t(4,8,1)")     -- latched gate

    — so positional assignment gave the gesture the LATCHING cell and the gate the
    momentary one, inverting 32 pairs across 13 files. PLN heard it before any
    check did: *"drums in revolution seem way too fast ... yea def an inversion"*.
    Encounter order is a fine tiebreak between two gestures; it is not a role.
    """
    roles: dict[int, str] = {}
    for orb in blocks:
        for _i, cc, _c, _e, role in button_roles.sites(orb.lines):
            if role is not None:
                roles.setdefault(cc, role)
    return roles


STEP = r"(\$|#|d\d+\b|let\b|--\s*(FIXME|TODO))"


def segment(lines: list[str], i: int, last: int, first: int = 1) -> list[int]:
    """The whole `$`-chain segment starting at 1-indexed line i.

    Commenting out a single LINE is wrong, and it produced a parse error the
    first time: a Tidal chain step routinely spans several lines —

        $ midiOn ("^53" - "^18") (        <- comment only this
          slice 4 "<0 1 0 1 2 2 [2 2] 3>"   and the body stays live
          )                                 and this ) is orphaned

    In this codebase a chain step starts with `$` or `#` (or `d<N>` for a new
    orbit), so the segment runs from i up to just before the next such line.

    BUT THE BOUNDARY IS PAREN-AWARE, and that is the whole subtlety (fixed
    2026-08-23, after this function refused 10 of 169 files in the corpus-wide
    #94 migration). A `$` is only a new chain step at depth 0. Inside an open
    paren it is a CONTINUATION of the current body:

        $ midiOn ("^44" + "^76") (      <- opens, depth 1
          const $ loopAt "<1!6 2 2>"
          $ someCyclesBy 0.2 (scramble 4)   <- `$` at depth 1: still the body
          $ slice 8 (run 8)
          $ "breaks165"
          )                                 <- depth back to 0

    Breaking on the first `$` truncated that segment after two lines, leaving
    depth 1, and the balance check then refused the whole file. The refusal was
    correct — the segment really was unbalanced — but the segment was wrong. A
    depth-blind boundary cannot describe a multi-line body, so every track that
    writes one (10 of 169: vendredi_saint, phunk, michael, feu, grace, clameur,
    8box, ataright, ccc0, computer_riddub) was unmigratable.

    A BLANK LINE still breaks unconditionally, at any depth: Pulsar chunks a
    buffer on blank lines, so a blank line inside an open paren is not a
    multi-line body, it is a source file that will not compile as written.
    Depth != 0 at the end is still a refusal, not a guess.
    """

    def delta(line: str) -> int:
        code = strip_comment(line)
        code = re.sub(r'"[^"]*"', '""', code)      # parens inside strings don't count
        return (code.count("(") - code.count(")")
                + code.count("[") - code.count("]"))

    def forward(start: int) -> tuple[list[int], int]:
        """Paren-aware scan from a chain-step start; -> (lines, closing depth)."""
        out = [start]
        depth = delta(lines[start - 1])
        j = start + 1
        while j <= last:
            t = lines[j - 1].strip()
            if not t:
                break                               # blank always ends the block
            if depth <= 0 and re.match(STEP, t):
                break                               # a new chain step, only at depth 0
            out.append(j)
            depth += delta(lines[j - 1])
            j += 1
        return out, depth

    # THE HIT LINE IS NOT NECESSARILY A CHAIN-STEP START. `^NN` is routinely
    # referenced from the MIDDLE of a multi-line body:
    #
    #     $ midiOn "^59" (              <- the step starts here
    #       someCyclesBy 0.2
    #       $ chunk 4 (hurry "^43")     <- but THIS is the line that mentions ^43
    #       )
    #
    # Scanning forward from the hit line swallows the body's closing `)` without
    # its opener and lands at depth -1, which is what made five files refuse
    # (ccc0, clameur, grace, feu, michael) after the depth-blind boundary was
    # fixed. Negative depth was the tell: too FEW openers means the anchor is
    # too late, not that the source is broken.
    #
    # So walk BACK to the nearest enclosing chain step whose own segment both
    # balances and actually contains the hit. That is the unit it is safe to
    # comment out. Refuse only when no such step exists.
    for start in range(i, first - 1, -1):
        if not re.match(STEP, lines[start - 1].strip()):
            continue
        out, depth = forward(start)
        if depth == 0 and i in out:
            return out
    raise SystemExit(
        f"migrate-columns: no balanced chain step encloses line {i}; refusing to "
        f"comment it out — this would produce broken Haskell.")


def plan_track(path: Path, helpers: set[int]) -> dict:
    """Per-orbit {src cc -> dest cc}, plus overflow and cross-orbit clashes."""
    track = load(path)
    blocks: dict[int, list] = {}
    for o in track.orbits():
        if 1 <= o.number <= 8:
            blocks.setdefault(o.number, []).append(o)

    # Pass 1: what each orbit WANTS, planned over ALL of that orbit's blocks.
    wants: dict[int, list[tuple[int, int, int]]] = {}   # orbit -> [(cc,line,dest)]
    overflow: list[tuple[int, int, int]] = []           # (orbit, cc, line)
    for number, orbs in blocks.items():
        pairs: list[tuple[int, int, int]] = []
        for pool, slots in ((KNOBS, knob_slots(number)),
                            (BUTTONS, button_slots(number))):
            sites = _seen_ccs(orbs, pool, helpers)
            # Buttons are claimed BY ROLE first (see _button_roles); whatever is
            # left falls back to encounter order over the remaining cells.
            free = list(slots)
            claimed: dict[int, int] = {}
            if pool is BUTTONS:
                home = lcxl_grid.orbit_home(number)
                roles = _button_roles(orbs)
                for cc, _line in sites:
                    want = home.get(roles.get(cc, ""))
                    if want is not None and want in free:
                        claimed[cc] = want
                        free.remove(want)
            for cc, line in sites:
                if cc in claimed:
                    pairs.append((cc, line, claimed[cc]))
                elif free:
                    pairs.append((cc, line, free.pop(0)))
                else:
                    overflow.append((number, cc, line))
        wants[number] = pairs

    # Pass 2: a destination is only free if NO overflow CC is squatting it. An
    # overflow CC does not move, so moving another orbit onto it would put two
    # orbits on one physical control — the failure this guard exists to prevent.
    # OVERFLOW IS COMMENTED OUT, NOT LEFT IN PLACE — so it does NOT squat.
    #
    # The first version left an over-budget control where it was. That looks
    # conservative and is actually a bug generator: once ^42 becomes d2's button,
    # any d1 line still referencing ^42 means pressing d2's gate also fires d1's
    # kick variant. Measured on the real setlist, that is exactly what happened:
    # perfect.tidal ended up with ^42 driving BOTH d1 and d2.
    #
    # It was also a cascade: d1's stuck ^42 blocked d2, which blocked d3, which
    # blocked d4, which blocked d5 — bombe_dj could only reach 4/8 aligned. PLN:
    # "we need finish the job. why half moved? cant you move all-but then comment
    # out with fixme?" Commenting the line out frees the control, so the rest of
    # the row aligns, and nothing is lost: the original is one line above under
    # two dashes, re-enableable the moment gSel (#54) can fold it onto one knob.
    #
    # A clashed control still stays put, so it IS sticky, and discovering a clash
    # changes the answer for orbits already processed. Hence the fixed point.
    base_sticky: set[int] = set()
    sticky = set(base_sticky)
    per_orbit: dict[int, dict[int, int]] = {}
    clashes: list[tuple[int, int, int, int, str]] = []
    for _round in range(len(wants) + 2):
        per_orbit, clashes = {}, []
        claimed: dict[int, int] = {}                     # dest -> orbit
        for orbit, pairs in wants.items():
            m: dict[int, int] = {}
            for cc, line, dest in pairs:
                if dest == cc:
                    claimed[dest] = orbit                # already home
                    continue
                if dest in sticky:
                    clashes.append((orbit, cc, line, dest,
                                    f"^{dest} is held by a control that cannot move"))
                elif dest in claimed and claimed[dest] != orbit:
                    clashes.append((orbit, cc, line, dest,
                                    f"^{dest} already claimed by d{claimed[dest]}"))
                else:
                    m[cc] = dest
                    claimed[dest] = orbit
            # 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{orbit}: {m}")
            per_orbit[orbit] = m
        grown = base_sticky | {cc for _o, cc, _l, _d, _w in clashes}
        if grown == sticky:
            break
        sticky = grown
    else:
        raise SystemExit(f"migrate-columns: {path.name} did not converge — "
                         f"report this, do not --apply")
    return dict(track=track, per_orbit=per_orbit,
                overflow=overflow, clashes=clashes)


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 = []
    # Overflow: COMMENT OUT every live line of that orbit which references the
    # over-budget control, and head the group with one FIXME. Every such line
    # must go, not just the ones mentioning the control alone — the control now
    # belongs to a DIFFERENT orbit, so any surviving reference is a cross-orbit
    # trigger (the perfect.tidal ^42-drives-d1-and-d2 bug).
    #
    # Clashes: annotate only. A clashed control keeps its own CC, so it is still
    # correct code; it is simply not where the grid wants it.
    edits: list[tuple[int, str, list[int], str]] = []   # (anchor, fixme, lines, note)
    # ALL blocks of each orbit, not just the last — see _seen_ccs.
    ranges: dict[int, list[tuple[int, int]]] = {}
    for o in res["track"].orbits():
        ranges.setdefault(o.number, []).append((o.start, o.start + len(o.lines) - 1))
    for orbit, cc, line in res["overflow"]:
        hits: set[int] = set()
        for start, end in ranges.get(orbit, [(line, line)]):
            for i in range(start, min(end, len(lines)) + 1):
                if (not lines[i - 1].lstrip().startswith("--")
                        and f'"^{cc}"' in strip_comment(lines[i - 1])):
                    # Expand to the whole chain segment, or the closing parens of
                    # a multi-line step are left dangling.
                    hits.update(segment(lines, i, end, start))
        hits = sorted(hits) if hits else [line]
        slots = (f"BT{orbit}" + (f" + BL{orbit}" if orbit >= 4
                                 else " only — BL1-3 are the family mutes")
                 if cc in BUTTONS
                 else f"B{orbit}" + (f" + C{orbit}" if orbit >= 4
                                     else " only (C1-3 are the family gF filters)"))
        tag = "#94" if cc in BUTTONS else "#54"

        # COMMENTING OUT A `$`-STEP CAN UNDER-APPLY THE STEP ABOVE IT, and that
        # is not a paren problem — it is an ARITY problem (found 2026-08-23 by
        # silent-eval: 4 of 169 files stopped compiling while the other 165 were
        # fine). In these chains a `$`-step is routinely the OPERAND of a
        # higher-order combinator on the line before it:
        #
        #     $ midiOff "^57" (mask "<[t f]!0 …>")   <- a FUNCTION, still hungry
        #     $ stack [ … ]                          <- ITS ARGUMENT
        #
        # Comment the `stack` and `midiOff` has nothing to transform: GHC says
        # "‘midiOff’ is applied to too few arguments" and the WHOLE do-block dies,
        # all twelve orbits, which is far worse than the cross-orbit trigger the
        # comment-out was protecting against. A `#`-step carries no such risk —
        # `#` takes a ControlPattern, so dropping one leaves a shorter but
        # well-typed chain.
        #
        # So: comment `#`-steps, ANNOTATE `$`-steps. Same principle the clash
        # branch below already applies ("a clashed control keeps its own CC, so
        # it is still correct code"). The FIXME says which one happened, because
        # an annotated line IS still firing and the reader has to know that.
        head = lines[hits[0] - 1].strip() if hits else ""
        commentable = head.startswith("#")

        if commentable:
            fixme = (f"-- FIXME({tag}) d{orbit} drives more controls than the grid gives it "
                     f"({slots}). ^{cc} now belongs to another orbit, so the line(s) below "
                     f"are COMMENTED OUT rather than left firing across columns. Nothing is "
                     f"lost — restore by removing the dashes once gSel folds this onto "
                     f"d{orbit}'s own control.")
            edits.append((hits[0], fixme, hits,
                          f"d{orbit} ^{cc} commented out ({len(hits)} line(s))"))
        else:
            fixme = (f"-- FIXME({tag}) d{orbit} drives more controls than the grid gives it "
                     f"({slots}). ^{cc} now belongs to another orbit, so this step STILL "
                     f"FIRES ACROSS COLUMNS — turning ^{cc} for its new owner also moves this. "
                     f"NOT commented out: this is a `$`-step and something above it is a "
                     f"partial application waiting for it, so removing it would stop the whole "
                     f"block compiling. Fold it onto d{orbit}'s own control with gSel.")
            edits.append((hits[0], fixme, [],
                          f"d{orbit} ^{cc} ANNOTATED, still live ($-step, {len(hits)} line(s))"))
    for orbit, cc, line, dest, reason in res.get("clashes", []):
        edits.append((line,
                      f"-- FIXME(#94) d{orbit}: ^{cc} should become ^{dest} ({label(dest)}) "
                      f"but {reason}. Left on ^{cc} — moving it would put two orbits on "
                      f"one physical control.",
                      [], f"d{orbit} ^{cc} CLASH -> ^{dest} at line {line}"))

    # Bottom-up so earlier line numbers stay valid as we insert.
    for anchor, fixme, hits, note in sorted(edits, key=lambda e: e[0], reverse=True):
        for i in sorted(hits, reverse=True):
            stripped = lines[i - 1].rstrip("\n")
            indent = stripped[: len(stripped) - len(stripped.lstrip())]
            nl = "\n" if lines[i - 1].endswith("\n") else ""
            lines[i - 1] = f"{indent}-- {stripped.lstrip()}{nl}"
        indent = re.match(r"\s*", lines[anchor - 1]).group(0)
        lines.insert(anchor - 1, f"{indent}{fixme}\n")
        notes.append(note)
    path.write_text("".join(lines))
    return n, notes


def resolve(names: list[str]) -> list[Path]:
    if not names:
        # setlist.py, NOT the generated .txt. On 2026-08-02 this tool read the
        # .txt while the .txt was 13 tracks and backlog.md was 15 — so the two
        # tracks PLN had just added were the only two the remap never touched,
        # and he found out by playing them: "wait bass was also on 89 not yet
        # 76 whyyy i thought we were systemic". Same drift as #120/#123, one
        # layer down. One parser per concept (#123): backlog.md is the set.
        return setlist.tracks()
    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")
    ap.add_argument("--force-partial", action="store_true",
                    help="apply even to a track with clashes (leaves it HALF aligned)")
    a = ap.parse_args()

    helpers = helper_ccs()
    total_moves = total_over = total_clash = 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  {len(res['clashes'])} clash")
        for s, d, o in sorted(moves, key=lambda x: x[2]):
            kind = "btn " if s in BUTTONS else "knob"
            print(f"     d{o:<2} {kind} ^{s:<3} {label(s):<4}  ->  ^{d:<3} {label(d)}")
        for o, cc, line in res["overflow"]:
            print(f"     d{o:<2} ^{cc:<3} {label(cc):<4} line {line}  -> FIXME (no slot)")
        for o, cc, line, dest, reason in res["clashes"]:
            print(f"     d{o:<2} ^{cc:<3} {label(cc):<4} line {line}  -> CLASH: {reason}")
        total_moves += len(moves); total_over += len(res["overflow"])
        total_clash += len(res["clashes"])
        if a.apply and res["clashes"] and not a.force_partial:
            # A track with clashes cannot be FULLY aligned, and applying only the
            # movable half is the worst outcome for muscle memory: the player
            # then has to remember which orbits moved and which did not. A
            # consistently-old track beats an inconsistently-new one. Skip it,
            # name it, and let the blocking decision be made deliberately.
            print(f"     SKIPPED — {len(res['clashes'])} clash(es); this track cannot be "
                  f"fully aligned. Resolve the blocking gesture first, or "
                  f"--force-partial to half-migrate it on purpose.")
            continue
        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())
