#!/usr/bin/env python3
"""setlist — THE parser for what a set IS, and in what order.

    tools/setlist.py                 # the ordered track paths
    tools/setlist.py --long          # + codename, bpm, section
    tools/setlist.py --check         # is armada/setlist_opal2026.txt in sync? exit 1 if not
    tools/setlist.py --emit          # regenerate it from backlog.md

WHY THIS EXISTS (PLN's ruling, 2026-08-01):

    "set SSOT is the backlog entry, always. if in the backlog i add a track or
     reorder them, its the truth."
    "if i added new ones, id not need to tell you to update, it should flow
     downstream, ditto if remove one"

So `backlog.md`'s "## OPAL 2026" section is canonical for BOTH membership and ORDER,
and every other representation of the set is a derived artifact that must follow it
without anyone being told.

WHAT WENT WRONG WITHOUT IT. On 2026-08-01 the backlog listed 15 tracks and every tool
in the repo believed 13 — `ouais_je_funk` and `lime_tacos` were missing, and
you_my_sunshine/mafia_sans_serif were in the wrong order. Consequence: preload warmed
50 banks where the set needs 53, so `dr`, `gfunk_bass` and `h2ogmcy` would have been
read from DISK on first play. That is the same mechanism PLN heard as crackle on the
take_5_drops transition the day before (#120). `check-preload.sh` reported "ok" the
whole time, because it asked one of the wrong answers.

The root cause was EIGHT tools each hardcoding `SETLIST = armada/setlist_opal2026.txt`
and one human keeping that file in sync by hand. See [[feedback_one_parser_per_concept]]
and [[feedback_parsers_over_copy]]: a catalog is a GENERATED ARTIFACT, and a parser miss
must never be papered over by hand-editing the artifact.

THE SHAPE, and why it is not "make all eight import this module":
`armada/setlist_opal2026.txt` stays exactly where it is and keeps its format, so all
eight consumers are untouched (this is J-7 to a gig; a refactor that breaks a tool costs
more than it buys). What changes is its STATUS: it is now generated, never hand-edited,
and `gig-up.sh` carries a HARD check that it still matches backlog.md. One parser, one
artifact, one answer at any moment — with a gate that fails loudly the moment they drift.

DO NOT MECHANIZE backlog.md. It is PLN's informal working document
([[reference_gig_tracklist_sources]]) — freeform, full of "??TRANSITION???" and "<3".
This reads it; it must never write it.
"""

from __future__ import annotations

import argparse
import dataclasses
import pathlib
import re
import sys
import unicodedata

ROOT = pathlib.Path(__file__).resolve().parent.parent
BACKLOG = ROOT / "backlog.md"
SETLIST = ROOT / "armada" / "setlist_opal2026.txt"
GIG = "OPAL 2026"

# Codename -> file stem, for the names normalisation cannot reach on its own. Each of
# these was verified by locating the actual file, not guessed; the mapping is inherited
# from the hand-resolved setlist_opal2026.txt, where it had already been checked.
# Matched as a SUBSTRING of the normalised codename (longest key first), so PLN's
# trailing chatter -- "Le shifteur marteau ? to finish!" -- does not break resolution.
ALIASES = {
    "ceci n est pas une bombe": "bombe_dj",
    "le shifteur marteau": "electric_hammer",
    "am i doing it right": "do_it_right",
    "take five drops": "take_5_drops",
    "eh ouais je funk": "ouais_je_funk",
    "lime tacos": "lime_tacos",
    "revolution": "the_revolution_will_be_sampled",
    "sunshine": "you_my_sunshine",
    "mafia": "mafia_sans_serif",
}

# A backlog line is a track if it is a list item. PLN uses both "-" and "--" and the
# distinction is not consistent enough across the file to carry meaning, so both count.
ITEM = re.compile(r"^\s*-{1,2}\s+(.*)$")
HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
BRACKET = re.compile(r"\[([^\]]*)\]")
BPM = re.compile(r"^(\d{2,3})\*?$")


@dataclasses.dataclass(frozen=True)
class Entry:
    path: pathlib.Path      # repo-relative
    codename: str           # as PLN wrote it, cleaned of tags
    bpm: int | None
    tags: tuple[str, ...]   # e.g. ("lightT",)
    section: str            # "Ouverture", "We call it NuJazz", "FINALE", "Encore"


class SetlistError(RuntimeError):
    """Raised loudly. Never guess a setlist -- a silent fallback to a stale list is
    exactly the failure mode this module retires."""


# A list item PLN wrote to think out loud, not to name a track. He drafts in this
# file, so notes and entries share the "- " prefix; these are the markers he uses.
# Skipped LOUDLY (reported on stderr) — never silently, or the set could lose a
# real track to a typo'd marker.
_NOTE_RE = re.compile(r"^\s*(?:Q|N\.?B|NOTE|TODO|FIXME|IDEA)\s*[:?]", re.I)

# How many words of decoration a real entry may carry around its track name —
# "[lightT][120] Vague de CRIME" spends 2, "[129] Desire [TODO BASS ETC!]" spends 4.
_MAX_DECORATION = 4


def _norm(s: str) -> str:
    """Fold to plain lowercase words: accents, markdown emphasis and punctuation out."""
    s = unicodedata.normalize("NFKD", s)
    s = "".join(c for c in s if not unicodedata.combining(c))
    s = s.lower().replace("_", " ")
    s = re.sub(r"[^a-z0-9]+", " ", s)
    return " ".join(s.split())


def _index() -> dict[str, list[pathlib.Path]]:
    """stem -> the live/ files carrying it. Built fresh every call: a new track file
    must be visible the moment PLN adds it to the backlog."""
    out: dict[str, list[pathlib.Path]] = {}
    for p in (ROOT / "live").rglob("*.tidal"):
        out.setdefault(p.stem, []).append(p)
    return out


def _resolve(codename: str, index: dict[str, list[pathlib.Path]]) -> pathlib.Path:
    n = _norm(codename)
    if not n:
        raise SetlistError(f"empty track name in {BACKLOG.name}")

    stem = None
    for key in sorted(ALIASES, key=len, reverse=True):
        if key in n:
            stem = ALIASES[key]
            break

    if stem is None:
        direct = n.replace(" ", "_")
        if direct in index:
            stem = direct
        else:
            # Token containment, both directions: "Piment Bresilien" -> piment_bresilien,
            # and a codename with extra words still finds a shorter stem.
            want = set(n.split())
            scored = []
            for cand in index:
                toks = set(_norm(cand).split())
                # `toks <= want` is the "codename carries extra words" direction —
                # tags and bpm, "[129] Desire [TODO BASS ETC!]". Bounded, because
                # unbounded it matches a track name mentioned in PROSE. On
                # 2026-08-02 PLN wrote a deliberation note as a list item —
                # "Q: This intro is quite raptech. jazz intro? quand on decolle
                # opening? reflect" — and {quand,on,decolle} being a subset of
                # those 13 words silently put quand_on_decolle in the set. The
                # real entries carry at most 4 decoration words; that note carried
                # 10. See _NOTE_RE for the other half of this guard.
                if (toks <= want and len(want - toks) <= _MAX_DECORATION) \
                        or want <= toks:
                    scored.append((len(toks & want), -len(toks ^ want), cand))
            if not scored:
                raise SetlistError(
                    f'cannot resolve "{codename}" to a .tidal file under live/.\n'
                    f"       Add it to ALIASES in tools/setlist.py, or rename the file.")
            scored.sort(reverse=True)
            best = scored[0]
            ties = [c for s, d, c in scored if (s, d) == (best[0], best[1])]
            if len(ties) > 1:
                raise SetlistError(
                    f'"{codename}" is ambiguous: {", ".join(sorted(ties))}.\n'
                    f"       Pin it in ALIASES in tools/setlist.py.")
            stem = best[2]

    paths = index.get(stem)
    if not paths:
        raise SetlistError(f'"{codename}" resolves to "{stem}" but no such file exists')
    if len(paths) > 1:
        rel = sorted(str(p.relative_to(ROOT)) for p in paths)
        raise SetlistError(f'"{codename}" -> "{stem}" matches {len(paths)} files: '
                           f'{", ".join(rel)}')
    return paths[0].relative_to(ROOT)


def entries(gig: str = GIG, backlog: pathlib.Path | None = None) -> list[Entry]:
    """Ordered entries for a gig's backlog section. Raises SetlistError, never guesses."""
    src = backlog or BACKLOG
    if not src.exists():
        raise SetlistError(f"no backlog at {src}")
    lines = src.read_text().splitlines()

    start = None
    for i, ln in enumerate(lines):
        m = HEADING.match(ln)
        if m and _norm(m.group(2)) == _norm(gig):
            start, depth = i + 1, len(m.group(1))
            break
    if start is None:
        raise SetlistError(f'no "{gig}" section in {src.name}')

    index = _index()
    out: list[Entry] = []
    section = ""
    for ln in lines[start:]:
        m = HEADING.match(ln)
        if m:
            # Do NOT try to tell "part of the set" from "a new topic" by heading text.
            # The first attempt whitelisted {FINALE, Encore, ...} and died instantly on
            # "## Livecoding Techno DNB Nu-jazz" — PLN's headings are prose, and any
            # whitelist is a list of the ones he happened to have written that day.
            #
            # The set's own structure is all at this depth or deeper ("## FINALE",
            # "### _Ouverture_"), so we run to the next TOP-level heading or EOF and
            # simply take the list items. Non-track content in between does not survive
            # that filter: "> Sunset Forest", "1h30 - 20h30-22h", "??TRANSITION???" and
            # the trailing `url: "..."` image block are none of them list items.
            if len(m.group(1)) < depth:
                break
            section = m.group(2).strip("_# ").split(":")[0].strip()
            continue
        im = ITEM.match(ln)
        if not im:
            continue
        raw = im.group(1).strip()
        # A markdown link is a reference, not a track ("- [MathMusic](https://...)").
        if not raw or re.match(r"^\[[^\]]*\]\(", raw) or raw.startswith(("http", "url:")):
            continue
        # PLN thinks out loud in this file, in list items. A marked note is not a
        # track — but say so on stderr, because a note silently dropped and a track
        # silently dropped look identical from here.
        if _NOTE_RE.match(raw):
            print(f"setlist: note, not a track — skipped: {raw}", file=sys.stderr)
            continue
        tags = tuple(t.strip() for t in BRACKET.findall(raw))
        name = BRACKET.sub(" ", raw)
        bpm = next((int(BPM.match(t).group(1)) for t in tags if BPM.match(t)), None)
        out.append(Entry(path=_resolve(name, index),
                         codename=" ".join(name.split()).strip(" ?!.-"),
                         bpm=bpm,
                         tags=tuple(t for t in tags if not BPM.match(t)),
                         section=section))

    if not out:
        raise SetlistError(f'"{gig}" section in {src.name} lists no resolvable tracks')
    dupes = {p for p in (e.path for e in out) if [e.path for e in out].count(p) > 1}
    if dupes:
        raise SetlistError("track listed twice: "
                           + ", ".join(sorted(str(d) for d in dupes)))
    return out


def tracks(gig: str = GIG) -> list[pathlib.Path]:
    """Ordered repo-relative paths. The one function every consumer should want."""
    return [e.path for e in entries(gig)]


def render(es: list[Entry]) -> str:
    """The generated setlist_opal2026.txt. Same format the eight consumers already parse:
    one repo-relative path per line, #-comments and blanks ignored, order = play order."""
    w = max(len(str(e.path)) for e in es) + 2
    L = [f"# {GIG} — the set, in play order.",
         "#",
         "# GENERATED by tools/setlist.py from backlog.md's \"## OPAL 2026\" section.",
         "# DO NOT EDIT THIS FILE — edit the backlog, then run:",
         "#     tools/gig-up.sh --converge      (or: tools/setlist.py --emit)",
         "#",
         "# PLN's ruling 2026-08-01: the backlog entry is the SSOT for membership AND",
         "# order. Hand-editing this file would recreate the exact drift it was",
         "# generated to end — see tools/setlist.py's header and #123.",
         "#",
         "# Order matters beyond bookkeeping: tools/orphan-orbits.py walks it pairwise",
         "# to find which orbits ghost across each transition (#68).",
         ""]
    sec = None
    for e in es:
        if e.section != sec:
            sec = e.section
            L += ["", f"# --- {sec} ---"] if sec else [""]
        bpm = f" [{e.bpm}]" if e.bpm else ""
        L.append(f"{str(e.path):<{w}}# {e.codename}{bpm}")
    return "\n".join(L).strip() + "\n"


def main() -> int:
    ap = argparse.ArgumentParser(prog="setlist", description=__doc__.split("\n")[0])
    ap.add_argument("--gig", default=GIG)
    ap.add_argument("--long", action="store_true", help="codename, bpm, section too")
    ap.add_argument("--check", action="store_true", help="exit 1 if the .txt is stale")
    ap.add_argument("--emit", action="store_true", help="regenerate the .txt")
    a = ap.parse_args()

    try:
        es = entries(a.gig)
    except SetlistError as exc:
        print(f"setlist: {exc}", file=sys.stderr)
        return 2

    if a.emit:
        text = render(es)
        stale = not SETLIST.exists() or SETLIST.read_text() != text
        SETLIST.write_text(text)
        print(f"setlist: {'regenerated' if stale else 'already current'} "
              f"{SETLIST.relative_to(ROOT)} — {len(es)} tracks")
        return 0

    if a.check:
        if not SETLIST.exists():
            print(f"setlist: {SETLIST.relative_to(ROOT)} does not exist", file=sys.stderr)
            return 1
        want = [e.path for e in es]
        have = [pathlib.Path(ln.split("#")[0].strip())
                for ln in SETLIST.read_text().splitlines() if ln.split("#")[0].strip()]
        if have == want:
            print(f"setlist: ok — {len(want)} tracks, matching backlog.md")
            return 0
        print(f"setlist: STALE — {SETLIST.relative_to(ROOT)} disagrees with backlog.md")
        for p in want:
            if p not in have:
                print(f"    backlog has, file missing:  {p}")
        for p in have:
            if p not in want:
                print(f"    file has, backlog dropped:  {p}")
        if sorted(have) == sorted(want):
            print("    same tracks, DIFFERENT ORDER — order is truth too (#123).")
        print("  fix: tools/setlist.py --emit   (never hand-edit the .txt)")
        return 1

    for e in es:
        if a.long:
            bpm = f"{e.bpm:>3}" if e.bpm else "  ?"
            print(f"{bpm}  {e.section:<20} {str(e.path):<52} {e.codename}")
        else:
            print(e.path)
    return 0


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