#!/usr/bin/env python3
"""set-coherence — which controls under PLN's hands do NOTHING on this track?

WHY THIS EXISTS
---------------
PLN, 2026-07-31, having walked the OPAL set by eye:

  *"when i went across set i saw some unmapped effects/button, a 'set coherence
    mapping review' and quick wins suggestions (bass octersub, lead slice, etc etc)
    could be good ways for you to fill-in suggest"*

The surface is a fixed grid: every orbit owns a known set of cells (an effect knob,
maybe a second one, a gate button, maybe a second one). A track that declares `d5`
but never references `^33` leaves d5's effect knob **inert for the length of that
track** — a knob sitting under his hand doing nothing. That is invisible from the
code, because absence has no line number.

This tool reports the two OPPOSITE errors together, which is the point:

  IDLE     the orbit owns a slot and the track never uses it   -> an unused affordance
  STARVED  the track drives more controls than the orbit owns  -> #54/#98 territory

They are the same coherence question from either side, and fixing one can create the
other. pvlint's PV008 and the FIXME(#54)/FIXME(#94) markers already cover STARVED;
nothing covered IDLE until now.

WHAT IT DOES NOT DO
-------------------
Write to a `.tidal`. Every suggestion here changes how a track SOUNDS, and PLN is
rehearsing on these files. Output is a report to skim and cherry-pick.

It also never suggests binding CC 77-84 or 13-16 (Ardour owns those track gains —
seeding one is the CC77-goes-to-silence footgun) or 93 (gPanic), or the family cells
49/50/51 + 73/74/75, which belong to gF/gM and not to any single orbit.

USAGE
    python3 tools/set-coherence.py                    # the OPAL set, summary
    python3 tools/set-coherence.py --detail           # per-orbit breakdown
    python3 tools/set-coherence.py --suggest          # + quick-win candidates
    python3 tools/set-coherence.py --md report.md     # write the full report
    python3 tools/set-coherence.py <track.tidal> ...  # specific tracks
"""

from __future__ import annotations

import argparse
import collections
import pathlib
import re
import sys

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

import lcxl_grid as grid  # noqa: E402
from pvlint.core import Track, strip_comment  # noqa: E402

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

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

# Slots an orbit can own that are actually PLAYABLE by hand during a track.
# `level` is excluded on purpose: rows A1-A4 and D are MIDI-learned to Ardour
# (grid.ARDOUR_CCS), so a track referencing one is a conflict, not a gap.
PLAYABLE = ("fx", "fx2", "gate", "gate2")

# Role vocabulary. Grounded in what PLN already plays rather than invented — the
# corpus check below reports how often he uses each, so a suggestion can say
# "you do this in N other tracks" instead of "you could try".
ROLE_OF = {1: "kick", 2: "perc", 3: "perc", 8: "breaks", 4: "bass"}

# Suggestions are GROUNDED, not invented. Measured across the 13 setlist tracks,
# what PLN already binds to a per-orbit effect KNOB when he binds anything:
#     perc  legato(4)
#     bass  lpf(3), gain(1), bandf(1)
#     lead  width(2), modIndex(1), filterRange(1), cutoff(1), resonance(1)
#     kick  (nothing — B1 is unused in 9 of 13 tracks)
# So the first suggestion per role echoes his own idiom; the rest extend it, and
# the two he named himself ("bass octersub, lead slice") are marked. See memory
# feedback_self_reference_rhythm_study: extract the grammar from his own tracks
# rather than importing generic Tidal advice.
SUGGEST = {
    "kick":   [("lpf / hpf",        "knob. B1 is idle in 9/13 — the kick has NO per-orbit knob anywhere in the set"),
               ("octersub",         "knob. sub weight under the kick, same family as the bass idea"),
               ("mask / struct",    "button. drop or stutter the 4-on-the-floor")],
    "perc":   [("legato",           "knob. YOUR idiom — the only thing you bind to a perc knob (4x)"),
               ("pan / width",      "knob. widen the hats without touching level"),
               ("ply",              "button. double-time on a push")],
    "bass":   [("octersub",         "knob. YOU NAMED THIS. note you currently bind lpf(3) here, never octersub"),
               ("lpf",              "knob. your existing bass idiom, if the slot is free on a track that lacks it"),
               ("crush",            "knob. grit on the low end")],
    "breaks": [("slice / chop",     "button. re-cut the break live"),
               ("loopAt + rev",     "button. stretch or reverse"),
               ("legato",           "knob. shorten the break's tails")],
    "lead":   [("slice",            "button. YOU NAMED THIS. re-order the melodic phrase"),
               ("cutoff / resonance", "knob. YOUR idiom — what you already bind to lead knobs"),
               ("width / modIndex", "knob. also yours; width appears 2x")],
    "fx":     [("room / sz",        "knob. open the space"),
               ("delay + delayfb",  "button. throw a repeat"),
               ("gain swell",       "knob. A-row slots are ~90% idle — see #98 on pooling these")],
}


def role(orbit: int) -> str:
    if orbit in ROLE_OF:
        return ROLE_OF[orbit]
    return "lead" if orbit <= 8 else "fx"


def setlist_tracks() -> list[pathlib.Path]:
    if not SETLIST.exists():
        raise SystemExit(f"set-coherence: no setlist at {SETLIST}")
    return [ROOT / ln.split("#")[0].strip()
            for ln in SETLIST.read_text().splitlines() if ln.split("#")[0].strip()]


def analyse(path: pathlib.Path) -> dict:
    """Per orbit: which owned slots are used, which are idle, what is borrowed."""
    text = path.read_text()
    out: dict[int, dict] = {}
    for orb in Track(path=str(path), text=text).orbits():
        code = "\n".join(strip_comment(ln) for ln in orb.lines)
        used = {int(m.group(1)) for m in CC_REF.finditer(code)}
        home = grid.orbit_home(orb.number)
        owned = {k: home[k] for k in PLAYABLE if k in home}
        idle = {k: cc for k, cc in owned.items() if cc not in used}
        # Referenced CCs that are neither this orbit's own nor a family/Ardour cell.
        foreign = sorted(used - set(owned.values())
                         - grid.FAMILY_CCS - grid.ARDOUR_CCS)
        prev = out.get(orb.number)
        if prev:                       # re-declared orbit: merge, keep the union
            prev["used"] |= used
            prev["idle"] = {k: cc for k, cc in owned.items()
                            if cc not in prev["used"]}
            prev["foreign"] = sorted(set(prev["foreign"]) | set(foreign))
            continue
        out[orb.number] = {"used": used, "owned": owned,
                           "idle": idle, "foreign": foreign}
    return out


def main() -> int:
    ap = argparse.ArgumentParser(prog="set-coherence")
    ap.add_argument("--detail", action="store_true")
    ap.add_argument("--suggest", action="store_true")
    ap.add_argument("--md", type=pathlib.Path)
    ap.add_argument("tracks", nargs="*", type=pathlib.Path)
    a = ap.parse_args()

    tracks = ([p if p.is_absolute() else ROOT / p for p in a.tracks]
              or setlist_tracks())

    L: list[str] = []
    def say(s: str = "") -> None:
        L.append(s)
        print(s)

    say("set-coherence — controls that sit idle under your hand, per track")
    say()
    say(f"{'track':32} {'orbits':>6} {'idle':>5} {'borrowed':>9}  idle slots")
    say("-" * 92)

    tot_idle = tot_orb = 0
    idle_by_role: collections.Counter[str] = collections.Counter()
    per_track: dict[str, dict] = {}
    for p in tracks:
        if not p.exists():
            say(f"  {p}: MISSING")
            continue
        info = analyse(p)
        per_track[p.stem] = info
        n_idle = sum(len(v["idle"]) for v in info.values())
        n_borrow = sum(len(v["foreign"]) for v in info.values())
        tot_idle += n_idle
        tot_orb += len(info)
        bits = []
        for n in sorted(info):
            for k, cc in sorted(info[n]["idle"].items()):
                bits.append(f"d{n}.{k}")
                idle_by_role[role(n)] += 1
        say(f"{p.stem:32} {len(info):6} {n_idle:5} {n_borrow:9}  {' '.join(bits)}")

    say()
    say(f"TOTAL: {tot_orb} declared orbits across {len(per_track)} tracks, "
        f"{tot_idle} idle slots")
    say()
    say("idle slots by role: " + ", ".join(f"{r}={c}" for r, c
                                           in idle_by_role.most_common()))

    if a.detail or a.suggest:
        for stem, info in per_track.items():
            rows = [(n, v) for n, v in sorted(info.items()) if v["idle"]]
            if not rows:
                continue
            say()
            say(f"### {stem}")
            for n, v in rows:
                r = role(n)
                owned = ", ".join(f"{k}=^{cc}" for k, cc in sorted(v["owned"].items()))
                idle = ", ".join(f"{k}=^{cc}" for k, cc in sorted(v["idle"].items()))
                say(f"  d{n} ({r}) owns [{owned}] — IDLE: {idle}")
                if v["foreign"]:
                    say(f"       also drives non-own CCs: "
                        f"{', '.join('^'+str(c) for c in v['foreign'])}")
                if a.suggest:
                    for name, why in SUGGEST[r]:
                        say(f"       + {name:20} {why}")

    if a.md:
        a.md.write_text("\n".join(L) + "\n")
        print(f"\nwrote {a.md}")
    return 0


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