# SPDX-License-Identifier: Apache-2.0
"""Run every check for v01 and write validation.json (shown on the overview site).

    ~/.venvs/goldfish-cad/bin/python v01/validate.py
"""
from __future__ import annotations

import datetime as dt
import json
import re
import subprocess
import sys
from pathlib import Path

HERE = Path(__file__).parent
PY = sys.executable
results = []


def check(name, what, fn):
    details = None
    try:
        r = fn()
        ok, detail = r[0], r[1]
        details = r[2] if len(r) > 2 else None
    except Exception as e:  # a crashing check is a failing check
        ok, detail = False, f"{type(e).__name__}: {e}"
    results.append({"check": name, "what": what, "ok": bool(ok), "detail": detail, **({"details": details} if details else {})})
    print(("PASS " if ok else "FAIL ") + name + " — " + detail)


def firmware_tests():
    r = subprocess.run([PY, "-m", "pytest", "-q", "tests", str(HERE.parent / "protocol" / "python" / "tests")], cwd=HERE / "firmware", capture_output=True, text=True)
    last = [l for l in r.stdout.strip().splitlines() if l.strip()][-1]
    return r.returncode == 0, last


def protocol_conformance():
    """Both reference implementations against protocol/vectors (SPEC §9)."""
    proto = HERE.parent / "protocol"
    py = subprocess.run([PY, "-m", "resolution_protocol.conformance"], cwd=proto / "python", capture_output=True, text=True)
    js = subprocess.run(["node", str(proto / "js" / "conformance.js")], capture_output=True, text=True)
    fz = subprocess.run([sys.executable, str(proto / "tools" / "fuzz_parity.py"), "400"], capture_output=True, text=True)
    lines = [x.stdout.strip().splitlines()[-1] for x in (py, js, fz) if x.stdout.strip()]
    return py.returncode == 0 and js.returncode == 0 and fz.returncode == 0, " · ".join(lines)


def fit():
    sys.path.insert(0, str(HERE / "cad"))
    from pebble_layout import Spec, pack
    spec = json.loads((HERE / "cad" / "pebble_spec.json").read_text())
    ok, fails, placed, info = pack(Spec(**spec))
    return ok, f"{len(placed)} keep-out boxes inside the cavity, none colliding" if ok else f"fails: {fails}"


def meshes():
    import trimesh
    out = []
    good = True
    L_ = json.loads((HERE / "cad" / "out_pebble" / "layout.json").read_text())
    want = {"slider_caps": 2, "test_coupons": 2, "board_straps": sum(1 for c in L_["comps"] if c["name"].startswith("strap of "))}   # two caps, printed together; the coupon plate and its loose tongue
    parts_ = json.loads((HERE / "cad" / "out_pebble" / "print.json").read_text())   # the parts this variant has
    for n in parts_:
        m = trimesh.load(HERE / "cad" / "out_pebble" / parts_[n].get("src", f"{n}.stl"))   # the bed-oriented print file
        good &= abs(m.bounds[0][2]) < 1e-6
        parts = m.split(only_watertight=False)
        ok = len(parts) == want.get(n, 1) and all(p.is_watertight for p in parts)
        good &= ok
        out.append(f"{n}{' ×' + str(len(parts)) if len(parts) > 1 else ''} {'✓' if ok else '✗'}")
    return good, "watertight bodies: " + ", ".join(out)


def walls():
    import numpy as np
    import trimesh
    sys.path.insert(0, str(HERE / "cad"))
    from build123d import export_stl
    from pebble_layout import Spec
    pb = Spec(**json.loads((HERE / "cad" / "pebble_spec.json").read_text())).pebble()
    tmpd = HERE / "cad" / "out_pebble"
    export_stl(pb.solid(), str(tmpd / ".outer.stl"))
    export_stl(pb.solid(inset=json.loads((HERE / 'cad' / 'pebble_spec.json').read_text()).get('wall', 1.8)), str(tmpd / ".cav.stl"))
    o, c = trimesh.load(tmpd / ".outer.stl"), trimesh.load(tmpd / ".cav.stl")
    pts, _ = trimesh.sample.sample_surface(o, 15000, seed=1)
    d = np.abs(trimesh.proximity.ProximityQuery(c).signed_distance(pts))
    (tmpd / ".outer.stl").unlink(); (tmpd / ".cav.stl").unlink()
    return d.min() >= 1.6 and np.median(d) >= 2.0, f"wall min {d.min():.2f} mm, 5th pct {np.percentile(d, 5):.2f}, median {np.median(d):.2f} (target 2.2, floor 1.6)"


def site_links():
    html = (HERE.parent / "site" / "index.html").read_text() if (HERE.parent / "site" / "index.html").exists() else ""
    srcs = re.findall(r'src="(https://[^"]+)"', html) + re.findall(r'"(https://cdn\.jsdelivr\.net/npm/[^"]+)"', html)
    pinned = all(re.search(r"@\d+\.\d+\.\d+", u) for u in srcs if "jsdelivr" in u)
    return bool(html) and pinned, f"{len(set(srcs))} CDN scripts, all version-pinned" if pinned else "unpinned CDN script found"


check("Unit + scenario tests", "Protocol reference + Goldfish bindings: profile, key chains, store, hand-off, light ring, end-to-end conference", firmware_tests)
check("Protocol conformance", "Resolution Protocol vectors (phrases en/es, sessions, retention) pass in Python and JavaScript, and a differential fuzz finds no divergence", protocol_conformance)
def generated_current():
    sys.path.insert(0, str(HERE / "cad"))
    import stamps
    st = stamps.stale()
    if st:
        return False, "stale: " + "; ".join(f"{k} ({', '.join(v[:3])}{' …' if len(v) > 3 else ''})" for k, v in st.items()) + " → run python v01/build.py"
    return True, "enclosure, wires and web meshes were built from the current code, spec and layout (out_pebble/stamps.json)"


check("Generated files current", "Every generated CAD file was rebuilt after the last change to its inputs (a stale step otherwise shows up only as wrong numbers downstream)", generated_current)
check("Fit check (pebble)", "Every part inside the cavity, no collisions", fit)
check("Printable meshes", "Each printed part is one watertight body", meshes)
check("Wall thickness", "Shell wall sampled at 15,000 points", walls)
def site_syntax():
    src = (HERE.parent / "site" / "workbench.src.html").read_text()
    blocks = [b for b in re.findall(r'<script(?: type="module")?>(.*?)</script>', src, re.S) if "/*CORE*/" not in b]
    bad = []
    for i, b in enumerate(blocks):
        f = HERE / "firmware" / "tests" / f".blk{i}.mjs"
        f.write_text(re.sub(r"^import .*?;$", "", b, flags=re.M))
        r = subprocess.run(["node", "--check", str(f)], capture_output=True, text=True)
        f.unlink()
        if r.returncode:
            bad.append(f"script {i}: {r.stderr.strip().splitlines()[-1]}")
    return not bad, f"{len(blocks)} inline scripts parse" if not bad else "; ".join(bad)


def geometry_review():
    r = subprocess.run([PY, str(HERE / "cad" / "review_geometry.py")], cwd=HERE / "cad", capture_output=True, text=True)
    rep = json.loads((HERE / "cad" / "out_pebble" / "review.json").read_text())
    f = rep["findings"]
    bad = [x for x in f if not x["ok"]]
    return r.returncode == 0 and not bad, f"{len(f) - len(bad)}/{len(f)} geometric checks pass" + (": " + "; ".join(x["what"] for x in bad) if bad else ""), f


def buildable():
    r = subprocess.run([PY, str(HERE / "cad" / "buildability.py")], cwd=HERE / "cad", capture_output=True, text=True)
    if r.returncode or not (HERE / "cad" / "out_pebble" / "buildability.json").exists():
        return False, "buildability.py failed: " + (r.stderr.strip().splitlines() or ["?"])[-1]
    rep = json.loads((HERE / "cad" / "out_pebble" / "buildability.json").read_text())["results"]
    bad = [x for x in rep if not x["ok"]]
    return not bad, f"{len(rep) - len(bad)}/{len(rep)} buildability checks pass (BOM, fasteners, closing, driver access, nut slots, board parts, cables, bed, supports, wiring)" + (": " + "; ".join(x["what"] for x in bad) if bad else "")


def electrical():
    subprocess.run([sys.executable, str(HERE / "sim" / "power_sim.py")], capture_output=True, text=True)
    rep = json.loads((HERE / "sim" / "power_report.json").read_text())
    c = rep["checks"]
    bad = [x for x in c if not x["ok"] and x.get("scope") == "v01"]
    gaps = [x for x in c if not x["ok"] and x.get("scope") != "v01"]
    runs = [r["runtime_h"] for r in rep["runs"]]
    return not bad, (f"{len(c) - len(bad) - len(gaps)}/{len(c)} pass; battery {min(runs):.1f}–{max(runs):.1f} h per charge"
                     + "".join(f"; {'requirement gap (v02)' if sc == 'v02' else 'to measure at bring-up'}: " + "; ".join(x["what"] for x in gaps if x.get("scope") == sc)
                               for sc in ("v02", "measure") if any(x.get("scope") == sc for x in gaps))
                     + (f"; FAIL: " + "; ".join(x["what"] for x in bad) if bad else "")), c


def display_parity():
    sys.path.insert(0, str(HERE / "firmware"))
    from goldfish.display import RING_LEDS, ring_frame, battery_frame, seat_frame
    cases = [([3, 0, 1, None, 4], [1], False, 0, None), ([2], [], False, 0, None), ([0] * 10, [3, 4], False, 0, None), ([3, 3, 3], [0, 1, 2], False, 0, None),
             ([], [], False, 0, None), ([3, 2], [0], True, 0, None), ([3, 2], [0], True, 1.3, None), ([3, 2], [0], True, 2.0, None), ([3, 2], [0], True, 7.9, None),
             ([3, 2, 1], [1], False, 0, 0.37), ([2], [], False, 0, 1.0)]
    py = [[list(c) if c != (0, 0, 0) else None for c in ring_frame(lv, set(sp), room_off=off, t=t, provisional=pv)] for lv, sp, off, t, pv in cases] + [[list(c) if c != (0, 0, 0) else None for c in battery_frame(q)] for q in (0.1, 0.5, 0.93)]
    seats = [[[3, 60.0], [1, 150.0], [4, 20.0]], [[0, 90.0], [2, 90.0]], [[3, 0.0], None, [2, 180.0], [1, 45.5], [4, 120.0]], []]
    seat_sp = [[1], [0], [2, 4], []]
    py += [[list(c) if c != (0, 0, 0) else None for c in seat_frame([tuple(p) if p else None for p in ps], set(sp))] for ps, sp in zip(seats, seat_sp)]
    # eye gestures: the same press sequences give the same outcomes (ADR-0015)
    from goldfish.device import Goldfish
    seqs = [[(1, 0, False), (5, 0, False), (5.3, 0, False)], [(1, 0, False), (1.2, 0, False)], [(1, 2.5, False)], [(1, 0, True)],
            [(1, 0, False), (2, 0, False), (2.2, 0, False), (2.4, 0, False)], [(1, 1.0, False), (3, 0, False), (3.5, 0, False)]]
    def run_py(seq):
        g = Goldfish(); g.hear("ana", "Morning.", 0)
        return [g.press_eye(t, h, l) for t, h, l in seq] + [g.room_off]
    py_eye = [run_py(q) for q in seqs]
    gjs = HERE / "firmware" / "js" / "goldfish.js"
    js = f"""global.ResolutionProtocol = require({json.dumps(str(HERE.parent / 'protocol' / 'js' / 'resolution.js'))});
const C = require({json.dumps(str(gjs))});
const cases = {json.dumps(cases)};
const out = cases.map(([lv, sp, off, t, pv]) => C.ringFrame(lv, new Set(sp), C.RING_LEDS, off, t, pv)).concat([0.1, 0.5, 0.93].map(q => C.batteryFrame(q))).concat({json.dumps(seats)}.map((ps, i) => C.seatFrame(ps, new Set({json.dumps(seat_sp)}[i]))));
const eye = {json.dumps(seqs)}.map(seq => {{ const s = C.newSession(); s.heard('ana', 0); return seq.map(([t, h, l]) => C.pressEye(s, t, h, l)).concat([C.roomOff(s)]); }});
console.log(JSON.stringify([C.RING_LEDS, out, eye]));"""
    tmp = None
    r = subprocess.run(["node", "-e", js], capture_output=True, text=True)
    n_js, js_frames, js_eye = json.loads(r.stdout)
    n_cad = json.loads((HERE / "cad" / "out_pebble" / "layout.json").read_text()).get("strip", {}).get("n")
    same = js_frames == py
    same_eye = js_eye == py_eye
    return same and same_eye and RING_LEDS == n_js == n_cad, (f"ring frames identical in firmware and site on {len(py)} cases (breathing, countdown, idle, seat-facing arcs); "
            f"eye gestures {'identical' if same_eye else 'DIFFER'} on {len(seqs)} press sequences; LED count firmware {RING_LEDS} · site {n_js} · CAD {n_cad}")


def firmware_flow():
    """ADR-0019: the site's meeting (firmware/scenarios/meeting.json), replayed through the real daemon on the sim board."""
    sys.path.insert(0, str(HERE / "firmware" / "tools"))
    sys.path.insert(0, str(HERE / "firmware"))
    import flow_check
    from goldfish.meeting import load_script
    sc = load_script()
    rows = flow_check.checks()
    bad = [r for r in rows if not r["ok"]]
    head = (f"the site's meeting ({len(sc['turns'])} lines, {len(sc['presses'])} scripted eye presses, then the 10-minute gap) run through "
            f"daemon.py on the sim board: {len(rows) - len(bad)}/{len(rows)} checks on what it put out (ring decoded from the SPI bytes, "
            f"the motor pin, the tag's memory and the note)")
    return not bad, head + ("" if not bad else "; FAIL: " + "; ".join(f"{r['area']}: {r['detail']}" for r in bad)), rows


def docs_facts():
    """Numbers the prose docs state, read back and compared with the generated data they come from.
    A statement that is reworded so the pattern no longer finds it fails too: update the pattern here, not the number."""
    root = HERE.parent
    lay = json.loads((HERE / "cad" / "out_pebble" / "layout.json").read_text())
    wires = json.loads((HERE / "cad" / "out_pebble" / "wires.json").read_text())["summary"]
    review = json.loads((HERE / "cad" / "out_pebble" / "review.json").read_text())["findings"]
    printed = json.loads((HERE / "cad" / "out_pebble" / "print.json").read_text())
    size = [round(v) for v in lay["outer"]]
    ctl = lay["controls"]
    gap = round(abs(ctl["power switch"][0] - ctl["mic switch"][0]))
    words = {"ten": 10, "eleven": 11, "twelve": 12, "nine": 9, "eight": 8}
    num = lambda s: words.get(s) if s in words else float(s)
    # (file, pattern with one group per number, the true numbers, what they are, tolerance: 0 for exact counts and sizes in whole mm,
    #  2 g for "about N g" (the mass itself is an estimate), 0.05 J for an energy given to one decimal)
    claims = [
        ("v01/README.md", r"\*\*Size:\*\* (\d+) × (\d+) × (\d+) mm", size, "outer size (layout.json outer)", 0),
        ("v01/README.md", r"\*\*Size:\*\*[^\n]*?about (\d+) g", [lay["mass_g"]], "mass (layout.json mass_g)", 2),
        ("v01/README.md", r"\*\*Light ring:\*\* (\d+) LEDs", [lay["strip"]["n"]], "ring LEDs (layout.json strip.n)", 0),
        ("v01/README.md", r"belly flank, (\d+) mm apart", [gap], "slide switch spacing (layout.json controls)", 0),
        ("BUILD-LOG.md", r"a yellow pebble, (\d+) × (\d+) × (\d+) mm", size, "overview size (layout.json outer)", 0),
        ("BUILD-LOG.md", r"a yellow pebble, [^\n]*? mm and about (\d+) g", [lay["mass_g"]], "overview mass (layout.json mass_g)", 2),
        ("BUILD-LOG.md", r"A (\d+)-LED ring at the seam", [lay["strip"]["n"]], "overview ring LEDs", 0),
        ("BUILD-LOG.md", r"plus (\w+) printed parts", [len([n for n in printed if n != "test_coupons"])], "overview printed parts (print.json, without the test coupon)", 0),
        ("BACKLOG.md", r"the (\d+)-LED ring \+ icon", [lay["strip"]["n"]], "B-08 ring LEDs", 0),
        ("BACKLOG.md", r"the (\d+) wire runs across the seam", [len(wires["seam_wires"])], "B-48 seam runs (wires.json)", 0),
        ("BACKLOG.md", r"At (\d+) g a 1 m drop", [lay["mass_g"]], "B-43 mass (layout.json mass_g)", 2),
        ("BACKLOG.md", r"a 1 m drop is ≈ ([\d.]+) J", [lay["mass_g"] / 1000 * 9.81], "B-43 drop energy at 1 m (m g h)", 0.05),
        ("AGENTS.md", r"\((\d+) at the last run; the count is `summary.wires`", [wires["wires"]], "wire count (wires.json)", 0),
        ("AGENTS.md", r"Geometric checks \((\d+) at the last run\)", [len(review)], "geometric checks (review.json)", 0),
    ]
    bad = []
    for f, pat, truth, what, tol in claims:
        m = re.search(pat, (root / f).read_text())
        if not m:
            bad.append(f"{f}: statement of {what} not found"); continue
        said = [num(g) for g in m.groups()]
        if any(s is None or abs(s - t) > tol for s, t in zip(said, truth)):
            bad.append(f"{f}: {what} says {m.group(0)!r}, the data says {', '.join(f'{t:g}' for t in truth)}")
    # every ADR is in the build log's decision table
    adrs = sorted(p.name for p in (root / "03-architecture" / "decisions").glob("0*.md") if not p.name.startswith("0000"))
    log = (root / "BUILD-LOG.md").read_text()
    missing = [a for a in adrs if f"(03-architecture/decisions/{a})" not in log]
    bad += [f"BUILD-LOG.md decision table lacks {a}" for a in missing]
    return not bad, (f"{len(claims)} stated numbers match layout, wires, review and print data; all {len(adrs)} ADRs in the build log's table"
                     if not bad else "; ".join(bad))


check("Geometric review", "Openings, light ring, strip path and clearance, controls, USB-C, mic seals, thermal, drop", geometry_review)
check("Buildable kit", "Walks the build as a builder would: every part bought, fasteners counted, shells close straight, a driver reaches every screw, nut slots open, no board part under a boss or head, cables long enough, prints fit the bed", buildable)
check("Electrical simulation", "Battery discharge, power tree, power switch, logic levels, I2C, drivers (v01/sim)", electrical)
check("Light ring parity", "The same ring frames in firmware (display.py) and on the site, and the same LED count as the CAD", display_parity)
check("Firmware flow", "The site's simulated meeting replayed through the real firmware (daemon, eye, ring encoder, motor, tag driver, protocol) on the sim board, checked end to end from its outputs", firmware_flow)
check("Site scripts parse","Every inline script on the overview site is valid JavaScript", site_syntax)
check("Site scripts pinned", "No floating CDN versions on the overview site", site_links)
check("Docs match the data", "Counts, sizes and masses stated in README, BUILD-LOG, BACKLOG and AGENTS agree with layout.json, wires.json, review.json and print.json; every ADR is in the build log", docs_facts)

json.dump({"run_at": dt.datetime.now().isoformat(timespec="minutes"), "results": results}, open(HERE / "validation.json", "w"), indent=1)
print(f"{sum(r['ok'] for r in results)}/{len(results)} passed")
sys.exit(0 if all(r["ok"] for r in results) else 1)   # so v01/build.py and scripts can stop on a failure
