#!/usr/bin/env python3
"""orphan-orbits — which orbits keep playing after you load the next track (#68).

THE MECHANISM (confirmed by ear, then by code — not a hypothesis)
PLN, 2026-07-28: loaded vague_de_crime, then played bombe_dj, and "heard diam's
voice, now still hearing the synth from crimewave... i hear it regardless of gains,
but filters work on them".

`dN $ ...` replaces orbit N and says NOTHING about the others. So every orbit the
outgoing track declares and the incoming one does not just keeps running, forever,
underneath the new track. vague_de_crime declares 1-8 and 10; bombe_dj declares
1-5, 7, 8, 9; so d6 and d10 survive — and d6 of vague_de_crime is literally the
crimewave synth he heard.

Both of his symptoms fall out of this, which is what confirms it:
  * "regardless of gains" — the new track's gain lines address only ITS OWN orbits,
    so nothing in it can turn the ghost down.
  * "but filters work on them" — gF3 is the GLOBAL CC51 DJ filter, applied inside
    the ghost's own still-running pattern, so it still filters. (An orbit that
    ignores the faders but answers the DJ filter is diagnostic of exactly this.)

This is not a rig fault. It will bite at EVERY transition where the outgoing track
declares an orbit the incoming one does not.

WHAT THIS TOOL DOES
    orphan-orbits.py                 walk the set in order; per transition, name the
                                     orbits that will ghost, and what they sound like
    orphan-orbits.py --matrix        the full A->B matrix, so #12 can ORDER the set to
                                     prefer low-orphan adjacencies
    orphan-orbits.py --pair A B      one transition
    orphan-orbits.py --silence TRACK the paste-ready preamble that pre-silences every
                                     orbit TRACK does not use
    orphan-orbits.py --json

It reads only. No .tidal file is edited, and nothing is sent to the rig.

WHY IT DOES NOT JUST FIX IT FOR YOU
The obvious automation — have tidal-remote eval a generated `dN $ silence` block —
does not work live: the remote channel's `eval-file` OPENS AND ACTIVATES the file in
Pulsar, so it would steal the editor away from the track being played, mid-transition.
Until there is a focus-free `code` command in the plugin, the fix is one of:
  1. `hush` between tracks. Zero code, safe under pressure, costs the tail.
  2. Paste `--silence TRACK`'s block at the top of the track (a .tidal edit, PLN's
     call). `dN` IS `xfade N`, so `d6 $ silence` should FADE the ghost rather than
     cut it — verify that by ear before assuming a cut.
  3. The musical answer: a real transition helper (#13).

VERIFY BY MEASUREMENT, not by reading this output:
    boot track A, boot track B, then probe the orphans it names —
      tools/probe-chain.py -d <orphans> -s 12 -t 1.5
    signal on an orphan = the ghost is real; -inf on all = fixed.
    (probe-chain makes ~18 PipeWire nodes per run and each costs Ardour an xrun.
     Do NOT read those as a rig fault — feedback_observer_perturbs.)
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from pvlint.core import Track  # noqa: E402  (the orbit parser already lives here)

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

# The rig has 14 orbits (reference_sc_boot). A silence preamble should cover the ones
# a track could plausibly leave behind, i.e. every orbit any track in the set uses.
MAX_ORBIT = 14

# What a ghost will SOUND like matters more than its number when you are on stage, so
# the report quotes the outgoing track's own line for each orphan.
SOUND_RE = re.compile(r'"([a-zA-Z_][\w]*)(?::\d+)?[^"]*"')


def load_setlist(path: Path = SETLIST) -> list[tuple[Path, str]]:
    """[(path, label)] in play order. Fails loudly on a missing file — a setlist
    that silently drops a track would silently drop a transition."""
    out: list[tuple[Path, str]] = []
    if not path.exists():
        raise SystemExit(f"orphan-orbits: no setlist at {path}")
    for raw in path.read_text().splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        rel, _, comment = line.partition("#")
        p = REPO / rel.strip()
        if not p.exists():
            raise SystemExit(f"orphan-orbits: setlist names a missing track: {rel.strip()}")
        out.append((p, comment.strip() or p.stem))
    return out


def resolve(raw: str) -> Path:
    p = Path(raw)
    if p.exists():
        return p
    if (REPO / raw).exists():
        return REPO / raw
    name = raw if raw.endswith(".tidal") else raw + ".tidal"
    hits = sorted((REPO / "live").rglob(f"*{name}")) + \
        sorted((REPO / "copycat").rglob(f"*{name}"))
    if len(hits) == 1:
        return hits[0]
    raise SystemExit(f"orphan-orbits: cannot resolve {raw!r} ({len(hits)} matches)")


def orbits_of(path: Path) -> set[int]:
    """The orbits a track DECLARES, i.e. the ones it will overwrite on load.

    Commented-out declarations do not count, and that is right in both directions:
    a `-- d6 $ ...` never plays (so it leaves no ghost), and it never overwrites
    (so an inherited d6 survives it).
    """
    return Track(path=str(path), text=path.read_text()).declared_orbits()


def sounds_of(path: Path) -> dict[int, str]:
    """orbit -> a short description of what it plays, for the report."""
    out: dict[int, str] = {}
    for orb in Track(path=str(path), text=path.read_text()).orbits():
        names = SOUND_RE.findall(orb.code())
        # Keep only plausible sample/synth NAMES. The regex also catches short quoted
        # mini-notation and note tokens ("f", "q", "t", "a e"), which produced hints
        # like `d7(FMRhodes1, q)` — noise in the one place the report has to be
        # readable at a glance on stage.
        picked = [n for n in names if len(n) >= 3 and not n.isdigit()][:2]
        out.setdefault(orb.number, ", ".join(picked) or "?")
    return out


def orphans(a: Path, b: Path) -> set[int]:
    """Orbits still playing from A after B is loaded."""
    return orbits_of(a) - orbits_of(b)


def silence_block(path: Path, max_orbit: int = MAX_ORBIT,
                  used_by_set: set[int] | None = None) -> list[str]:
    """`dN $ silence` for every orbit this track does NOT declare.

    Scoped to the orbits the SET actually uses when that is known, rather than all
    14: silencing an orbit nobody ever touches is harmless but it is noise in the
    file, and a shorter block is one PLN might actually paste. Same reasoning as
    measuring the set rather than the corpus.

    `silence`, never `# gain 0`: gain 0 leaves the pattern scheduled and still
    consuming a voice, and a later global gain change can bring it back.
    """
    mine = orbits_of(path)
    pool = sorted(used_by_set) if used_by_set else list(range(1, max_orbit + 1))
    return [f"d{n} $ silence" for n in pool if n not in mine]


def cmd_chain(setlist: list[tuple[Path, str]], as_json: bool = False) -> int:
    rows = []
    for (pa, la), (pb, lb) in zip(setlist, setlist[1:]):
        orph = sorted(orphans(pa, pb))
        rows.append({"from": str(pa.relative_to(REPO)), "from_label": la,
                     "to": str(pb.relative_to(REPO)), "to_label": lb,
                     "orphans": orph})
    if as_json:
        print(json.dumps(rows, indent=2))
        return 0
    total = sum(len(r["orphans"]) for r in rows)
    dirty = [r for r in rows if r["orphans"]]
    print("=" * 78)
    print(" ORPHAN ORBITS ALONG THE SET  (#68)")
    print("=" * 78)
    print(f" {len(rows)} transitions, {len(dirty)} of them leave ghosts, "
          f"{total} orphaned orbits in total\n")
    for r in rows:
        arrow = "→"
        if not r["orphans"]:
            print(f"  clean   {r['from_label']:<26} {arrow} {r['to_label']}")
            continue
        snd = sounds_of(REPO / r["from"])
        names = ", ".join(f"d{n}({snd.get(n, '?')})" for n in r["orphans"])
        print(f"  GHOST   {r['from_label']:<26} {arrow} {r['to_label']}")
        print(f"          leaves {names}")
    if dirty:
        print("\n WORST OFFENDERS (tracks that leave the most behind them):")
        by_from: dict[str, int] = {}
        for r in dirty:
            by_from[r["from_label"]] = by_from.get(r["from_label"], 0) + len(r["orphans"])
        for label, n in sorted(by_from.items(), key=lambda kv: -kv[1])[:5]:
            print(f"   {n:>2} orbit(s)  after {label}")
        print("\n Under pressure: `hush` between these two. To fix it properly, see")
        print(" --silence <the INCOMING track> and paste the block at its top.")
    print("=" * 78)
    return 0


def cmd_matrix(setlist: list[tuple[Path, str]]) -> int:
    """The full A->B matrix. #12 orders the set; this tells it which adjacencies
    are cheap, so the ordering decision can be made with the ghosts in view."""
    names = [lb.split("[")[0].strip()[:11] for _, lb in setlist]
    orbs = [orbits_of(p) for p, _ in setlist]
    print("\n  rows = outgoing, cols = incoming. cell = how many orbits ghost.")
    print("  '.' = clean transition.\n")
    print("  " + " " * 13 + "".join(f"{i+1:>4}" for i in range(len(setlist))))
    for i, nm in enumerate(names):
        cells = []
        for j in range(len(setlist)):
            if i == j:
                cells.append("   -")
            else:
                n = len(orbs[i] - orbs[j])
                cells.append("   ." if n == 0 else f"{n:>4}")
        print(f"  {i+1:>2} {nm:<11}" + "".join(cells))
    print("\n  declared orbits per track:")
    for i, (p, lb) in enumerate(setlist):
        print(f"   {i+1:>2} {lb.split('[')[0].strip():<24} "
              f"{sorted(orbs[i])}")
    # The two big emitters are worth naming outright.
    worst = sorted(range(len(setlist)), key=lambda i: -len(orbs[i]))[:2]
    print(f"\n  Widest emitters: " + ", ".join(
        f"{names[i]} ({len(orbs[i])} orbits)" for i in worst) +
        " — almost anything after them inherits ghosts.")

    # A track that declares a SUPERSET of everything else has an all-clean column:
    # it overwrites every orbit, so it can follow anything without inheriting a
    # ghost. That makes it a free RESET POINT, which is a real ordering lever for
    # #12 — put one after the messiest stretch and the ghosts stop propagating.
    union = set().union(*orbs) if orbs else set()
    resets = [i for i in range(len(setlist))
              if all(len(orbs[j] - orbs[i]) == 0 for j in range(len(setlist)) if j != i)]
    if resets:
        print("  RESET POINTS (clean to enter from anywhere — they declare every orbit")
        print("  the rest of the set uses, so nothing survives into them):")
        for i in resets:
            print(f"    {i+1:>2} {names[i]}")
    else:
        print(f"  No track covers the set's full orbit span {sorted(union)}, so every")
        print("  ordering leaves some ghosts somewhere — the block from --silence is the fix.")
    return 0


def cmd_pair(a: str, b: str) -> int:
    pa, pb = resolve(a), resolve(b)
    orph = sorted(orphans(pa, pb))
    print(f"  {pa.name} → {pb.name}")
    print(f"  outgoing declares {sorted(orbits_of(pa))}")
    print(f"  incoming declares {sorted(orbits_of(pb))}")
    if not orph:
        print("  CLEAN — the incoming track overwrites everything the outgoing one used.")
        return 0
    snd = sounds_of(pa)
    print(f"  ORPHANS {orph}:")
    for n in orph:
        print(f"    d{n:<3} {snd.get(n, '?')}")
    print(f"\n  probe it:  tools/probe-chain.py -d {','.join(map(str, orph))} -s 12 -t 1.5")
    return 1


def cmd_silence(track: str, scope_to_set: bool = True) -> int:
    p = resolve(track)
    used = None
    if scope_to_set and SETLIST.exists():
        used = set()
        for sp, _ in load_setlist():
            used |= orbits_of(sp)
    block = silence_block(p, used_by_set=used)
    print(f"-- orphan guard for {p.name} (generated by tools/orphan-orbits.py, #68)")
    print(f"-- this track declares {sorted(orbits_of(p))}; the lines below silence every")
    print("-- other orbit the set uses, so nothing from the PREVIOUS track survives.")
    print("-- Paste as its own block at the TOP of the file. `dN` is `xfade N`, so this")
    print("-- should fade the ghost out rather than cut it — confirm by ear.")
    if not block:
        print("-- (nothing to silence: this track already declares every orbit in the set)")
        return 0
    for line in block:
        print(line)
    return 0


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        prog="orphan-orbits.py", description=__doc__.split("\n")[0],
        formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--setlist", type=Path, default=SETLIST)
    ap.add_argument("--matrix", action="store_true", help="full A->B orphan matrix")
    ap.add_argument("--pair", nargs=2, metavar=("FROM", "TO"))
    ap.add_argument("--silence", metavar="TRACK")
    ap.add_argument("--all-orbits", action="store_true",
                    help="with --silence: cover all 14 orbits, not just the set's")
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args(argv)

    if a.pair:
        return cmd_pair(*a.pair)
    if a.silence:
        return cmd_silence(a.silence, scope_to_set=not a.all_orbits)
    setlist = load_setlist(a.setlist)
    if a.matrix:
        return cmd_matrix(setlist)
    return cmd_chain(setlist, as_json=a.json)


if __name__ == "__main__":
    sys.exit(main())
