#!/usr/bin/env python3
"""Build the self-contained HTML viewer for rootfs-triage-wiki."""

from __future__ import annotations

import json
import re
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "viewer.html"
SOUND_INDEX_OUT = ROOT / "sound-index.json"


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8", errors="replace")


def title_for(path: Path, text: str) -> str:
    match = re.search(r"^#\s+(.+?)\s*$", text, re.MULTILINE)
    if match:
        return match.group(1)
    return path.stem.replace("-", " ").replace("_", " ").title()


def content_group_for(path: Path) -> str:
    """Separate narrative research from generated catalogs and galleries."""
    name = path.name
    if path.suffix != ".md":
        return "source"
    if name.startswith("sound-best-named-gallery-") or name in {
        "sound-asr-catalog-labels.md",
        "sound-asr-draft-labels.md",
        "sound-asr-review-candidates.md",
        "sound-pokedex-voice.md",
    }:
        return "voice"
    if re.match(r"^(asset-.+-gallery|radium-section3-bitmap-gallery|sound-gallery)-", name):
        return "media"
    return "wiki"


def page_order() -> list[str]:
    readme = read_text(ROOT / "README.md")
    ordered = ["README.md"]
    for target in re.findall(r"\[[^\]]+\]\(([^)#?]+)", readme):
        target_path = ROOT / target
        if target_path.suffix not in {".md", ".c"}:
            continue
        if target not in ordered and target_path.exists():
            ordered.append(target)
    for path in sorted(ROOT.glob("*.md")):
        name = path.name
        if name not in ordered:
            ordered.append(name)
    c_export = "conagent-pseudocode.c"
    if (ROOT / c_export).exists() and c_export not in ordered:
        ordered.append(c_export)
    return ordered


def build_pages() -> list[dict[str, str]]:
    pages = []
    for name in page_order():
        path = ROOT / name
        text = read_text(path)
        kind = "markdown" if path.suffix == ".md" else "source"
        pages.append(
            {
                "path": name,
                "title": title_for(path, text),
                "kind": kind,
                "group": content_group_for(path),
                "text": text,
            }
        )
    return pages


def parse_directive_attrs(raw: str) -> dict[str, str]:
    return dict(re.findall(r'([A-Za-z0-9_-]+)="([^"]*)"', raw))


def build_sound_index() -> dict[str, object]:
    """Build one canonical entry for every best-named main/menu sound."""
    sounds: list[dict[str, str]] = []
    source_pages: list[str] = []
    seen_sources: set[str] = set()
    for path in sorted(ROOT.glob("sound-best-named-gallery-*.md")):
        source_pages.append(path.name)
        for match in re.finditer(r"^::audio\{(.*)\}\s*$", read_text(path), re.MULTILINE):
            attrs = parse_directive_attrs(match.group(1))
            src = attrs.get("src", "")
            if not src or src in seen_sources:
                continue
            seen_sources.add(src)
            sounds.append(
                {
                    "id": f"sound-{len(sounds) + 1:04d}",
                    "src": src,
                    "filename": Path(src).name,
                    "title": attrs.get("title", Path(src).name),
                    "meta": attrs.get("meta", ""),
                    "source_page": path.name,
                }
            )
    return {
        "schema_version": 1,
        "generated_from": source_pages,
        "count": len(sounds),
        "sounds": sounds,
    }


HTML_TEMPLATE = r"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Pokemon Spike 3 Rootfs Triage Wiki</title>
  <style>
    :root {
      --page: #f3f6fb;
      --panel: #ffffff;
      --panel-soft: #f8fafc;
      --ink: #172033;
      --muted: #657083;
      --quiet: #8a95a8;
      --line: #d8e0ec;
      --strong-line: #b7c4d6;
      --accent: #2457d6;
      --accent-soft: #e8eefc;
      --green: #0f8a64;
      --amber: #a66708;
      --danger: #c9333a;
      --code-bg: #f4f7fb;
      --mark: #ffe9a8;
      --shadow: 0 18px 50px rgba(23, 32, 51, .10);
    }

    * { box-sizing: border-box; }

    html {
      color: var(--ink);
      background: var(--page);
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
      font-size: 15px;
      line-height: 1.58;
    }

    body { margin: 0; min-width: 320px; }

    a { color: var(--accent); text-decoration: none; }
    a:visited { color: #5146b8; }
    a:hover { text-decoration: underline; }

    button,
    input { font: inherit; }

    .app-shell {
      display: grid;
      grid-template-columns: 320px minmax(0, 1fr);
      min-height: 100vh;
    }

    .sidebar {
      position: sticky;
      top: 0;
      height: 100vh;
      overflow: auto;
      border-right: 1px solid var(--line);
      background: #111827;
      color: #f9fafb;
      padding: 18px 16px 22px;
    }

    .brand {
      display: grid;
      grid-template-columns: 42px minmax(0, 1fr);
      align-items: center;
      gap: 12px;
      color: inherit;
      margin-bottom: 18px;
    }

    .brand:hover { text-decoration: none; }

    .brand-mark {
      display: grid;
      place-items: center;
      width: 42px;
      height: 42px;
      border-radius: 10px;
      background: linear-gradient(135deg, #e12d39 0%, #f59e0b 52%, #2457d6 100%);
      box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
      color: #fff;
      font-size: 18px;
      font-weight: 800;
      letter-spacing: 0;
    }

    .brand strong {
      display: block;
      font-size: 15px;
      line-height: 1.15;
    }

    .brand span span {
      display: block;
      color: #aeb8c8;
      font-size: 12px;
      margin-top: 2px;
    }

    .sidebar-stats {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 8px;
      margin: 14px 0 16px;
    }

    .stat {
      border: 1px solid rgba(255, 255, 255, .11);
      border-radius: 8px;
      padding: 10px;
      background: rgba(255, 255, 255, .05);
    }

    .stat strong {
      display: block;
      font-size: 18px;
      line-height: 1;
    }

    .stat span {
      color: #aeb8c8;
      font-size: 11px;
    }

    .nav-filter,
    .search-wrap input {
      width: 100%;
      border: 1px solid var(--line);
      border-radius: 8px;
      background: #fff;
      color: var(--ink);
      padding: 9px 10px;
      min-height: 38px;
      outline: none;
    }

    .nav-filter {
      border-color: rgba(255, 255, 255, .18);
      background: rgba(255, 255, 255, .08);
      color: #f9fafb;
      margin-bottom: 14px;
    }

    .nav-filter::placeholder { color: #aeb8c8; }

    .side-section {
      margin-top: 16px;
      padding-top: 14px;
      border-top: 1px solid rgba(255, 255, 255, .12);
    }

    .side-heading {
      display: flex;
      justify-content: space-between;
      gap: 10px;
      color: #c7d0de;
      font-size: 11px;
      font-weight: 700;
      letter-spacing: .08em;
      margin: 0 0 8px;
      text-transform: uppercase;
    }

    .side-heading span {
      color: #8fa0b7;
      font-weight: 600;
      letter-spacing: 0;
      text-transform: none;
    }

    .page-list {
      list-style: none;
      margin: 0;
      padding: 0;
      display: grid;
      gap: 3px;
      font-size: 13px;
    }

    .page-list li.hidden { display: none; }

    .page-list a {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      align-items: center;
      gap: 8px;
      border-radius: 8px;
      color: #d7deea;
      padding: 7px 9px;
      min-width: 0;
    }

    .page-list a:hover {
      background: rgba(255, 255, 255, .08);
      color: #fff;
      text-decoration: none;
    }

    .page-list a.active {
      background: #fff;
      color: var(--ink);
      font-weight: 700;
      box-shadow: 0 10px 30px rgba(0, 0, 0, .20);
    }

    .page-list .link-title {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .page-list .link-kind {
      border-radius: 999px;
      color: #8fa0b7;
      font-size: 10px;
      font-weight: 700;
      text-transform: uppercase;
    }

    .page-list a.active .link-kind { color: var(--muted); }

    .main {
      min-width: 0;
      background:
        linear-gradient(180deg, rgba(255, 255, 255, .72), rgba(255, 255, 255, 0) 220px),
        var(--page);
    }

    .topbar {
      position: sticky;
      top: 0;
      z-index: 15;
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto minmax(260px, 360px);
      align-items: center;
      gap: 14px;
      min-height: 64px;
      padding: 12px 28px;
      border-bottom: 1px solid rgba(216, 224, 236, .88);
      background: rgba(255, 255, 255, .86);
      backdrop-filter: blur(16px);
    }

    .mobile-nav {
      display: none;
      border: 1px solid var(--line);
      border-radius: 8px;
      background: #fff;
      color: var(--ink);
      min-height: 38px;
      padding: 0 11px;
      cursor: pointer;
    }

    .page-kicker {
      min-width: 0;
      color: var(--muted);
      font-size: 12px;
    }

    .page-kicker strong {
      display: block;
      color: var(--ink);
      font-size: 14px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .page-kicker code {
      color: var(--muted);
      background: transparent;
      font-size: 12px;
    }

    .tabs {
      display: inline-flex;
      align-items: center;
      border: 1px solid var(--line);
      border-radius: 9px;
      background: var(--panel-soft);
      padding: 3px;
    }

    .tab {
      border: 0;
      border-radius: 7px;
      background: transparent;
      color: var(--muted);
      cursor: pointer;
      font-size: 13px;
      font-weight: 700;
      min-height: 30px;
      padding: 0 12px;
    }

    .tab.active {
      background: var(--panel);
      color: var(--ink);
      box-shadow: 0 1px 4px rgba(23, 32, 51, .12);
    }

    .search-wrap {
      position: relative;
      min-width: 0;
    }

    .search-controls {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 116px;
      gap: 7px;
    }

    .search-controls select {
      border: 1px solid var(--line);
      border-radius: 8px;
      background: var(--panel);
      color: var(--ink);
      padding: 0 8px;
      min-height: 38px;
      outline: none;
    }

    .search-results {
      position: absolute;
      z-index: 20;
      top: calc(100% + 8px);
      right: 0;
      width: min(540px, 92vw);
      max-height: min(540px, 70vh);
      overflow: auto;
      border: 1px solid var(--line);
      border-radius: 10px;
      background: #fff;
      box-shadow: var(--shadow);
      display: none;
    }

    .search-results.open { display: block; }

    .search-results > a {
      display: block;
      padding: 10px 12px;
      border-bottom: 1px solid var(--line);
      color: var(--ink);
    }

    .search-results > a:last-child { border-bottom: 0; }
    .search-results > a:hover { background: var(--panel-soft); text-decoration: none; }
    .search-results small { display: block; color: var(--muted); margin-top: 2px; }

    .sound-search-summary {
      position: sticky;
      top: 0;
      z-index: 1;
      border-bottom: 1px solid var(--line);
      background: #fff;
      color: var(--muted);
      padding: 9px 12px;
      font-size: 12px;
    }

    .sound-search-summary strong { color: var(--ink); }

    .sound-result {
      border-bottom: 1px solid var(--line);
      padding: 11px 12px 12px;
    }

    .sound-result:last-child { border-bottom: 0; }

    .sound-result > strong {
      display: block;
      line-height: 1.35;
      overflow-wrap: anywhere;
    }

    .sound-result audio {
      display: block;
      width: 100%;
      height: 34px;
      margin: 9px 0 7px;
    }

    .sound-result-actions {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      min-width: 0;
    }

    .sound-result-actions code {
      color: var(--quiet);
      font-size: 10px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .sound-download {
      flex: 0 0 auto;
      border: 1px solid var(--line);
      border-radius: 7px;
      background: var(--panel-soft);
      color: var(--accent);
      padding: 5px 8px;
      font-size: 11px;
      font-weight: 700;
    }

    .sound-download:hover { background: var(--accent-soft); text-decoration: none; }

    .article-layout {
      display: grid;
      grid-template-columns: minmax(0, 960px) 280px;
      justify-content: center;
      gap: 24px;
      align-items: start;
      padding: 26px 28px 48px;
    }

    .article {
      min-width: 0;
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 10px;
      box-shadow: 0 10px 35px rgba(23, 32, 51, .06);
      overflow: hidden;
    }

    .article-inner {
      padding: 30px 36px 36px;
    }

    .page-subtitle {
      display: inline-flex;
      max-width: 100%;
      color: var(--muted);
      font-size: 13px;
      margin: -5px 0 22px;
      padding: 5px 9px;
      border: 1px solid var(--line);
      border-radius: 999px;
      background: var(--panel-soft);
      overflow-wrap: anywhere;
    }

    .wiki-content h1,
    .wiki-content h2,
    .article-inner > h1 {
      font-weight: 760;
      letter-spacing: 0;
      line-height: 1.18;
      color: var(--ink);
    }

    .wiki-content h1,
    .article-inner > h1 {
      font-size: 32px;
      margin: 0 0 16px;
    }

    .wiki-content h2 {
      font-size: 22px;
      margin: 30px 0 12px;
      padding-top: 20px;
      border-top: 1px solid var(--line);
    }

    .wiki-content h3 {
      font-size: 17px;
      margin: 24px 0 8px;
    }

    .wiki-content h4 {
      font-size: 15px;
      margin: 18px 0 6px;
    }

    .wiki-content p { margin: 0 0 13px; }

    .wiki-content ul,
    .wiki-content ol {
      margin: 0 0 15px 22px;
      padding: 0;
    }

    .wiki-content li { margin: 4px 0; }

    .wiki-content blockquote {
      margin: 16px 0;
      border-left: 4px solid var(--accent);
      background: var(--accent-soft);
      padding: 12px 14px;
      color: #23304a;
    }

    .wiki-content code,
    .source-view code {
      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
      font-size: .92em;
      background: var(--code-bg);
      border: 1px solid #e5ebf4;
      padding: 1px 5px;
      border-radius: 5px;
      overflow-wrap: anywhere;
      word-break: break-word;
    }

    .wiki-content pre,
    .source-view pre {
      background: #0f172a;
      color: #e5edf8;
      border: 1px solid #1f2a44;
      border-radius: 9px;
      padding: 14px;
      overflow: auto;
      font-size: 13px;
      line-height: 1.48;
      max-width: 100%;
      white-space: pre;
    }

    .wiki-content pre code,
    .source-view pre code {
      background: transparent;
      border: 0;
      color: inherit;
      padding: 0;
      border-radius: 0;
      overflow-wrap: normal;
      word-break: normal;
    }

    .table-scroll {
      max-width: 100%;
      overflow-x: auto;
      border: 1px solid var(--line);
      border-radius: 9px;
      margin: 14px 0 20px;
    }

    .wiki-content table {
      border-collapse: collapse;
      margin: 0;
      max-width: 100%;
      table-layout: fixed;
      width: 100%;
      font-size: 14px;
    }

    .table-scroll table { min-width: 560px; }

    .wiki-content th,
    .wiki-content td {
      border-bottom: 1px solid var(--line);
      border-right: 1px solid var(--line);
      padding: 8px 10px;
      vertical-align: top;
      overflow-wrap: anywhere;
      word-break: break-word;
    }

    .wiki-content th:last-child,
    .wiki-content td:last-child { border-right: 0; }

    .wiki-content tr:last-child td { border-bottom: 0; }

    .wiki-content th {
      background: #eef3f9;
      text-align: left;
      font-weight: 700;
    }

    .wiki-content tr:nth-child(even) td { background: #fbfcfe; }

    .media-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
      gap: 14px;
      margin: 18px 0 24px;
    }

    .media-card {
      border: 1px solid var(--line);
      border-radius: 10px;
      background: #fff;
      min-width: 0;
      overflow: hidden;
      box-shadow: 0 8px 24px rgba(23, 32, 51, .05);
    }

    .media-card strong {
      display: block;
      font-size: 12px;
      line-height: 1.35;
      overflow-wrap: anywhere;
      word-break: break-word;
      padding: 10px 11px 7px;
    }

    .media-card small {
      display: block;
      color: var(--muted);
      font-size: 11px;
      overflow-wrap: anywhere;
      word-break: break-word;
      padding: 0 11px 9px;
    }

    .media-card audio,
    .media-card video,
    .media-card iframe {
      display: block;
      width: 100%;
      max-width: 100%;
    }

    .media-card audio { padding: 0 10px 10px; }

    .media-card img {
      display: block;
      width: 100%;
      aspect-ratio: 16 / 10;
      max-height: 260px;
      object-fit: contain;
      background: #f1f5f9;
      border-top: 1px solid var(--line);
      border-bottom: 1px solid var(--line);
    }

    .media-card video { max-height: 280px; background: #000; }

    .media-card iframe {
      height: 220px;
      border: 0;
      border-top: 1px solid var(--line);
      border-bottom: 1px solid var(--line);
      background: #fff;
    }

    .font-sample {
      border-top: 1px solid var(--line);
      border-bottom: 1px solid var(--line);
      background: #f8fafc;
      padding: 14px;
      font-size: 28px;
      line-height: 1.2;
      overflow-wrap: anywhere;
    }

    .missing-link {
      color: var(--danger);
      border-bottom: 1px dotted var(--danger);
      cursor: help;
    }

    .right-rail {
      position: sticky;
      top: 88px;
      display: grid;
      gap: 14px;
      max-height: calc(100vh - 108px);
      overflow: auto;
    }

    .toc {
      border: 1px solid var(--line);
      border-radius: 10px;
      background: rgba(255, 255, 255, .82);
      box-shadow: 0 8px 28px rgba(23, 32, 51, .05);
      padding: 14px;
      font-size: 13px;
    }

    .toc h2,
    .backlinks h2,
    .page-meta h2 {
      font-size: 12px;
      font-weight: 800;
      letter-spacing: .08em;
      margin: 0 0 10px;
      padding: 0;
      color: var(--muted);
      text-transform: uppercase;
    }

    .toc ol,
    .backlinks ul {
      margin: 0;
      padding: 0;
      list-style: none;
      display: grid;
      gap: 6px;
    }

    .toc li,
    .backlinks li { min-width: 0; }

    .toc a,
    .backlinks a {
      display: block;
      color: var(--ink);
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .toc a:hover,
    .backlinks a:hover { color: var(--accent); }

    .backlinks {
      margin-top: 16px;
      border-top: 1px solid var(--line);
      padding-top: 13px;
    }

    .page-meta {
      border: 1px solid var(--line);
      border-radius: 10px;
      background: #fff;
      padding: 14px;
      font-size: 12px;
      color: var(--muted);
    }

    .meta-grid {
      display: grid;
      gap: 8px;
    }

    .meta-grid div {
      display: grid;
      gap: 1px;
      min-width: 0;
    }

    .meta-grid strong {
      color: var(--ink);
      overflow-wrap: anywhere;
    }

    mark { background: var(--mark); padding: 0 2px; border-radius: 2px; }

    .footer {
      color: var(--muted);
      font-size: 12px;
      border-top: 1px solid var(--line);
      background: var(--panel-soft);
      padding: 12px 36px;
    }

    .scrim {
      display: none;
      position: fixed;
      inset: 0;
      z-index: 29;
      background: rgba(15, 23, 42, .42);
    }

    body.nav-open { overflow: hidden; }
    body.nav-open .scrim { display: block; }

    @media (max-width: 1180px) {
      .article-layout { grid-template-columns: minmax(0, 1fr); }
      .right-rail {
        position: static;
        max-height: none;
        order: -1;
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
    }

    @media (max-width: 900px) {
      .app-shell { grid-template-columns: 1fr; }
      .sidebar {
        position: fixed;
        z-index: 30;
        inset: 0 auto 0 0;
        width: min(86vw, 340px);
        transform: translateX(-102%);
        transition: transform .18s ease;
      }
      body.nav-open .sidebar { transform: translateX(0); }
      .topbar {
        grid-template-columns: auto minmax(0, 1fr);
        padding: 10px 14px;
      }
      .mobile-nav { display: inline-block; }
      .page-kicker { grid-column: 2; }
      .tabs { grid-column: 1 / -1; width: 100%; justify-content: center; }
      .tab { flex: 1; }
      .search-wrap { grid-column: 1 / -1; }
      .search-controls { grid-template-columns: minmax(0, 1fr) 108px; }
      .article-layout {
        display: flex;
        flex-direction: column;
        padding: 14px 12px 28px;
      }
      .right-rail { grid-template-columns: 1fr; width: 100%; }
      .article { border-radius: 9px; width: 100%; }
      .article-inner { padding: 22px 18px 28px; }
      .footer { padding: 12px 18px; }
      .wiki-content h1,
      .article-inner > h1 { font-size: 26px; }
      .wiki-content code { word-break: break-all; }
      .wiki-content pre code { word-break: normal; }
      .media-grid { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
    }
  </style>
</head>
<body>
  <div class="app-shell">
    <aside class="sidebar" id="sidebar">
      <a class="brand" href="#/README.md" aria-label="Open wiki home">
        <span class="brand-mark" aria-hidden="true">S3</span>
        <span>
          <strong>Spike 3 Rootfs Wiki</strong>
          <span>static evidence workspace</span>
        </span>
      </a>
      <div class="sidebar-stats" aria-label="Wiki counts">
        <div class="stat"><strong id="page-count">0</strong><span>research</span></div>
        <div class="stat"><strong id="voice-count">0</strong><span>voice</span></div>
        <div class="stat"><strong id="media-count">0</strong><span>media</span></div>
      </div>
      <input class="nav-filter" id="nav-filter" type="search" autocomplete="off" placeholder="Filter pages">
      <nav class="side-section" aria-label="Research pages">
        <p class="side-heading">Research <span id="visible-page-count"></span></p>
        <ul class="page-list" id="page-list"></ul>
      </nav>
      <nav class="side-section" aria-label="Voice line catalogs">
        <p class="side-heading">Voice lines <span id="visible-voice-count"></span></p>
        <ul class="page-list" id="voice-list"></ul>
      </nav>
      <nav class="side-section" aria-label="Media galleries">
        <p class="side-heading">Media galleries <span id="visible-media-count"></span></p>
        <ul class="page-list" id="media-list"></ul>
      </nav>
      <nav class="side-section" aria-label="Generated artifacts">
        <p class="side-heading">Generated files <span id="visible-artifact-count"></span></p>
        <ul class="page-list" id="artifact-list"></ul>
      </nav>
    </aside>

    <main class="main">
      <div class="topbar">
        <button class="mobile-nav" id="mobile-nav" type="button" aria-controls="sidebar" aria-expanded="false">Menu</button>
        <div class="page-kicker">
          <strong id="current-title">Pokemon Spike 3 Rootfs Triage Wiki</strong>
          <code id="current-path">README.md</code>
        </div>
        <div class="tabs" role="tablist" aria-label="View mode">
          <button class="tab active" id="read-tab" type="button">Article</button>
          <button class="tab" id="source-tab" type="button">Source</button>
        </div>
        <div class="search-wrap">
          <div class="search-controls">
            <input id="search" type="search" autocomplete="off" placeholder="Search research pages">
            <select id="search-scope" aria-label="Search scope">
              <option value="wiki">Research</option>
              <option value="sounds">Sounds</option>
              <option value="voice">Voice pages</option>
              <option value="media">Media</option>
              <option value="all">Everything</option>
            </select>
          </div>
          <div class="search-results" id="search-results"></div>
        </div>
      </div>

      <div class="article-layout">
        <article class="article">
          <div class="article-inner" id="article-view"></div>
          <div class="footer">
            Built from files in <code>rootfs-triage-wiki/</code>. This viewer is static and does not execute target rootfs binaries.
          </div>
        </article>
        <aside class="right-rail" aria-label="Page tools">
          <section class="toc" id="toc" aria-label="Contents"></section>
          <section class="page-meta" id="page-meta" aria-label="Page metadata"></section>
        </aside>
      </div>
    </main>
    <div class="scrim" id="scrim" aria-hidden="true"></div>
  </div>

  <script id="wiki-data" type="application/json">__WIKI_DATA__</script>
  <script>
    const wiki = JSON.parse(document.getElementById("wiki-data").textContent);
    const pageMap = new Map(wiki.pages.map((page) => [page.path, page]));
    let currentPath = "README.md";
    let viewMode = "article";
    let headingIndex = [];

    const pageList = document.getElementById("page-list");
    const voiceList = document.getElementById("voice-list");
    const mediaList = document.getElementById("media-list");
    const artifactList = document.getElementById("artifact-list");
    const articleView = document.getElementById("article-view");
    const tocView = document.getElementById("toc");
    const pageMeta = document.getElementById("page-meta");
    const currentTitle = document.getElementById("current-title");
    const currentPathEl = document.getElementById("current-path");
    const pageCount = document.getElementById("page-count");
    const voiceCount = document.getElementById("voice-count");
    const mediaCount = document.getElementById("media-count");
    const visiblePageCount = document.getElementById("visible-page-count");
    const visibleVoiceCount = document.getElementById("visible-voice-count");
    const visibleMediaCount = document.getElementById("visible-media-count");
    const visibleArtifactCount = document.getElementById("visible-artifact-count");
    const navFilter = document.getElementById("nav-filter");
    const mobileNav = document.getElementById("mobile-nav");
    const scrim = document.getElementById("scrim");
    const searchInput = document.getElementById("search");
    const searchScope = document.getElementById("search-scope");
    const searchResults = document.getElementById("search-results");
    const readTab = document.getElementById("read-tab");
    const sourceTab = document.getElementById("source-tab");

    function escapeHtml(value) {
      return String(value)
        .replaceAll("&", "&amp;")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;")
        .replaceAll('"', "&quot;")
        .replaceAll("'", "&#39;");
    }

    function escapeAttr(value) {
      return escapeHtml(value).replaceAll("`", "&#96;");
    }

    function wordCount(text) {
      const words = text.trim().match(/\S+/g);
      return words ? words.length : 0;
    }

    function kindLabel(page) {
      if (page.kind !== "markdown") return page.path.split(".").pop() || "file";
      if (page.group === "voice") return "voice";
      if (page.group === "media") return "gallery";
      if (/gallery|asset|sound|radium/i.test(page.path)) return "asset";
      if (/conagent|emulator|behavior|package|firmware/i.test(page.path)) return "re";
      return "note";
    }

    function setNavOpen(open) {
      document.body.classList.toggle("nav-open", open);
      mobileNav.setAttribute("aria-expanded", open ? "true" : "false");
    }

    function slugify(value) {
      return value
        .toLowerCase()
        .replace(/<[^>]+>/g, "")
        .replace(/`/g, "")
        .replace(/&[a-z0-9#]+;/gi, "")
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-+|-+$/g, "") || "section";
    }

    function splitFragment(href) {
      const hash = href.indexOf("#");
      if (hash === -1) return [href, ""];
      return [href.slice(0, hash), href.slice(hash + 1)];
    }

    function routeFor(href, fromPath = currentPath) {
      if (!href) return "#/" + fromPath;
      if (/^(https?:|mailto:|tel:)/i.test(href)) return href;
      const [rawPath, rawFragment] = splitFragment(href);
      let target = rawPath || fromPath;
      target = target.replace(/^\.\//, "");
      if (target.includes("/")) {
        const base = fromPath.split("/").slice(0, -1);
        for (const part of target.split("/")) {
          if (!part || part === ".") continue;
          if (part === "..") base.pop();
          else base.push(part);
        }
        target = base.join("/");
      }
      const fragment = rawFragment ? "#" + encodeURIComponent(rawFragment) : "";
      if (pageMap.has(target)) return "#/" + target + fragment;
      return href;
    }

    function renderInline(raw, fromPath = currentPath) {
      let text = escapeHtml(raw);
      text = text.replace(/`([^`]+)`/g, (match, code) => {
        const decoded = code
          .replaceAll("&lt;", "<")
          .replaceAll("&gt;", ">")
          .replaceAll("&quot;", '"')
          .replaceAll("&#39;", "'")
          .replaceAll("&amp;", "&");
        if (pageMap.has(decoded)) {
          return `<code><a href="${escapeAttr(routeFor(decoded, fromPath))}">${escapeHtml(decoded)}</a></code>`;
        }
        return `<code>${code}</code>`;
      });
      text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, href) => {
        const route = routeFor(href, fromPath);
        const external = /^(https?:|mailto:|tel:)/i.test(route);
        const missing = route === href && !external && !href.startsWith("#") && /\.(md|c)(#.*)?$/i.test(href);
        if (missing) {
          return `<span class="missing-link" title="Target not embedded in viewer">${label}</span>`;
        }
        const targetAttrs = external ? ' target="_blank" rel="noopener noreferrer"' : "";
        return `<a href="${escapeAttr(route)}"${targetAttrs}>${label}</a>`;
      });
      text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
      text = text.replace(/\*([^*]+)\*/g, "<em>$1</em>");
      return text;
    }

    function parseAttrs(raw) {
      const attrs = {};
      const re = /([A-Za-z0-9_-]+)="([^"]*)"/g;
      let match;
      while ((match = re.exec(raw)) !== null) {
        attrs[match[1]] = match[2];
      }
      return attrs;
    }

    function renderMediaDirective(line) {
      if (line === "::gallery-start") return '<div class="media-grid">';
      if (line === "::gallery-end") return "</div>";
      const match = line.match(/^::(audio|image|video|textasset|font)\{(.*)\}\s*$/);
      if (!match) return null;
      const kind = match[1];
      const attrs = parseAttrs(match[2]);
      const src = attrs.src || "";
      const title = attrs.title || src;
      const meta = attrs.meta || "";
      const link = attrs.link || src;
      const escapedSrc = escapeAttr(src);
      const escapedTitle = escapeHtml(title);
      const escapedMeta = escapeHtml(meta);
      const escapedLink = escapeAttr(link);
      let media = "";
      if (kind === "audio") {
        media = `<audio controls preload="none" src="${escapedSrc}"></audio>`;
      } else if (kind === "image") {
        media = `<a href="${escapedLink}" target="_blank" rel="noopener noreferrer"><img loading="lazy" src="${escapedSrc}" alt="${escapeAttr(title)}"></a>`;
      } else if (kind === "video") {
        media = `<video controls preload="metadata" src="${escapedSrc}"></video>`;
      } else if (kind === "font") {
        const family = (attrs.family || "asset_font").replace(/[^A-Za-z0-9_-]/g, "_");
        media = `<style>@font-face{font-family:"${escapeAttr(family)}";src:url("${escapedSrc}")}</style><div class="font-sample" style="font-family:&quot;${escapeAttr(family)}&quot;, sans-serif">Aa Bb Cc 123<br>Pokemon SPIKE 3</div>`;
      } else {
        media = `<iframe loading="lazy" src="${escapedSrc}" title="${escapeAttr(title)}"></iframe>`;
      }
      const pathLine = link ? `<small><a href="${escapedLink}" target="_blank" rel="noopener noreferrer">${escapeHtml(link)}</a></small>` : "";
      const metaLine = meta ? `<small>${escapedMeta}</small>` : "";
      return `<div class="media-card"><strong>${escapedTitle}</strong>${media}${metaLine}${pathLine}</div>`;
    }

    function parseTable(lines, start, fromPath) {
      const rows = [];
      let i = start;
      while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
        rows.push(lines[i]);
        i++;
      }
      if (rows.length < 2 || !/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(rows[1])) {
        return null;
      }
      const split = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
      const head = split(rows[0]);
      const body = rows.slice(2).map(split);
      let html = '<div class="table-scroll"><table><thead><tr>';
      html += head.map((cell) => `<th>${renderInline(cell, fromPath)}</th>`).join("");
      html += "</tr></thead><tbody>";
      html += body.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, fromPath)}</td>`).join("")}</tr>`).join("");
      html += "</tbody></table></div>";
      return { html, next: i };
    }

    function flushParagraph(parts, out, fromPath) {
      if (!parts.length) return;
      out.push(`<p>${renderInline(parts.join(" "), fromPath)}</p>`);
      parts.length = 0;
    }

    function renderMarkdown(markdown, fromPath) {
      const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
      const out = [];
      const paragraph = [];
      headingIndex = [];
      let listType = null;
      let inFence = false;
      let fenceLang = "";
      let fenceLines = [];

      function closeList() {
        if (!listType) return;
        out.push(`</${listType}>`);
        listType = null;
      }

      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
        if (fence) {
          if (inFence) {
            out.push(`<pre><code class="language-${escapeAttr(fenceLang)}">${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
            inFence = false;
            fenceLang = "";
            fenceLines = [];
          } else {
            flushParagraph(paragraph, out, fromPath);
            closeList();
            inFence = true;
            fenceLang = fence[1] || "";
          }
          continue;
        }
        if (inFence) {
          fenceLines.push(line);
          continue;
        }

        if (!line.trim()) {
          flushParagraph(paragraph, out, fromPath);
          closeList();
          continue;
        }

        const mediaDirective = renderMediaDirective(line.trim());
        if (mediaDirective !== null) {
          flushParagraph(paragraph, out, fromPath);
          closeList();
          out.push(mediaDirective);
          continue;
        }

        const table = parseTable(lines, i, fromPath);
        if (table) {
          flushParagraph(paragraph, out, fromPath);
          closeList();
          out.push(table.html);
          i = table.next - 1;
          continue;
        }

        const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
        if (heading) {
          flushParagraph(paragraph, out, fromPath);
          closeList();
          const level = heading[1].length;
          const label = renderInline(heading[2], fromPath);
          const id = slugify(heading[2]);
          headingIndex.push({ level, label: heading[2], id });
          out.push(`<h${level} id="${escapeAttr(id)}">${label}</h${level}>`);
          continue;
        }

        const unordered = line.match(/^\s*[-*]\s+(.+)$/);
        const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
        if (unordered || ordered) {
          flushParagraph(paragraph, out, fromPath);
          const type = unordered ? "ul" : "ol";
          if (listType && listType !== type) closeList();
          if (!listType) {
            out.push(`<${type}>`);
            listType = type;
          }
          out.push(`<li>${renderInline((unordered || ordered)[1], fromPath)}</li>`);
          continue;
        }

        const quote = line.match(/^>\s?(.+)$/);
        if (quote) {
          flushParagraph(paragraph, out, fromPath);
          closeList();
          out.push(`<blockquote>${renderInline(quote[1], fromPath)}</blockquote>`);
          continue;
        }

        closeList();
        paragraph.push(line.trim());
      }

      if (inFence) {
        out.push(`<pre><code class="language-${escapeAttr(fenceLang)}">${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
      }
      flushParagraph(paragraph, out, fromPath);
      closeList();
      return out.join("\n");
    }

    function renderSource(page) {
      const lang = page.path.endsWith(".c") ? "c" : "markdown";
      headingIndex = [{ level: 2, label: "Raw source", id: "raw-source" }];
      return `<h1>${escapeHtml(page.title)}</h1><p class="page-subtitle">Source view of <code>${escapeHtml(page.path)}</code></p><div class="source-view"><pre id="raw-source"><code class="language-${lang}">${escapeHtml(page.text)}</code></pre></div>`;
    }

    function makeToc(page) {
      const headings = headingIndex.filter((heading) => heading.level > 1 && heading.level <= 3);
      const backlinks = wiki.pages.filter((candidate) => {
        if (candidate.path === page.path) return false;
        return candidate.text.includes(`](${page.path}`) || candidate.text.includes("`" + page.path + "`");
      });
      let html = "";
      if (headings.length) {
        html += "<h2>Contents</h2><ol>";
        html += headings.map((heading) => `<li><a href="#/${escapeAttr(page.path)}#${escapeAttr(heading.id)}">${escapeHtml(heading.label)}</a></li>`).join("");
        html += "</ol>";
      } else {
        html += "<h2>Contents</h2><p>No headings.</p>";
      }
      if (backlinks.length) {
        html += '<div class="backlinks"><h2>Referenced by</h2><ul>';
        html += backlinks.map((source) => `<li><a href="#/${escapeAttr(source.path)}">${escapeHtml(source.title)}</a></li>`).join("");
        html += "</ul></div>";
      }
      return html;
    }

    function makePageMeta(page) {
      const bytes = new Blob([page.text]).size;
      return `<h2>Page Info</h2><div class="meta-grid">
        <div><span>Path</span><strong>${escapeHtml(page.path)}</strong></div>
        <div><span>Type</span><strong>${escapeHtml(kindLabel(page))}</strong></div>
        <div><span>Size</span><strong>${bytes.toLocaleString()} bytes</strong></div>
        <div><span>Words</span><strong>${wordCount(page.text).toLocaleString()}</strong></div>
      </div>`;
    }

    function parseRoute() {
      let hash = window.location.hash || "#/README.md";
      if (!hash.startsWith("#/")) hash = "#/README.md";
      const withoutHash = hash.slice(2);
      const fragmentIndex = withoutHash.indexOf("#");
      const path = decodeURIComponent(fragmentIndex === -1 ? withoutHash : withoutHash.slice(0, fragmentIndex));
      const fragment = fragmentIndex === -1 ? "" : decodeURIComponent(withoutHash.slice(fragmentIndex + 1));
      return { path: pageMap.has(path) ? path : "README.md", fragment };
    }

    function setMode(mode) {
      viewMode = mode;
      readTab.classList.toggle("active", mode === "article");
      sourceTab.classList.toggle("active", mode === "source");
      renderPage();
    }

    function renderPage() {
      const route = parseRoute();
      currentPath = route.path;
      const page = pageMap.get(currentPath) || pageMap.get("README.md");
      document.title = `${page.title} - Pokemon Spike 3 Rootfs Triage Wiki`;
      currentTitle.textContent = page.title;
      currentPathEl.textContent = page.path;
      articleView.innerHTML = viewMode === "source" || page.kind === "source"
        ? renderSource(page)
        : `<div class="wiki-content">${renderMarkdown(page.text, page.path)}</div>`;
      tocView.innerHTML = makeToc(page);
      pageMeta.innerHTML = makePageMeta(page);
      document.querySelectorAll(".page-list a").forEach((link) => {
        link.classList.toggle("active", link.dataset.path === currentPath);
      });
      setNavOpen(false);
      if (route.fragment) {
        const wanted = document.getElementById(route.fragment) || document.getElementById(slugify(route.fragment));
        if (wanted) wanted.scrollIntoView({ block: "start" });
      } else {
        window.scrollTo({ top: 0 });
      }
    }

    function renderNavItem(page) {
      return `<li data-title="${escapeAttr((page.title + " " + page.path).toLowerCase())}"><a data-path="${escapeAttr(page.path)}" href="#/${escapeAttr(page.path)}"><span class="link-title">${escapeHtml(page.title)}</span><span class="link-kind">${escapeHtml(kindLabel(page))}</span></a></li>`;
    }

    function renderNavigation() {
      const pages = wiki.pages.filter((page) => page.group === "wiki");
      const voices = wiki.pages.filter((page) => page.group === "voice");
      const media = wiki.pages.filter((page) => page.group === "media");
      const artifacts = wiki.pages.filter((page) => page.group === "source");
      pageCount.textContent = pages.length.toLocaleString();
      voiceCount.textContent = voices.length.toLocaleString();
      mediaCount.textContent = media.length.toLocaleString();
      pageList.innerHTML = pages.map(renderNavItem).join("");
      voiceList.innerHTML = voices.map(renderNavItem).join("");
      mediaList.innerHTML = media.map(renderNavItem).join("");
      artifactList.innerHTML = artifacts.map(renderNavItem).join("");
      filterNavigation(navFilter.value);
    }

    function filterNavigation(query) {
      const q = query.trim().toLowerCase();
      let pagesVisible = 0;
      let voicesVisible = 0;
      let mediaVisible = 0;
      let artifactsVisible = 0;
      pageList.querySelectorAll("li").forEach((item) => {
        const show = !q || item.dataset.title.includes(q);
        item.classList.toggle("hidden", !show);
        if (show) pagesVisible++;
      });
      voiceList.querySelectorAll("li").forEach((item) => {
        const show = !q || item.dataset.title.includes(q);
        item.classList.toggle("hidden", !show);
        if (show) voicesVisible++;
      });
      mediaList.querySelectorAll("li").forEach((item) => {
        const show = !q || item.dataset.title.includes(q);
        item.classList.toggle("hidden", !show);
        if (show) mediaVisible++;
      });
      artifactList.querySelectorAll("li").forEach((item) => {
        const show = !q || item.dataset.title.includes(q);
        item.classList.toggle("hidden", !show);
        if (show) artifactsVisible++;
      });
      visiblePageCount.textContent = pagesVisible.toLocaleString();
      visibleVoiceCount.textContent = voicesVisible.toLocaleString();
      visibleMediaCount.textContent = mediaVisible.toLocaleString();
      visibleArtifactCount.textContent = artifactsVisible.toLocaleString();
    }

    function search(query) {
      const q = query.trim().toLowerCase();
      const scope = searchScope.value;
      if (!q) {
        searchResults.classList.remove("open");
        searchResults.innerHTML = "";
        return;
      }
      if (scope === "sounds") {
        const terms = q.split(/\s+/).filter(Boolean);
        const matches = wiki.sounds.map((sound) => {
          const title = sound.title.toLowerCase();
          const filename = sound.filename.toLowerCase();
          const haystack = `${title} ${filename} ${sound.meta.toLowerCase()}`;
          if (!terms.every((term) => haystack.includes(term))) return null;
          let score = 0;
          if (title === q) score += 100;
          if (title.startsWith(q)) score += 40;
          if (title.includes(q)) score += 20;
          if (filename.includes(q)) score += 10;
          return { sound, score };
        }).filter(Boolean);
        matches.sort((a, b) => b.score - a.score || a.sound.title.localeCompare(b.sound.title));
        const cards = matches.slice(0, 24).map(({ sound }) => (
          `<div class="sound-result" data-sound-id="${escapeAttr(sound.id)}">
            <strong>${escapeHtml(sound.title)}</strong>
            <small>${escapeHtml(sound.meta)}</small>
            <audio controls preload="none" src="${escapeAttr(sound.src)}"></audio>
            <div class="sound-result-actions">
              <a class="sound-download" href="${escapeAttr(sound.src)}" download="${escapeAttr(sound.filename)}">Download WAV</a>
              <code title="${escapeAttr(sound.filename)}">${escapeHtml(sound.filename)}</code>
            </div>
          </div>`
        )).join("");
        searchResults.innerHTML = `<div class="sound-search-summary"><strong>${matches.length.toLocaleString()}</strong> matches in ${wiki.sounds.length.toLocaleString()} indexed sounds${matches.length > 24 ? "; showing the first 24" : ""}.</div>` +
          (cards || '<div class="sound-result"><strong>No matching sounds</strong><small>Try a character, callout phrase, object number, source, or filename.</small></div>');
        searchResults.classList.add("open");
        return;
      }
      const hits = [];
      for (const page of wiki.pages) {
        if (scope !== "all" && page.group !== scope) continue;
        const titleHit = page.title.toLowerCase().includes(q);
        const idx = page.text.toLowerCase().indexOf(q);
        if (!titleHit && idx === -1) continue;
        const start = Math.max(0, idx - 44);
        const end = Math.min(page.text.length, idx + q.length + 74);
        let snippet = idx === -1 ? page.path : page.text.slice(start, end).replace(/\s+/g, " ");
        snippet = escapeHtml(snippet);
        if (idx !== -1) {
          snippet = snippet.replace(new RegExp(escapeHtml(q).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "ig"), (m) => `<mark>${m}</mark>`);
        }
        hits.push({ page, snippet, titleHit });
      }
      hits.sort((a, b) => Number(b.titleHit) - Number(a.titleHit) || a.page.title.localeCompare(b.page.title));
      searchResults.innerHTML = hits.slice(0, 12).map(({ page, snippet }) => (
        `<a href="#/${escapeAttr(page.path)}"><strong>${escapeHtml(page.title)}</strong><small>${snippet}</small></a>`
      )).join("") || '<a href="#/README.md"><strong>No matches</strong><small>Try a path, hash, service name, or binary name.</small></a>';
      searchResults.classList.add("open");
    }

    readTab.addEventListener("click", () => setMode("article"));
    sourceTab.addEventListener("click", () => setMode("source"));
    window.addEventListener("hashchange", () => renderPage());
    navFilter.addEventListener("input", (event) => filterNavigation(event.target.value));
    mobileNav.addEventListener("click", () => setNavOpen(!document.body.classList.contains("nav-open")));
    scrim.addEventListener("click", () => setNavOpen(false));
    searchInput.addEventListener("input", (event) => search(event.target.value));
    searchScope.addEventListener("change", () => {
      const labels = { wiki: "research pages", sounds: "all sounds", voice: "voice pages", media: "media galleries", all: "everything" };
      searchInput.placeholder = `Search ${labels[searchScope.value]}`;
      search(searchInput.value);
    });
    searchInput.addEventListener("keydown", (event) => {
      if (event.key !== "Enter") return;
      if (searchScope.value === "sounds") return;
      const first = searchResults.querySelector("a");
      if (first) first.click();
    });
    document.addEventListener("click", (event) => {
      if (!searchResults.contains(event.target) && event.target !== searchInput) {
        searchResults.classList.remove("open");
      }
    });

    renderNavigation();
    renderPage();
  </script>
</body>
</html>
"""


def main() -> None:
    sound_index = build_sound_index()
    SOUND_INDEX_OUT.write_text(json.dumps(sound_index, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    data = {"pages": build_pages(), "sounds": sound_index["sounds"]}
    encoded = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
    OUT.write_text(HTML_TEMPLATE.replace("__WIKI_DATA__", encoded), encoding="utf-8")
    print(f"wrote {SOUND_INDEX_OUT.relative_to(ROOT.parent)} ({sound_index['count']} sounds)")
    print(f"wrote {OUT.relative_to(ROOT.parent)}")


if __name__ == "__main__":
    main()
