#!/usr/bin/env python3
"""Stamp docs/shubnikov/index.html from data/census.json.

The page's skeleton — the seventeen wallpaper-group tabs, the sixty-eight film
panels, the colour-count sections and every count in them — is static HTML, so
the shape of the census reads without JavaScript and every deep link resolves
to a real element.  Three things are left to shub-app.mjs: the 990 picture
cards, because each one is a live canvas; the paragraph that describes a film
group, because it is nothing but census fields; and the note under each colour
count and clock group, because the same six sentences would otherwise be
stamped six hundred times.  Those notes travel in a small JSON island instead,
which is what keeps this file near 140 KB rather than 390 KB.

Prose comes from the sibling module ``prose.py`` when it is there, and falls
back to the defaults below when it is not.  Its one placeholder,
``{{TWO_COLOUR_TYPES}}``, is filled from the summary in ``../data/names.json``,
and its sentence is cut when that file is absent.  Names for the
clock-invisible colourings come from the same file; the page only tries to
fetch it when it exists at stamp time.

    python3 tools/make_page.py           # write index.html
    python3 tools/make_page.py --check   # exit 1 if index.html has drifted
"""

from __future__ import annotations

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

HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
CENSUS = ROOT / "data" / "census.json"
NAMES = ROOT / "data" / "names.json"
OUT = ROOT / "index.html"
STAMP = "<!-- generated by tools/make_page.py — do not hand-edit -->"
VERSION = "shubnikov-v2"
# The colouring that opens the page, beside the lede: P4₂cm in four colours,
# a four-fold pinwheel whose colours turn with the film.
HERO = "g130-n4-2"

LATTICE_RUNS = [
    ("Oblique", ["p1", "p2"]),
    ("Rectangular", ["pm", "pg", "cm", "pmm", "pmg", "pgg", "cmm"]),
    ("Square", ["p4", "p4m", "p4g"]),
    ("Hexagonal", ["p3", "p3m1", "p31m", "p6", "p6m"]),
]
NS = [2, 3, 4, 6]
KEYS = ["ct0", "ctne"]
# The crystal system a wallpaper group belongs to.  The census's own `lattice`
# field is the mesh the pictures are drawn on (square or triangular), which is
# not the same word: pm and pmm are rectangular groups drawn on a square mesh.
CRYSTAL_SYSTEM = {hm: name.lower() for name, members in LATTICE_RUNS for hm in members}

# ---------------------------------------------------------------- prose ----

DEFAULTS = {
    "TITLE": "Shubnikov — the 990 cyclic colourings of the 68 forward film groups",
    "SUBTITLE": "Every way of colouring a looping pattern so that its symmetries permute the colours in a cycle.",
    "DESCRIPTION": (
        "All 990 cyclic colourings of the 68 forward film groups, one monochrome animation each, "
        "grouped by wallpaper group: the two-colour case is Shubnikov's black-and-white symmetry, "
        "and three, four and six colours carry it further."
    ),
    "LEDE_HTML": (
        "<p>A <b>film group</b> is the symmetry group of a looping animation of a plane pattern. "
        "Sixty-eight of the 275 film groups are <i>forward</i>: no symmetry runs time backwards. "
        "A <b>cyclic colouring</b> of such a group is an onto homomorphism "
        "<span class=\"sym\">σ: G → ℤ<sub>n</sub></span> — each symmetry <i>g</i> carries colour "
        "<i>k</i> to colour <i>k</i> + σ(<i>g</i>), for every <i>k</i> at once. There are exactly "
        "990 of them up to equivalence, and each one is drawn here as its own small film.</p>"
        "<p>The <i>n</i> = 2 column is Shubnikov's black-and-white symmetry: a two-colouring is an "
        "index-2 subgroup, and every index-2 subgroup is normal, so every two-colouring is cyclic. "
        "Three, four and six colours are the rest of the table, and they are 683 of the 990.</p>"
    ),
    "SECTIONS": [],
    "N_TITLES": {2: "Two colours", 3: "Three colours", 4: "Four colours", 6: "Six colours"},
    "N_NOTES": {
        2: "The black-and-white case: the colour-keeping symmetries form a subgroup of index two.",
        3: "Three colours cycling: σ lands in ℤ₃, so the colour-keeping subgroup has index three.",
        4: "Four colours cycling; the squares of the colour-swapping symmetries land in the index-two subgroup.",
        6: "Six colours cycling: the richest case a plane lattice supports.",
    },
    "CLOCK_TITLES": {
        "ct0": "Invisible to the clock (cₜ = 0)",
        "ctne": "Colour moves with the clock",
    },
    "CLOCK_NOTES": {
        "ct0": "One period of the film leaves every colour where it was, so the colouring is already "
               "a colouring of the still pattern.",
        "ctne": "One period of the film advances every colour by cₜ, so the coloured film only "
                "closes after several periods.",
    },
    "FAMILY_NOTES": {},
    "FOOTER_HTML": (
        "<p>The 990 are counted up to <b>E<sub>fwd</sub></b>: two colourings are the same when an "
        "affine map of spacetime that keeps the direction of time carries one to the other, up to "
        "renaming colours by a unit of ℤ<sub>n</sub>. The stricter equivalence that keeps "
        "mirror-image films apart, as the 230 space groups do, gives 1006.</p>"
    ),
}


def load_prose():
    prose = dict(DEFAULTS)
    sys.path.insert(0, str(HERE))
    try:
        import prose as module  # type: ignore
    except Exception:
        return prose, False
    finally:
        sys.path.pop(0)
    for key in DEFAULTS:
        if hasattr(module, key):
            value = getattr(module, key)
            if isinstance(DEFAULTS[key], dict) and isinstance(value, dict):
                merged = dict(DEFAULTS[key])
                merged.update({int(k) if str(k).isdigit() else k: v for k, v in value.items()})
                prose[key] = merged
            else:
                prose[key] = value
    return prose, True


PLACEHOLDER = "{{TWO_COLOUR_TYPES}}"
# the sentence the placeholder sits in, so it can be cut whole when the
# number is not available: from a tag boundary or full stop to the next one
SENTENCE = re.compile(r"\s*[^.<>]*\{\{TWO_COLOUR_TYPES\}\}[^.<>]*\.")


def fill_placeholders(value, summary):
    """Substitute {{TWO_COLOUR_TYPES}} everywhere in the prose, or cut its sentence."""
    if isinstance(value, dict):
        return {k: fill_placeholders(v, summary) for k, v in value.items()}
    if isinstance(value, list):
        return [fill_placeholders(v, summary) for v in value]
    if not isinstance(value, str) or PLACEHOLDER not in value:
        return value
    realised = (summary or {}).get("twoColourTypesRealised")
    if realised is None:
        return SENTENCE.sub("", value)
    # The prose reads "… realise {{TWO_COLOUR_TYPES}} two-colour wallpaper types".
    total = (summary or {}).get("twoColourTypesTotal", 46)
    phrase = f"all {total}" if realised == total else f"{realised} of the {total}"
    extra = ""
    if not (summary or {}).get("cyclicColourGroupsUnrealised", ["?"]):
        n = summary.get("distinctColourGroups")
        extra = (f" Adding the clock-invisible colourings with three, four and six colours, every "
                 f"cyclic plane colour group with two, three, four or six colours appears, {n} in all.")
    return value.replace(PLACEHOLDER + " two-colour wallpaper types.",
                         f"{phrase} two-colour wallpaper types.{extra}").replace(PLACEHOLDER, phrase)


# ------------------------------------------------------------- helpers ----

E = html.escape


def film_counts(film: dict) -> dict[int, int]:
    return {n: int(film["counts"].get(str(n), 0)) for n in NS}


def film_total(film: dict) -> int:
    return sum(film_counts(film).values())


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

def counts_table(census: dict) -> str:
    rows = []
    families = census["families"]
    for lattice, members in LATTICE_RUNS:
        rows.append(f'<tr class="lattice-head"><th colspan="7" scope="colgroup">{E(lattice)} lattice</th></tr>')
        for hm in members:
            fam = families[hm]
            by = fam["byN"]
            cells = "".join(
                f'<td data-label="n={n}">{int(by.get(str(n), 0))}</td>' for n in NS
            )
            rows.append(
                f'<tr><th scope="row"><a href="#{E(hm)}">{E(hm)}</a>'
                f'<span class="orbifold">{E(fam["orbifold"])}</span></th>'
                f'{cells}'
                f'<td data-label="total"><b>{fam["total"]}</b></td>'
                f'<td data-label="invisible to the clock">{fam["ct0"]}</td></tr>'
            )
    totals = census["totals"]
    foot = "".join(f'<td data-label="n={n}">{totals["byN"][str(n)]}</td>' for n in NS)
    return (
        '<div class="shub-table-wrap"><table class="shub-counts">'
        '<caption class="visually-hidden">Cyclic colourings per wallpaper group and number of colours</caption>'
        '<thead><tr><th scope="col">Group</th>'
        + "".join(f'<th scope="col">n = {n}</th>' for n in NS)
        + '<th scope="col">total</th><th scope="col">cₜ = 0</th></tr></thead>'
        '<tbody>' + "".join(rows) + '</tbody>'
        f'<tfoot><tr><th scope="row">All 68 films</th>{foot}'
        f'<td data-label="total"><b>{totals["all"]}</b></td>'
        f'<td data-label="invisible to the clock">{totals["ct0"]}</td></tr></tfoot>'
        '</table></div>'
    )


def film_panel(census: dict, prose: dict, gid: str, only: bool, index: int = 0) -> str:
    film = census["films"][gid]
    counts = film_counts(film)
    by_key = {}
    for cls in census["classes"]:
        if cls["gid"] == gid:
            by_key.setdefault((cls["n"], cls["key"]), 0)
            by_key[(cls["n"], cls["key"])] += 1

    # The per-film prose — the description of the film group, the note under
    # each colour count and each clock group — is written by shub-app.mjs from
    # the census and the prose island, so the same sentence is not stamped
    # sixty-eight times into the file.
    sections = []
    for n in NS:
        total = counts[n]
        empty = total == 0
        open_attr = " open" if n == 2 and not empty else ""
        title = prose["N_TITLES"].get(n, f"{n} colours")
        groups = "".join(
            f'<section class="shub-ck" data-key="{key}">'
            f'<h4>{prose["CLOCK_TITLES"][key]} <b>{by_key[(n, key)]}</b></h4>'
            f'<div class="shub-grid" data-grid></div></section>'
            for key in KEYS if by_key.get((n, key))
        )
        body = groups if not empty else (
            '<p class="shub-empty">No cyclic colouring of this film group uses this many colours.</p>'
        )
        sections.append(
            f'<details class="shub-n" data-n="{n}"'
            f'{" data-empty=\"true\"" if empty else ""}'
            f' id="sec-{E(gid)}-n{n}"{open_attr}>'
            f'<summary>{title} <b>{"none" if empty else total}</b></summary>'
            f'{body}</details>'
        )

    hidden = "" if only or index == 0 else " hidden"
    labelled = "" if only else f' role="tabpanel" aria-labelledby="filmtab-{E(gid)}"'
    return (
        f'<div class="shub-film" id="film-{E(gid)}" data-film="{E(gid)}"{labelled}{hidden}>'
        f'{"".join(sections)}'
        f'</div>'
    )


def family_panel(census: dict, prose: dict, hm: str, index: int) -> str:
    fam = census["families"][hm]
    films = fam["films"]
    note = prose["FAMILY_NOTES"].get(hm, "")
    tabs = ""
    if len(films) > 1:
        buttons = []
        for i, gid in enumerate(films):
            film = census["films"][gid]
            total = film_total(film)
            selected = "true" if i == 0 else "false"
            buttons.append(
                f'<button type="button" class="shub-filmtab" role="tab" id="filmtab-{E(gid)}"'
                f' data-film="{E(gid)}" aria-controls="film-{E(gid)}" aria-selected="{selected}"'
                f' tabindex="{0 if i == 0 else -1}"'
                f' data-label-stem="{E(gid)}, {E(film["signature"])}, space group {E(film["sgName"])}"'
                f' aria-label="{E(gid)}, {E(film["signature"])}, space group {E(film["sgName"])}, '
                f'{total} colourings">'
                f'<span class="sig">{E(film["signature"])}</span>'
                f'<span class="sub">{E(gid)} · {E(film["sgName"])} · N={film["N"]} · {total}</span>'
                f'</button>'
            )
        tabs = (
            f'<div class="shub-filmtabs" role="tablist" aria-label="Film groups over {E(hm)}">'
            + "".join(buttons) + '</div>'
        )
    panels = "".join(
        film_panel(census, prose, gid, only=len(films) == 1, index=i)
        for i, gid in enumerate(films)
    )
    return (
        f'<section class="shub-panel" id="panel-{E(hm)}" role="tabpanel" aria-labelledby="tab-{E(hm)}"'
        f' data-family="{E(hm)}"{"" if index == 0 else " hidden"}>'
        f'<div class="shub-family-head"><h2>{E(hm)}</h2>'
        f'<span class="orb">{E(fam["orbifold"])}</span>'
        f'<span class="lat">{E(CRYSTAL_SYSTEM[hm])} lattice · {len(films)} film group'
        f'{"s" if len(films) != 1 else ""} · {fam["total"]} colourings</span></div>'
        + (f'<p class="shub-family-note">{note}</p>' if note else "")
        + tabs + panels + '</section>'
    )


def tabstrip(census: dict) -> str:
    groups = []
    first = True
    for lattice, members in LATTICE_RUNS:
        buttons = []
        for hm in members:
            fam = census["families"][hm]
            selected = "true" if first else "false"
            tabindex = 0 if first else -1
            buttons.append(
                f'<button type="button" class="shub-tab" role="tab" id="tab-{E(hm)}"'
                f' data-family="{E(hm)}" aria-controls="panel-{E(hm)}" aria-selected="{selected}"'
                f' tabindex="{tabindex}"'
                f' data-label-stem="{E(hm)}, orbifold {E(fam["orbifold"])}, {E(lattice.lower())} lattice"'
                f' aria-label="{E(hm)}, orbifold {E(fam["orbifold"])}, {E(lattice.lower())} lattice, '
                f'{fam["total"]} colourings">'
                f'<span class="hm">{E(hm)}</span>'
                f'<span class="orb">{E(fam["orbifold"])}</span>'
                f'<span class="n" data-tabcount>{fam["total"]}</span>'
                f'</button>'
            )
            first = False
        groups.append(
            f'<span class="shub-tabgroup" role="presentation">'
            f'<span class="shub-tabgroup-label" aria-hidden="true">{E(lattice)}</span>'
            + "".join(buttons) + '</span>'
        )
    return (
        '<div class="shub-tabstrip" role="tablist" aria-label="Wallpaper groups">'
        + "".join(groups) + '</div>'
    )


HEADER = """<header class="site">
  <div class="inner">
    <a class="title" href="../index.html">Spacetime Groups</a>
    <nav>
      <span class="navgroup">
        <span class="navgroup-label">Background</span>
        <a href="../index.html">Tutorial</a>
        <a href="../notation.html">Notation</a>
        <a href="../colourings-tutorial.html">Colourings</a>
        <a href="../subgroups-vs-colourings.html">Subgroups</a>
        <a href="../torus-cayley.html">Torus Cayley graphs</a>
        <a href="../hierarchy.html">Hierarchy</a>
        <a href="../history.html">History</a>
      </span>
      <span class="navgroup">
        <span class="navgroup-label">Catalogues</span>
        <a href="../showcase/">Showcase</a>
        <a href="../catalog.html">275 spacetime</a>
        <a href="../wallpaper.html">275 spacetime (grouped)</a>
        <a href="../correspondence.html">68 colored&lt;&gt;polar crystals</a>
        <a href="../patterns.html">609 patterns</a>
        <a href="../escher.html">107 Escher</a>
        <a href="../gallery.html">Gallery</a>
        <a href="../scott-gray-groups.html">Reaction–diffusion</a>
      </span>
      <span class="navgroup">
        <span class="navgroup-label">Colourings</span>
        <a href="../colour/">Entangled colourings</a>
        <a href="../colour/gyre/">Gyre</a>
        <a href="../colour/trefoil/">Trefoil</a>
        <a href="../monochrome/">Monochrome</a>
        <a href="../shubnikov/" class="here">Shubnikov</a>
      </span>
      <span class="navgroup">
        <span class="navgroup-label">Developer</span>
        <a href="../reports/">Reports</a>
        <a href="../motifs.html">Motifs</a>
        <a href="../designer.html">Designer</a>
        <a href="../billiards.html">Billiards</a>
      </span>
      <span class="navgroup navgroup-superseded">
        <span class="navgroup-label">Superseded</span>
        <a href="../crystals-colored.html">269 coloured crystals</a>
      </span>
    </nav>
  </div>
</header>"""


DIALOG = """  <dialog class="shub-dialog" id="shub-viewer" closedby="any" aria-labelledby="shub-viewer-title">
    <form method="dialog" class="shub-dialog-head">
      <h3 id="shub-viewer-title">Colouring</h3>
      <span class="where" data-viewer-where></span>
      <button class="shub-dialog-close" value="close" aria-label="Close the viewer">Close</button>
    </form>
    <div class="shub-dialog-body">
      <div class="shub-stage"><canvas data-viewer-canvas width="640" height="640"></canvas></div>
      <div class="shub-transport">
        <button type="button" class="shub-chip wide" data-viewer-play aria-pressed="true">Pause</button>
        <input type="range" data-viewer-scrub min="0" max="1000" value="0" step="1"
               aria-label="Time within one full coloured period">
        <span class="time" data-viewer-time></span>
        <label class="time">Speed
          <select data-viewer-speed aria-label="Playback speed">
            <option value="0.25">¼×</option>
            <option value="0.5">½×</option>
            <option value="1" selected>1×</option>
            <option value="2">2×</option>
          </select>
        </label>
        <button type="button" class="shub-chip" data-viewer-view>One colour</button>
      </div>
      <div class="shub-legend" data-viewer-legend></div>
      <div class="shub-algebra">
        <h4>Algebra</h4>
        <dl data-viewer-algebra></dl>
      </div>
    </div>
  </dialog>"""


def prose_island(prose: dict) -> str:
    """The prose the page needs per film, handed to shub-app.mjs as data.

    These four sentences and two notes would otherwise be stamped six hundred
    times over — once under every colour count of every film — so the file
    carries one copy and the script puts them where they belong.
    """
    payload = {
        "nNote": {str(n): prose["N_NOTES"].get(n, "") for n in NS},
        "keyNote": {key: prose["CLOCK_NOTES"].get(key, "") for key in KEYS},
    }
    text = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
    return f'<script type="application/json" id="shub-prose">{text}</script>'


def build(census: dict, prose: dict, names_present: bool) -> str:
    title = prose["TITLE"]
    sections = "".join(
        f'<details class="shub-section" id="{E(section["id"])}">'
        f'<summary>{section["summary"]}</summary>{section["html"]}</details>'
        for section in prose["SECTIONS"]
    )
    totals = census["totals"]
    names_attr = f' data-names="data/names.json?v={VERSION}"' if names_present else ""
    names_credit = (
        ', the names in <a href="data/names.json">data/names.json</a> by '
        '<a href="tools/name_colourings.py">tools/name_colourings.py</a>,'
    ) if names_present else ""
    panels = "".join(family_panel(census, prose, hm, i) for i, hm in enumerate(census["order"]))
    return f"""<!doctype html>
<html lang="en">
{STAMP}
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="{E(prose["DESCRIPTION"])}">
  <meta name="theme-color" content="#ffffff">
  <title>{E(title)}</title>
  <link rel="canonical" href="https://yaroslavvb.github.io/animated-groups-fable/shubnikov/">
  <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎞️</text></svg>">
  <link rel="stylesheet" href="../css/style.css?v=collapsible-header-v1">
  <script defer src="../js/site-header.js?v=collapsible-header-v1"></script>
  <link rel="stylesheet" href="shubnikov.css?v={VERSION}">
  <script type="module" src="shub-app.mjs?v={VERSION}"></script>
</head>
<body>
  <a class="skip-link" href="#groups">Skip to the wallpaper groups</a>
{HEADER}
  <main class="shubnikov-page" aria-busy="true" data-census="data/census.json?v={VERSION}"{names_attr}>
    <p class="overline">Cyclic colourings of film groups</p>
    <h1>Shubnikov</h1>
    <p class="shub-subtitle">{E(prose["SUBTITLE"])}</p>
    <div class="shub-top">
      <div class="shub-lede">{prose["LEDE_HTML"]}</div>
      <div class="shub-hero" data-hero="{HERO}">
        <p class="shub-hero-wait">A colouring is drawing…</p>
      </div>
    </div>
    <div class="shub-notes">
{sections}
      <details class="shub-section" id="counts">
        <summary>Counts — {totals["all"]} colourings over 17 wallpaper groups</summary>
        <p>Every cyclic colouring of every forward film group, counted up to E<sub>fwd</sub>.
          The last column is the colourings a clock cannot see: one period of the film leaves
          every colour where it was.</p>
        {counts_table(census)}
      </details>
    </div>

    <div class="shub-controls" role="group" aria-label="Filters and playback">
      <div class="shub-controlset" role="group" aria-label="Number of colours">
        <span class="label">Colours</span>
        <button type="button" class="shub-chip" data-filter-n="all" aria-pressed="true">All</button>
        <button type="button" class="shub-chip" data-filter-n="2" aria-pressed="false">2</button>
        <button type="button" class="shub-chip" data-filter-n="3" aria-pressed="false">3</button>
        <button type="button" class="shub-chip" data-filter-n="4" aria-pressed="false">4</button>
        <button type="button" class="shub-chip" data-filter-n="6" aria-pressed="false">6</button>
      </div>
      <div class="shub-controlset" role="group" aria-label="The clock">
        <span class="label">Clock</span>
        <button type="button" class="shub-chip" data-filter-key="all" aria-pressed="true">All</button>
        <button type="button" class="shub-chip" data-filter-key="ct0" aria-pressed="false">Invisible to the clock</button>
        <button type="button" class="shub-chip" data-filter-key="ctne" aria-pressed="false">Moves with the clock</button>
      </div>
      <div class="shub-controlset" role="group" aria-label="View">
        <span class="label">View</span>
        <button type="button" class="shub-chip" data-view="greys" aria-pressed="true">Greys</button>
        <button type="button" class="shub-chip" data-view="one" aria-pressed="false">One colour</button>
      </div>
      <div class="shub-controlset">
        <button type="button" class="shub-chip wide" data-play-all aria-pressed="true">Pause all</button>
      </div>
      <p class="shub-status"><span data-status role="status">Loading the census…</span><span data-motion aria-hidden="true"></span></p>
    </div>

    <div id="groups">
{tabstrip(census)}
{panels}
    </div>
    {prose_island(prose)}

    <footer class="shub-footer">
{prose["FOOTER_HTML"]}
      <p>The census is a single file: <a href="data/census.json">data/census.json</a>
        ({CENSUS.stat().st_size // 1024} KB), built by
        <a href="tools/build_census.py">tools/build_census.py</a>{names_credit} and drawn in the browser by
        <a href="shub-field.mjs">shub-field.mjs</a> and <a href="shub-render.mjs">shub-render.mjs</a>.
        This page is stamped by <a href="tools/make_page.py">tools/make_page.py</a>.
        See also the <a href="../correspondence.html">film groups per wallpaper group</a>, the
        <a href="../monochrome/">monochrome reaction–diffusion atlas</a> and the
        <a href="../reports/symmetry-meeting-notes-week-38/">week-38 notes</a> the census was checked against.</p>
    </footer>
  </main>
{DIALOG}
</body>
</html>
"""


def check_wave_budget(census: dict) -> None:
    """The shader's uWaves array is fixed; the CPU twin's is not.

    A census that asked for more waves than the shader holds would draw one
    picture on the GPU and a different one on the CPU, with no error anywhere.
    The array size is the only thing tying the two together, so it is read from
    the renderer rather than repeated here.
    """
    source = (ROOT / "shub-render.mjs").read_text()
    found = re.search(r"export const MAX_WAVES = (\d+)", source)
    if not found:
        raise SystemExit("shub-render.mjs: cannot find MAX_WAVES")
    limit = int(found.group(1))
    wanted = int(census["model"]["maxModes"])
    if wanted > limit:
        raise SystemExit(
            f"census asks for {wanted} waves but the shader holds {limit}: "
            f"raise MAX_WAVES in shub-render.mjs"
        )


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--check", action="store_true", help="exit 1 if index.html has drifted")
    args = parser.parse_args()

    census = json.loads(CENSUS.read_text())
    if census.get("schema") != "shubnikov-census-v1":
        raise SystemExit(f"census: unexpected schema {census.get('schema')!r}")
    if HERO not in {cls["id"] for cls in census["classes"]}:
        raise SystemExit(f"census: no class {HERO!r} for the opening picture")
    check_wave_budget(census)
    prose, have_prose = load_prose()
    names_summary = None
    if NAMES.exists():
        names = json.loads(NAMES.read_text())
        if names.get("schema") != "shubnikov-names-v1":
            raise SystemExit(f"names: unexpected schema {names.get('schema')!r}")
        names_summary = names.get("summary") or {}
    prose = fill_placeholders(prose, names_summary)
    text = build(census, prose, NAMES.exists())

    if args.check:
        if not OUT.exists():
            print("index.html is missing")
            return 1
        if OUT.read_text() != text:
            print("index.html has drifted from a fresh stamp")
            return 1
        print(f"index.html matches a fresh stamp ({len(text) // 1024} KB)")
        return 0

    OUT.write_text(text)
    print(
        f"wrote {OUT.relative_to(ROOT.parent)} — {len(text) // 1024} KB, "
        f"{len(census['order'])} families, {len(census['films'])} films, "
        f"{census['totals']['all']} colourings"
        f"{'' if have_prose else ' (prose.py absent: placeholder prose)'}"
        f"{'' if NAMES.exists() else ' (names.json absent)'}"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
