#!/usr/bin/env python3
"""Headlessly run Spectranos: render each of the six scenes, verify the
tune tables are well-formed, and verify SPACE unwinds cleanly to BASIC."""
import struct, sys, zlib
sys.path.insert(0, "/var/www/html/thespeccy-app/tools")
from z80mini import Z80

SCRATCH = "/tmp/claude-0/-var-www-html/bf8c776a-3726-43e2-a9e3-4d930143eaaa/scratchpad"
code = open("/var/www/html/thespeccy-app/tools/spectranos.bin", "rb").read()

ADDR = {}
for line in open("/var/www/html/thespeccy-app/tools/spectranos.sym"):
    parts = line.split()
    if len(parts) >= 3 and parts[1] == "EQU":
        ADDR[parts[0]] = int(parts[2].rstrip("H"), 16)

BASIC_RET = 0x1234           # sentinel "BASIC continues here" address

def render(name):
    PAL = [(0,0,0),(0,0,215),(215,0,0),(215,0,215),(0,215,0),(0,215,215),(215,215,0),(215,215,215)]
    BRT = [(0,0,0),(0,0,255),(255,0,0),(255,0,255),(0,255,0),(0,255,255),(255,255,0),(255,255,255)]
    def off(y, cx): return 16384 + (((y & 0xC0) << 5) | ((y & 7) << 8) | ((y & 0x38) << 2) | cx)
    rows = []
    for y in range(192):
        r = bytearray()
        for cx in range(32):
            bits = cpu.mem[off(y, cx)]
            attr = cpu.mem[22528 + (y // 8) * 32 + cx]
            pal = BRT if attr & 0x40 else PAL
            ink, paper = pal[attr & 7], pal[(attr >> 3) & 7]
            for bit in range(8):
                r += bytes(ink if bits & (0x80 >> bit) else paper)
        rows.append(bytes(r))
    big = []
    for r in rows:
        r2 = b"".join(r[i:i + 3] * 2 for i in range(0, len(r), 3))
        big += [r2, r2]
    raw = b"".join(b"\x00" + r for r in big)
    def chunk(t, d):
        c = t + d
        return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c))
    with open(f"{SCRATCH}/{name}.png", "wb") as f:
        f.write(b"\x89PNG\r\n\x1a\n")
        f.write(chunk(b"IHDR", struct.pack(">IIBBBBB", 512, 384, 8, 2, 0, 0, 0)))
        f.write(chunk(b"IDAT", zlib.compress(raw, 9)))
        f.write(chunk(b"IEND", b""))
    print(f"  render -> {name}.png")


def fresh_cpu():
    cpu = Z80()
    cpu.mem[32768:32768 + len(code)] = code
    cpu.pc = ADDR["start"]
    cpu.sp = 0xFFF0
    cpu.mem[cpu.sp] = BASIC_RET & 0xFF        # the "BASIC" return address
    cpu.mem[cpu.sp + 1] = BASIC_RET >> 8
    cpu.on_in = lambda port: 0xFF             # nothing pressed, by default
    cpu.call_stubs[ADDR["beep"]] = lambda: None   # skip real audio timing
    return cpu


# ---- 1. render all six scenes as they're first drawn ----
cpu = fresh_cpu()
names = ["scene1", "scene2", "scene3", "scene4", "scene5", "scene6"]
seen = set()
guard = 0
while len(seen) < 6:
    guard += 1
    assert guard < 2_000_000, "runaway before all scenes seen"
    if cpu.pc in (ADDR[n] for n in names) and cpu.pc not in seen:
        seen.add(cpu.pc)
        label = [n for n in names if ADDR[n] == cpu.pc][0]
        # let the scene's drawing code run before rendering: step until
        # its first playtune call (i.e. drawing is done, tune starting)
        target = ADDR["playtune"]
        inner_guard = 0
        while cpu.pc != target:
            cpu.step()
            inner_guard += 1
            assert inner_guard < 200_000, f"{label} never reached playtune"
        render(f"sp_{label}")
    cpu.step()
print(f"PASS: all six scenes rendered ({cpu.steps:,} steps)")

# ---- 2. tune tables well-formed: even length, zero-terminated, no zero pitch ----
for name in ("tune1", "tune2", "tune3", "tune4", "tune5", "tune6", "tune6b"):
    addr = ADDR[name]
    i = addr
    notes = 0
    while True:
        cyc, pitch = code[i - 32768], code[i - 32768 + 1]
        if cyc == 0:
            break
        assert pitch != 0, f"{name}: zero pitch would divide/loop forever"
        notes += 1
        i += 2
        assert notes < 80, f"{name}: runaway, no terminator found"
    print(f"  {name}: {notes} notes, terminated correctly")
print("PASS: all tune tables well-formed")

# ---- 2b. real-hardware timing: each scene must actually hold for a
# few seconds, not fly past in milliseconds (this is the exact bug
# class that shipped once already — a fast logic-only check on note
# COUNT would never catch it, only computing real T-states does).
CLOCK = 3_500_000
def note_tstates(b, c):
    inner = 16 * (c - 1) + 11
    per_outer = 11 + 7 + 11 + inner + 10
    return (per_outer + 13) * (b - 1) + (per_outer + 8)

total_ms = 0
for name in ("tune1", "tune2", "tune3", "tune4", "tune5", "tune6"):
    addr = ADDR[name] - 32768
    i, scene_ts = addr, 0
    while code[i] != 0:
        scene_ts += note_tstates(code[i], code[i + 1])
        i += 2
    scene_ms = scene_ts / CLOCK * 1000
    total_ms += scene_ms
    assert scene_ms >= 1500, f"{name}: only {scene_ms:.0f}ms on real hardware — far too fast to read the caption"
    print(f"  {name}: {scene_ms:.0f} ms on real 3.5MHz hardware")

hb, hc = code[ADDR["tune6b"] - 32768], code[ADDR["tune6b"] - 32768 + 1]
hold_ms = note_tstates(hb, hc) / CLOCK * 1000 * 8   # holdloop runs it 8x
total_ms += hold_ms
print(f"  tune6b x8 (title-card hold): {hold_ms:.0f} ms")
print(f"  TOTAL one pass through the reel: {total_ms/1000:.1f} s")
assert 10_000 <= total_ms <= 40_000, f"reel duration {total_ms/1000:.1f}s outside a sane 10-40s range"
print("PASS: real-hardware timing is watchable, not a blur")

# ---- 3. bar tables terminated correctly, columns in range ----
for name in ("bars2", "bars3", "bars4", "bars5", "bars6"):
    addr = ADDR[name]
    i = addr - 32768
    n = 0
    while code[i] != 0xFF:
        col, wid, hgt = code[i], code[i+1], code[i+2]
        assert col + wid <= 32, f"{name} entry {n}: col {col}+wid {wid} exceeds 32 columns"
        assert hgt < ADDR["HORIZON"] if "HORIZON" in ADDR else hgt < 15, \
            f"{name} entry {n}: height {hgt} would draw above row 0"
        i += 3
        n += 1
    print(f"  {name}: {n} bars, in range")
print("PASS: all bar tables in range")

# ---- 4. SPACE unwinds cleanly all the way back to BASIC, from mid-reel ----
cpu = fresh_cpu()
cpu.on_in = lambda port: 0xFF & ~1 if (port >> 8) == 0x7F else 0xFF   # SPACE held down
guard = 0
while cpu.pc != BASIC_RET:
    cpu.step()
    guard += 1
    assert guard < 500_000, "SPACE never reached BASIC_RET — exit path is broken"
print(f"PASS: SPACE unwound cleanly to BASIC in {guard} steps "
      f"(landed exactly on the sentinel return address)")

print("\nALL CHECKS PASSED")
