#!/usr/bin/env python3
"""Does tools/setlist.py actually FOLLOW backlog.md, or does it merely agree with it today?

PLN's requirement (2026-08-01) is a live one, not a one-off resolution:

    "if i added new ones, id not need to tell you to update, it should flow
     downstream, ditto if remove one"

A test that only asserts "the parser returns the 15 tracks currently in the backlog"
would pass just as happily against a hardcoded list — which is precisely the artifact
being retired here. So every test below MUTATES a synthetic backlog and asserts the
output moved with it. Add / remove / reorder / break: the parser must track all four.

    python3 tools/tests/test_setlist.py
"""

from __future__ import annotations

import importlib.util
import pathlib
import sys
import tempfile

ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
spec = importlib.util.spec_from_file_location("setlist", ROOT / "tools" / "setlist.py")
S = importlib.util.module_from_spec(spec)
# Register BEFORE exec: on Python 3.14 @dataclass resolves its own module via
# sys.modules[cls.__module__], which is None for a spec-loaded module that was never
# registered — it raises AttributeError inside dataclasses, nowhere near the real cause.
sys.modules["setlist"] = S
spec.loader.exec_module(S)

FAILED: list[str] = []


def check(name: str, cond: bool, detail: str = "") -> None:
    print(f"  {'ok  ' if cond else 'FAIL'}  {name}" + (f"   {detail}" if detail else ""))
    if not cond:
        FAILED.append(name)


def backlog(body: str) -> pathlib.Path:
    """A throwaway backlog whose OPAL section is `body`. Deliberately reproduces the
    awkward shape of the real file: a prose h2 right after the gig heading (the thing
    that broke the first implementation), a blockquote, a bare duration line, PLN's
    ??TRANSITION??? markers, and a trailing image block of non-list `url:` lines."""
    f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False)
    f.write("# Some earlier section\n- ignore me\n\n"
            "## OPAL 2026\n\n"
            "## Livecoding Techno DNB Nu-jazz\n"
            "> Sunset Forest\n1h30 - 20h30-22h\n"
            f"### _Ouverture_\n{body}\n"
            "## Images\n"
            '  url: "/home/pln/Work/Art/GLITCHWAVE/outputs/digital_flow.gif"\n')
    f.close()
    return pathlib.Path(f.name)


def paths(body: str) -> list[str]:
    return [str(p) for p in (e.path for e in S.entries("OPAL 2026", backlog(body)))]


BASE = ("-- [lightT][124] Ceci n'est pas Une Bombe\n"
        "-- [lightT][133] WAP\n"
        "??TRANSITION???\n"
        "-- [129] Desire [TODO BASS ETC!]\n")

BOMBE = "live/midi/nova/techno/bombe_dj.tidal"
WAP = "live/midi/nova/dnb/wap.tidal"
DESIRE = "live/collab/raph/desire.tidal"
FUNK = "live/midi/nova/lounge/ouais_je_funk.tidal"

print(__doc__.strip().splitlines()[0])
print("\nbaseline")
base = paths(BASE)
check("resolves PLN's freeform codenames", base == [BOMBE, WAP, DESIRE], f"{base}")
check("ignores ??TRANSITION???, blockquote, duration, url: block", len(base) == 3)

print("\nmutation: ADD a track (the #123 requirement)")
added = paths(BASE.replace("-- [lightT][133] WAP\n",
                           "-- [lightT][133] WAP\n-- [highTech][120] Eh ouais je Funk?\n"))
check("a new backlog line appears downstream", added == [BOMBE, WAP, FUNK, DESIRE],
      f"{added}")
check("it lands in PLN's position, not appended", added.index(FUNK) == 2)

print("\nmutation: REMOVE a track")
removed = paths(BASE.replace("-- [lightT][133] WAP\n", ""))
check("a deleted backlog line disappears downstream", removed == [BOMBE, DESIRE],
      f"{removed}")

print("\nmutation: REORDER (order is truth too)")
reordered = paths("-- [lightT][133] WAP\n-- [lightT][124] Ceci n'est pas Une Bombe\n")
check("order follows the backlog", reordered == [WAP, BOMBE], f"{reordered}")
# Swapping the two backlog lines must swap the output. (Do NOT assert "not sorted()"
# here: dnb/wap genuinely sorts before techno/bombe_dj, so that assertion passes or
# fails on path spelling, not on ordering behaviour. It cost a red herring once.)
check("...and swapping the lines swapped the output", reordered == base[1::-1] != base[:2])

print("\nloudness: it must never guess")
try:
    paths("-- [124] Un Morceau Qui N'Existe Pas\n")
    check("unresolvable name raises", False, "it returned silently")
except S.SetlistError as e:
    check("unresolvable name raises SetlistError", True)
    check("...and names the offender", "Un Morceau" in str(e), str(e).split("\n")[0])
try:
    paths("-- [124] WAP\n-- [133] WAP\n")
    check("duplicate track raises", False, "it returned silently")
except S.SetlistError as e:
    check("duplicate track raises SetlistError", True, str(e).split("\n")[0])
try:
    S.entries("NO SUCH GIG 1999", backlog(BASE))
    check("missing gig section raises", False, "it returned silently")
except S.SetlistError:
    check("missing gig section raises SetlistError", True)

print("\na note is not a track (2026-08-02)")
# PLN drafts in backlog.md, and he drafts in list items. This one cost a phantom:
# {quand, on, decolle} is a subset of the note's 13 words, so the resolver's
# "codename carries extra words" branch matched it and quand_on_decolle joined the
# set — flowing straight into preload, the remap and the gig gate.
NOTE = ("-- Q: This intro is quite raptech. jazz intro? "
        "quand on decolle opening? reflect\n")
noted = paths(BASE + NOTE)
check("a marked note is skipped, not resolved", noted == base, f"{noted}")
try:
    paths(BASE + "-- honestly the quand on decolle opening might work better here\n")
    check("UNMARKED prose cannot resolve to a phantom track", False)
except S.SetlistError:
    # Loud, not silent, and that is the right end of the trade: an unmarked note
    # turns the gig gate red and PLN adds "Q:" or deletes the line. A phantom
    # track goes to the venue.
    check("UNMARKED prose cannot resolve to a phantom track", True)
check("but decoration still resolves — 4 extra words is a real entry",
      paths("-- [129] Desire [TODO BASS ETC!]\n") == [DESIRE])
try:
    paths("-- [124] Definitely Not A Track Here\n")
    check("an UNRESOLVABLE plain name still raises — notes must not silence it", False)
except S.SetlistError:
    check("an UNRESOLVABLE plain name still raises — notes must not silence it", True)

print("\nthe real backlog")
real = S.entries()
check("parses backlog.md", len(real) >= 13, f"{len(real)} tracks")
check("every track file exists", all((ROOT / e.path).exists() for e in real))
check("ouais_je_funk is in the set (was missing from all 8 tools)",
      any(e.path.name == "ouais_je_funk.tidal" for e in real))
check("lime_tacos is OUT (PLN 2026-08-02: 'actually weak, removed from set')",
      not any(e.path.name == "lime_tacos.tidal" for e in real))
check("a deliberation note is not a track (the quand_on_decolle phantom)",
      not any(e.path.name == "quand_on_decolle.tidal" for e in real))
names = [e.path.stem for e in real]
check("you_my_sunshine precedes mafia_sans_serif (PLN 2026-08-01)",
      names.index("you_my_sunshine") < names.index("mafia_sans_serif"))
check("the generated .txt round-trips through the parser",
      [pathlib.Path(ln.split("#")[0].strip())
       for ln in S.render(real).splitlines() if ln.split("#")[0].strip()]
      == [e.path for e in real])

print(f"\n{len(FAILED)} failed" if FAILED else "\nall passed")
sys.exit(1 if FAILED else 0)
