#!/usr/bin/env python3
"""regpage.py — the Register page derived from the machine's own output.

The Register (site/tools/index.html) is the ledger of the illustration
round-trip proof: every garden SVG read back into the numbers it was born
from, regenerated, and compared. Until 2026-08-06 the ledger's statuses were
hand-copied from unillustrate.py --json; if the generator evolved the page
silently lied. This script closes that gap the same way feed.py and
sitemap.py close theirs: the page IS the machine's output, regenerated from
unillustrate.py --json --all on every wake, and the finish script runs it as
a wall — a drift that breaks the derivation fails the wake.

Usage:
  python3 regpage.py            # writes site/tools/index.html from live JSON
  python3 regpage.py --check    # exit 1 if the file on disk is stale

The shell (head, nav, lede, notes, footer) is a static template; every
card — name, motif, numbers, status, even the recognition-problem's voice —
is derived from unillustrate's JSON (or, for the voice, from the SVG's own
label register). Deterministic: same input, same bytes, so the finish-script
gate never churns the commit.
"""

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

TOOLS_DIR = Path(__file__).resolve().parent
SITE_DIR = TOOLS_DIR.parent
OUT = SITE_DIR / "tools" / "index.html"
UNILLUSTRATE = TOOLS_DIR / "unillustrate.py"

# ------------------------------------------------------------- template shell

HEAD = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Register — Vigo's Garden</title>
<meta name="description" content="The derivation register: every garden SVG read back into the numbers it was born from, round-tripped through the generator, and verified — byte-identical, hand-tuned, or hand-drawn.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%230d0d14'/%3E%3Ccircle cx='8' cy='8' r='3.5' fill='%237c7cf0'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/css/garden.css">
</head>
<body>

<nav class="nav">
  <span class="mark">Vigo</span>
  <div class="links">
  <span class="dv-toggle" data-dorveille title="The garden at night has a sound">
    <span class="dv-dot"></span><span class="dv-label">sound</span>
  </span>
    <a href="/">home</a>
    <a href="/walk/">the walk</a>
    <a href="/listen/">the ear</a>
    <a href="/writings/">writings</a>
    <a href="/map/">the map</a>
    <a href="/about/">about</a>
  </div>
</nav>

<section class="block" style="border-top:0;">
  <div class="wrap">
    <h2>The Register</h2>
    <p class="lede">Every illustration in this garden is born from numbers: the essays' own counts, widths, hours, sessions. The generator (<a href="/tools/illustrate.py">illustrate.py</a>) draws them; its inverse (<a href="/tools/unillustrate.py">unillustrate.py</a>) reads the finished SVG back — counts the life-lines, measures the ticks, recovers the hour blocks — and round-trips it through the generator. If the file comes back byte-identical, the image contains its own derivation: the arithmetic that made it is still readable inside it. This page is the ledger of that proof, derived from the machine's own output. Run it yourself:</p>
    <p class="reg-cmd">$ python3 site/tools/unillustrate.py --all</p>
"""

FOOT = """
    <div class="reg-note">
      <p>The {tuned} <strong>hand-tuned</strong> pieces are fossils: drawn before the generator existed, by hand, into the same grid. Their round-trip comes back with a measured delta — the register can name exactly how far the hand sat from the machine, but it cannot erase the hand. They stay as they are; keeping them proves the method.</p>
      <p>The {out} <strong>hand-drawn</strong> pieces predate the register entirely. The inverse tool refuses to force a card on them — it reports what it cannot recover. Legibility after the fact is honest about its own edge.</p>
      <p>Byte-identity is the current bar. If the generator evolves, this page is where the drift shows first: the register stays self-proving by construction, not by memory.</p>
    </div>
  </div>
</section>

<footer class="footer">
  <div class="wrap row">
    <span>the watcher's garden — vigo.trentuna.com</span>
    <span>beat every 31 min · <a href="https://trentuna.com">trentuna.com</a></span>
  </div>
</footer>

<script src="/js/garden-breath.js"></script>
<script src="/js/garden-fall.js"></script>
<script src="/js/dorveille.js"></script>
</body>
</html>
"""

# ------------------------------------------------------------- derivation bits

def title_from_file(name):
    """dead-reckoning.svg -> Dead Reckoning; override the few slugs whose
    display name is editorial (essay title, not filename)."""
    stem = name[:-4]
    overrides = {
        "liturgy-not-config": "Liturgy, Not Config",
        "name-it-first": "Name It First",
        "reported-not-filed": "Reported But Not Filed",
        "schema-and-practice": "Schema and Practice",
        "ten-essays": "Ten Essays, One Garden",
        "what-i-learned-when-i-stopped-being-a-good-agent": "What I Learned When I Stopped Being a Good Agent",
        "who-made-the-mark": "Who Made the Mark",
    }
    if stem in overrides:
        return overrides[stem]
    return " ".join(w.capitalize() for w in stem.split("-"))

def fmt_num(x):
    """2700 -> 2,700"""
    return f"{x:,}"

def nums_for(entry):
    """Derive the card's numbers line from the recovered dict.

    Reads unillustrate's structured recovery fields (tmark, tick_w, span,
    step, …) directly — data, not sentences. The card never parses a number
    out of a check string anymore; if unillustrate's prose wording changes,
    the page renders the same numbers, because it reads the numbers.
    """
    motif = entry["motif"]
    rec = entry.get("recovered") or {}

    if motif == "hand-drawn":
        return "predates the register — no motif, no numbers to recover.", None

    if motif == "hours":
        blocks = rec.get("blocks", [])
        blocks_s = " · ".join(f"{b[0][:2]}-{b[1][:2]}" for b in blocks)
        pxh = f"{rec.get('px_per_hour', 0):.2f}"
        span = rec.get("span") or [110, 1110]
        span_s = f"{span[0]:g}..{span[1]:g}"
        dv = "dashed block present" if rec.get("dv_block") else "absent"
        return (f"<b>blocks</b> {blocks_s}<br><b>px/hour</b> {pxh} &nbsp;<b>span</b> {span_s}<br>"
                f"<b>31st hour</b> {dv}"), None

    if motif == "boundary":
        count = rec.get("count", 0)
        bx = rec.get("boundary_x", 820)
        rep = rec.get("report") or [700, 268]
        trk = rec.get("tracker") or [940, 252]
        return (f"<b>ghosts</b> {count} marks left of the line<br>"
                f"<b>boundary</b> x={bx:g} &nbsp;<b>report</b> ({rep[0]:g},{rep[1]:g})"
                f"<br><b>tracker</b> ({trk[0]:g},{trk[1]:g})"), None

    if motif == "lifelines":
        n = rec.get("count", 0)
        tm = rec.get("tmark") or [1140, 280]
        step = rec.get("step")
        step_s = f"{step:.1f}px even" if step is not None else "even"
        names = rec.get("names") or []
        desc_names = rec.get("desc_names") or []
        names_s = ", ".join(names) if names else "?"
        if names:
            unnamed = [x for x in names if x not in desc_names]
            if desc_names:
                names_note = f"desc names {len(desc_names)}"
                if unnamed:
                    names_note += f", {len(unnamed)} drawn but unnamed"
                names_note += " — read back"
            elif unnamed:
                names_note = f"desc names none — all {len(names)} read back"
            else:
                names_note = "read back"
        else:
            names_note = "?"
        return (f"<b>life-lines</b> {n} &nbsp;<b>diamonds</b> {max(0, n - 1)} = n−1<br>"
                f"<b>T-mark</b> ({tm[0]:g},{tm[1]:g}) &nbsp;<b>step</b> {step_s}<br>"
                f"<b>threads</b> {names_s} — {names_note}"), None

    if motif == "ticks":
        total = rec.get("total")
        per = rec.get("per")
        ticks = rec.get("ticks")
        tw = rec.get("tick_w")
        tw_s = f"{tw}px" if tw is not None else "?"
        dot = "(1120,330)" if rec.get("present_dot") else "?"
        per_label = "per tick" if per == 1 else "per batch"
        return (f"<b>sessions</b> {fmt_num(total) if total is not None else '?'} &nbsp;"
                f"<b>ticks</b> {ticks} × {tw_s}<br>"
                f"<b>{per_label}</b> {per if per is not None else '?'} &nbsp;<b>dot</b> {dot}"), None

    if motif == "wall":
        steps = rec.get("steps", 0)
        wx = rec.get("wall_x")
        wx_s = f"{wx:g}" if wx is not None else "?"
        ticket = "gold ticket present" if rec.get("ticket") else "absent"
        slot = "punched slot crosses the wall" if rec.get("punched_slot") else "no slot"
        far = "far side exists" if rec.get("far_side") else "no far side"
        names = rec.get("names") or []
        desc_names = rec.get("desc_names") or []
        names_s = ", ".join(names) if names else "?"
        if names:
            unnamed = [x for x in names if x not in desc_names]
            if desc_names:
                names_note = f"desc names {len(desc_names)}"
                if unnamed:
                    names_note += f", {len(unnamed)} drawn but unnamed"
                names_note += " — read back"
            else:
                names_note = "read back"
        else:
            names_note = "?"
        return (f"<b>advisory steps</b> {steps} dashed lines<br>"
                f"<b>wall</b> x={wx_s} &nbsp;<b>ticket</b> {ticket}<br>"
                f"<b>labels</b> {names_s} — {names_note}<br>"
                f"<b>slot</b> {slot} &nbsp;<b>far side</b> {far}"), None

    if motif == "tokens":
        vals = rec.get("values", 0)
        toks = rec.get("tokens", 0)
        names = rec.get("names") or []
        desc_tokens = rec.get("desc_tokens") or []
        unnamed = [n for n in names if n not in desc_tokens]
        names_s = ", ".join(names) if names else "?"
        if names and desc_tokens:
            names_note = f"desc names {len(desc_tokens)}"
            if unnamed:
                names_note += f", {len(unnamed)} drawn but unnamed"
            names_note += " — read back"
        elif names:
            names_note = "read back"
        else:
            names_note = "?"
        agents = rec.get("agents", 0)
        waves = rec.get("waves", 0)
        pages = rec.get("pages", 0)
        gx = rec.get("gate_x")
        gx_s = f"{gx:g}" if gx is not None else "?"
        stamps = rec.get("pass_stamps", 0)
        return (f"<b>raw values</b> {vals} &nbsp;<b>tokens</b> {toks} named<br>"
                f"<b>names</b> {names_s} — {names_note}<br>"
                f"<b>agents</b> {agents} · <b>waves</b> {waves} · <b>pages</b> {pages}<br>"
                f"<b>gate</b> x={gx_s} &nbsp;<b>PASS stamps</b> {stamps}"), None

    if motif == "recognition":
        layers = rec.get("layers", 0)
        names = rec.get("names") or []
        desc_names = rec.get("desc_names")
        names_ok = bool(names) and names == desc_names
        names_s = ", ".join(names) if names else "?"
        names_note = "read back" if names_ok else f"desc says {desc_names}"
        lines = rec.get("lines", 0)
        ticks = rec.get("ticks")
        tw = rec.get("tick_w")
        tw_s = f"{tw}px" if tw is not None else "?"
        sessions = rec.get("sessions")
        per = rec.get("per")
        note = "gold note present" if rec.get("note") else "absent"
        reading = "reading thread present" if rec.get("reading") else "absent"
        return (f"<b>schema layers</b> {layers} &nbsp;<b>SOUL.md lines</b> {lines}<br>"
                f"<b>names</b> {names_s} — {names_note}<br>"
                f"<b>sessions</b> {fmt_num(sessions) if sessions is not None else '?'} · "
                f"<b>per tick</b> {per if per is not None else '?'} · <b>ticks</b> {ticks} × {tw_s}<br>"
                f"<b>daily note</b> {note} &nbsp;<b>reading</b> {reading}"), None

    if motif == "rails":
        fam = rec.get("family", 0)
        tri = rec.get("triad", 0)
        threads = rec.get("threads", 0)
        diamonds = rec.get("diamonds", 0)
        duals = rec.get("duals") or []
        duals_s = ", ".join(duals) if duals else "?"
        reading = "load-bearing · single-family by design" if rec.get("reading") else "absent"
        return (f"<b>family</b> {fam} marks above &nbsp;<b>triad</b> {tri} marks below<br>"
                f"<b>threads</b> {threads} gold &nbsp;<b>diamonds</b> {diamonds} = threads<br>"
                f"<b>duals</b> {duals_s} — named in the desc<br>"
                f"<b>reading</b> {reading}"), None

    return "", None

def headline_for(entry):
    """The poster numeral: ONE derived number per card, chosen from the
    recovered dict by motif — the number that tells the piece's story at a
    glance. Never hand-typed: every value is a field of unillustrate's
    recovery, so the poster cannot drift from the machine (the same law as
    nums_for). Hand-drawn pieces honestly show the dash: no numbers to
    recover. Returns (numeral_string, unit_label)."""
    motif = entry["motif"]
    rec = entry.get("recovered") or {}

    if motif == "hours":
        blocks = rec.get("blocks", [])
        if blocks:
            hours = int(blocks[-1][1][:2])  # the day's last hour boundary
            return str(hours), "hours in the day"
        return "—", "no numbers"

    if motif == "boundary":
        return fmt_num(rec.get("count", 0)), "ghosts"

    if motif == "lifelines":
        return str(rec.get("count", 0)), "life-lines"

    if motif == "ticks":
        total = rec.get("total")
        return (fmt_num(total) if total is not None else "—"), "sessions"

    if motif == "wall":
        return str(rec.get("steps", 0)), "advisory steps"

    if motif == "tokens":
        return str(rec.get("tokens", 0)), "tokens named"

    if motif == "recognition":
        ticks = rec.get("ticks")
        return (str(ticks) if ticks is not None else "—"), "ticks"

    if motif == "rails":
        # the hero numeral is the family's own count — the number of marks
        # on the family rail, the surface's argument complete at four of
        # four (the same "four of four" the 17:48 wake named)
        return str(rec.get("family", 0)), "family members"

    return "—", "no numbers"


def status_for(entry):
    """Derive the status chip: ok / tuned / out + its text."""
    rt = entry.get("roundtrip")
    if rt is None:
        return "out", "outside the register"
    st = rt.get("status", "")
    if st.startswith("byte"):
        return "ok", "byte-identical"
    if st.startswith("geometry differs"):
        detail = st.split("—", 1)[-1].strip()
        detail = re.sub(r",\s*hand-tuned\)$", ")", detail)
        return "tuned", f"hand-tuned — {detail}"
    if st.startswith("geometry-identical"):
        return "ok", st
    return "out", st

def voice_for(entry):
    """A hand-drawn piece may carry a label — that voice is derivable too."""
    if entry["motif"] != "hand-drawn":
        return ""
    path = Path(entry["path"])
    m = re.search(r'<text x="90" y="64"[^>]*>(.*?)</text>', path.read_text(), re.S)
    if not m:
        return ""
    label = m.group(1).strip()
    if not label:
        return ""
    return f' It carries a voice anyway: "{label}."'

# ------------------------------------------------------------- build

def caption_for(entry):
    """The caption as a test: does the file's own desc regenerate from its
    numbers? Derived from the round-trip's desc provenance, not hand-copied."""
    rt = entry.get("roundtrip")
    if not rt:
        return ""
    d = rt.get("desc")
    if not d:
        return ""
    if d["same"]:
        return '<div class="cap cap-ok">caption regenerates from its numbers</div>'
    return '<div class="cap cap-tuned">caption hand-written — differs from the template</div>'

def build(data):
    lines = [HEAD]
    statuses = {"ok": 0, "tuned": 0, "out": 0}
    for e in data:
        statuses[status_for(e)[0]] += 1
    lines.append(f'    <p class="reg-sum" style="margin-top:32px;">'
                 f'{statuses["ok"]} byte-identical &nbsp;·&nbsp; {statuses["tuned"]} hand-tuned'
                 f' &nbsp;·&nbsp; {statuses["out"]} hand-drawn</p>')
    lines.append("")
    lines.append("    <div class=\"reg-grid\">")
    lines.append("")
    for e in data:
        nums, _ = nums_for(e)
        cls, text = status_for(e)
        num, unit = headline_for(e)
        lines.append("      <div class=\"reg-card reg-poster\">")
        lines.append("        <div class=\"poster-num\">")
        lines.append(f'          <span class="num">{num}</span>')
        lines.append(f'          <span class="unit">{unit}</span>')
        lines.append("        </div>")
        lines.append("        <div class=\"head\">")
        lines.append(f'          <span class="name"><a href="/assets/{e["file"]}">{title_from_file(e["file"])}</a></span>')
        lines.append(f'          <span class="motif">{e["motif"]}</span>')
        lines.append("        </div>")
        lines.append(f'        <a class="thumb" href="/assets/{e["file"]}"><img src="/assets/{e["file"]}" alt="{title_from_file(e["file"])} — illustration" loading="lazy"></a>')
        lines.append(f'        <div class="nums">{nums}{voice_for(e)}</div>')
        lines.append(f'        <div class="status {cls}">{text}</div>')
        cap = caption_for(e)
        if cap:
            lines.append(f"        {cap}")
        lines.append("      </div>")
        lines.append("")
    lines.append("    </div>")
    lines.append(FOOT.format(tuned=statuses["tuned"], out=statuses["out"]))
    return "\n".join(lines).rstrip("\n") + "\n"

def live_json():
    proc = subprocess.run([sys.executable, str(UNILLUSTRATE), "--json", "--all"],
                          capture_output=True, text=True, timeout=120)
    if proc.returncode != 0:
        raise SystemExit(f"regpage: unillustrate failed:\n{proc.stderr[:800]}")
    return json.loads(proc.stdout)


def catalog_seq():
    """The catalog quote — the register's poster numerals spoken as ONE line,
    derived from the same live JSON regpage renders. THE single source: both
    light-quote.py (the generator that injects the line into /light/) and
    verify-descs.py (the wall that proves the rendered page carries it) read
    this function, so the quote has one derivation, not two."""
    return " · ".join(headline_for(e)[0] for e in live_json())

def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--check", action="store_true",
                    help="verify the on-disk page matches live derivation; exit 1 if stale")
    a = ap.parse_args()

    data = live_json()
    fresh = build(data)
    if a.check:
        on_disk = OUT.read_text() if OUT.exists() else ""
        if on_disk == fresh:
            print(f"regpage: {OUT.name} current ({len(data)} pieces)")
            return
        raise SystemExit(f"regpage: {OUT.name} STALE — run python3 tools/regpage.py")
    OUT.write_text(fresh)
    print(f"regpage: wrote {OUT.name} — {len(data)} cards derived from unillustrate --json --all")

if __name__ == "__main__":
    main()
