#!/usr/bin/env python3
"""Validate semantic classifications and render a self-contained HTML review."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import Any


SCHEMA_VERSION = 1
COLLECTOR_NAME = "semantic-diff-review/collect_changes.py"
HUNK_ID = re.compile(r"^H-[0-9A-F]{12,64}(?:-[0-9A-F]{8})?(?:-[0-9]+)?$")
RISK_LEVELS = {"low", "medium", "high"}
CLASSIFICATION_KEYS = {"schema_version", "groups"}
GROUP_KEYS = {
    "title",
    "purpose",
    "risk",
    "review_points",
    "suggested_commit_message",
    "hunk_ids",
}
RISK_KEYS = {"level", "rationale"}


class RenderError(RuntimeError):
    """Raised when evidence or semantic classification is invalid."""


def load_json(path: Path) -> Any:
    try:
        with path.open("r", encoding="utf-8") as handle:
            return json.load(handle)
    except FileNotFoundError as exc:
        raise RenderError(f"File not found: {path}") from exc
    except json.JSONDecodeError as exc:
        raise RenderError(f"Invalid JSON in {path}: {exc}") from exc


def require_dict(value: Any, label: str) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise RenderError(f"{label} must be an object")
    return value


def require_exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
    actual = set(value)
    missing = sorted(expected - actual)
    unknown = sorted(actual - expected)
    if missing or unknown:
        details = []
        if missing:
            details.append(f"missing {', '.join(missing)}")
        if unknown:
            details.append(f"unknown {', '.join(unknown)}")
        raise RenderError(f"{label} has invalid fields: {'; '.join(details)}")


def require_string(value: Any, label: str, *, allow_empty: bool = False) -> str:
    if not isinstance(value, str):
        raise RenderError(f"{label} must be a string")
    if not allow_empty and not value.strip():
        raise RenderError(f"{label} must not be empty")
    return value


def canonical_evidence(records: list[dict[str, Any]]) -> str:
    return json.dumps(records, ensure_ascii=True, sort_keys=True, separators=(",", ":"))


def validate_changes(document: Any) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    root = require_dict(document, "changes")
    if root.get("schema_version") != SCHEMA_VERSION:
        raise RenderError("Unsupported changes schema_version")
    if root.get("generator") != COLLECTOR_NAME:
        raise RenderError("changes.json was not produced by the bundled collector")
    repository = require_dict(root.get("repository"), "changes.repository")
    require_string(repository.get("root"), "changes.repository.root")
    head = repository.get("head")
    if head is not None:
        require_string(head, "changes.repository.head")
    target_value = repository.get("target")
    if target_value is None:
        target = {"kind": "working-tree"}
        repository = {**repository, "target": target}
    else:
        target = require_dict(target_value, "changes.repository.target")
        kind = require_string(target.get("kind"), "changes.repository.target.kind")
        if kind == "working-tree":
            require_exact_keys(target, {"kind"}, "changes.repository.target")
        elif kind == "commit":
            require_exact_keys(
                target,
                {"kind", "revision", "commit", "base"},
                "changes.repository.target",
            )
            require_string(target["revision"], "changes.repository.target.revision")
            require_string(target["commit"], "changes.repository.target.commit")
            require_string(target["base"], "changes.repository.target.base")
        else:
            raise RenderError(
                "changes.repository.target.kind must be working-tree or commit"
            )

    records = root.get("hunks")
    if not isinstance(records, list):
        raise RenderError("changes.hunks must be an array")

    seen: set[str] = set()
    validated: list[dict[str, Any]] = []
    required_fields = {
        "id",
        "scope",
        "status",
        "old_path",
        "new_path",
        "kind",
        "header",
        "additions",
        "deletions",
        "patch_sha256",
        "patch",
    }
    for index, raw_record in enumerate(records):
        label = f"changes.hunks[{index}]"
        record = require_dict(raw_record, label)
        require_exact_keys(record, required_fields, label)
        hunk_id = require_string(record["id"], f"{label}.id")
        if not HUNK_ID.fullmatch(hunk_id):
            raise RenderError(f"{label}.id is not a valid collector hunk ID")
        if hunk_id in seen:
            raise RenderError(f"Duplicate collected hunk ID: {hunk_id}")
        seen.add(hunk_id)

        scope = require_string(record["scope"], f"{label}.scope")
        if scope not in {"staged", "unstaged", "untracked", "commit"}:
            raise RenderError(
                f"{label}.scope must be staged, unstaged, untracked, or commit"
            )
        require_string(record["status"], f"{label}.status")
        require_string(record["old_path"], f"{label}.old_path")
        require_string(record["new_path"], f"{label}.new_path")
        kind = require_string(record["kind"], f"{label}.kind")
        if kind not in {"text", "binary-or-metadata", "empty"}:
            raise RenderError(f"{label}.kind is invalid")
        require_string(record["header"], f"{label}.header", allow_empty=True)
        for stat in ("additions", "deletions"):
            if not isinstance(record[stat], int) or record[stat] < 0:
                raise RenderError(f"{label}.{stat} must be a non-negative integer")
        patch = require_string(record["patch"], f"{label}.patch", allow_empty=True)
        expected_hash = require_string(
            record["patch_sha256"], f"{label}.patch_sha256"
        )
        actual_hash = hashlib.sha256(
            patch.encode("utf-8", "surrogateescape")
        ).hexdigest()
        if actual_hash != expected_hash:
            raise RenderError(
                f"Collected patch integrity check failed for {hunk_id}; re-run collection"
            )
        validated.append(record)

    digest = require_string(root.get("evidence_sha256"), "changes.evidence_sha256")
    actual_digest = hashlib.sha256(canonical_evidence(validated).encode("ascii")).hexdigest()
    if digest != actual_digest:
        raise RenderError("Collected evidence integrity check failed; re-run collection")
    return repository, validated


def validate_classification(
    document: Any, hunks: list[dict[str, Any]]
) -> list[dict[str, Any]]:
    root = require_dict(document, "classification")
    require_exact_keys(root, CLASSIFICATION_KEYS, "classification")
    if root["schema_version"] != SCHEMA_VERSION:
        raise RenderError("Unsupported classification schema_version")
    groups = root["groups"]
    if not isinstance(groups, list):
        raise RenderError("classification.groups must be an array")

    known_ids = {hunk["id"] for hunk in hunks}
    assigned: list[str] = []
    validated: list[dict[str, Any]] = []
    for index, raw_group in enumerate(groups):
        label = f"classification.groups[{index}]"
        group = require_dict(raw_group, label)
        require_exact_keys(group, GROUP_KEYS, label)
        title = require_string(group["title"], f"{label}.title")
        purpose = require_string(group["purpose"], f"{label}.purpose")
        risk = require_dict(group["risk"], f"{label}.risk")
        require_exact_keys(risk, RISK_KEYS, f"{label}.risk")
        level = require_string(risk["level"], f"{label}.risk.level").lower()
        if level not in RISK_LEVELS:
            raise RenderError(f"{label}.risk.level must be low, medium, or high")
        rationale = require_string(risk["rationale"], f"{label}.risk.rationale")
        points = group["review_points"]
        if not isinstance(points, list) or not points:
            raise RenderError(f"{label}.review_points must be a non-empty array")
        review_points = [
            require_string(point, f"{label}.review_points[{point_index}]")
            for point_index, point in enumerate(points)
        ]
        message = require_string(
            group["suggested_commit_message"], f"{label}.suggested_commit_message"
        )
        hunk_ids = group["hunk_ids"]
        if not isinstance(hunk_ids, list) or not hunk_ids:
            raise RenderError(f"{label}.hunk_ids must be a non-empty array")
        normalized_ids = [
            require_string(hunk_id, f"{label}.hunk_ids[{hunk_index}]")
            for hunk_index, hunk_id in enumerate(hunk_ids)
        ]
        unknown = sorted(set(normalized_ids) - known_ids)
        if unknown:
            raise RenderError(f"{label} references unknown hunk IDs: {', '.join(unknown)}")
        assigned.extend(normalized_ids)
        validated.append(
            {
                "id": f"group-{index + 1}",
                "title": title,
                "purpose": purpose,
                "risk": {"level": level, "rationale": rationale},
                "review_points": review_points,
                "suggested_commit_message": message,
                "hunk_ids": normalized_ids,
            }
        )

    if not known_ids and groups:
        raise RenderError("classification.groups must be empty when there are no hunks")
    duplicates = sorted({item for item in assigned if assigned.count(item) > 1})
    if duplicates:
        raise RenderError(f"Hunk IDs assigned more than once: {', '.join(duplicates)}")
    missing = sorted(known_ids - set(assigned))
    if missing:
        raise RenderError(f"Unclassified hunk IDs: {', '.join(missing)}")
    return validated


def build_payload(
    repository: dict[str, Any],
    hunks: list[dict[str, Any]],
    groups: list[dict[str, Any]],
) -> dict[str, Any]:
    by_id = {hunk["id"]: hunk for hunk in hunks}
    rendered_groups = []
    for group in groups:
        group_hunks = [by_id[hunk_id] for hunk_id in group["hunk_ids"]]
        paths = sorted(
            {
                hunk["new_path"]
                if hunk["new_path"] != "/dev/null"
                else hunk["old_path"]
                for hunk in group_hunks
            }
        )
        rendered_groups.append(
            {
                **group,
                "hunks": group_hunks,
                "stats": {
                    "additions": sum(hunk["additions"] for hunk in group_hunks),
                    "deletions": sum(hunk["deletions"] for hunk in group_hunks),
                    "files": len(paths),
                    "hunks": len(group_hunks),
                },
            }
        )

    root = repository["root"]
    return {
        "repository": {
            "name": Path(root).name or root,
            "root": root,
            "head": repository.get("head"),
            "target": repository["target"],
        },
        "totals": {
            "groups": len(rendered_groups),
            "hunks": len(hunks),
            "additions": sum(hunk["additions"] for hunk in hunks),
            "deletions": sum(hunk["deletions"] for hunk in hunks),
        },
        "groups": rendered_groups,
    }


def safe_json_for_html(payload: dict[str, Any]) -> str:
    encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
    return encoded.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")


HTML_TEMPLATE = r'''<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta name="color-scheme" content="dark">
  <title>Semantic Diff Review</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #090c10;
      --surface: #0f141b;
      --surface-2: #151b24;
      --surface-3: #1b2330;
      --border: #273140;
      --border-soft: #1d2632;
      --text: #e6edf3;
      --muted: #8b98a8;
      --faint: #5f6b79;
      --accent: #7c9cff;
      --accent-soft: rgba(124, 156, 255, .12);
      --green: #57d18c;
      --green-soft: rgba(46, 160, 88, .13);
      --red: #ff7b72;
      --red-soft: rgba(248, 81, 73, .13);
      --amber: #e3b341;
      --amber-soft: rgba(227, 179, 65, .13);
      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }

    * { box-sizing: border-box; }
    html, body { height: 100%; }
    body {
      margin: 0;
      overflow: hidden;
      background: var(--bg);
      color: var(--text);
      font-family: var(--sans);
      font-size: 14px;
    }

    button { font: inherit; }
    .shell { display: grid; grid-template-rows: 58px minmax(0, 1fr); height: 100vh; }
    .topbar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 20px;
      padding: 0 20px;
      border-bottom: 1px solid var(--border);
      background: rgba(15, 20, 27, .95);
      box-shadow: 0 8px 28px rgba(0, 0, 0, .22);
      z-index: 5;
    }
    .brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
    .brand-mark {
      display: grid;
      place-items: center;
      width: 30px;
      height: 30px;
      border: 1px solid rgba(124, 156, 255, .45);
      border-radius: 8px;
      background: linear-gradient(145deg, rgba(124,156,255,.22), rgba(87,209,140,.08));
      color: #a9bcff;
      font: 700 15px var(--mono);
    }
    .brand-copy { min-width: 0; }
    .brand-title { font-weight: 650; letter-spacing: -.01em; }
    .repo-line { color: var(--muted); font: 11px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .top-stats { display: flex; align-items: center; gap: 13px; color: var(--muted); font-size: 12px; white-space: nowrap; }
    .top-stats b { color: var(--text); font-weight: 600; }
    .add { color: var(--green) !important; }
    .del { color: var(--red) !important; }
    .integrity {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 5px 9px;
      border: 1px solid rgba(87, 209, 140, .25);
      border-radius: 999px;
      background: rgba(87, 209, 140, .08);
      color: #8ae2ad;
      font-size: 11px;
    }
    .integrity::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 10px var(--green); }

    .workspace { display: grid; grid-template-columns: 282px minmax(390px, 1fr) 350px; min-height: 0; }
    .sidebar, .inspector { background: var(--surface); min-height: 0; overflow: auto; }
    .sidebar { border-right: 1px solid var(--border); padding: 18px 12px; }
    .inspector { border-left: 1px solid var(--border); padding: 22px 20px 32px; }
    .diff-pane { min-width: 0; min-height: 0; overflow: auto; background: #0b0f14; }

    .eyebrow {
      margin: 0 8px 10px;
      color: var(--faint);
      font-size: 10px;
      font-weight: 700;
      letter-spacing: .13em;
      text-transform: uppercase;
    }
    .group-list { display: grid; gap: 7px; }
    .group-button {
      width: 100%;
      padding: 12px;
      border: 1px solid transparent;
      border-radius: 9px;
      background: transparent;
      color: inherit;
      text-align: left;
      cursor: pointer;
      transition: background .15s ease, border-color .15s ease, transform .15s ease;
    }
    .group-button:hover { background: var(--surface-2); border-color: var(--border-soft); }
    .group-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    .group-button.active { background: var(--accent-soft); border-color: rgba(124, 156, 255, .35); }
    .group-index { color: var(--faint); font: 10px var(--mono); }
    .group-name { margin-top: 5px; font-size: 13px; font-weight: 620; line-height: 1.35; }
    .group-meta { display: flex; gap: 8px; margin-top: 9px; color: var(--muted); font: 10px var(--mono); }

    .empty-state { display: grid; place-items: center; min-height: 100%; padding: 40px; text-align: center; }
    .empty-card { max-width: 440px; }
    .empty-icon { color: var(--green); font: 38px var(--mono); }
    .empty-card h1 { margin: 15px 0 8px; font-size: 22px; }
    .empty-card p { margin: 0; color: var(--muted); line-height: 1.6; }

    .pane-header {
      position: sticky;
      top: 0;
      z-index: 3;
      padding: 20px 22px 15px;
      border-bottom: 1px solid var(--border);
      background: rgba(11, 15, 20, .94);
      backdrop-filter: blur(12px);
    }
    .pane-header h1 { margin: 0; font-size: 18px; letter-spacing: -.015em; }
    .pane-meta { display: flex; flex-wrap: wrap; gap: 13px; margin-top: 9px; color: var(--muted); font: 11px var(--mono); }
    .diff-stack { display: grid; gap: 14px; padding: 16px 18px 34px; }
    .hunk-card { overflow: hidden; border: 1px solid var(--border); border-radius: 9px; background: #0d1218; box-shadow: 0 8px 28px rgba(0, 0, 0, .16); }
    .hunk-bar { display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-bottom: 1px solid var(--border); background: var(--surface-2); }
    .scope {
      padding: 3px 6px;
      border: 1px solid var(--border);
      border-radius: 5px;
      color: #b8c2ce;
      background: var(--surface-3);
      font: 9px var(--mono);
      letter-spacing: .06em;
      text-transform: uppercase;
    }
    .scope.staged { color: #8ae2ad; border-color: rgba(87,209,140,.28); background: rgba(87,209,140,.08); }
    .scope.untracked { color: #f2cb6c; border-color: rgba(227,179,65,.28); background: rgba(227,179,65,.08); }
    .scope.commit { color: #a9bcff; border-color: rgba(124,156,255,.35); background: rgba(124,156,255,.10); }
    .path { min-width: 0; overflow: hidden; color: #c9d3df; font: 11px var(--mono); text-overflow: ellipsis; white-space: nowrap; }
    .hunk-id { margin-left: auto; color: var(--faint); font: 9px var(--mono); white-space: nowrap; }
    .diff { margin: 0; padding: 10px 0; overflow-x: auto; color: #b9c3cf; font: 11px/1.55 var(--mono); tab-size: 4; }
    .diff-line { display: block; min-width: max-content; padding: 0 14px; white-space: pre; }
    .diff-line.addition { color: #a8e6bd; background: var(--green-soft); }
    .diff-line.deletion { color: #ffaaa4; background: var(--red-soft); }
    .diff-line.hunk { color: #a9bcff; background: rgba(124,156,255,.08); }
    .diff-line.file { color: #d6a8ff; }
    .diff-line.meta { color: #6f7d8c; }
    .no-patch { padding: 24px 16px; color: var(--muted); font-size: 12px; text-align: center; }

    .inspector h2 { margin: 0 0 18px; font-size: 17px; line-height: 1.35; letter-spacing: -.01em; }
    .section { padding: 17px 0; border-top: 1px solid var(--border-soft); }
    .section:first-of-type { border-top: 0; padding-top: 0; }
    .section-label { margin-bottom: 9px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
    .section p { margin: 0; color: #b9c3cf; line-height: 1.6; }
    .risk-row { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; }
    .risk-badge { padding: 4px 8px; border-radius: 999px; font: 700 10px var(--mono); text-transform: uppercase; }
    .risk-badge.low { color: #8ae2ad; background: var(--green-soft); border: 1px solid rgba(87,209,140,.25); }
    .risk-badge.medium { color: #f0c762; background: var(--amber-soft); border: 1px solid rgba(227,179,65,.25); }
    .risk-badge.high { color: #ff9a93; background: var(--red-soft); border: 1px solid rgba(248,81,73,.25); }
    .review-points { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
    .review-points li { position: relative; padding-left: 17px; color: #b9c3cf; line-height: 1.5; }
    .review-points li::before { content: "›"; position: absolute; left: 0; color: var(--accent); font: 700 15px var(--mono); }
    .commit-box { position: relative; padding: 12px 40px 12px 12px; border: 1px solid var(--border); border-radius: 8px; background: #0b0f14; color: #d7e0ea; font: 11px/1.55 var(--mono); word-break: break-word; }
    .copy-button { position: absolute; top: 7px; right: 7px; width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); cursor: pointer; }
    .copy-button:hover { color: var(--text); border-color: #3a4758; }
    .source-note { display: flex; gap: 9px; margin-top: 19px; padding: 11px; border: 1px solid var(--border-soft); border-radius: 8px; color: var(--muted); background: rgba(255,255,255,.015); font-size: 11px; line-height: 1.45; }
    .source-note span:first-child { color: var(--green); }

    @media (max-width: 1050px) {
      body { overflow: auto; }
      .shell { min-height: 100vh; height: auto; }
      .workspace { grid-template-columns: 230px minmax(0, 1fr); grid-template-rows: minmax(620px, auto) auto; }
      .inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border); }
    }
    @media (max-width: 720px) {
      .top-stats .desktop-stat, .integrity { display: none; }
      .workspace { display: block; }
      .sidebar { border-right: 0; border-bottom: 1px solid var(--border); overflow: visible; }
      .group-list { grid-auto-flow: column; grid-auto-columns: minmax(210px, 75vw); overflow-x: auto; padding-bottom: 4px; }
      .diff-pane { min-height: 600px; }
      .inspector { border-left: 0; }
    }
  </style>
</head>
<body>
  <div class="shell">
    <header class="topbar">
      <div class="brand">
        <div class="brand-mark">Δ</div>
        <div class="brand-copy">
          <div class="brand-title">Semantic Diff Review</div>
          <div class="repo-line" id="repo-line"></div>
        </div>
      </div>
      <div class="top-stats">
        <span><b id="total-groups">0</b> groups</span>
        <span class="desktop-stat"><b id="total-hunks">0</b> hunks</span>
        <span class="desktop-stat"><b class="add" id="total-additions">+0</b></span>
        <span class="desktop-stat"><b class="del" id="total-deletions">−0</b></span>
        <span class="integrity">Git-derived patches</span>
      </div>
    </header>
    <main class="workspace">
      <nav class="sidebar" aria-label="Semantic groups">
        <div class="eyebrow">Change groups</div>
        <div class="group-list" id="group-list"></div>
      </nav>
      <section class="diff-pane" id="diff-pane" aria-label="Selected group diff"></section>
      <aside class="inspector" id="inspector" aria-label="Semantic analysis"></aside>
    </main>
  </div>
  <script id="review-data" type="application/json">__REVIEW_DATA__</script>
  <script>
    (() => {
      "use strict";
      const data = JSON.parse(document.getElementById("review-data").textContent);
      const groupList = document.getElementById("group-list");
      const diffPane = document.getElementById("diff-pane");
      const inspector = document.getElementById("inspector");
      let selected = 0;

      const node = (tag, className, text) => {
        const element = document.createElement(tag);
        if (className) element.className = className;
        if (text !== undefined) element.textContent = text;
        return element;
      };

      const pathFor = (hunk) => hunk.new_path === "/dev/null" ? hunk.old_path : hunk.new_path;

      const lineClass = (line) => {
        if (line.startsWith("@@")) return "hunk";
        if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) return "file";
        if (line.startsWith("+") && !line.startsWith("+++")) return "addition";
        if (line.startsWith("-") && !line.startsWith("---")) return "deletion";
        if (/^(index |new file mode |deleted file mode |similarity index |rename |copy |Binary files |GIT binary patch)/.test(line)) return "meta";
        return "context";
      };

      const renderSidebar = () => {
        groupList.replaceChildren();
        data.groups.forEach((group, index) => {
          const button = node("button", `group-button${index === selected ? " active" : ""}`);
          button.type = "button";
          button.setAttribute("aria-pressed", String(index === selected));
          button.append(node("div", "group-index", `GROUP ${String(index + 1).padStart(2, "0")}`));
          button.append(node("div", "group-name", group.title));
          const meta = node("div", "group-meta");
          meta.append(node("span", "", `${group.stats.files} file${group.stats.files === 1 ? "" : "s"}`));
          meta.append(node("span", "", `${group.stats.hunks} hunk${group.stats.hunks === 1 ? "" : "s"}`));
          button.append(meta);
          button.addEventListener("click", () => { selected = index; render(); });
          groupList.append(button);
        });
      };

      const renderDiff = (group) => {
        diffPane.replaceChildren();
        const header = node("header", "pane-header");
        header.append(node("h1", "", group.title));
        const meta = node("div", "pane-meta");
        meta.append(node("span", "", `${group.stats.files} files`));
        meta.append(node("span", "", `${group.stats.hunks} hunks`));
        meta.append(node("span", "add", `+${group.stats.additions}`));
        meta.append(node("span", "del", `−${group.stats.deletions}`));
        header.append(meta);
        diffPane.append(header);

        const stack = node("div", "diff-stack");
        group.hunks.forEach((hunk) => {
          const card = node("article", "hunk-card");
          const bar = node("div", "hunk-bar");
          bar.append(node("span", `scope ${hunk.scope}`, hunk.scope));
          bar.append(node("span", "path", pathFor(hunk)));
          bar.append(node("span", "hunk-id", hunk.id));
          card.append(bar);
          if (!hunk.patch) {
            card.append(node("div", "no-patch", "Git emitted no textual patch for this empty-file change."));
          } else {
            const pre = node("pre", "diff");
            const lines = hunk.patch.split("\n");
            if (lines.at(-1) === "") lines.pop();
            lines.forEach((line) => pre.append(node("span", `diff-line ${lineClass(line)}`, line)));
            card.append(pre);
          }
          stack.append(card);
        });
        diffPane.append(stack);
        diffPane.scrollTop = 0;
      };

      const renderInspector = (group) => {
        inspector.replaceChildren();
        inspector.append(node("h2", "", group.title));

        const purpose = node("section", "section");
        purpose.append(node("div", "section-label", "Purpose"));
        purpose.append(node("p", "", group.purpose));
        inspector.append(purpose);

        const risk = node("section", "section");
        risk.append(node("div", "section-label", "Risk"));
        const riskRow = node("div", "risk-row");
        riskRow.append(node("span", `risk-badge ${group.risk.level}`, group.risk.level));
        risk.append(riskRow);
        risk.append(node("p", "", group.risk.rationale));
        inspector.append(risk);

        const review = node("section", "section");
        review.append(node("div", "section-label", "Review points"));
        const list = node("ul", "review-points");
        group.review_points.forEach((point) => list.append(node("li", "", point)));
        review.append(list);
        inspector.append(review);

        const commit = node("section", "section");
        commit.append(node("div", "section-label", "Suggested commit"));
        const box = node("div", "commit-box", group.suggested_commit_message);
        const copy = node("button", "copy-button", "⧉");
        copy.type = "button";
        copy.title = "Copy commit message";
        copy.setAttribute("aria-label", "Copy suggested commit message");
        copy.addEventListener("click", async () => {
          try {
            await navigator.clipboard.writeText(group.suggested_commit_message);
            copy.textContent = "✓";
            setTimeout(() => { copy.textContent = "⧉"; }, 1200);
          } catch (_error) {
            copy.textContent = "!";
          }
        });
        box.append(copy);
        commit.append(box);
        inspector.append(commit);

        const note = node("div", "source-note");
        note.append(node("span", "", "●"));
        note.append(node("span", "", "Every patch shown in the center pane is preserved from Git diff output. Semantic text is escaped classification metadata."));
        inspector.append(note);
        inspector.scrollTop = 0;
      };

      const renderEmpty = () => {
        groupList.replaceChildren();
        diffPane.replaceChildren();
        inspector.replaceChildren();
        const state = node("div", "empty-state");
        const card = node("div", "empty-card");
        card.append(node("div", "empty-icon", "✓"));
        const commitTarget = data.repository.target.kind === "commit";
        card.append(node("h1", "", commitTarget ? "Commit has no changes" : "Working tree is clean"));
        card.append(node("p", "", commitTarget
          ? "No changes were found between the selected commit and its first parent. Git state was not modified."
          : "No staged, unstaged, or untracked changes were collected. Git state was not modified."));
        state.append(card);
        diffPane.append(state);
      };

      const render = () => {
        if (!data.groups.length) { renderEmpty(); return; }
        renderSidebar();
        renderDiff(data.groups[selected]);
        renderInspector(data.groups[selected]);
      };

      const target = data.repository.target;
      const targetLabel = target.kind === "commit"
        ? `commit ${target.commit.slice(0, 10)}`
        : (data.repository.head ? `working tree @ ${data.repository.head.slice(0, 10)}` : "working tree @ unborn HEAD");
      document.getElementById("repo-line").textContent = `${data.repository.name} · ${targetLabel}`;
      document.getElementById("repo-line").title = data.repository.root;
      document.getElementById("total-groups").textContent = data.totals.groups;
      document.getElementById("total-hunks").textContent = data.totals.hunks;
      document.getElementById("total-additions").textContent = `+${data.totals.additions}`;
      document.getElementById("total-deletions").textContent = `−${data.totals.deletions}`;
      render();
    })();
  </script>
</body>
</html>
'''


def render_html(payload: dict[str, Any]) -> str:
    return HTML_TEMPLATE.replace("__REVIEW_DATA__", safe_json_for_html(payload))


def atomic_write(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(
        mode="w",
        encoding="utf-8",
        dir=path.parent,
        prefix=f".{path.name}.",
        suffix=".tmp",
        delete=False,
    ) as handle:
        temp_path = Path(handle.name)
        handle.write(content)
        handle.flush()
        os.fsync(handle.fileno())
    os.replace(temp_path, path)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--changes",
        default=".semantic-review/changes.json",
        help="Collector JSON input",
    )
    parser.add_argument(
        "--classification",
        default=".semantic-review/classification.json",
        help="Semantic classification JSON input",
    )
    parser.add_argument(
        "--output",
        default=".semantic-review/review.html",
        help="Self-contained HTML output",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    changes_path = Path(args.changes).expanduser().resolve()
    classification_path = Path(args.classification).expanduser().resolve()
    output_path = Path(args.output).expanduser().resolve()
    try:
        repository, hunks = validate_changes(load_json(changes_path))
        groups = validate_classification(load_json(classification_path), hunks)
        atomic_write(output_path, render_html(build_payload(repository, hunks, groups)))
    except (OSError, RenderError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    print(f"Rendered {len(groups)} groups and {len(hunks)} hunks -> {output_path}")
    return 0


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