#!/usr/bin/env python3
"""Generate traceable Rust shims and test/sample parity ledgers from LibreMetaverse.

This is deliberately a source inventory generator, not a C# parser. It handles
the declaration and NUnit forms present in the pinned upstream checkout and
fails loudly when its expected counts drift.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import keyword
import re
import subprocess
import tempfile
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path

UPSTREAM_COMMIT = "2aa70bb68513b39795da5d13c88f31b86e85a3ba"


@dataclass(frozen=True)
class Project:
    source: str
    crate: str
    namespace: str


PROJECTS = (
    Project("LibreMetaverse.Types", "libremetaverse-types", "LibreMetaverse"),
    Project("LibreMetaverse.StructuredData", "libremetaverse-structured-data", "LibreMetaverse.StructuredData"),
    Project("LibreMetaverse.Imaging.Abstractions", "libremetaverse-imaging", "LibreMetaverse.Imaging"),
    Project("LibreMetaverse.Imaging.Skia", "libremetaverse-imaging-skia", "LibreMetaverse.Imaging.Skia"),
    Project("PrimMesher", "libremetaverse-prim-mesher", "LibreMetaverse.PrimMesher"),
    Project("LibreMetaverse", "libremetaverse", "LibreMetaverse"),
    Project("LibreMetaverse.Rendering.Simple", "libremetaverse-rendering-simple", "LibreMetaverse.Rendering"),
    Project("LibreMetaverse.Rendering.MeshFoundry", "libremetaverse-rendering-mesh-foundry", "LibreMetaverse.Rendering"),
    Project("LibreMetaverse.LslTools", "libremetaverse-lsl-tools", "LibreMetaverse.LslTools"),
    Project("LibreMetaverse.RLV", "libremetaverse-rlv", "LibreMetaverse.RLV"),
    Project("LibreMetaverse.Utilities", "libremetaverse-utilities", "LibreMetaverse.Utilities"),
    Project("LibreMetaverse.Voice.Vivox", "libremetaverse-voice-vivox", "LibreMetaverse.Voice.Vivox"),
    Project("LibreMetaverse.Voice.WebRTC", "libremetaverse-voice-webrtc", "LibreMetaverse.Voice.WebRTC"),
)

TYPE_RE = re.compile(
    r"^\s*public\s+(?:(?:static|abstract|sealed|partial|readonly|unsafe|ref|new)\s+)*"
    r"(?P<kind>class|struct|interface|enum|delegate|record(?:\s+(?:class|struct))?)\s+"
    r"(?P<name>@?[A-Za-z_][A-Za-z0-9_]*)(?P<generic><[^>{}()]+>)?",
    re.MULTILINE,
)
NAMESPACE_RE = re.compile(r"^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE)
TEST_ATTR_RE = re.compile(r"\[(Test|TestCase)(?:\((.*?)\))?\]", re.DOTALL)
CATEGORY_RE = re.compile(r'\[Category\("([^"]+)"\)\]')
METHOD_RE = re.compile(
    r"\b(?:public|internal)\s+(?:static\s+)?(?:async\s+)?"
    r"(?:void|Task(?:\s*<[^>]+>)?|ValueTask(?:\s*<[^>]+>)?)\s+"
    r"([A-Za-z_][A-Za-z0-9_]*)\s*\(",
    re.DOTALL,
)
CLASS_RE = re.compile(
    r"\b(?:(?:public|internal|private|protected|sealed|partial|abstract|static)\s+)*"
    r"class\s+([A-Za-z_][A-Za-z0-9_]*)"
)
PARITY_MARKER_RE = re.compile(
    r"^// parity-case: (?P<id>\S+) (?P<body_sha256>[0-9a-f]{64}) "
    r"(?P<status>translated|ignored-live|benchmark)$",
    re.MULTILINE,
)
RUST_TEST_RE = re.compile(r"(?:#\[[^\]]+\]\s*)*fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.MULTILINE)
FORBIDDEN_REVIEW_BODY_RE = re.compile(
    r"\bpending\s*\(|\bassert_[A-Za-z0-9_]*case\s*\(|"
    r"\b(?:todo|unimplemented|unimplemented_api)\s*!|\bnot_implemented\s*\("
)
EXPECTED_TESTS = 1289
PARITY_FILES = (
    Path("tests/compat/tests/generated_parity.rs"),
    Path("tests/PARITY.md"),
    Path("tests/upstream-tests.json"),
)


def snake(name: str) -> str:
    name = name.lstrip("@").replace("-", "_")
    name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
    name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name).lower()
    name = re.sub(r"[^a-z0-9_]", "_", name)
    if not name or name[0].isdigit():
        name = f"item_{name}"
    if keyword.iskeyword(name) or name in {"crate", "self", "super", "type", "match", "mod", "move", "ref", "use", "where", "loop", "async", "await", "dyn"}:
        name += "_"
    return name


def rust_type_name(name: str) -> str:
    name = name.lstrip("@")
    replacements = {
        "Self": "SelfType",
        "box": "BoxShim",
        "type": "TypeShim",
    }
    if name in replacements:
        return replacements[name]
    return re.sub(r"[^A-Za-z0-9_]", "_", name)


def source_files(root: Path) -> list[Path]:
    return sorted(p for p in root.rglob("*.cs") if "obj" not in p.parts and "bin" not in p.parts)


def mask_csharp_comments(text: str) -> str:
    """Blank comments without shifting source offsets or touching string literals."""
    masked = list(text)
    index = 0
    state = "code"
    while index < len(text):
        char = text[index]
        following = text[index + 1] if index + 1 < len(text) else ""
        if state == "code":
            if char == '"':
                state = "string"
            elif char == "'":
                state = "char"
            elif char == "/" and following == "/":
                masked[index] = masked[index + 1] = " "
                index += 1
                state = "line_comment"
            elif char == "/" and following == "*":
                masked[index] = masked[index + 1] = " "
                index += 1
                state = "block_comment"
        elif state in {"string", "char"}:
            if char == "\\":
                index += 1
            elif (state == "string" and char == '"') or (state == "char" and char == "'"):
                state = "code"
        elif state == "line_comment":
            if char == "\n":
                state = "code"
            else:
                masked[index] = " "
        elif state == "block_comment":
            if char == "*" and following == "/":
                masked[index] = masked[index + 1] = " "
                index += 1
                state = "code"
            elif char != "\n":
                masked[index] = " "
        index += 1
    return "".join(masked)


def mask_rust_noncode(text: str) -> str:
    """Blank Rust comments and string/character contents without shifting offsets."""
    masked = list(text)
    index = 0
    state = "code"
    block_depth = 0
    raw_hashes = 0
    while index < len(text):
        char = text[index]
        following = text[index + 1] if index + 1 < len(text) else ""
        if state == "code":
            raw = re.match(r'(?:b|c)?r(#{0,255})"', text[index:])
            if raw:
                raw_hashes = len(raw.group(1))
                for offset in range(len(raw.group(0))):
                    masked[index + offset] = " "
                index += len(raw.group(0)) - 1
                state = "raw_string"
            elif char == '"':
                masked[index] = " "
                state = "string"
            elif char == "'" and re.match(r"'(?:\\.|[^\\'\n])'", text[index:]):
                masked[index] = " "
                state = "char"
            elif char == "/" and following == "/":
                masked[index] = masked[index + 1] = " "
                index += 1
                state = "line_comment"
            elif char == "/" and following == "*":
                masked[index] = masked[index + 1] = " "
                index += 1
                block_depth = 1
                state = "block_comment"
        elif state in {"string", "char"}:
            masked[index] = " "
            if char == "\\" and index + 1 < len(text):
                masked[index + 1] = " "
                index += 1
            elif (state == "string" and char == '"') or (state == "char" and char == "'"):
                state = "code"
        elif state == "raw_string":
            masked[index] = " "
            ending = '"' + "#" * raw_hashes
            if text.startswith(ending, index):
                for offset in range(len(ending)):
                    masked[index + offset] = " "
                index += len(ending) - 1
                state = "code"
        elif state == "line_comment":
            if char == "\n":
                state = "code"
            else:
                masked[index] = " "
        elif state == "block_comment":
            masked[index] = " "
            if char == "/" and following == "*":
                masked[index + 1] = " "
                index += 1
                block_depth += 1
            elif char == "*" and following == "/":
                masked[index + 1] = " "
                index += 1
                block_depth -= 1
                if block_depth == 0:
                    state = "code"
        index += 1
    return "".join(masked)


def rust_test_body(text: str, rust_test: re.Match[str]) -> str:
    masked = mask_rust_noncode(text)
    brace = masked.find("{", rust_test.end())
    if brace < 0:
        raise RuntimeError(f"Rust test {rust_test.group(1)} has no body")
    depth = 0
    for index in range(brace, len(masked)):
        if masked[index] == "{":
            depth += 1
        elif masked[index] == "}":
            depth -= 1
            if depth == 0:
                return text[brace + 1 : index]
    raise RuntimeError(f"Rust test {rust_test.group(1)} has an unterminated body")


def validate_reviewed_body(path: Path, rust_test: re.Match[str], body: str) -> str:
    code = mask_rust_noncode(body).strip()
    if not code:
        raise RuntimeError(f"Reviewed parity test has an empty body: {path}:{rust_test.group(1)}")
    forbidden = FORBIDDEN_REVIEW_BODY_RE.search(code)
    if forbidden:
        raise RuntimeError(
            f"Reviewed parity test uses forbidden placeholder/dispatcher `{forbidden.group(0)}`: "
            f"{path}:{rust_test.group(1)}"
        )
    if not re.search(r"\bassert(?:_[A-Za-z0-9_]+)?!|\.expect\s*\(|\.unwrap(?:_err)?\s*\(|\?", code):
        raise RuntimeError(
            f"Reviewed parity test has no explicit assertion or checked API result: "
            f"{path}:{rust_test.group(1)}"
        )
    return hashlib.sha256(body.encode()).hexdigest()


def namespace_at(text: str, position: int) -> str:
    matches = [m for m in NAMESPACE_RE.finditer(text) if m.start() <= position]
    return matches[-1].group(1) if matches else ""


def generic_arity(generic: str | None) -> int:
    if not generic:
        return 0
    return len([p for p in generic[1:-1].split(",") if p.strip()])


@dataclass
class ModuleNode:
    types: dict[str, tuple[str, int, str]] = field(default_factory=dict)
    children: dict[str, "ModuleNode"] = field(default_factory=dict)


def add_type(root: ModuleNode, modules: list[str], name: str, kind: str, arity: int, csharp_name: str) -> None:
    node = root
    for module in modules:
        node = node.children.setdefault(module, ModuleNode())
    node.types.setdefault(name, (kind, arity, csharp_name))


def render_type(name: str, kind: str, arity: int, csharp_name: str, indent: str) -> list[str]:
    rust_name = rust_type_name(name)
    kind_label = kind.replace(" ", "_")
    if kind == "interface":
        params = "" if arity == 0 else "<" + ", ".join(f"T{i} = ()" for i in range(arity)) + ">"
        return [
            f"{indent}/// Shim for C# `{csharp_name}`.",
            f"{indent}pub trait {rust_name}{params} {{",
            f"{indent}    const CSHARP_NAME: &'static str = \"{csharp_name}\";",
            f"{indent}}}",
        ]
    params = "" if arity == 0 else "<" + ", ".join(f"T{i} = ()" for i in range(arity)) + ">"
    marker = "" if arity == 0 else "(" + "std::marker::PhantomData<(" + ", ".join(f"T{i}" for i in range(arity)) + ",)>" + ")"
    declaration = f"pub struct {rust_name}{params}" + (";" if not marker else f"{marker};")
    return [
        f"{indent}/// `{kind}` shim for C# `{csharp_name}`; members remain intentionally unimplemented.",
        f"{indent}#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]",
        f"{indent}{declaration}",
        f"{indent}impl{('<' + ', '.join(f'T{i}' for i in range(arity)) + '>') if arity else ''} {rust_name}{('<' + ', '.join(f'T{i}' for i in range(arity)) + '>') if arity else ''} {{",
        f"{indent}    pub const CSHARP_NAME: &'static str = \"{csharp_name}\";",
        f"{indent}    pub const CSHARP_KIND: &'static str = \"{kind_label}\";",
        f"{indent}}}",
    ]


def render_node(node: ModuleNode, indent: str = "") -> list[str]:
    lines: list[str] = []
    for name, (kind, arity, csharp_name) in sorted(node.types.items()):
        lines.extend(render_type(name, kind, arity, csharp_name, indent))
        lines.append("")
    for name, child in sorted(node.children.items()):
        lines.append(f"{indent}pub mod {name} {{")
        lines.extend(render_node(child, indent + "    "))
        lines.append(f"{indent}}}")
        lines.append("")
    return lines


def generate_apis(upstream: Path, output: Path) -> tuple[int, int]:
    surface_lines = ["project\tfile\tline\tdeclaration_sha256\tdeclaration"]
    type_lines = ["crate\tnamespace\trust_path\tkind\tcsharp_name\tfile\tline"]
    total_types = 0
    total_members = 0
    for project in PROJECTS:
        project_root = upstream / project.source
        tree = ModuleNode()
        for path in source_files(project_root):
            text = path.read_text(encoding="utf-8-sig")
            relative = path.relative_to(upstream).as_posix()
            for line_no, line in enumerate(text.splitlines(), 1):
                if re.match(r"^\s*public\s+", line):
                    declaration = " ".join(line.strip().split())
                    digest = hashlib.sha256(declaration.encode()).hexdigest()
                    surface_lines.append(f"{project.source}\t{relative}\t{line_no}\t{digest}\t{declaration}")
                    total_members += 1
            for match in TYPE_RE.finditer(text):
                namespace = namespace_at(text, match.start())
                remainder = namespace
                if remainder == project.namespace:
                    remainder = ""
                elif remainder.startswith(project.namespace + "."):
                    remainder = remainder[len(project.namespace) + 1 :]
                modules = [snake(part) for part in remainder.split(".") if part]
                name = match.group("name").lstrip("@")
                kind = match.group("kind")
                arity = generic_arity(match.group("generic"))
                csharp_name = ".".join(filter(None, [namespace, name]))
                add_type(tree, modules, name, kind, arity, csharp_name)
                rust_path = "::".join(modules + [rust_type_name(name)])
                line_no = text.count("\n", 0, match.start()) + 1
                type_lines.append(f"{project.crate}\t{namespace}\t{rust_path}\t{kind}\t{csharp_name}\t{relative}\t{line_no}")
                total_types += 1
        generated = [
            "// @generated by tools/generate_surface.py; do not edit by hand.",
            f"// Source: LibreMetaverse {UPSTREAM_COMMIT}",
            "#![allow(non_camel_case_types)]",
            "",
        ] + render_node(tree)
        crate_file = output / "crates" / project.crate / "src" / "generated.rs"
        crate_file.parent.mkdir(parents=True, exist_ok=True)
        crate_file.write_text("\n".join(generated).rstrip() + "\n")
    api_dir = output / "api"
    api_dir.mkdir(parents=True, exist_ok=True)
    (api_dir / "SURFACE.tsv").write_text("\n".join(surface_lines) + "\n")
    (api_dir / "TYPES.tsv").write_text("\n".join(type_lines) + "\n")
    return total_types, total_members


def enclosing_class_match(text: str, position: int) -> re.Match[str] | None:
    containing: list[re.Match[str]] = []
    for match in CLASS_RE.finditer(text, 0, position):
        brace = text.find("{", match.end())
        if brace == -1 or brace >= position:
            continue
        depth = 0
        for index in range(brace, len(text)):
            if text[index] == "{":
                depth += 1
            elif text[index] == "}":
                depth -= 1
                if depth == 0:
                    if position < index:
                        containing.append(match)
                    break
    return containing[-1] if containing else None


def attributes_before(text: str, position: int) -> str:
    match = re.search(r"((?:\s*\[[^\]]+\]\s*)+)$", text[:position], re.DOTALL)
    return match.group(1) if match else ""


def method_body_hash(text: str, method_start: int) -> str:
    brace = text.find("{", method_start)
    arrow = text.find("=>", method_start)
    if arrow != -1 and (brace == -1 or arrow < brace):
        end = text.find(";", arrow)
        body = text[method_start : end + 1]
    elif brace != -1:
        depth = 0
        end = brace
        for end in range(brace, len(text)):
            if text[end] == "{":
                depth += 1
            elif text[end] == "}":
                depth -= 1
                if depth == 0:
                    break
        body = text[method_start : end + 1]
    else:
        body = text[method_start : method_start + 500]
    return hashlib.sha256(body.encode()).hexdigest()


def stable_case_id(source: str, fixture: str, method: str, attribute: str, parameters: str | None) -> str:
    test_id = f"{source}::{fixture}.{method}"
    if attribute == "Test":
        return f"{test_id}::test"
    normalized = " ".join((parameters or "").split())
    digest = hashlib.sha256(normalized.encode()).hexdigest()[:16]
    return f"{test_id}::case:{digest}"


def helper_types(roots: tuple[Path, ...], upstream: Path) -> dict[str, str]:
    helpers: dict[str, str] = {}
    for root in roots:
        for path in source_files(root):
            text = path.read_text(encoding="utf-8-sig")
            searchable = mask_csharp_comments(text)
            if TEST_ATTR_RE.search(searchable):
                continue
            relative = path.relative_to(upstream).as_posix()
            for match in CLASS_RE.finditer(searchable):
                helpers.setdefault(match.group(1), relative)
    return helpers


def extract_tests(upstream: Path) -> list[dict[str, object]]:
    roots = (upstream / "LibreMetaverse.Tests", upstream / "LibreMetaverse.Rendering.Tests")
    helpers = helper_types(roots, upstream)
    tests: list[dict[str, object]] = []
    for root in roots:
        for path in source_files(root):
            text = path.read_text(encoding="utf-8-sig")
            searchable = mask_csharp_comments(text)
            relative = path.relative_to(upstream).as_posix()
            dependencies = sorted(
                helper_path
                for helper, helper_path in helpers.items()
                if helper_path != relative and re.search(rf"\b{re.escape(helper)}\b", text)
            )
            attrs = list(TEST_ATTR_RE.finditer(searchable))
            for attr_index, attr in enumerate(attrs):
                next_attr = attrs[attr_index + 1].start() if attr_index + 1 < len(attrs) else len(text)
                method = METHOD_RE.search(searchable, attr.end(), next_attr + 1200)
                if method is None:
                    method = METHOD_RE.search(searchable, attr.end())
                if method is None:
                    raise RuntimeError(f"No test method after {relative}:{text.count(chr(10), 0, attr.start()) + 1}")
                if attr.group(1) == "Test" and TEST_ATTR_RE.search(searchable, attr.end(), method.start()):
                    continue
                name = method.group(1)
                class_match = enclosing_class_match(searchable, method.start())
                fixture = class_match.group(1) if class_match else "UnknownFixture"
                attr_text = " ".join(attr.group(0).split())
                parameters = " ".join((attr.group(2) or "").split()) or None
                line = text.count("\n", 0, attr.start()) + 1
                category_text = searchable[attr.start() : method.start()]
                if class_match:
                    category_text += attributes_before(searchable, class_match.start())
                categories = sorted(set(CATEGORY_RE.findall(category_text)))
                tests.append(
                    {
                        "id": stable_case_id(relative, fixture, name, attr.group(1), parameters),
                        "csharp_test_id": f"{relative}::{fixture}.{name}",
                        "parameter_case": parameters,
                        "source": relative,
                        "line": line,
                        "fixture": fixture,
                        "method": name,
                        "attribute": attr_text,
                        "body_sha256": method_body_hash(text, method.start()),
                        "categories": categories,
                        "fixture_dependencies": dependencies,
                    }
                )
    ids = [str(test["id"]) for test in tests]
    duplicates = sorted(case_id for case_id, count in Counter(ids).items() if count > 1)
    if duplicates:
        raise RuntimeError("Duplicate stable NUnit case IDs: " + ", ".join(duplicates))
    return tests


def reviewed_tests(root: Path) -> dict[str, dict[str, object]]:
    reviews: dict[str, dict[str, object]] = {}
    review_roots = (root / "tests" / "compat" / "tests", root / "crates")
    for path in sorted(path for review_root in review_roots for path in review_root.rglob("*.rs")):
        if path.name == "generated_parity.rs":
            continue
        text = path.read_text()
        for marker in PARITY_MARKER_RE.finditer(text):
            next_marker = PARITY_MARKER_RE.search(text, marker.end())
            rust_test = RUST_TEST_RE.search(text, marker.end(), next_marker.start() if next_marker else len(text))
            if rust_test is None:
                raise RuntimeError(f"Parity marker has no following Rust test: {path}:{text.count(chr(10), 0, marker.start()) + 1}")
            body_sha256 = validate_reviewed_body(path, rust_test, rust_test_body(text, rust_test))
            case_id = marker.group("id")
            if case_id in reviews:
                raise RuntimeError(f"Duplicate reviewed parity case: {case_id}")
            reviews[case_id] = {
                "body_sha256": marker.group("body_sha256"),
                "rust_body_sha256": body_sha256,
                "status": marker.group("status"),
                "rust_file": path.relative_to(root).as_posix(),
                "rust_line": text.count("\n", 0, marker.start()) + 1,
                "rust_test": rust_test.group(1),
            }
    return reviews


def render_parity_report(tests: list[dict[str, object]]) -> str:
    status_counts = {status: sum(test["status"] == status for test in tests) for status in ("pending", "translated", "ignored-live", "benchmark", "drifted")}
    live_candidates = sum("RequiresLiveServer" in test["categories"] for test in tests)
    benchmark_candidates = sum("Benchmark" in test["categories"] for test in tests)
    lines = [
        "# NUnit to Rust parity ledger",
        "",
        f"Generated from LibreMetaverse `{UPSTREAM_COMMIT}`. Stable IDs identify one NUnit `[Test]` or `[TestCase]` invocation; the body hash covers the original C# method declaration and body.",
        "",
        "## Status",
        "",
        f"- Total: **{len(tests):,}**",
        f"- Pending/unreviewed: **{status_counts['pending']:,}**",
        f"- Translated/reviewed: **{status_counts['translated']:,}**",
        f"- Ignored live/reviewed: **{status_counts['ignored-live']:,}** ({live_candidates:,} upstream live candidates)",
        f"- Benchmarks/reviewed: **{status_counts['benchmark']:,}** ({benchmark_candidates:,} upstream benchmark candidates)",
        f"- Drifted: **{status_counts['drifted']:,}**",
        "",
        "Reviewed Rust tests live outside `generated_parity.rs` and carry a `parity-case` marker. Regeneration preserves those files and fails if their source body hash drifts.",
        "",
        "| Stable case ID | C# test | Parameter case | Source | Categories | Fixtures | Rust location | Status | Body SHA-256 |",
        "|---|---|---|---|---|---|---|---|---|",
    ]
    for test in tests:
        parameter = str(test["parameter_case"] or "").replace("|", "&#124;")
        categories = ", ".join(test["categories"])
        fixtures = ", ".join(test["fixture_dependencies"])
        rust_location = (
            f"{test['rust_file']}:{test['rust_line']} (`{test['rust_test']}`)"
            if test["status"] != "pending"
            else "pending source translation"
        )
        lines.append(
            f"| `{test['id']}` | `{test['fixture']}.{test['method']}` | `{parameter}` | `{test['source']}:{test['line']}` | `{categories}` | `{fixtures}` | `{rust_location}` | `{test['status']}` | `{test['body_sha256']}` |"
        )
    return "\n".join(lines) + "\n"


def generate_tests(upstream: Path, output: Path, review_root: Path | None = None) -> int:
    tests = extract_tests(upstream)
    reviews = reviewed_tests(review_root or output)
    tests_by_id = {str(test["id"]): test for test in tests}
    stale = sorted(set(reviews) - set(tests_by_id))
    if stale:
        raise RuntimeError("Reviewed cases missing from pinned upstream: " + ", ".join(stale))
    drifted = sorted(
        case_id
        for case_id, review in reviews.items()
        if review["body_sha256"] != tests_by_id[case_id]["body_sha256"]
    )
    if drifted:
        raise RuntimeError("Reviewed cases have drifted C# bodies: " + ", ".join(drifted))

    rust_lines = [
        "// @generated by tools/generate_surface.py; do not edit by hand.",
        f"// Source: LibreMetaverse {UPSTREAM_COMMIT}",
        "// Pending cases are catalog entries, not fake executable Rust tests.",
    ]
    for test in tests:
        review = reviews.get(str(test["id"]))
        if review:
            test.update(review)
            test["semantic_review"] = "reviewed"
            continue
        if len(rust_lines) == 3:
            rust_lines.append("")
        test.update(
            {
                "rust_file": "tests/compat/tests/generated_parity.rs",
                "rust_line": len(rust_lines) + 1,
                "rust_test": "",
                "rust_body_sha256": None,
                "status": "pending",
                "semantic_review": "unreviewed",
            }
        )
        rust_lines.append(f"// pending-parity-case: {test['id']} {test['body_sha256']}")
    generated_dir = output / "tests" / "compat" / "tests"
    generated_dir.mkdir(parents=True, exist_ok=True)
    (generated_dir / "generated_parity.rs").write_text("\n".join(rust_lines) + "\n")
    (output / "tests" / "PARITY.md").write_text(render_parity_report(tests))
    report = {
        "pending": sum(test["status"] == "pending" for test in tests),
        "translated": sum(test["status"] == "translated" for test in tests),
        "ignored_live": sum(test["status"] == "ignored-live" for test in tests),
        "benchmark": sum(test["status"] == "benchmark" for test in tests),
        "drifted": 0,
        "unreviewed": sum(test["semantic_review"] == "unreviewed" for test in tests),
    }
    catalog = {
        "schema_version": 2,
        "upstream_commit": UPSTREAM_COMMIT,
        "expected_cases": EXPECTED_TESTS,
        "report": report,
        "tests": tests,
    }
    (output / "tests" / "upstream-tests.json").write_text(json.dumps(catalog, indent=2) + "\n")
    return len(tests)


def scan_parity_markers(root: Path) -> dict[str, list[dict[str, object]]]:
    return {case_id: [review] for case_id, review in reviewed_tests(root).items()}


def check_test_parity(root: Path, require_reviewed: bool = False) -> dict[str, int]:
    catalog_path = root / "tests" / "upstream-tests.json"
    catalog = json.loads(catalog_path.read_text())
    if catalog.get("schema_version") != 2:
        raise RuntimeError("Unsupported test parity catalog schema")
    tests = catalog.get("tests", [])
    expected = catalog.get("expected_cases")
    if expected != EXPECTED_TESTS or len(tests) != EXPECTED_TESTS:
        raise RuntimeError(f"Expected {EXPECTED_TESTS} cataloged NUnit invocations, found {len(tests)}")
    if catalog.get("upstream_commit") != UPSTREAM_COMMIT:
        raise RuntimeError("Test parity catalog targets the wrong upstream commit")

    ids = [test["id"] for test in tests]
    duplicate_ids = sorted(case_id for case_id, count in Counter(ids).items() if count > 1)
    if duplicate_ids:
        raise RuntimeError("Duplicate catalog case IDs: " + ", ".join(duplicate_ids))
    by_id = {test["id"]: test for test in tests}
    markers = scan_parity_markers(root)
    reviewed_ids = {case_id for case_id, test in by_id.items() if test["status"] != "pending"}
    missing = sorted(reviewed_ids - set(markers))
    stale = sorted(set(markers) - set(by_id))
    premature = sorted(set(markers) - reviewed_ids)
    duplicates = sorted(case_id for case_id, entries in markers.items() if len(entries) != 1)
    if missing:
        raise RuntimeError("Missing Rust parity cases: " + ", ".join(missing))
    if stale:
        raise RuntimeError("Stale Rust parity cases: " + ", ".join(stale))
    if premature:
        raise RuntimeError("Catalog still marks reviewed Rust cases pending: " + ", ".join(premature))
    if duplicates:
        raise RuntimeError("Duplicate Rust parity cases: " + ", ".join(duplicates))

    drifted: list[str] = []
    mismatched: list[str] = []
    for case_id in reviewed_ids:
        test = by_id[case_id]
        marker = markers[case_id][0]
        if marker["body_sha256"] != test["body_sha256"]:
            drifted.append(case_id)
        for key in ("status", "rust_file", "rust_line", "rust_test", "rust_body_sha256"):
            if marker[key] != test[key]:
                mismatched.append(f"{case_id}:{key}")
    if drifted:
        raise RuntimeError("Drifted reviewed C# test bodies: " + ", ".join(drifted))
    if mismatched:
        raise RuntimeError("Stale parity catalog metadata: " + ", ".join(mismatched))

    report = {
        "pending": sum(test["status"] == "pending" for test in tests),
        "translated": sum(test["status"] == "translated" for test in tests),
        "ignored_live": sum(test["status"] == "ignored-live" for test in tests),
        "benchmark": sum(test["status"] == "benchmark" for test in tests),
        "drifted": 0,
        "unreviewed": sum(test["semantic_review"] != "reviewed" for test in tests),
    }
    if catalog.get("report") != report:
        raise RuntimeError("Stale parity summary report")
    if require_reviewed and report["unreviewed"]:
        raise RuntimeError(f"{report['unreviewed']} NUnit invocations remain semantically unreviewed")
    return report


def verify_upstream(upstream: Path) -> None:
    commit = subprocess.run(
        ["git", "-C", str(upstream), "rev-parse", "HEAD"],
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    if commit != UPSTREAM_COMMIT:
        raise RuntimeError(f"Expected LibreMetaverse {UPSTREAM_COMMIT}, found {commit}")


def check_test_regeneration(upstream: Path, output: Path) -> None:
    with tempfile.TemporaryDirectory() as temporary:
        generated = Path(temporary)
        count = generate_tests(upstream, generated, review_root=output)
        if count != EXPECTED_TESTS:
            raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {count}")
        stale = [str(path) for path in PARITY_FILES if not (output / path).exists() or (output / path).read_bytes() != (generated / path).read_bytes()]
        if stale:
            raise RuntimeError("Stale generated test parity files: " + ", ".join(stale))


def generate_program_manifest(upstream: Path, output: Path) -> int:
    roots = [
        upstream / "Programs" / "VivoxTest",
        upstream / "Programs" / "WebRtcTest",
        *sorted((upstream / "Programs" / "examples").iterdir()),
        *sorted((upstream / "Programs" / "tools").iterdir()),
    ]
    entries = []
    for root in roots:
        if not root.is_dir() or not list(root.glob("*.csproj")):
            continue
        files = []
        for path in source_files(root):
            data = path.read_bytes()
            files.append({"path": path.relative_to(upstream).as_posix(), "sha256": hashlib.sha256(data).hexdigest()})
        entries.append({"project": root.name, "files": files})
    (output / "programs" / "upstream-programs.json").write_text(json.dumps({"upstream_commit": UPSTREAM_COMMIT, "projects": entries}, indent=2) + "\n")
    command_files = source_files(upstream / "Programs" / "examples" / "TestClient" / "Commands")
    command_names = [path.stem for path in command_files]
    command_rs = [
        "// @generated by tools/generate_surface.py; do not edit by hand.",
        "pub const TEST_CLIENT_COMMANDS: &[&str] = &[",
        *[f"    {json.dumps(name)}," for name in command_names],
        "];",
        "",
    ]
    (output / "programs" / "src" / "commands.rs").write_text("\n".join(command_rs))
    return len(entries)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--upstream", type=Path, default=Path("../libremetaverse"))
    parser.add_argument("--output", type=Path, default=Path("."))
    parser.add_argument("--tests-only", action="store_true")
    parser.add_argument("--check", action="store_true")
    args = parser.parse_args()
    upstream = args.upstream.resolve()
    output = args.output.resolve()
    verify_upstream(upstream)
    if args.check:
        check_test_regeneration(upstream, output)
        report = check_test_parity(output)
        print(f"test parity is current: {report}")
        return
    if args.tests_only:
        tests = generate_tests(upstream, output)
        if tests != EXPECTED_TESTS:
            raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {tests}")
        print(f"generated {tests} test parity cases")
        return
    types, members = generate_apis(upstream, output)
    tests = generate_tests(upstream, output)
    programs = generate_program_manifest(upstream, output)
    if tests != EXPECTED_TESTS:
        raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {tests}")
    print(f"generated {types} type declarations, {members} public declaration lines, {tests} tests, and {programs} program manifests")


if __name__ == "__main__":
    main()
