#!/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)):]
        out.append((name, orb.number, orb.start, "\n".join(lines)))
    return out


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)

    return f"""{{-# LANGUAGE OverloadedStrings #-}}
{{-# OPTIONS_GHC -Wno-missing-signatures -Wno-name-shadowing -Wno-type-defaults #-}}
module Main where
import Sound.Tidal.Context
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 ()
"""


def check_track(path: Path, seeded: bool, keep: bool) -> tuple[str, str]:
    """Returns (verdict, detail). verdict in ok / SILENT / BUILD."""
    track = load(str(path))
    if not track.orbits():
        return "ok", "  (no dN declarations)"
    src = 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 "BUILD", err.strip()
        r = subprocess.run([str(work / "run")], capture_output=True, text=True)
        return ("SILENT" if r.returncode else "ok"), 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 = 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 == "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
        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 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 bad:
        print(f"silent-eval: FAIL — {bad} track(s) have an orbit that emits "
              f"NOTHING on a fresh boot.")
        return 1
    print("silent-eval: OK — every declared orbit emits events.")
    return 0


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