#!/usr/bin/env python3
"""Freeze source-proven V2 HTTP methods without inventing API contracts.

The V2 inventory is path-only.  This helper can be pointed at the pinned V2
checkout to record handler-exported HTTP methods for paths missing from the
frozen x402 registry.  It deliberately does *not* derive request/response
schemas, HTTP success/error statuses, payment state, or response headers:
those require route-specific captures or separately reviewed source evidence.
CORS-only ``OPTIONS`` exports are intentionally excluded because the existing
V2 registry represents application operations, not browser preflight handling.

With ``--v2-root`` it regenerates and verifies the frozen source hashes.
Without it, ``--check`` performs only offline structural validation of the
checked-in evidence so CI remains network- and snapshot-independent.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[2]
INVENTORY = ROOT / "baseline" / "extracted" / "v2_api_routes.txt"
X402_REGISTRY = ROOT / "baseline" / "x402-route-registry.json"
V2_HEAD = ROOT / "baseline" / "V2_HEAD_COMMIT.txt"
OUTPUT = ROOT / "baseline" / "schemas" / "v2" / "route-source-method-evidence.json"
SOURCE_ROOT = Path("repos/theloopbreaker-site")
HANDLER_METHOD = re.compile(
    r"\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD)\b"
)


def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def inventory_paths() -> list[str]:
    return [line.strip() for line in INVENTORY.read_text().splitlines() if line.strip()]


def x402_paths() -> set[str]:
    payload = json.loads(X402_REGISTRY.read_text())
    return {route["path"] for route in payload["routes"]}


def expected_paths() -> list[str]:
    registered = x402_paths()
    return [path for path in inventory_paths() if path not in registered]


def handler_path(v2_root: Path, path: str) -> Path:
    # The path inventory uses Next.js dynamic-segment notation, which is also
    # the literal directory name in the canonical source tree.
    return v2_root / SOURCE_ROOT / ("app" + path) / "route.ts"


def build(v2_root: Path) -> dict[str, Any]:
    entries: list[dict[str, Any]] = []
    for path in expected_paths():
        handler = handler_path(v2_root, path)
        if not handler.is_file():
            raise ValueError(f"missing canonical V2 route handler for {path}: {handler}")
        methods = HANDLER_METHOD.findall(handler.read_text())
        if not methods:
            raise ValueError(f"no exported V2 HTTP method found for {path}: {handler}")
        entries.append(
            {
                "path": path,
                "source": str(handler.relative_to(v2_root)),
                "sha256": sha256(handler),
                "methods": methods,
            }
        )
    return {
        "schemaVersion": "1.0.0",
        "evidencePolicy": (
            "Methods are frozen only when exported by the exact route handler in the "
            "pinned V2 checkout. No request/response schema, response status, header, "
            "payment, finality, privacy, or idempotency metadata is inferred."
        ),
        "v2Snapshot": {
            "commit": V2_HEAD.read_text().strip(),
            "sourceRoot": str(SOURCE_ROOT),
        },
        "inventory": {
            "file": str(INVENTORY.relative_to(ROOT)),
            "sha256": sha256(INVENTORY),
            "routeCount": len(inventory_paths()),
        },
        "selection": {
            "excludedRegistry": str(X402_REGISTRY.relative_to(ROOT)),
            "excludedRegistrySha256": sha256(X402_REGISTRY),
            "pathCount": len(entries),
            "methodCount": sum(len(entry["methods"]) for entry in entries),
        },
        "entries": entries,
    }


def canonical(payload: dict[str, Any]) -> str:
    return json.dumps(payload, indent=2) + "\n"


def validate_offline(payload: dict[str, Any]) -> None:
    if payload.get("schemaVersion") != "1.0.0":
        raise ValueError("unexpected source-method evidence schemaVersion")
    if payload.get("v2Snapshot", {}).get("commit") != V2_HEAD.read_text().strip():
        raise ValueError("source-method evidence is bound to the wrong V2 snapshot")
    inventory = payload.get("inventory", {})
    if inventory.get("sha256") != sha256(INVENTORY) or inventory.get("routeCount") != len(inventory_paths()):
        raise ValueError("source-method evidence inventory binding is stale")
    selection = payload.get("selection", {})
    if (
        selection.get("excludedRegistrySha256") != sha256(X402_REGISTRY)
        or selection.get("pathCount") != len(expected_paths())
    ):
        raise ValueError("source-method evidence x402 selection binding is stale")

    entries = payload.get("entries")
    if not isinstance(entries, list):
        raise ValueError("source-method evidence entries must be a list")
    paths = [entry.get("path") for entry in entries]
    if paths != expected_paths() or len(set(paths)) != len(paths):
        raise ValueError("source-method evidence paths do not exactly cover the registry gap")
    allowed = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}
    method_count = 0
    for entry in entries:
        source = entry.get("source")
        source_hash = entry.get("sha256")
        methods = entry.get("methods")
        if not isinstance(source, str) or not source.startswith(f"{SOURCE_ROOT}/app/"):
            raise ValueError(f"invalid source path for {entry.get('path')!r}")
        if not isinstance(source_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", source_hash):
            raise ValueError(f"invalid source hash for {entry.get('path')!r}")
        if not isinstance(methods, list) or not methods or any(method not in allowed for method in methods):
            raise ValueError(f"invalid methods for {entry.get('path')!r}")
        method_count += len(methods)
    if selection.get("methodCount") != method_count:
        raise ValueError("source-method evidence method count is stale")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--v2-root",
        type=Path,
        help="pinned V2 checkout root containing repos/theloopbreaker-site",
    )
    parser.add_argument("--check", action="store_true", help="verify checked-in evidence")
    args = parser.parse_args()

    if args.v2_root is not None:
        expected = canonical(build(args.v2_root))
        if args.check:
            if not OUTPUT.exists() or OUTPUT.read_text() != expected:
                print(
                    "V2 source-method evidence is stale: run "
                    f"{Path(__file__).relative_to(ROOT)} --v2-root <pinned-v2-root>"
                )
                return 1
            print(f"V2 source-method evidence matches pinned source: {OUTPUT.relative_to(ROOT)}")
            return 0
        OUTPUT.parent.mkdir(parents=True, exist_ok=True)
        OUTPUT.write_text(expected)
        print(f"wrote {OUTPUT.relative_to(ROOT)}")
        return 0

    if not args.check:
        parser.error("--v2-root is required to generate evidence")
    try:
        validate_offline(json.loads(OUTPUT.read_text()))
    except (OSError, ValueError, json.JSONDecodeError) as exc:
        print(f"invalid V2 source-method evidence: {exc}")
        return 1
    print(f"V2 source-method evidence is structurally current: {OUTPUT.relative_to(ROOT)}")
    return 0


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