#!/usr/bin/env python3
"""silent-eval — prove every dN in a track EMITS EVENTS, cold, with no rig.

Why this exists (2026-07-29, J-5 to OPAL, #79)
----------------------------------------------
PLN, playing: "ctrl_enter on do it right, i hear the 4-bar pattern each bar
lower, 4th barely audible, its clearly a xfade".

Every `dN` is `xfade N` with `xfadeIn 4` (BootTidal.hs:100,126), so what you hear
for four bars after an eval is the PREVIOUS pattern leaving. If the new pattern is
silent, the crossfade hands it a graceful exit and the bug looks like a "drift".
The sound right after ctrl+enter is not evidence the eval worked — it is evidence
the LAST one did.

So the question is "why did the eval produce silence", and the classic answer on
this rig is #55: a bare "^NN" whose CC has never arrived yields NO EVENTS — not a
default of 0, NOTHING — and `#` cannot emit without a right-hand value, so one
unseeded control empties an entire orbit.

THE GAP THIS FILLS
------------------
Two guards already exist and neither can see this:

  * tools/check-boot.sh  runs the g* HELPERS against an empty control map. But
    the dangerous "^NN" uses are in the TRACKS — `# crushbus 41 (range 16 4.5
    "^53")` is do_it_right's line, not BootTidal's. check-boot is green while
    that orbit is silent.
  * tools/check-tracks.sh  measures real audio, which is the ground truth, but it
    needs the rig, a quiet house, and ~45s per track.

The middle tier was missing: EXECUTE the track's own patterns, with a
deliberately empty control map, and assert events come out. That is pure pattern
evaluation — no stream, no scsynth, no MIDI, no port 6010 — so it is safe to run
mid-set, and it answers all 13 tracks in one pass without anyone's ears.

METHOD
------
Dedent BootTidal's helper `let` block into a real module (the same extraction
check-boot.sh uses), append the track's own `let` bindings so local definitions
SHADOW the boot ones exactly as they do live, rewrite each `dN $ ...` into a
plain binding, and `query` it over cycle 0..1 with `M.empty` as the controls.

WHAT A FAILURE MEANS
--------------------
  SILENT        the orbit emits zero events on a fresh boot. It will be
                inaudible until some knob is physically moved. This is the bug.
  ok            it emits events cold. (It can still be silenced by a control
                that is moved to a bad VALUE — that is `gig-log.py controls`.)
  BUILD FAILED  the harness could not compile the track. Reported as its own
                verdict and never as "silent": a harness limitation must never
                masquerade as a finding about the music.

Usage
-----
    tools/silent-eval.py                     # the whole OPAL setlist
    tools/silent-eval.py live/techno/x.tidal # just these
    tools/silent-eval.py --keep              # keep the generated .hs to read
    tools/silent-eval.py --seeded            # query with the #55 seed applied
                                             #   -> proves the seed is the fix

Exit 0 = every declared orbit of every track emits events cold.
"""
from __future__ import annotations

import argparse
import re
import subprocess
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from pvlint.core import Track, load, strip_comment  # noqa: E402

ROOT = Path(__file__).resolve().parent.parent
BOOT = ROOT / "BootTidal.hs"
SETLIST = ROOT / "armada" / "setlist_opal2026.txt"

# The helper let-block, located by CONTENT so edits to BootTidal do not rot this
# (same anchor check-boot.sh uses — keep them in step).
HELPER_ANCHOR = "let -- DPV specific parameters"
SEED_ANCHOR = "let _seed = concat"

ORBIT_START = re.compile(r"^(d(\d+))(\s|$)")
LET_RE = re.compile(r"^let\s+(\S+)")


def helper_block(boot: Path = BOOT) -> str:
    """BootTidal's g* helper block, dedented into module-level bindings."""
    lines = boot.read_text().splitlines()
    try:
        start = next(i for i, l in enumerate(lines) if l.startswith(HELPER_ANCHOR))
    except StopIteration:
        raise SystemExit(f"silent-eval: no {HELPER_ANCHOR!r} in {boot}")
    end = next(i for i in range(start + 1, len(lines)) if lines[i].startswith(":}"))
    body = lines[start:end]
    body[0] = body[0][len("let "):]
    return "\n".join(l[4:] if l.startswith("    ") else l for l in body)


def seed_pairs(boot: Path = BOOT) -> list[tuple[str, float]]:
    """The #55 seed list, evaluated in Python so --seeded needs no Haskell.

    Deliberately re-derived from the Haskell source rather than duplicated: a
    seed list that drifts from BootTidal.hs would make this tool confidently
    wrong in the reassuring direction.
    """
    text = boot.read_text()
    i = text.find(SEED_ANCHOR)
    if i < 0:
        return []
    body = text[i:text.index("] :: [(String, Double)]", i)]
    out: list[tuple[str, float]] = []
    # Line-based on purpose. A single regex over the whole block cannot span
    # `n <- [13..20] ++ [29..36]` without a `[^\]]` class that stops at the
    # first `]` — which silently yielded 4 seeds instead of 60 and made a
    # "seeded" run look identical to an unseeded one. Each seed entry is one
    # line, so parse one line at a time and let a miss be visible.
    for raw in body.splitlines():
        line = strip_comment(raw)
        m = re.search(r"\(\s*show n\s*,\s*([-\d.]+)\s*\)\s*\|\s*n\s*<-\s*(.+?)\s*\]\s*$",
                      line)
        if m:
            val = float(m.group(1))
            for rng in m.group(2).split("++"):
                rng = rng.strip()
                rm = re.match(r"^\[\s*(\d+)\s*\.\.\s*(\d+)\s*\]$", rng)
                if rm:
                    out += [(str(n), val)
                            for n in range(int(rm.group(1)), int(rm.group(2)) + 1)]
                    continue
                lm = re.match(r"^\[([\d,\s]+)\]$", rng)
                if lm:
                    out += [(n.strip(), val)
                            for n in lm.group(1).split(",") if n.strip()]
            continue
        for lit in re.finditer(r'\(\s*"(\d+)"\s*,\s*([-\d.]+)\s*\)', line):
            out.append((lit.group(1), float(lit.group(2))))
    return out


def track_lets(track: Track) -> list[str]:
    """The track's own top-level `let` bindings, as module bindings.

    These SHADOW BootTidal's when a track redefines gMask/gMute/gM* — which most
    of the OPAL set does — so the harness must carry them or it tests the wrong
    definitions. In Haskell a later top-level binding is a duplicate rather than
    a shadow, so callers rename the boot ones out of the way.
    """
    out = []
    for raw in track.lines:
        code = strip_comment(raw)
        if code.startswith("let ") and "=" in code:
            out.append(code[len("let "):].rstrip())
    return out


def orbit_bindings(track: Track) -> list[tuple[str, int, int, str]]:
    """(binding name, orbit number, 1-indexed line, Haskell source)."""
    out = []
    for orb in track.orbits():
        lines = [strip_comment(l).rstrip() for l in orb.lines]
        lines = [l for l in lines if l.strip()]
        if not lines:
            continue
        m = ORBIT_START.match(lines[0])
        if not m:
            continue
        name = f"orb{orb.number}_{orb.start}"
        # `dN` -> `name = idcp` keeps BOTH shapes valid: `dN $ x` becomes
        # `name = idcp $ x`, and a bare `dN` with the `$` on the next line
        # becomes `name = idcp` + `  $ x`, which still parses as application.
        lines[0] = f"{name} = idcp" + lines[0][len(m.group(1)):]
        # Continuation lines MUST be indented. Pulsar sends a block wrapped in
        # GHCi's `:{ … :}`, which suspends layout, so PLN can and does write
        #     d1
        #     $ whenmod 128 129 (…)
        # with the `$` at column 0. In a real module that column-0 `$` opens a
        # NEW top-level declaration and is a hard parse error — so the harness
        # was reporting "THE TRACK DOES NOT COMPILE — it will fail live too"
        # about code that plays perfectly. Two spaces restores the layout
        # continuation and preserves every relative indent below it.
        lines[1:] = ["  " + l for l in lines[1:]]
        out.append((name, orb.number, orb.start, "\n".join(lines)))
    return out


# Haskell keywords that can open a line and would otherwise be scraped as names.
_HS_KEYWORDS = {"import", "module", "where", "let", "in", "do", "if", "then",
                "else", "case", "of", "data", "type", "class", "instance",
                "newtype", "deriving", "infix", "infixl", "infixr"}


def top_level_names(src: str) -> set[str]:
    """Lowercase identifiers bound at column 0 — i.e. what this module defines."""
    got = set(re.findall(r"(?m)^([a-z][A-Za-z0-9_']*)(?=[ \t]*=|[ \t]+[a-z_(\[\"])",
                         src))
    return got - _HS_KEYWORDS


def build_module(track: Track, seeded: bool) -> str:
    helpers = helper_block()
    # A track that redefines a boot helper must WIN. Rename the boot definition
    # rather than dropping it: something else in the block may still call it.
    locals_ = track_lets(track)
    names = {LET_RE.match("let " + l).group(1) for l in locals_ if LET_RE.match("let " + l)}
    for n in names:
        helpers = re.sub(rf"(?m)^{re.escape(n)}\b", f"boot_{n}", helpers)

    binds = orbit_bindings(track)
    seed = ""
    if seeded:
        pairs = seed_pairs()
        entries = ", ".join(f'("{k}", VF {v})' for k, v in pairs)
        seed = f"ctrls = M.fromList [{entries}]"
    else:
        seed = "ctrls = M.empty"

    checks = "\n".join(
        f'  audible "d{n} (line {ln})" {name} failed' for name, n, ln, _ in binds)
    body = "\n\n".join(src for _, _, _, src in binds)

    # Everything the module defines at top level must be HIDDEN from the Context
    # import, or the two collide. This is the harness diverging from the boot
    # environment, which #93 named as the root cause of both its build failures:
    # in ghci a `let cutoff = pF "cutoff"` SHADOWS the imported `cutoff`, quietly
    # and legally. At module top level there is no shadowing — GHC reports
    # "Ambiguous occurrence" and the track is unverifiable, for a difference that
    # does not exist live.
    #
    # Derived from the generated text rather than listed, so it cannot drift when
    # BootTidal gains a helper. Same discipline as the rest of the toolbox:
    # generate the guard from the source of truth, do not copy it.
    hidden = sorted(top_level_names("\n".join([helpers, *locals_, body])))
    hide = f" hiding ({', '.join(hidden)})" if hidden else ""

    src = _module_text(hide, helpers, locals_, body, seed, checks)
    # Which generated lines came from the TRACK, so a build error can be blamed
    # correctly. Found by substring rather than arithmetic: the template is one
    # f-string and any edit to it would silently shift a hand-counted offset.
    ranges = []
    for chunk in ([body] + locals_ if body else locals_):
        if not chunk.strip():
            continue
        i = src.find(chunk)
        if i < 0:
            continue
        a = src.count("\n", 0, i) + 1
        ranges.append((a, a + chunk.count("\n")))
    return src, ranges


def _module_text(hide, helpers, locals_, body, seed, checks) -> str:
    return f"""{{-# LANGUAGE OverloadedStrings #-}}
-- ExtendedDefaultRules and NoMonomorphismRestriction are ON BY DEFAULT IN GHCI,
-- which is where these patterns actually run. Without them a chord literal like
-- "<gb3'maj db3'maj bb2'min bb2'maj>" cannot pick an IsString instance
-- (ParseBP's `Pattern a` vs Simple's `ControlPattern`) and the track is reported
-- unverifiable for a reason that never exists on the rig. Matching the boot
-- environment is the fix; annotating each literal would have been treating the
-- symptom one track at a time.
{{-# LANGUAGE ExtendedDefaultRules #-}}
{{-# LANGUAGE NoMonomorphismRestriction #-}}
{{-# OPTIONS_GHC -Wno-missing-signatures -Wno-name-shadowing -Wno-type-defaults #-}}
module Main where
import Sound.Tidal.Context{hide}
import qualified Data.Map.Strict as M
import System.Exit (exitFailure)
import Data.IORef

p :: Int -> ControlPattern -> IO ()
p = undefined
setI :: String -> Pattern Int -> IO ()
setI = undefined

-- `dN` is rewritten to `idcp`, so both `dN $ x` and a bare `dN` with the `$`
-- on the following line stay valid Haskell.
idcp :: ControlPattern -> ControlPattern
idcp = id

{helpers}

{chr(10).join(locals_)}

{body}

{seed}

-- Query a WINDOW, never a single cycle. Half this set's parts are alternations
-- whose cycle 0 is legitimately empty — `mask "<f!24 t!8>"` on a riser is silent
-- for 24 bars BY DESIGN — and a one-cycle query calls every one of them dead.
-- 64 covers the longest alternation in the corpus with room to spare.
cycles :: Time
cycles = 64

audible :: String -> ControlPattern -> IORef Bool -> IO ()
audible label pat failed = do
  let evs = query pat (State (Arc 0 cycles) ctrls)
  if null evs
    then do putStrLn ("  SILENT  " ++ label ++ "  (nothing in "
                      ++ show (floor cycles :: Int) ++ " cycles)")
            writeIORef failed True
    else do
      let firstAt = floor (minimum (map (start . part) evs)) :: Int
          late = if firstAt > 4 then "  [first sounds at cycle "
                                     ++ show firstAt ++ "]" else ""
      putStrLn ("  ok      " ++ label ++ "  -> "
                ++ show (length evs) ++ " event(s)" ++ late)

main :: IO ()
main = do
  failed <- newIORef False
{checks if checks else '  return ()'}
  bad <- readIORef failed
  if bad then exitFailure else return ()
"""


ERR_LINE_RE = re.compile(r"(?m)^<track>:(\d+):\d+: error:")


def blame(err: str, ranges: list[tuple[int, int]]) -> str:
    """`TRACK` if any GHC error points INTO the track's own lines, else `HARNESS`.

    The distinction is the whole point of this verdict and it used to be a
    constant. Every build failure was printed as "harness limit, NOT a track
    verdict", which was true for the two real harness bugs and catastrophically
    wrong for the third: desire.tidal has `# pan 0.42plz /se` committed on line 46
    and does not compile at all. The reassuring label hid a broken setlist track
    behind a message that told the reader to ignore it.

    A parser miss must never masquerade as a data conflict — and the inverse,
    which is what happened here, is worse.
    """
    hits = [int(m.group(1)) for m in ERR_LINE_RE.finditer(err)]
    return "TRACK" if any(a <= h <= b for h in hits for a, b in ranges) else "HARNESS"


def relocate(err: str, src: str, path: Path) -> str:
    """Rewrite `<track>:N` to the line in the REAL .tidal file, where possible.

    A generated-module line number is useless to the person who has to fix the
    file — and worse, it looks authoritative. Mapped by matching the offending
    line's TEXT back into the source, which survives the harness's rewrites
    (comment stripping, blank-line removal, `dN` -> `orbN_M = idcp`) as long as
    the code itself is intact. Ambiguous or unfound lines are left as-is rather
    than guessed at.
    """
    gen = src.split("\n")
    try:
        want = [l.rstrip() for l in path.read_text().split("\n")]
    except OSError:
        return err

    def sub(m: re.Match) -> str:
        n = int(m.group(1))
        if not (1 <= n <= len(gen)):
            return m.group(0)
        needle = gen[n - 1].strip()
        if len(needle) < 4:
            return m.group(0)
        hits = [i + 1 for i, l in enumerate(want) if l.strip() == needle]
        if len(hits) != 1:
            return m.group(0)
        return f"{path.name}:{hits[0]}:{m.group(2)}"

    return re.sub(r"<track>:(\d+):(\d+)", sub, err)


def check_track(path: Path, seeded: bool, keep: bool) -> tuple[str, str]:
    """Returns (verdict, detail). verdict in ok / SILENT / BUILD / BROKEN."""
    track = load(str(path))
    if not track.orbits():
        return "ok", "  (no dN declarations)"
    src, ranges = build_module(track, seeded)
    with tempfile.TemporaryDirectory() as td:
        work = Path(td)
        hs = work / "Main.hs"
        hs.write_text(src)
        if keep:
            dest = ROOT / "output" / f"silent-eval-{path.stem}.hs"
            dest.parent.mkdir(exist_ok=True)
            dest.write_text(src)
        b = subprocess.run(
            ["ghc", "-package", "tidal", "-package", "containers",
             "-o", str(work / "run"), "-outputdir", str(work / "o"), str(hs)],
            capture_output=True, text=True)
        if b.returncode != 0:
            err = b.stderr.replace(str(hs), "<track>")
            return ("BROKEN" if blame(err, ranges) == "TRACK" else "BUILD"), \
                relocate(err.strip(), src, path)
        r = subprocess.run([str(work / "run")], capture_output=True, text=True)
        if not r.returncode:
            return "ok", r.stdout.rstrip()
        # The probe sets its failure flag ONLY when it prints a SILENT line, so
        # a non-zero exit with no SILENT line did not measure silence — it threw
        # while QUERYING the pattern. Reporting that as "FAIL — 0 orbit(s)
        # SILENT" is a verdict about the music that the run never reached, and
        # it hid the stderr that says what actually went wrong.
        if "SILENT" not in r.stdout:
            return "CRASH", ((r.stdout.rstrip() + "\n") if r.stdout.strip() else "") \
                + r.stderr.strip()
        return "SILENT", r.stdout.rstrip()


def setlist_tracks() -> list[Path]:
    if not SETLIST.exists():
        raise SystemExit(f"silent-eval: no setlist at {SETLIST}")
    out = []
    for raw in SETLIST.read_text().splitlines():
        line = raw.split("#")[0].strip()
        if line:
            out.append(ROOT / line)
    return out


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        prog="silent-eval.py", description=__doc__.split("\n")[0],
        formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("tracks", nargs="*", type=Path)
    ap.add_argument("--seeded", action="store_true",
                    help="query with the #55 boot seed applied (proves the fix)")
    ap.add_argument("--keep", action="store_true",
                    help="write the generated Haskell to output/ for reading")
    a = ap.parse_args(argv)

    tracks = a.tracks or setlist_tracks()
    mode = "WITH the #55 boot seed" if a.seeded else "with an EMPTY control map"
    print(f"silent-eval: {len(tracks)} track(s), {mode}\n")

    bad = built = broken = crashed = 0
    for t in tracks:
        if not t.exists():
            print(f"  {t.name}: no such file")
            bad += 1
            continue
        verdict, detail = check_track(t, a.seeded, a.keep)
        if verdict == "BROKEN":
            # The error points INTO the track's own lines. This is not a harness
            # limitation and must never be worded like one — the file does not
            # compile, so every orbit in its block is dead on ctrl+enter.
            print(f"  {t.stem}: ✖ THE TRACK DOES NOT COMPILE — it will fail live "
                  f"too. Every dN in its block is dead.")
            print("\n".join("      " + l for l in detail.splitlines()[:8]))
            broken += 1
            bad += 1
            continue
        if verdict == "BUILD":
            print(f"  {t.stem}: BUILD FAILED — harness limit, NOT a track verdict")
            print("\n".join("      " + l for l in detail.splitlines()[:6]))
            built += 1
            continue
        if verdict == "CRASH":
            # It compiled and then threw while being queried. Live, the
            # scheduler queries every cycle, so this is a real track fault —
            # just not the one the SILENT wording would have claimed.
            print(f"  {t.stem}: ✖ THROWS WHEN QUERIED — it compiles, then blows "
                  f"up. Live, the scheduler queries every cycle.")
            print("\n".join("      " + l for l in detail.splitlines()[:6]))
            crashed += 1
            bad += 1
            continue
        n_silent = detail.count("SILENT")
        head = "ok" if verdict == "ok" else f"FAIL — {n_silent} orbit(s) SILENT"
        print(f"  {t.stem}: {head}")
        for line in detail.splitlines():
            if "SILENT" in line or verdict == "ok" and False:
                print("    " + line.strip())
        if verdict != "ok":
            bad += 1

    print()
    if broken:
        print(f"silent-eval: ✖ {broken} track(s) DO NOT COMPILE. This is a gig "
              f"blocker, not a tooling note — fix the .tidal file.")
    if built:
        print(f"silent-eval: {built} track(s) could not be built by the harness "
              f"— fix the harness, do not read these as passes")
    if crashed:
        print(f"silent-eval: ✖ {crashed} track(s) COMPILE BUT THROW when queried "
              f"— read the exception, it is not a silence problem.")
    # Only the tracks that actually MEASURED silence get the silence wording;
    # broken/crashed ones never got far enough for that to be a claim about them.
    silent_only = bad - broken - crashed
    if silent_only > 0:
        print(f"silent-eval: FAIL — {silent_only} track(s) have an orbit that emits "
              f"NOTHING on a fresh boot.")
    # The exit code answers "is anything wrong", NOT "is anything silent". Keying
    # it to silence alone would print OK and exit 0 for a corpus where every
    # track fails to compile — a false green from the one tool whose whole job is
    # catching the failure that makes no noise.
    if bad:
        return 1
    print("silent-eval: OK — every declared orbit emits events.")
    return 0


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