#!/usr/bin/env python3
"""Build the offline Dark Factory wiki browser from Markdown pages."""

from __future__ import annotations

import argparse
import ast
import html
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any


WIKI_DIR = Path(__file__).resolve().parent
INDEX_HTML = WIKI_DIR / "index.html"
DATA_JSON = WIKI_DIR / "wiki-data.json"

SCHEMA_VERSION = "1.0"
ALLOWED_TYPES = {
    "index",
    "schema",
    "log",
    "source",
    "concept",
    "system",
    "comparison",
    "query",
}
REQUIRED_FIELDS = {
    "title",
    "slug",
    "type",
    "created",
    "updated",
    "status",
    "tags",
    "sources",
    "related",
    "confidence",
    "contradictions",
    "bead_refs",
}
LIST_FIELDS = {"tags", "sources", "related", "contradictions", "bead_refs"}
TYPE_ORDER = {
    "index": 0,
    "schema": 1,
    "log": 2,
    "concept": 3,
    "system": 4,
    "comparison": 5,
    "source": 6,
    "query": 7,
}
START_SLUGS = [
    "dark-factory",
    "what-is-the-durable-artifact-in-a-dark-factory",
    "dot-pipeline",
    "agentic-pipeline-runner",
    "code-review-vs-validation",
    "agent-orchestrator",
]
CURATED_GRAPH = [
    ("software-factory", "dark-factory", "lights-off model"),
    ("dark-factory", "dot-pipeline", "process artifact"),
    ("dark-factory", "code-review-vs-validation", "validation shift"),
    ("dark-factory", "digital-twin-universe", "realistic checks"),
    ("dot-pipeline", "agentic-pipeline-runner", "execution"),
    ("agentic-pipeline-runner", "cxdb", "event log"),
    ("cxdb", "healer", "diagnosis"),
    ("agentic-pipeline-runner", "agent-orchestrator", "worker substrate"),
    ("strongdm-attractor", "attractor-pattern", "spec corpus"),
    ("attractor-pattern", "nlspec", "spec layer"),
    ("attractor-pattern", "dot-pipeline", "graph layer"),
    ("llm-wiki-pattern", "index", "knowledge layer"),
]
CURATED_POSITIONS = {
    "software-factory": (86, 118),
    "dark-factory": (260, 118),
    "dot-pipeline": (440, 118),
    "agentic-pipeline-runner": (625, 118),
    "agent-orchestrator": (790, 78),
    "cxdb": (625, 250),
    "healer": (790, 250),
    "code-review-vs-validation": (260, 280),
    "digital-twin-universe": (86, 280),
    "strongdm-attractor": (86, 405),
    "attractor-pattern": (260, 405),
    "nlspec": (440, 405),
    "llm-wiki-pattern": (625, 405),
    "index": (790, 405),
}

WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
MD_URL_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)\s*$")
URL_RE = re.compile(r"^https?://", re.IGNORECASE)


class BuildError(Exception):
    """Raised when wiki input or generated output is invalid."""


def is_url(value: str) -> bool:
    return bool(URL_RE.match(value))


def parse_scalar(raw: str) -> Any:
    value = raw.strip()
    if value == "[]":
        return []
    if value == "":
        return ""
    if value[0:1] in {"'", '"'} and value[-1:] == value[0]:
        try:
            return ast.literal_eval(value)
        except (SyntaxError, ValueError):
            return value[1:-1]
    return value


def parse_frontmatter(text: str, path: Path) -> tuple[dict[str, Any], str]:
    lines = text.splitlines()
    if not lines or lines[0].strip() != "---":
        raise BuildError(f"{path}: missing opening frontmatter marker")

    try:
        end = next(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---")
    except StopIteration as exc:
        raise BuildError(f"{path}: missing closing frontmatter marker") from exc

    raw_frontmatter = lines[1:end]
    body = "\n".join(lines[end + 1 :]).strip()
    data: dict[str, Any] = {}
    index = 0

    while index < len(raw_frontmatter):
        line = raw_frontmatter[index]
        if not line.strip():
            index += 1
            continue
        if line.startswith(" "):
            raise BuildError(f"{path}: unexpected indented frontmatter line: {line!r}")
        key, separator, raw_value = line.partition(":")
        if not separator:
            raise BuildError(f"{path}: frontmatter line lacks ':' separator: {line!r}")
        key = key.strip()
        raw_value = raw_value.strip()

        if raw_value:
            data[key] = parse_scalar(raw_value)
            index += 1
            continue

        items: list[Any] = []
        index += 1
        while index < len(raw_frontmatter):
            child = raw_frontmatter[index]
            if not child.strip():
                index += 1
                continue
            if child.startswith("  - "):
                items.append(parse_scalar(child[4:]))
                index += 1
                continue
            break
        data[key] = items

    return data, body


def strip_markdown(value: str, include_urls: bool = False) -> str:
    def wikilink(match: re.Match[str]) -> str:
        return match.group(1)

    def md_link(match: re.Match[str]) -> str:
        label, url = match.group(1), match.group(2)
        return f"{label} {url}" if include_urls else label

    text = WIKILINK_RE.sub(wikilink, value)
    text = MD_URL_RE.sub(md_link, text)
    text = re.sub(r"`([^`]+)`", r"\1", text)
    text = re.sub(r"^[#>*\-\s]+", "", text)
    text = re.sub(r"[*_]{1,3}", "", text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def extract_wikilinks(body: str) -> list[str]:
    found = []
    seen = set()
    for match in WIKILINK_RE.finditer(body):
        slug = match.group(1).strip()
        if slug and slug not in seen:
            found.append(slug)
            seen.add(slug)
    return found


def extract_external_links(body: str) -> list[dict[str, str]]:
    links: list[dict[str, str]] = []
    seen = set()
    for match in MD_URL_RE.finditer(body):
        label = strip_markdown(match.group(1))
        url = match.group(2)
        if url not in seen:
            links.append({"label": label or url, "url": url, "origin": "body"})
            seen.add(url)
    return links


def extract_headings(body: str) -> list[dict[str, str]]:
    headings = []
    for line in body.splitlines():
        match = HEADING_RE.match(line)
        if match:
            headings.append({"level": str(len(match.group(1))), "title": strip_markdown(match.group(2))})
    return headings


def extract_section_bullets(body: str, section_names: set[str]) -> list[str]:
    bullets: list[str] = []
    in_section = False
    for line in body.splitlines():
        heading = HEADING_RE.match(line)
        if heading:
            level = len(heading.group(1))
            title = strip_markdown(heading.group(2)).lower()
            in_section = level == 2 and title in section_names
            continue
        if in_section and line.startswith("- "):
            bullets.append(strip_markdown(line[2:], include_urls=True))
    return bullets


def summarize_body(body: str) -> str:
    paragraph: list[str] = []
    after_title = False

    for raw_line in body.splitlines():
        line = raw_line.strip()
        if not line:
            if paragraph:
                break
            continue
        if line.startswith("# "):
            after_title = True
            continue
        if not after_title:
            continue
        if line.startswith("## "):
            if paragraph:
                break
            continue
        if line.startswith("Source URL:") or line.startswith("Published:"):
            continue
        if line.startswith("- "):
            return trim_sentence(strip_markdown(line[2:], include_urls=False), 210)
        paragraph.append(strip_markdown(line, include_urls=False))

    summary = " ".join(paragraph).strip()
    if not summary:
        for raw_line in body.splitlines():
            line = raw_line.strip()
            if line and not line.startswith("#") and not line.startswith("Source URL:"):
                summary = strip_markdown(line, include_urls=False)
                break
    return trim_sentence(summary, 240)


def trim_sentence(text: str, limit: int) -> str:
    text = re.sub(r"\s+", " ", text).strip()
    if len(text) <= limit:
        return text
    clipped = text[: limit - 1].rsplit(" ", 1)[0].rstrip(".,;:")
    return clipped + "..."


def slug_for_anchor(slug: str) -> str:
    normalized = re.sub(r"[^A-Za-z0-9_-]+", "-", slug).strip("-").lower()
    return normalized or "page"


def load_pages() -> list[dict[str, Any]]:
    pages = []
    for path in sorted(WIKI_DIR.rglob("*.md")):
        relative = path.relative_to(WIKI_DIR).as_posix()
        text = path.read_text(encoding="utf-8")
        frontmatter, body = parse_frontmatter(text, path)
        for field in REQUIRED_FIELDS:
            if field not in frontmatter:
                raise BuildError(f"{relative}: missing required frontmatter field {field!r}")
        for field in LIST_FIELDS:
            if not isinstance(frontmatter[field], list):
                raise BuildError(f"{relative}: frontmatter field {field!r} must be a list")

        slug = str(frontmatter["slug"])
        page_type = str(frontmatter["type"])
        source_links = []
        for item in frontmatter["sources"]:
            source = str(item)
            if is_url(source):
                source_links.append({"label": source, "url": source, "origin": "frontmatter"})
        seen_urls = {link["url"] for link in source_links}
        for link in extract_external_links(body):
            if link["url"] not in seen_urls:
                source_links.append(link)
                seen_urls.add(link["url"])

        body_tensions = extract_section_bullets(body, {"open tensions", "caveats"})
        tensions = [str(item) for item in frontmatter["contradictions"] if str(item).strip()]
        for tension in body_tensions:
            if tension and tension not in tensions:
                tensions.append(tension)

        page = {
            "title": str(frontmatter["title"]),
            "slug": slug,
            "type": page_type,
            "status": str(frontmatter["status"]),
            "created": str(frontmatter["created"]),
            "updated": str(frontmatter["updated"]),
            "confidence": str(frontmatter["confidence"]),
            "tags": [str(item) for item in frontmatter["tags"]],
            "sources": [str(item) for item in frontmatter["sources"]],
            "source_refs": [str(item) for item in frontmatter["sources"] if not is_url(str(item))],
            "source_links": source_links,
            "related": [str(item) for item in frontmatter["related"]],
            "wikilinks": extract_wikilinks(body),
            "tensions": tensions,
            "bead_refs": [str(item) for item in frontmatter["bead_refs"]],
            "summary": summarize_body(body),
            "headings": extract_headings(body),
            "markdown_path": relative,
            "html_id": f"page-{slug_for_anchor(slug)}",
            "body_text": strip_markdown(body, include_urls=True),
        }
        page["search_text"] = " ".join(
            [
                page["title"],
                page["slug"],
                page["type"],
                page["confidence"],
                " ".join(page["tags"]),
                " ".join(page["sources"]),
                " ".join(page["related"]),
                " ".join(page["wikilinks"]),
                page["summary"],
                page["body_text"],
            ]
        )
        pages.append(page)

    return sorted(pages, key=lambda page: (TYPE_ORDER.get(page["type"], 99), page["title"].lower()))


def build_graph(pages: list[dict[str, Any]]) -> list[dict[str, Any]]:
    known = {page["slug"] for page in pages}
    edge_kinds: dict[tuple[str, str], set[str]] = defaultdict(set)

    def add_edge(source: str, target: str, kind: str) -> None:
        if source != target and target in known:
            edge_kinds[(source, target)].add(kind)

    for page in pages:
        slug = page["slug"]
        for target in page["related"]:
            add_edge(slug, target, "related")
        for target in page["source_refs"]:
            add_edge(slug, target, "source")
        for target in page["wikilinks"]:
            add_edge(slug, target, "wikilink")

    return [
        {"source": source, "target": target, "kinds": sorted(kinds)}
        for (source, target), kinds in sorted(edge_kinds.items())
    ]


def attach_backlinks(pages: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
    by_slug = {page["slug"]: page for page in pages}
    backlinks: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
    for edge in edges:
        backlinks[edge["target"]][edge["source"]].update(edge["kinds"])

    for page in pages:
        page["backlinks"] = [
            {
                "slug": source,
                "title": by_slug[source]["title"],
                "type": by_slug[source]["type"],
                "kinds": sorted(kinds),
            }
            for source, kinds in sorted(backlinks[page["slug"]].items(), key=lambda item: by_slug[item[0]]["title"].lower())
        ]


def build_data() -> dict[str, Any]:
    pages = load_pages()
    edges = build_graph(pages)
    attach_backlinks(pages, edges)
    by_slug = {page["slug"]: page for page in pages}
    counts_by_type = {page_type: 0 for page_type in sorted(ALLOWED_TYPES)}
    for page in pages:
        counts_by_type[page["type"]] = counts_by_type.get(page["type"], 0) + 1

    tensions = [
        {"slug": page["slug"], "title": page["title"], "type": page["type"], "text": tension}
        for page in pages
        for tension in page["tensions"]
    ]
    source_urls = sorted({link["url"] for page in pages for link in page["source_links"]})

    data = {
        "metadata": {
            "title": "Dark Factory Wiki Browser",
            "subtitle": "Offline human browser for the Markdown Dark Factory LLM wiki.",
            "schema_version": SCHEMA_VERSION,
            "markdown_root": "reports/wiki",
            "canonical_index": "reports/wiki/index.md",
            "html_artifact": "reports/wiki/index.html",
            "json_artifact": "reports/wiki/wiki-data.json",
            "page_count": len(pages),
            "source_url_count": len(source_urls),
            "beads_issue": "dark-factory-e68",
        },
        "counts": {
            "pages": len(pages),
            "types": counts_by_type,
            "graph_edges": len(edges),
            "open_tensions": len(tensions),
            "source_urls": len(source_urls),
        },
        "pages": pages,
        "graph": {
            "nodes": [
                {
                    "slug": page["slug"],
                    "title": page["title"],
                    "type": page["type"],
                    "confidence": page["confidence"],
                }
                for page in pages
            ],
            "edges": edges,
            "curated_edges": [
                {"source": source, "target": target, "label": label}
                for source, target, label in CURATED_GRAPH
                if source in by_slug and target in by_slug
            ],
        },
        "sections": {
            "start_here": [slug for slug in START_SLUGS if slug in by_slug],
            "source_urls": source_urls,
            "tensions": tensions,
        },
    }
    validate_data(data)
    return data


def validate_data(data: dict[str, Any]) -> None:
    errors = []
    pages = data["pages"]
    slugs = [page["slug"] for page in pages]
    known = set(slugs)

    if len(slugs) != len(set(slugs)):
        duplicates = sorted({slug for slug in slugs if slugs.count(slug) > 1})
        errors.append(f"duplicate slugs: {', '.join(duplicates)}")

    for page in pages:
        label = page["markdown_path"]
        if page["type"] not in ALLOWED_TYPES:
            errors.append(f"{label}: unknown type {page['type']!r}")
        for source in page["source_refs"]:
            if source not in known:
                errors.append(f"{label}: source ref {source!r} does not resolve")
        for target in page["related"]:
            if target not in known:
                errors.append(f"{label}: related page {target!r} does not resolve")
        for target in page["wikilinks"]:
            if target not in known:
                errors.append(f"{label}: wikilink {target!r} does not resolve")
        if not page["summary"]:
            errors.append(f"{label}: summary excerpt is empty")

    for edge in data["graph"]["edges"]:
        if edge["source"] not in known:
            errors.append(f"graph edge source {edge['source']!r} does not resolve")
        if edge["target"] not in known:
            errors.append(f"graph edge target {edge['target']!r} does not resolve")

    all_text_by_slug = {page["slug"]: page["search_text"].lower() for page in pages}
    smoke_queries = ["healer", "dot", "agent-orchestrator", "validation"]
    for query in smoke_queries:
        if not any(query.lower() in text for text in all_text_by_slug.values()):
            errors.append(f"search smoke query {query!r} has no matches")

    source_pages = [page for page in pages if page["type"] == "source"]
    if not source_pages:
        errors.append("source-only filter has no source pages")
    if any(page["type"] != "source" for page in source_pages):
        errors.append("source-only filter contains non-source pages")
    if not data["sections"]["source_urls"]:
        errors.append("no source URLs were preserved")

    markdown_files = sorted(path.relative_to(WIKI_DIR).as_posix() for path in WIKI_DIR.rglob("*.md"))
    represented_files = sorted(page["markdown_path"] for page in pages)
    if markdown_files != represented_files:
        errors.append("Markdown representation mismatch between files and data pages")

    if errors:
        raise BuildError("\n".join(errors))


def page_by_slug(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {page["slug"]: page for page in data["pages"]}


def group_for_type(page_type: str) -> str:
    if page_type in {"index", "schema", "log"}:
        return "infrastructure"
    return page_type


def pill_class(page_type: str) -> str:
    return {
        "concept": "pill--blue",
        "system": "pill--green",
        "comparison": "pill--amber",
        "source": "pill--violet",
        "query": "pill--red",
        "index": "pill--slate",
        "schema": "pill--slate",
        "log": "pill--slate",
    }.get(page_type, "pill--slate")


def render_chip(label: str, href: str | None = None, class_name: str = "chip") -> str:
    escaped_label = html.escape(label)
    if href:
        return f'<a class="{class_name}" href="{html.escape(href, quote=True)}">{escaped_label}</a>'
    return f'<span class="{class_name}">{escaped_label}</span>'


def render_relation_chips(slugs: list[str], by_slug: dict[str, dict[str, Any]], empty: str) -> str:
    if not slugs:
        return f'<span class="muted">{html.escape(empty)}</span>'
    chips = []
    for slug in slugs[:8]:
        page = by_slug.get(slug)
        if page:
            chips.append(render_chip(page["title"], f"#{page['html_id']}"))
        else:
            chips.append(render_chip(slug))
    extra = len(slugs) - len(chips)
    if extra > 0:
        chips.append(render_chip(f"+{extra} more"))
    return "".join(chips)


def render_source_chips(page: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> str:
    chips = []
    for slug in page["source_refs"][:6]:
        source = by_slug.get(slug)
        if source:
            chips.append(render_chip(source["title"], f"#{source['html_id']}", "chip chip--source"))
        else:
            chips.append(render_chip(slug, None, "chip chip--source"))
    for link in page["source_links"][:4]:
        label = link["label"]
        if label == link["url"]:
            label = re.sub(r"^https?://", "", label).rstrip("/")
        chips.append(render_chip(trim_sentence(label, 54), link["url"], "chip chip--external"))
    if not chips:
        return '<span class="muted">No direct source links listed.</span>'
    return "".join(chips)


def render_page_card(page: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> str:
    tags = " ".join(render_chip(tag, None, "tag") for tag in page["tags"][:6])
    backlinks = [item["slug"] for item in page["backlinks"]]
    search = html.escape(strip_markdown(page["search_text"], include_urls=True).lower(), quote=True)
    return f"""
<article class="wiki-card" id="{html.escape(page['html_id'], quote=True)}" data-type="{html.escape(page['type'])}" data-group="{group_for_type(page['type'])}" data-search="{search}">
  <div class="card-meta">
    <span class="pill {pill_class(page['type'])}">{html.escape(page['type'])}</span>
    <span class="pill">confidence {html.escape(page['confidence'])}</span>
    <span class="pill">updated {html.escape(page['updated'])}</span>
  </div>
  <h3>{html.escape(page['title'])}</h3>
  <p>{html.escape(page['summary'])}</p>
  <div class="tag-row" aria-label="Tags">{tags}</div>
  <div class="link-block">
    <strong>Read next</strong>
    <div class="chip-row">{render_relation_chips(page['related'], by_slug, "No related pages listed.")}</div>
  </div>
  <div class="link-block">
    <strong>Backlinks</strong>
    <div class="chip-row">{render_relation_chips(backlinks, by_slug, "No backlinks yet.")}</div>
  </div>
  <div class="link-block">
    <strong>Sources</strong>
    <div class="chip-row">{render_source_chips(page, by_slug)}</div>
  </div>
  <div class="card-actions">
    <a class="open-link" href="{html.escape(page['markdown_path'], quote=True)}">Open Markdown</a>
  </div>
</article>
""".strip()


def render_start_here(data: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> str:
    items = []
    for index, slug in enumerate(data["sections"]["start_here"], start=1):
        page = by_slug[slug]
        items.append(
            f"""
<a class="start-item" href="#{html.escape(page['html_id'], quote=True)}">
  <span class="start-index">{index}</span>
  <span><strong>{html.escape(page['title'])}</strong><small>{html.escape(page['summary'])}</small></span>
</a>
""".strip()
        )
    return "\n".join(items)


def render_curated_graph(data: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> str:
    lines = []
    labels = []
    for source, target, label in CURATED_GRAPH:
        if source not in by_slug or target not in by_slug:
            continue
        x1, y1 = CURATED_POSITIONS[source]
        x2, y2 = CURATED_POSITIONS[target]
        lines.append(
            f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" marker-end="url(#arrow)"><title>{html.escape(label)}</title></line>'
        )
    for slug, (x, y) in CURATED_POSITIONS.items():
        if slug not in by_slug:
            continue
        page = by_slug[slug]
        label = page["title"]
        short = label if len(label) <= 24 else label[:22] + "..."
        labels.append(
            f"""
<a href="#{html.escape(page['html_id'], quote=True)}" aria-label="{html.escape(label, quote=True)}">
  <circle cx="{x}" cy="{y}" r="38" class="node node--{group_for_type(page['type'])}"></circle>
  <text x="{x}" y="{y - 5}" text-anchor="middle">{html.escape(short)}</text>
  <text x="{x}" y="{y + 13}" text-anchor="middle" class="node-type">{html.escape(page['type'])}</text>
</a>
""".strip()
        )
    return f"""
<svg class="graph" viewBox="0 0 880 480" role="img" aria-label="Dark Factory concept graph">
  <defs>
    <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M 0 0 L 10 5 L 0 10 z"></path>
    </marker>
  </defs>
  <g class="edges">
    {"".join(lines)}
  </g>
  <g class="nodes">
    {"".join(labels)}
  </g>
</svg>
""".strip()


def render_cards_for_types(data: dict[str, Any], types: set[str]) -> str:
    by_slug = page_by_slug(data)
    return "\n".join(render_page_card(page, by_slug) for page in data["pages"] if page["type"] in types)


def render_tensions(data: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> str:
    items = []
    for tension in data["sections"]["tensions"]:
        page = by_slug[tension["slug"]]
        items.append(
            f"""
<article class="tension-card">
  <div class="card-meta"><span class="pill {pill_class(page['type'])}">{html.escape(page['type'])}</span><a class="text-link" href="#{html.escape(page['html_id'], quote=True)}">{html.escape(page['title'])}</a></div>
  <p>{html.escape(tension['text'])}</p>
</article>
""".strip()
        )
    return "\n".join(items)


CSS = r"""
:root {
  --page: #f7f8fa;
  --surface: #ffffff;
  --surface-soft: #f1f5f9;
  --ink: #17202a;
  --ink-muted: #4d5b6b;
  --ink-soft: #697789;
  --line: #d8e0ea;
  --line-soft: #e8edf3;
  --blue: #1d4ed8;
  --blue-soft: #eaf2ff;
  --green: #0f766e;
  --green-soft: #e7f7f3;
  --amber: #a15c07;
  --amber-soft: #fff4db;
  --red: #b42318;
  --red-soft: #fff1f0;
  --violet: #5b35b1;
  --violet-soft: #f3efff;
  --slate: #405064;
  --slate-soft: #eef2f6;
  --shadow: 0 10px 24px rgba(23, 32, 42, 0.07);
  --radius: 8px;
  --font-body: "Avenir Next", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
  --font-serif: Charter, Georgia, serif;
  --font-mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html {
  max-width: 100%;
  overflow-x: hidden;
  scroll-behavior: smooth;
}
body {
  margin: 0;
  max-width: 100%;
  overflow-x: hidden;
  background: var(--page);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}
a { color: var(--blue); text-underline-offset: 3px; }
a:focus-visible, button:focus-visible, input:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 2px;
}
button, input { font: inherit; }
input { min-width: 0; }
a, p, small, strong, span { overflow-wrap: break-word; }
.page {
  display: grid;
  grid-template-columns: minmax(230px, 280px) minmax(0, 1fr);
  gap: 24px;
  width: min(1500px, 100%);
  margin: 0 auto;
  padding: 24px;
}
.side-nav {
  position: sticky;
  top: 24px;
  align-self: start;
  max-height: calc(100vh - 48px);
  overflow: auto;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 18px;
}
.brand {
  display: grid;
  gap: 4px;
  padding-bottom: 14px;
  border-bottom: 1px solid var(--line-soft);
}
.brand__eyebrow {
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 12px;
  text-transform: uppercase;
}
.brand__title { margin: 0; font-size: 22px; line-height: 1.15; }
.nav-group { margin-top: 18px; }
.nav-group h2 {
  margin: 0 0 8px;
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 12px;
  text-transform: uppercase;
}
.nav-link {
  display: block;
  padding: 7px 8px;
  border-radius: 6px;
  color: var(--ink-muted);
  font-size: 14px;
  text-decoration: none;
}
.nav-link:hover, .nav-link:focus { background: var(--blue-soft); color: var(--blue); }
.main { min-width: 0; }
.hero, .section-panel, .toolbar, .wiki-card, .tension-card, .note, .fact {
  min-width: 0;
}
.card-grid > *, .start-list > *, .tension-list > *, .map-layout > *, .maintenance-grid > *, .fact-grid > * {
  min-width: 0;
}
.hero, .section-panel {
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}
.hero { padding: 28px; }
.hero__topline, .card-meta, .artifact-links, .tag-row, .chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.hero h1 {
  margin: 0;
  max-width: 920px;
  font-size: 42px;
  line-height: 1.06;
  letter-spacing: 0;
}
.hero__lede {
  max-width: 850px;
  margin: 14px 0 0;
  color: var(--ink-muted);
  font-family: var(--font-serif);
  font-size: 21px;
  overflow-wrap: break-word;
}
.pill, .chip, .tag {
  display: inline-flex;
  align-items: center;
  max-width: 100%;
  min-width: 0;
  min-height: 28px;
  padding: 3px 9px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-soft);
  color: var(--ink-muted);
  font-size: 13px;
  line-height: 1.2;
  overflow-wrap: anywhere;
  text-decoration: none;
}
.pill--blue { border-color: #bfdbfe; background: var(--blue-soft); color: var(--blue); }
.pill--green { border-color: #99f6e4; background: var(--green-soft); color: var(--green); }
.pill--amber { border-color: #f6d58b; background: var(--amber-soft); color: var(--amber); }
.pill--red { border-color: #fecaca; background: var(--red-soft); color: var(--red); }
.pill--violet { border-color: #ddd6fe; background: var(--violet-soft); color: var(--violet); }
.pill--slate { border-color: #d2dbe6; background: var(--slate-soft); color: var(--slate); }
.chip:hover { border-color: #bfdbfe; background: var(--blue-soft); color: var(--blue); }
.chip--source { background: var(--violet-soft); border-color: #ddd6fe; color: var(--violet); }
.chip--external { background: var(--green-soft); border-color: #99f6e4; color: var(--green); }
.tag { min-height: 24px; padding: 2px 8px; font-size: 12px; }
.fact-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 10px;
  margin-top: 22px;
}
.fact {
  min-width: 0;
  padding: 14px;
  background: var(--surface-soft);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
.fact strong { display: block; font-size: 24px; line-height: 1; }
.fact span { display: block; margin-top: 5px; color: var(--ink-soft); font-size: 13px; }
.artifact-links { margin-top: 18px; }
.artifact-links a {
  display: inline-flex;
  max-width: 100%;
  min-width: 0;
  min-height: 32px;
  align-items: center;
  padding: 4px 10px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--surface);
  color: var(--ink-muted);
  font-size: 14px;
  overflow-wrap: anywhere;
  text-decoration: none;
}
.artifact-links a:hover { color: var(--blue); border-color: #bfdbfe; background: var(--blue-soft); }
.toolbar {
  position: sticky;
  top: 0;
  z-index: 5;
  display: grid;
  grid-template-columns: minmax(220px, 1fr) auto;
  gap: 12px;
  align-items: center;
  margin: 18px 0;
  padding: 12px;
  background: rgba(247, 248, 250, 0.94);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  backdrop-filter: blur(8px);
}
.search {
  display: grid;
  grid-template-columns: 22px minmax(0, 1fr);
  gap: 8px;
  align-items: center;
  min-height: 42px;
  padding: 0 12px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 6px;
}
.search svg { color: var(--ink-soft); }
.search input { width: 100%; border: 0; outline: 0; color: var(--ink); background: transparent; }
.filters { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.filter-btn {
  max-width: 100%;
  min-width: 0;
  min-height: 38px;
  padding: 6px 11px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--surface);
  color: var(--ink-muted);
  cursor: pointer;
  overflow-wrap: anywhere;
}
.filter-btn[aria-pressed="true"] { border-color: #bfdbfe; background: var(--blue-soft); color: var(--blue); }
.result-line { margin: -6px 0 18px; color: var(--ink-soft); font-size: 14px; }
.empty-state[hidden] { display: none; }
.empty-state {
  margin-bottom: 18px;
  padding: 16px;
  background: var(--amber-soft);
  border: 1px solid #f6d58b;
  border-radius: var(--radius);
  color: var(--amber);
}
.section-panel { margin-top: 24px; padding: 24px; }
.section-head { max-width: 860px; margin-bottom: 16px; }
.section-head h2 { margin: 0; font-size: 28px; line-height: 1.15; }
.section-head p { margin: 8px 0 0; color: var(--ink-muted); }
.start-list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
}
.start-item {
  display: grid;
  grid-template-columns: 34px minmax(0, 1fr);
  gap: 10px;
  align-items: start;
  min-width: 0;
  padding: 14px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  background: var(--surface-soft);
  color: var(--ink);
  overflow-wrap: anywhere;
  text-decoration: none;
}
.start-item:hover { border-color: #bfdbfe; background: var(--blue-soft); }
.start-index {
  display: inline-grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 999px;
  background: var(--ink);
  color: white;
  font-family: var(--font-mono);
  font-size: 13px;
}
.start-item small { display: block; margin-top: 4px; color: var(--ink-muted); font-size: 14px; line-height: 1.4; }
.map-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
  gap: 18px;
  align-items: start;
}
.graph-wrap {
  max-width: 100%;
  overflow: auto;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  background: #fbfcfe;
}
.graph { display: block; min-width: 760px; width: 100%; height: auto; }
.graph line { stroke: #9aa9bb; stroke-width: 2; }
.graph marker path { fill: #9aa9bb; }
.graph .node { fill: white; stroke: #7c8da3; stroke-width: 2; }
.graph .node--concept { fill: var(--blue-soft); stroke: #93c5fd; }
.graph .node--system { fill: var(--green-soft); stroke: #5eead4; }
.graph .node--comparison { fill: var(--amber-soft); stroke: #f6d58b; }
.graph .node--source { fill: var(--violet-soft); stroke: #ddd6fe; }
.graph .node--query { fill: var(--red-soft); stroke: #fecaca; }
.graph .node--infrastructure { fill: var(--slate-soft); stroke: #cbd5e1; }
.graph text { fill: var(--ink); font-size: 12px; font-weight: 700; pointer-events: none; }
.graph .node-type { fill: var(--ink-soft); font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; }
.map-notes {
  display: grid;
  gap: 10px;
}
.note {
  padding: 14px;
  background: var(--surface-soft);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
.note strong { display: block; }
.note span { display: block; margin-top: 5px; color: var(--ink-muted); font-size: 14px; }
.card-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 14px;
}
.card-grid--three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.wiki-card, .tension-card {
  min-width: 0;
  padding: 18px;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
.wiki-card { display: grid; gap: 12px; }
.wiki-card.is-hidden { display: none; }
.wiki-card h3 {
  margin: 0;
  font-size: 22px;
  line-height: 1.16;
  overflow-wrap: anywhere;
}
.wiki-card p, .tension-card p { margin: 0; color: var(--ink-muted); }
.wiki-card p, .tension-card p, .note span, .start-item small {
  overflow-wrap: anywhere;
}
.link-block { display: grid; gap: 6px; }
.link-block strong {
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 12px;
  text-transform: uppercase;
}
.muted { color: var(--ink-soft); font-size: 14px; }
.card-actions {
  margin-top: 2px;
  padding-top: 12px;
  border-top: 1px solid var(--line-soft);
}
.open-link, .text-link {
  color: var(--blue);
  font-weight: 700;
  text-decoration-thickness: 1px;
}
.tension-list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}
.maintenance-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(260px, 0.65fr);
  gap: 16px;
}
.artifact-list {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.artifact-list a {
  display: flex;
  justify-content: space-between;
  min-width: 0;
  gap: 12px;
  padding: 10px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 6px;
  color: var(--ink);
  overflow-wrap: anywhere;
  text-decoration: none;
}
.artifact-list a:hover { background: var(--blue-soft); border-color: #bfdbfe; color: var(--blue); }
.artifact-list span { color: var(--ink-soft); font-size: 13px; }
@media (max-width: 1080px) {
  .page { grid-template-columns: 1fr; }
  .side-nav { position: static; max-height: none; }
  .toolbar { top: 0; }
  .map-layout, .maintenance-grid { grid-template-columns: 1fr; }
  .card-grid--three { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
  body { font-size: 16px; }
  body { overflow-x: hidden; }
  .page { width: 100vw; max-width: 100vw; padding: 12px; overflow-x: hidden; }
  .main { order: 1; }
  .side-nav { order: 2; }
  .main, .side-nav, .hero, .section-panel, .toolbar { max-width: 100%; }
  .pill, .chip, .tag, .filter-btn, .artifact-links a { max-width: 100%; overflow-wrap: anywhere; }
  .side-nav nav {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
  }
  .nav-group { margin-top: 12px; }
  .hero, .section-panel { padding: 18px; }
  .hero h1 { font-size: 32px; }
  .hero__lede { font-size: 18px; }
  .fact-grid, .start-list, .toolbar, .card-grid, .card-grid--three, .tension-list {
    grid-template-columns: 1fr;
  }
  .filters { justify-content: flex-start; }
  .filter-btn { flex: 1 1 auto; }
}
"""


SCRIPT = r"""
(function () {
  const searchInput = document.getElementById("wikiSearch");
  const filterButtons = Array.from(document.querySelectorAll(".filter-btn"));
  const cards = Array.from(document.querySelectorAll(".wiki-card"));
  const resultLine = document.getElementById("resultLine");
  const emptyState = document.getElementById("emptyState");
  let activeFilter = "all";

  function normalize(value) {
    return (value || "").toLowerCase().replace(/\s+/g, " ").trim();
  }

  function cardText(card) {
    return normalize([card.dataset.search, card.id, card.textContent].join(" "));
  }

  function matchesFilter(card) {
    return activeFilter === "all" || card.dataset.type === activeFilter || card.dataset.group === activeFilter;
  }

  function applyFilters() {
    const query = normalize(searchInput.value);
    let visible = 0;
    cards.forEach((card) => {
      const matchesQuery = query === "" || cardText(card).includes(query);
      const isVisible = matchesFilter(card) && matchesQuery;
      card.classList.toggle("is-hidden", !isVisible);
      if (isVisible) visible += 1;
    });
    const label = activeFilter === "all" ? "wiki pages" : activeFilter + " pages";
    resultLine.textContent = visible === 1 ? "Showing 1 " + label + "." : "Showing " + visible + " " + label + ".";
    emptyState.hidden = visible !== 0;
  }

  filterButtons.forEach((button) => {
    button.addEventListener("click", () => {
      activeFilter = button.dataset.filter;
      filterButtons.forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
      applyFilters();
    });
  });

  searchInput.addEventListener("input", applyFilters);
  applyFilters();
})();
"""


def render_html(data: dict[str, Any]) -> str:
    by_slug = page_by_slug(data)
    concepts_systems = render_cards_for_types(data, {"concept", "system"})
    comparisons = render_cards_for_types(data, {"comparison"})
    sources = render_cards_for_types(data, {"source"})
    queries = render_cards_for_types(data, {"query"})
    maintenance = render_cards_for_types(data, {"index", "schema", "log"})
    tensions = render_tensions(data, by_slug)
    embedded_data = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{html.escape(data['metadata']['title'])}</title>
<style>
{CSS}
</style>
</head>
<body>
<div class="page">
  <aside class="side-nav" aria-label="Wiki navigation">
    <div class="brand">
      <span class="brand__eyebrow">Dark Factory</span>
      <h2 class="brand__title">Wiki Browser</h2>
    </div>
    <nav>
      <div class="nav-group">
        <h2>Sections</h2>
        <a class="nav-link" href="#start">Start Here</a>
        <a class="nav-link" href="#factory-map">Factory Map</a>
        <a class="nav-link" href="#concepts-systems">Concepts &amp; Systems</a>
        <a class="nav-link" href="#comparisons">Comparisons</a>
        <a class="nav-link" href="#sources">Source Cards</a>
        <a class="nav-link" href="#queries">Query Notes</a>
        <a class="nav-link" href="#tensions">Open Tensions</a>
        <a class="nav-link" href="#maintenance">Maintenance</a>
      </div>
      <div class="nav-group">
        <h2>Key Pages</h2>
        {''.join(f'<a class="nav-link" href="#{html.escape(by_slug[slug]["html_id"], quote=True)}">{html.escape(by_slug[slug]["title"])}</a>' for slug in data["sections"]["start_here"])}
      </div>
      <div class="nav-group">
        <h2>Artifacts</h2>
        <a class="nav-link" href="index.md">Markdown index</a>
        <a class="nav-link" href="wiki-data.json">JSON graph data</a>
        <a class="nav-link" href="build_viewer.py">Build script</a>
      </div>
    </nav>
  </aside>

  <main class="main">
    <header class="hero" id="top">
      <div class="hero__topline">
        <span class="pill pill--blue">{data['counts']['pages']} Markdown pages</span>
        <span class="pill pill--green">{data['counts']['graph_edges']} graph edges</span>
        <span class="pill pill--amber">{data['counts']['open_tensions']} tensions</span>
        <span class="pill pill--violet">{data['counts']['source_urls']} source URLs</span>
      </div>
      <h1>Dark Factory LLM Wiki</h1>
      <p class="hero__lede">A local, source-backed browser for the dark factory operating model, DOT pipeline layer, validation stack, orchestrator boundary, and maintained LLM wiki corpus.</p>
      <div class="fact-grid" aria-label="Wiki summary">
        <div class="fact"><strong>{data['counts']['types'].get('concept', 0)}</strong><span>concept pages</span></div>
        <div class="fact"><strong>{data['counts']['types'].get('system', 0)}</strong><span>system pages</span></div>
        <div class="fact"><strong>{data['counts']['types'].get('comparison', 0)}</strong><span>comparisons</span></div>
        <div class="fact"><strong>{data['counts']['types'].get('source', 0)}</strong><span>source cards</span></div>
        <div class="fact"><strong>{data['counts']['types'].get('query', 0)}</strong><span>query notes</span></div>
      </div>
      <div class="artifact-links" aria-label="Primary artifacts">
        <a href="index.md">Open index.md</a>
        <a href="wiki-data.json">Open wiki-data.json</a>
        <a href="build_viewer.py">Open build_viewer.py</a>
        <a href="log.md">Open log.md</a>
      </div>
    </header>

    <div class="toolbar" role="search">
      <label class="search" for="wikiSearch">
        <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M10.5 4a6.5 6.5 0 0 1 5.17 10.44l4.44 4.45-1.42 1.41-4.43-4.44A6.5 6.5 0 1 1 10.5 4Zm0 2a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Z" fill="currentColor"></path>
        </svg>
        <input id="wikiSearch" type="search" placeholder="Search healer, DOT, agent-orchestrator, validation">
      </label>
      <div class="filters" aria-label="Filter cards">
        <button class="filter-btn" data-filter="all" aria-pressed="true">All</button>
        <button class="filter-btn" data-filter="concept" aria-pressed="false">Concepts</button>
        <button class="filter-btn" data-filter="system" aria-pressed="false">Systems</button>
        <button class="filter-btn" data-filter="comparison" aria-pressed="false">Comparisons</button>
        <button class="filter-btn" data-filter="source" aria-pressed="false">Sources</button>
        <button class="filter-btn" data-filter="query" aria-pressed="false">Queries</button>
        <button class="filter-btn" data-filter="infrastructure" aria-pressed="false">Maintenance</button>
      </div>
    </div>
    <p class="result-line" id="resultLine">Showing all wiki pages.</p>
    <div class="empty-state" id="emptyState" hidden>No wiki pages match the current search and filter.</div>

    <section class="section-panel" id="start">
      <div class="section-head">
        <h2>Start Here</h2>
        <p>The reading path starts with the operating model, then moves through durable artifacts, graph execution, validation, and orchestration boundaries.</p>
      </div>
      <div class="start-list">
        {render_start_here(data, by_slug)}
      </div>
    </section>

    <section class="section-panel" id="factory-map">
      <div class="section-head">
        <h2>Factory Map</h2>
        <p>The graph links the durable process artifact, runner, validation evidence, repair loop, and adjacent worker orchestration layer.</p>
      </div>
      <div class="map-layout">
        <div class="graph-wrap">{render_curated_graph(data, by_slug)}</div>
        <div class="map-notes">
          <div class="note"><strong>Intent stays human-owned.</strong><span>Specs, gates, threat models, and risk policy remain the durable control surface.</span></div>
          <div class="note"><strong>DOT is the process artifact.</strong><span>The graph captures sequencing, agent/tool calls, joins, checks, and human gates.</span></div>
          <div class="note"><strong>Validation replaces routine diff reading.</strong><span>CXDB, Healer, holdouts, digital twins, CI, and independent review provide evidence.</span></div>
          <div class="note"><strong>Orchestration is adjacent.</strong><span>Agent Orchestrator manages sessions and PR workflows; the runner owns graph execution.</span></div>
        </div>
      </div>
    </section>

    <section class="section-panel" id="concepts-systems">
      <div class="section-head">
        <h2>Concepts &amp; Systems</h2>
        <p>Reusable vocabulary and concrete subsystems for the dark factory model.</p>
      </div>
      <div class="card-grid">{concepts_systems}</div>
    </section>

    <section class="section-panel" id="comparisons">
      <div class="section-head">
        <h2>Comparisons</h2>
        <p>Boundary pages that sharpen tradeoffs and prevent category drift.</p>
      </div>
      <div class="card-grid">{comparisons}</div>
    </section>

    <section class="section-panel" id="sources">
      <div class="section-head">
        <h2>Source Cards</h2>
        <p>Provenance pages with captured source URLs and compact implications.</p>
      </div>
      <div class="card-grid card-grid--three">{sources}</div>
    </section>

    <section class="section-panel" id="queries">
      <div class="section-head">
        <h2>Query Notes</h2>
        <p>Reusable answers to questions future agents are likely to ask again.</p>
      </div>
      <div class="card-grid">{queries}</div>
    </section>

    <section class="section-panel" id="tensions">
      <div class="section-head">
        <h2>Open Tensions</h2>
        <p>Caveats and disagreements preserved from page frontmatter and tension sections.</p>
      </div>
      <div class="tension-list">{tensions}</div>
    </section>

    <section class="section-panel" id="maintenance">
      <div class="section-head">
        <h2>Maintenance</h2>
        <p>Infrastructure pages and generated artifacts for refreshing the browser layer.</p>
      </div>
      <div class="maintenance-grid">
        <div class="card-grid">{maintenance}</div>
        <ul class="artifact-list">
          <li><a href="index.md"><strong>Canonical wiki index</strong><span>Markdown</span></a></li>
          <li><a href="SCHEMA.md"><strong>Schema reference</strong><span>Markdown</span></a></li>
          <li><a href="log.md"><strong>Ingest and update log</strong><span>Markdown</span></a></li>
          <li><a href="wiki-data.json"><strong>Structured graph data</strong><span>JSON</span></a></li>
          <li><a href="build_viewer.py"><strong>Generator and checks</strong><span>Python</span></a></li>
        </ul>
      </div>
    </section>
  </main>
</div>
<script type="application/json" id="wikiData">{embedded_data}</script>
<script>
{SCRIPT}
</script>
</body>
</html>
"""


def write_outputs(data: dict[str, Any]) -> None:
    DATA_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    INDEX_HTML.write_text(render_html(data), encoding="utf-8")


def check_outputs(data: dict[str, Any]) -> None:
    expected_json = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
    expected_html = render_html(data)
    errors = []
    if not DATA_JSON.exists():
        errors.append(f"{DATA_JSON.relative_to(WIKI_DIR)} is missing")
    elif DATA_JSON.read_text(encoding="utf-8") != expected_json:
        errors.append(f"{DATA_JSON.relative_to(WIKI_DIR)} is stale")
    if not INDEX_HTML.exists():
        errors.append(f"{INDEX_HTML.relative_to(WIKI_DIR)} is missing")
    elif INDEX_HTML.read_text(encoding="utf-8") != expected_html:
        errors.append(f"{INDEX_HTML.relative_to(WIKI_DIR)} is stale")
    if errors:
        raise BuildError("\n".join(errors))


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Build the offline Dark Factory wiki browser.")
    parser.add_argument("--check", action="store_true", help="Validate that generated artifacts are current.")
    args = parser.parse_args(argv)

    try:
        data = build_data()
        if args.check:
            check_outputs(data)
            print(
                "OK: wiki viewer artifacts are current; "
                f"{data['counts']['pages']} pages, {data['counts']['graph_edges']} edges, "
                f"{data['counts']['source_urls']} source URLs."
            )
        else:
            write_outputs(data)
            print(f"Wrote {INDEX_HTML.relative_to(WIKI_DIR)} and {DATA_JSON.relative_to(WIKI_DIR)}.")
    except BuildError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    return 0


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