#!/usr/bin/env python3
"""check-boot-blocks — every block of BootTidal.hs must PARSE the way Pulsar sends it.

Why this exists (2026-07-29, J-5 to OPAL, #79)
----------------------------------------------
Pulsar does not feed BootTidal.hs to ghci verbatim. pulsar-tidalcycles does this
(lib/boot-tidal.js + lib/repl.js):

    text.split('\\n\\n')                     -- chunk on BLANK LINES
        .map(b => b.replace(':{','').replace(':}',''))
    ...then tidalSendExpression() wraps EACH chunk in its own `:{ ... :}`

and a ghci `:{ ... :}` accepts exactly ONE statement.

So the file's own `:{ :}` markers are meaningless — what decides the chunking is
BLANK LINES, and any chunk holding more than one statement is a parse error.

That is not hypothetical. The #55 control seed was written as

    :{
    let _seed = concat [ ... ]
    :}
    mapM_ (\\(k, v) -> setF k (pure v)) _seed
    putStrLn "[BootTidal] seeded ..."

which is correct in a file read line-by-line, and which Pulsar fused into one
statement. It died with "parse error (possibly incorrect indentation or
mismatched brackets)" on EVERY boot from 2026-07-27 to 2026-07-29. The seed never
ran once. 23 orbits across the OPAL set emitted nothing until a knob was
physically moved, which is what PLN heard as "no bass — when i move the knob C5
it starts sounding".

It typechecked the whole time. check-boot.sh proved the Haskell was valid
Haskell, and it was. The failure was in the SEAM — how the file gets chunked and
fed — and green checks on a component say nothing about whether the data reaches
it. This guard tests the seam.

Method
------
Replicate Pulsar's chunking exactly, wrap each chunk as repl.js does, and feed
the lot to a bare ghci. Then look ONLY for parse errors: a bare ghci has no Tidal
in scope, so "Variable not in scope" and type errors are EXPECTED and ignored.
A parse error is not about scope — it means the chunk is not one statement, and
it will fail identically in the real boot.

No Tidal, no stream, no scsynth, no port 6010. Safe to run mid-set.

Usage
-----
    tools/check-boot-blocks.py [BootTidal.hs]
Exit 0 = every block parses as a single statement.
"""
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
MARKER = "@@PVBLOCK"


def pulsar_blocks(text: str) -> list[tuple[int, str]]:
    """(1-indexed start line, chunk) exactly as pulsar-tidalcycles produces them.

    The `.replace()` calls are deliberately NON-global, mirroring the JS: it uses
    string arguments, which replace only the first occurrence. Reproducing the
    bug-for-bug behaviour is the whole point — a "cleaner" split here would test
    something Pulsar never does.
    """
    out: list[tuple[int, str]] = []
    line = 1
    for raw in text.split("\n\n"):
        chunk = raw.replace(":{", "", 1).replace(":}", "", 1)
        if raw.startswith(":set"):
            for sub in chunk.split("\n"):
                out.append((line, sub))
                line += 1
        else:
            out.append((line, chunk))
            line += raw.count("\n") + 1
        line += 1                      # the blank line that delimited the chunk
    return out


def build_script(blocks: list[tuple[int, str]]) -> str:
    parts = []
    for i, (start, chunk) in enumerate(blocks):
        if not chunk.strip():
            continue
        parts.append(f'putStrLn "{MARKER} {i} {start}"')
        if chunk.startswith(":set") or chunk.startswith(":"):
            parts.append(chunk)        # ghci directives are not wrappable
        else:
            parts.append(":{\n" + chunk + "\n:}")
    return "\n".join(parts) + "\n"


def main(argv: list[str]) -> int:
    boot = Path(argv[1]) if len(argv) > 1 else ROOT / "BootTidal.hs"
    if not boot.exists():
        print(f"check-boot-blocks: no such file: {boot}", file=sys.stderr)
        return 2
    blocks = pulsar_blocks(boot.read_text())
    script = build_script(blocks)

    print(f"check-boot-blocks: {len(blocks)} block(s) from {boot.name}, "
          f"fed as pulsar-tidalcycles feeds them ...")
    try:
        # STDOUT+STDERR must be ONE INTERLEAVED stream. Capturing them separately
        # and concatenating puts every marker before every error, so each error
        # gets attributed to the LAST block instead of its own — which is exactly
        # how the first run of this tool blamed the wrong block.
        r = subprocess.run(["ghci", "-v0"], input=script, stdout=subprocess.PIPE,
                           stderr=subprocess.STDOUT, text=True, timeout=600)
    except FileNotFoundError:
        print("check-boot-blocks: SKIP — ghci not on PATH", file=sys.stderr)
        return 0
    except subprocess.TimeoutExpired:
        print("check-boot-blocks: FAIL — ghci timed out", file=sys.stderr)
        return 2

    # Attribute each parse error to the block whose marker preceded it.
    cur: tuple[int, int] | None = None
    bad: dict[tuple[int, int], list[str]] = {}
    for ln in r.stdout.splitlines():
        if MARKER in ln:
            i, start = ln.split(MARKER)[1].split()[:2]
            cur = (int(i), int(start))
            continue
        if "parse error" in ln and cur is not None:
            bad.setdefault(cur, []).append(ln.strip())

    if not bad:
        print("check-boot-blocks: OK — every block parses as a single statement.")
        return 0

    print("", file=sys.stderr)
    for (i, start), errs in sorted(bad.items()):
        print(f"check-boot-blocks: FAIL — block {i} (starts near "
              f"{boot.name}:{start}) is NOT one statement:", file=sys.stderr)
        for e in errs[:3]:
            print(f"    {e}", file=sys.stderr)
        preview = blocks[i][1].strip().splitlines()
        for pl in preview[:4]:
            print(f"    | {pl}", file=sys.stderr)
        if len(preview) > 4:
            print(f"    | ... ({len(preview)} lines)", file=sys.stderr)
    print("", file=sys.stderr)
    print("Fix: put a BLANK LINE between the statements. Pulsar chunks this file",
          file=sys.stderr)
    print("on blank lines and wraps each chunk in its own `:{ ... :}`, which",
          file=sys.stderr)
    print("holds exactly ONE statement. This is how the #55 seed silently never",
          file=sys.stderr)
    print("ran for two days (#79).", file=sys.stderr)
    return 1


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