# SPDX-License-Identifier: Apache-2.0
"""Build site/index.html — the Goldfish workbench (opens from disk) — and, with --web, the deploy folder site/web/:
the landing page at the root (landing.src.html + landing/) and the workbench under workbench/.

    python3 site/build.py          # workbench → index.html
    python3 site/build.py --web    # site/web/: landing page at /, workbench at /workbench/
    python3 site/build_v1.py       # previous long-form page → spec-v1.html
"""
import base64, csv, json, re, shutil, sys
from pathlib import Path

WEB = "--web" in sys.argv          # deployable folder with assets as files (site/web/)

HERE = Path(__file__).parent
ROOT = HERE.parent
V01 = ROOT / "v01"
CAD = V01 / "cad"
OUTDIR = HERE / "web" if WEB else HERE
if WEB:
    shutil.rmtree(OUTDIR, ignore_errors=True); (OUTDIR / "assets").mkdir(parents=True)
def enc(p):
    if not WEB:
        return base64.b64encode(p.read_bytes()).decode()
    name = f"{p.parent.name}-{p.name}" if p.parent.name != "web" else p.name
    shutil.copy(p, OUTDIR / "assets" / name)
    return "../assets/" + name   # the workbench page lives in workbench/; assets stay at the root

meshes = {n: enc(CAD / "out_pebble" / "web" / f"{n}.stl") for n in ("top_shell", "bottom_shell", "eye_guide", "eye_lens", "read_button", "bumper_band", "foot", "slider_caps", "eye_cradle", "power_cap", "mic_cap", "battery_deck", "board_straps")
          if (CAD / "out_pebble" / "web" / f"{n}.stl").exists()}
layout = json.loads((CAD / "out_pebble" / "layout.json").read_text())
wires = json.loads((CAD / "out_pebble" / "wires.json").read_text()) if (CAD / "out_pebble" / "wires.json").exists() else {}
# ADR-0020 "Open" view: the hinge line (each part's shell and each wire's lid end come from the CAD scripts)
layout["hinge"] = {"y": layout["outer"][1] / 2, "z": layout["z_split"], "side": "+Y, away from the slide switches"}
printinfo = json.loads((CAD / "out_pebble" / "print.json").read_text())
bom = list(csv.DictReader(open(V01 / "bom.csv")))
valid = json.loads((V01 / "validation.json").read_text()) if (V01 / "validation.json").exists() else {}
PROTO = ROOT / "protocol"
roompy = (PROTO / "python" / "resolution_protocol" / "session.py").read_text()   # shown in the Rules tab
spec = json.loads((CAD / "pebble_spec.json").read_text())
# the browser runs the protocol (protocol/js) with Goldfish's profile, grammars and bindings (v01/firmware/js)
gf_profile = json.loads((V01 / "profile" / "goldfish.profile.json").read_text())
gf_locales = {p.stem: json.loads(p.read_text()) for p in sorted((PROTO / "locales").glob("*.json"))}
gf_vectors = {"phrases": [json.loads(p.read_text()) for p in sorted((PROTO / "vectors").glob("phrases.*.json"))],
              "sessions": json.loads((PROTO / "vectors" / "sessions.json").read_text())}
core = ("window.GF_PROFILE = " + json.dumps(gf_profile) + ";\nwindow.GF_LOCALES = " + json.dumps(gf_locales, ensure_ascii=False) +
        ";\nwindow.GF_VECTORS = " + json.dumps(gf_vectors, ensure_ascii=False) + ";\n" +
        (PROTO / "js" / "resolution.js").read_text() + "\n" + (V01 / "firmware" / "js" / "goldfish.js").read_text())

PCB_COLOR = {"charger_bq25185": 0x1d1d1f, "charger_bq24074": 0x1d1d1f, "usbc_4090": 0x1d1d1f, "atecc608": 0x1d1d1f, "mic_sph0645": 0x1d1d1f,
             "nfc_st25dv": 0xb3261e, "boost_u3v40f5": 0x1f4e8c, "radxa_zero3e": 0x24613a}
vendor = {}
for key, color in PCB_COLOR.items():
    d = CAD / "vendor" / "models" / key
    entry = {"pcbColor": color}
    pairs = (("_pcb", "pcb"), ("_parts", "parts")) if (d / f"{key}_pcb.stl").exists() else (("", "all"),)
    for suffix, name in pairs:
        f = d / f"{key}{suffix}.stl"
        if f.exists() and f.stat().st_size < 1_600_000:
            entry[name] = enc(f)
    if len(entry) > 1:
        vendor[key] = entry

# print files: full-resolution STLs + a written spec, as single files and one zip (web build); the local build links into v01/cad
import zipfile
def print_spec_md():
    lines = ["# Goldfish v01c — print files", "", "FDM, 0.4 mm nozzle. Fits are designed at 0.15 mm clearance.", "",
             "| File | Material | Colour | Qty | Layer | Walls | Infill | Orientation | Supports | Notes |", "|---|---|---|---|---|---|---|---|---|---|"]
    for k, v in printinfo.items():
        sp = v.get("spec", {})
        lines.append(f"| {v.get('file', k + '.stl')} | {sp.get('material', v['material'])} | {sp.get('colour', '')} | {v['qty']} | {sp.get('layer_mm', '')} mm | {sp.get('walls', '')} | {sp.get('infill', '')} | {sp.get('orientation', '')} | {sp.get('supports', '')} | {sp.get('notes', '')} |")
    return "\n".join(lines) + "\n"
PRINT_BASE = "../v01/cad/out_pebble/bed/"
if WEB:
    pd = OUTDIR / "print"; pd.mkdir(parents=True, exist_ok=True)
    (pd / "PRINT-SPEC.md").write_text(print_spec_md())
    with zipfile.ZipFile(pd / "goldfish-v01c-print-files.zip", "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("PRINT-SPEC.md", print_spec_md())
        for k, v in printinfo.items():
            src = CAD / "out_pebble" / v.get("src", v.get("file", k + ".stl"))   # bed-oriented copy
            if src.exists():
                shutil.copy(src, pd / src.name); z.write(src, src.name)
    PRINT_BASE = "../print/"
else:
    (CAD / "out_pebble" / "PRINT-SPEC.md").write_text(print_spec_md())

# product docs: the site renders the repo's own markdown tables (one source of truth)
def md_table(path, heading):
    """First markdown table after the line that starts with `heading`; cells keep inline markdown."""
    lines = (ROOT / path).read_text().splitlines()
    i = next(k for k, l in enumerate(lines) if l.strip().startswith(heading))
    rows = []
    for l in lines[i + (0 if lines[i].strip().startswith("|") else 1):]:
        if l.strip().startswith("|"):
            cells = [c.strip() for c in l.strip().strip("|").split("|")]
            if not all(set(c) <= set("-: ") for c in cells):
                rows.append(cells)
        elif rows:
            break
    return rows
def md_section(path, heading, until="## "):
    lines = (ROOT / path).read_text().splitlines()
    i = next(k for k, l in enumerate(lines) if l.strip().startswith(heading))
    out = []
    for l in lines[i + 1:]:
        if l.startswith(until) and out:
            break
        if not l.strip().startswith("|"):
            out.append(l)
    return "\n".join(out).strip()
docs = {
    "roadmap": md_table("ROADMAP.md", "## Recommended roadmap"),
    "roadmap_note": md_section("ROADMAP.md", "## Recommended roadmap"),
    "backlog": md_table("BACKLOG.md", "| ID"),
}
if (ROOT / "01-concept" / "market-comparison.md").exists():
    docs["market"] = md_table("01-concept/market-comparison.md", "## Comparison")
    docs["market_note"] = md_section("01-concept/market-comparison.md", "## What this means for Goldfish")

html = (HERE / "workbench.src.html").read_text()
# facts the prose states, taken from the generated data so they can't drift: @@gf.name@@ in the source
power = json.loads((V01 / "sim" / "power_report.json").read_text())
def runtime(scenario, bound="mid"):
    return next(r["runtime_h"] for r in power["runs"] if r["scenario"].startswith(scenario) and r["bound"] == bound)
facts = {"leds": layout["strip"]["n"],
         "batt_busy": f"{runtime('Transcribing'):.1f}", "batt_listen": f"{runtime('Listening'):.1f}",
         "batt_worst": f"{min(r['runtime_h'] for r in power['runs']):.1f}"}
_heat = next(c["detail"] for c in power["checks"] if c["what"].startswith("Shell temperature"))
_cap = next(c["detail"] for c in power["checks"] if c["what"].startswith("Thermal budget"))
facts.update(cell_mah=f"{power['design']['cell_mAh']:,}", shed_w=re.search(r"sheds ≈ ([\d.]+) W", _heat).group(1),
             radxa_w=re.search(r"Radxa to ≤ ([\d.]+) W", _cap).group(1),
             size=" × ".join(f"{v:.0f}" for v in layout["outer"]), mass=f"{layout['mass_g']:.0f}")
# the Sources tab lists every ADR in the folder (title up to its first " — " or ": "); the header gives the month of the last validation run
import datetime as _dt, html as _html
def _adr(f):
    num, title = f.read_text().splitlines()[0].lstrip("# ").split(": ", 1)
    return f"{num} {title.split(' — ')[0].split(': ')[0]}"
facts["adrs"] = _html.escape(" · ".join(_adr(f) for f in sorted((ROOT / "03-architecture" / "decisions").glob("0*.md")) if not f.name.startswith("0000")))
assert not any(c in facts["adrs"] for c in "`$"), "ADR titles go into a JS template literal"
facts["month"] = _dt.date.fromisoformat(valid.get("run_at", _dt.date.today().isoformat())[:10]).strftime("%B %Y")
for k, v in facts.items():
    html = html.replace(f"@@gf.{k}@@", str(v))
assert "@@gf." not in html, re.findall(r"@@gf\.\w+@@", html)
if not WEB:   # the local build has no files/ folder: point at the repo itself
    html = html.replace('href="files/view.html#', 'href="../').replace('href="files/v01/bom.csv"', 'href="../v01/bom.csv"').replace('href="files/"', 'href="../README.md"')
for token, value in (("/*MESHES*/{}", meshes), ("/*LAYOUT*/{}", layout), ("/*WIRES*/{}", wires), ("/*BOM*/[]", bom), ("/*VALID*/{}", valid), ("/*VENDOR*/{}", vendor),
                     ("/*PRINT*/{}", printinfo), ('/*PRINTBASE*/""', PRINT_BASE), ("/*DOCS*/{}", docs), ("/*SOURCING*/{}", json.loads((V01 / "sourcing.json").read_text()) if (V01 / "sourcing.json").exists() else {}), ('/*ROOMPY*/""', roompy), ("/*SPECJSON*/{}", spec)):
    assert token in html, token
    html = html.replace(token, json.dumps(value))
# ADR-0019: the Meeting tab runs the real firmware in Pyodide. Its Python sources, the profile, the grammars and the meeting
# script go in as files (path → text), written into Pyodide's file system at the same paths as in the repo.
FW_FILES = (sorted((PROTO / "python" / "resolution_protocol").glob("*.py")) + sorted((V01 / "firmware" / "goldfish").glob("*.py"))
            + [V01 / "firmware" / "scenarios" / "meeting.json", V01 / "profile" / "goldfish.profile.json"] + sorted((PROTO / "locales").glob("*.json")))
firmware = {str(f.relative_to(ROOT)): f.read_text() for f in FW_FILES}
meeting = json.loads((V01 / "firmware" / "scenarios" / "meeting.json").read_text())
for token, value in (("/*MEETING*/{}", meeting), ("/*FIRMWARE*/{}", firmware)):
    assert token in html, token
    html = html.replace(token, json.dumps(value, ensure_ascii=False).replace("</", "<\\/"))
assert "/*CORE*/" in html
html = html.replace("/*CORE*/", core.replace("</script>", "<\\/script>"))
# link previews: absolute URLs, so only on the web build (the deploy address is the owner's: AGENTS.md)
SITE_URL = "https://npc.here.now/demo-goldfish/"
OG = lambda url: (f'<meta property="og:url" content="{url}">\n<meta property="og:image" content="{SITE_URL}preview.png">\n'
                  '<meta property="og:image:width" content="1200">\n<meta property="og:image:height" content="630">\n'
                  '<meta property="og:image:alt" content="The Goldfish pebble: a yellow printed case with a light band round its seam and an eye on top">'
                  if WEB and (V01 / "renders" / "preview.png").exists() else "")
html = html.replace("<!--OGIMAGE-->", OG(SITE_URL + "workbench/"))
if WEB:
    # the workbench moves to workbench/; shared folders (files, print, assets, r) stay at the root
    for a, b in (('href="files/', 'href="../files/'), ('href="llms.txt"', 'href="../llms.txt"'), ('href="sitemap.xml"', 'href="../sitemap.xml"'),
                 ("new URL('r', location.href", "new URL('../r', location.href")):
        assert a in html, a
        html = html.replace(a, b)
    (OUTDIR / "workbench").mkdir(exist_ok=True)
    (OUTDIR / "workbench" / "index.html").write_text(html)
    # the landing page (B-74) at the root: its photos, the 3D model, the 404 page and the device logic as one script
    landing = (HERE / "landing.src.html").read_text().replace("<!--OGIMAGE-->", OG(SITE_URL))
    (OUTDIR / "index.html").write_text(landing)
    shutil.copytree(HERE / "landing" / "photos", OUTDIR / "photos", ignore=shutil.ignore_patterns("*.md"))
    shutil.copytree(HERE / "landing" / "voices", OUTDIR / "voices")   # the "Listen in" script, and its audio once site/landing_voices.py has run
    (OUTDIR / "model").mkdir(); shutil.copy(HERE / "landing" / "model" / "goldfish-parts.json", OUTDIR / "model")
    if (HERE / "landing" / "model" / "goldfish-parts.json").stat().st_mtime < (CAD / "out_pebble" / "layout.json").stat().st_mtime:
        print("warning: site/landing/model/goldfish-parts.json is older than the CAD; rerun site/landing_model.py")
    shutil.copy(HERE / "landing" / "404.html", OUTDIR / "404.html")
    shutil.copy(HERE / "favicon.svg", OUTDIR / "favicon.svg")
    (OUTDIR / "js").mkdir()
    (OUTDIR / "js" / "gf.js").write_text("\n".join([
        "// SPDX-License-Identifier: Apache-2.0",
        "// Built by site/build.py: resolution.js + goldfish.js, unchanged, with the profile and locales. Defines window.GF.",
        "window.GF_PROFILE = " + json.dumps(gf_profile) + ";", "window.GF_LOCALES = " + json.dumps(gf_locales, ensure_ascii=False) + ";",
        (PROTO / "js" / "resolution.js").read_text(),
        "window.GF = (function () { const module = undefined, require = undefined;",
        (V01 / "firmware" / "js" / "goldfish.js").read_text(),
        "return { RP, GF_P, newSession, parse, ringFrame, seatFrame, batteryFrame, pressEye, provisionalLeft, roomOff,",
        "         RUNG_NAMES, LIGHT_RGB, RING_LEDS, HAPTIC, distil, cap, ledBearings };", "})();"]))
else:
    (OUTDIR / "index.html").write_text(html)
shutil.copy(V01 / "renders" / "reader.png", OUTDIR / "reader.png")
if (V01 / "renders" / "preview.png").exists():
    shutil.copy(V01 / "renders" / "preview.png", OUTDIR / "preview.png")
if WEB:
    (OUTDIR / "r").mkdir(exist_ok=True)
    shutil.copy(V01 / "reader" / "index.html", OUTDIR / "r" / "index.html")
# published files (web build): the docs a builder needs, the protocol, the licences, and a source zip
def _doc1(f):
    """A source file's one-line description: its module docstring's first line, else its name."""
    t = f.read_text(errors="ignore")
    m = re.search(r'"""(.+?)(?:\n|""")', t) if f.suffix == ".py" else None
    return (m.group(1).strip().rstrip(".") if m else f.name)[:140]


FILES = [
    ("Pick this up", [("README.md", "Repository overview"), ("AGENTS.md", "Contributing: the pipeline, the checks, the rules"),
                      ("BUILD-LOG.md", "Build log: history and current state, \"Start here\" at the top")]),
    ("Build the kit", [("v01/README.md", "v01 kit overview"), ("v01/assembly.md", "Assembly: who it is for, tools, LiPo safety, steps, bring-up checks"),
                       ("v01/wiring.md", "Wiring, pad level"), ("v01/bom.csv", "Bill of materials (every part, buy links)"),
                       ("v01/profile/goldfish.profile.json", "Goldfish profile (the device's settings, as an RP profile)"),
                       ("v01/note-format.md", "Note format: the Markdown a phone receives, for people and language models")]),
    ("Resolution Protocol", [("protocol/README.md", "Protocol overview and quick start"), ("protocol/SPEC.md", "Specification (RP 0.2 draft)"),
                             ("protocol/research/prior-art.md", "Prior art"), ("protocol/js/resolution.js", "Reference implementation, JavaScript"),
                             ("protocol/js/conformance.js", "Conformance runner, JavaScript"),
                             ("protocol/locales/en.json", "English grammar"), ("protocol/locales/es.json", "Spanish grammar (draft)"),
                             ("protocol/vectors/sessions.json", "Session vectors"), ("protocol/vectors/phrases.en.json", "Phrase vectors, English"),
                             ("protocol/vectors/retention.json", "Retention vectors"), ("protocol/vectors/profiles.json", "Profile vectors"),
                             ("protocol/schemas/profile.schema.json", "Profile schema"), ("protocol/schemas/locale.schema.json", "Locale schema"),
                             ("protocol/schemas/event.schema.json", "Event schema")]),
    ("Decisions and plans", [("ROADMAP.md", "Roadmap"), ("BACKLOG.md", "Backlog"), ("03-architecture/code.md", "Code architecture")]
                            + [(str(f.relative_to(ROOT)), f.read_text().splitlines()[0].lstrip("# ").strip())
                               for f in sorted((ROOT / "03-architecture" / "decisions").glob("0*.md")) if not f.name.startswith("0000")]),
    ("Source code", [(str(f.relative_to(ROOT)), _doc1(f)) for f in
                     sorted((V01 / "firmware" / "goldfish").glob("*.py")) + [V01 / "firmware" / "js" / "goldfish.js"]
                     + sorted((PROTO / "python" / "resolution_protocol").glob("*.py"))
                     + sorted((V01 / "cad").glob("*.py")) + [V01 / "cad" / "pebble_spec.json", V01 / "sim" / "power_sim.py", V01 / "sim" / "design.json",
                                                            V01 / "build.py", V01 / "validate.py", HERE / "build.py"]
                     if f.exists()]),
    ("Licences", [("LICENSE", "Which licence covers what"), ("LICENSES/Apache-2.0.txt", "Apache 2.0 (code)"),
                  ("LICENSES/CERN-OHL-P-2.0.txt", "CERN-OHL-P v2 (hardware design files)"), ("LICENSES/CC-BY-4.0.txt", "CC BY 4.0 (specification, docs, data)")]),
]
if WEB:
    import subprocess, html as _h
    fd = OUTDIR / "files"
    if fd.exists():
        shutil.rmtree(fd)
    fd.mkdir()
    for _, items in FILES:
        for rel, _t in items:
            (fd / rel).parent.mkdir(parents=True, exist_ok=True)
            shutil.copy(ROOT / rel, fd / rel)
    # source zip: everything in git plus new untracked files, minus vendor models and generated meshes (those are in the print zip)
    listed = subprocess.run(["git", "ls-files", "--cached", "--others", "--exclude-standard"], cwd=ROOT, capture_output=True, text=True).stdout.split()
    skip = ("v01/cad/vendor/", "site/web/", "site/index.html", "site/spec-v1.html", "v01/cad/out_pebble/", "v01/cad/out/", "phone-sim/", "v01/renders/")
    with zipfile.ZipFile(fd / "goldfish-source.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for rel in listed:
            if not rel.startswith(skip) and (ROOT / rel).is_file():
                z.write(ROOT / rel, "goldfish/" + rel)
    rows = ""
    for head, items in FILES:
        rows += f"<h2>{_h.escape(head)}</h2><ul>"
        for rel, title in items:
            href = f"view.html#{rel}" if rel.endswith((".md", ".txt")) or rel == "LICENSE" else rel
            rows += f'<li><a href="{href}">{_h.escape(title)}</a> <span class="p">{_h.escape(rel)}</span></li>'
        rows += "</ul>"
    zsize = (fd / "goldfish-source.zip").stat().st_size / 1e6
    page = (HERE / "files.src.html").read_text()
    (fd / "index.html").write_text(page.replace("<!--ROWS-->", rows).replace("<!--ZIP-->", f"{zsize:.1f}"))
    shutil.copy(HERE / "view.src.html", fd / "view.html")
    # for search engines and language models: llms.txt (llmstxt.org), a sitemap and robots.txt, every link to a raw file
    one = lambda rel: SITE_URL + "files/" + rel
    llms = ["# Goldfish", "",
            "> A voice recorder with no radio. Each voice sets how much of its words are kept, the record forgets on a schedule, "
            "and the device answers with light, never sound. An open kit (v01: a " + facts["size"] + " mm printed pebble round a Radxa "
            "ZERO 3E, about " + facts["mass"] + " g) and a device-independent draft standard, the Resolution Protocol.", "",
            "The landing page (" + SITE_URL + ") explains what Goldfish is for. "
            "The workbench (" + SITE_URL + "workbench/) shows the specification, the 3D model with every wire pad to pad, the print files, and the "
            "firmware running in the browser. Everything below is the repository's own text, served raw. Model-written text in the "
            "product is labelled machine-generated. Licences: code Apache-2.0, hardware CERN-OHL-P-2.0, docs CC BY 4.0.", ""]
    for head, items in FILES:
        llms.append(f"## {head if head != 'Licences' else 'Optional'}")
        llms += [f"- [{rel}]({one(rel)}): {title}" for rel, title in items]
        llms.append("")
    llms += ["## Downloads", f"- [Source zip]({SITE_URL}files/goldfish-source.zip): everything in the repository except vendor models and generated meshes",
             f"- [Print files]({SITE_URL}print/): the STL files and the print settings", ""]
    (OUTDIR / "llms.txt").write_text("\n".join(llms))
    urls = [SITE_URL, SITE_URL + "workbench/", SITE_URL + "files/", SITE_URL + "llms.txt", SITE_URL + "r/"] + [one(rel) for _, items in FILES for rel, _t in items]
    stamp = _dt.date.today().isoformat()
    (OUTDIR / "sitemap.xml").write_text('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
                                         + "".join(f"  <url><loc>{_h.escape(u)}</loc><lastmod>{stamp}</lastmod></url>\n" for u in urls) + "</urlset>\n")
    (OUTDIR / "robots.txt").write_text(f"User-agent: *\nAllow: /\nSitemap: {SITE_URL}sitemap.xml\n")

size = sum(f.stat().st_size for f in OUTDIR.rglob("*") if f.is_file()) if WEB else len(html)
print("workbench →", OUTDIR / ("workbench/index.html" if WEB else "index.html"), round(len(html) / 1e6, 2), "MB page ·", round(size / 1e6, 2), "MB total · vendor:", len(vendor))
