#!/usr/bin/env python3
"""Build a deterministic legacy FastAPI -> Go OpenAPI route contract matrix.

The script uses Python AST only; importing the legacy application is deliberately
avoided so the audit does not require its runtime dependencies or secrets.
"""
from __future__ import annotations

import argparse
import ast
import re
from dataclasses import dataclass
from pathlib import Path

import yaml

METHODS = {"get", "post", "put", "patch", "delete"}
PARAM = re.compile(r"\{[^{}]+\}")


@dataclass(frozen=True)
class Route:
    method: str
    path: str
    source: str
    line: int


def literal(node: ast.AST | None, default: str = "") -> str:
    return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else default


def legacy_routes(root: Path) -> list[Route]:
    result: list[Route] = []
    for source in sorted((root / "app").glob("*.py")):
        tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
        prefixes: dict[str, str] = {"app": ""}
        for node in tree.body:
            if not isinstance(node, (ast.Assign, ast.AnnAssign)):
                continue
            value = node.value
            if not isinstance(value, ast.Call) or not isinstance(value.func, ast.Name) or value.func.id != "APIRouter":
                continue
            targets = node.targets if isinstance(node, ast.Assign) else [node.target]
            prefix = next((literal(item.value) for item in value.keywords if item.arg == "prefix"), "")
            for target in targets:
                if isinstance(target, ast.Name):
                    prefixes[target.id] = prefix
        for node in ast.walk(tree):
            if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                continue
            for decorator in node.decorator_list:
                if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
                    continue
                owner = decorator.func.value
                if not isinstance(owner, ast.Name) or decorator.func.attr.lower() not in METHODS or not decorator.args:
                    continue
                route_path = literal(decorator.args[0], "")
                if route_path == "" and not (isinstance(decorator.args[0], ast.Constant) and decorator.args[0].value == ""):
                    continue
                full = (prefixes.get(owner.id, "") + route_path) or "/"
                result.append(Route(decorator.func.attr.upper(), full, source.name, decorator.lineno))
    return sorted(set(result), key=lambda item: (item.path, item.method, item.source, item.line))


def current_routes(spec: Path) -> set[tuple[str, str]]:
    document = yaml.safe_load(spec.read_text(encoding="utf-8"))
    return {(method.upper(), path) for path, item in document["paths"].items() for method in item if method.lower() in METHODS}


def canonical(path: str) -> str:
    if path.startswith("/admin/api"):
        path = "/api/v1/admin" + path[len("/admin/api"):]
    elif path.startswith("/portal/api"):
        path = "/api/v1/portal" + path[len("/portal/api"):]
    return PARAM.sub("{}", path.rstrip("/") or "/")


ALIASES = {
    "/api/v1/admin/keys": "/api/v1/admin/api-keys",
    "/api/v1/admin/kb": "/api/v1/admin/knowledge-bases",
    "/api/v1/admin/model_pricing": "/api/v1/admin/model-prices",
    "/api/v1/admin/logs": "/api/v1/admin/audit-events",
    "/api/v1/admin/users": "/api/v1/admin/identities/portal-users",
    "/api/v1/admin/admins": "/api/v1/admin/identities/admins",
    "/api/v1/admin/system_info": "/api/v1/admin/system-info",
    "/api/v1/admin/totp/backup_codes": "/api/v1/admin/totp/backup-codes",
    "/api/v1/portal/totp/backup_codes": "/api/v1/portal/totp/backup-codes",
    "/v1/apps": "/v1/applications",
}

# Capability replacements whose new API intentionally does not mirror the old
# path shape. They remain migration work (client payload/response validation),
# but are not incorrectly reported as an unimplemented domain.
CAPABILITY_REPLACEMENTS = [
    (re.compile(r"^/api/v1/admin/(rules|rule_dimensions|content_audit_scopes)(/|$)"), "/api/v1/admin/content-policies"),
    (re.compile(r"^/api/v1/admin/(rate_limit_rules|model_qps)(/|$)"), "/api/v1/admin/api-keys and /api/v1/admin/model-routes"),
    (re.compile(r"^/api/v1/admin/(model_aliases|routing_groups)(/|$)"), "/api/v1/admin/model-routes"),
    (re.compile(r"^/api/v1/admin/(stats|dept_stats|cost_report|pricing_config|dept_budgets)(/|$)"), "/api/v1/admin/usage/daily and /api/v1/admin/model-prices"),
    (re.compile(r"^/api/v1/admin/(notify_config)(/|$)"), "/api/v1/admin/notification-channels"),
    (re.compile(r"^/api/v1/admin/(logs|log_search|log_views)(/|$)"), "/api/v1/admin/audit-events"),
    (re.compile(r"^/api/v1/admin/malicious_domains(/|$)"), "/api/v1/admin/content-policies"),
    (re.compile(r"^/api/v1/admin/prompts/categories(/|$)"), "/api/v1/admin/prompt-categories"),
    (re.compile(r"^/api/v1/admin/prompts/\{\}/preview$"), "/api/v1/admin/prompts/{prompt_id}/render"),
    (re.compile(r"^/api/v1/admin/knowledge-bases/\{\}/documents/(paste|upload)$"), "/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents"),
    (re.compile(r"^/api/v1/admin/knowledge-bases/\{\}/test_search$"), "/api/v1/admin/knowledge-bases/{knowledge_base_id}/search"),
    (re.compile(r"^/api/v1/admin/tools/\{\}/test_invoke$"), "/api/v1/admin/tools/{tool_id}/test"),
    (re.compile(r"^/api/v1/admin/applications/\{\}/composition$"), "/api/v1/admin/applications/{application_id}"),
    (re.compile(r"^/api/v1/admin/audit_settings(/|$)"), "/api/v1/admin/content-policies and /api/v1/admin/notification-channels"),
    (re.compile(r"^/api/v1/admin/feishu_auth(/|$)"), "/api/v1/admin/identity-providers (generic OIDC/SAML)"),
    (re.compile(r"^/api/v1/portal/feishu(/|$)"), "/api/v1/portal/sso (generic OIDC/SAML)"),
    (re.compile(r"^/api/v1/admin/providers/\{\}/backends(/|$)"), "/api/v1/admin/providers/{provider_id}/models and /api/v1/admin/model-routes"),
    (re.compile(r"^/api/v1/admin/providers/\{\}/fetch_models$"), "/api/v1/admin/providers/{provider_id}/models/sync"),
    (re.compile(r"^/api/v1/admin/audit-events(/search|/\{\}/view_history)?$"), "/api/v1/admin/audit-events (bounded immutable audit query)"),
    (re.compile(r"^/api/v1/admin/applications/\{\}/test$"), "/v1/applications/{application_code}/chat/completions"),
    (re.compile(r"^/api/v1/admin/applications/\{\}/keys$"), "/api/v1/admin/api-keys (scoped credentials)"),
    (re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/approve$"), "/api/v1/admin/identities/portal-users/{identity_id} active=true"),
    (re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/reset_password$"), "/api/v1/admin/identities/portal-users/{identity_id} password field"),
    (re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/feishu$"), "/api/v1/admin/identity-providers (generic identity link lifecycle)"),
    (re.compile(r"^/api/v1/admin/identities/(admins|portal-users)/\{\}$"), "/api/v1/admin/identities/*/{identity_id} active=false"),
    (re.compile(r"^/api/v1/admin/departments/\{\}$"), "/api/v1/admin/departments/{department_id} active=false"),
    (re.compile(r"^/api/v1/admin/identities/admins/change_password$"), "/api/v1/admin/password"),
    (re.compile(r"^/api/v1/admin/api-keys/\{\}$"), "/api/v1/admin/api-keys/{api_key_id}/limits"),
    (re.compile(r"^/api/v1/admin/providers/\{\}$"), "/api/v1/admin/providers/{provider_id} enabled=false"),
    (re.compile(r"^/v1/apps/\{\}/chat/completions$"), "/v1/applications/{application_code}/chat/completions"),
]

RETIRED = [
    (re.compile(r"^/(admin|portal)/ui$"), "Art Design Pro SPA deployment"),
    (re.compile(r"^/api/v1/admin/logs/\{\}/view_raw$"), "retired: unbounded raw-body access is excluded by storage/compliance ADR"),
    (re.compile(r"^/api/v1/admin/logs$"), "retired: immutable audit data is removed only by the configured retention worker"),
    (re.compile(r"^/api/v1/admin/tools/\{\}/headers$"), "retired: decrypted secret headers are intentionally write-only"),
]


def alias(path: str) -> str:
    for old, new in sorted(ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
        if path == old or path.startswith(old + "/"):
            return new + path[len(old):]
    return path


def compare(legacy: list[Route], current: set[tuple[str, str]]) -> list[tuple[Route, str, str]]:
    normalized_current = {(method, canonical(path)): path for method, path in current}
    rows = []
    for route in legacy:
        expected = canonical(route.path)
        direct = normalized_current.get((route.method, expected))
        if direct:
            rows.append((route, "covered", direct))
            continue
        replacement = alias(expected)
        target = normalized_current.get((route.method, replacement))
        if target:
            rows.append((route, "replaced", target))
            continue
        if route.method == "PATCH":
            target = normalized_current.get(("PUT", replacement))
            if target:
                rows.append((route, "replaced", target + " (PUT with full revision payload)"))
                continue
        decision = next((target for pattern, target in CAPABILITY_REPLACEMENTS if pattern.search(replacement)), None)
        if decision:
            rows.append((route, "replaced", decision))
            continue
        retirement = next((target for pattern, target in RETIRED if pattern.search(expected)), None)
        rows.append((route, "retired" if retirement else "missing", retirement or replacement))
    return rows


def write_markdown(rows: list[tuple[Route, str, str]], destination: Path) -> None:
    counts = {status: sum(1 for _, value, _ in rows if value == status) for status in ("covered", "replaced", "retired", "missing")}
    lines = [
        "# 旧 Python → Go 路由契约矩阵", "",
        "> 由 `scripts/compare_route_contracts.py` 通过 AST 与 OpenAPI 自动生成；`replaced` 表示能力有新契约承接，仍需兼容层或客户端迁移，不能视为原路由原样可用。", "",
        f"- 旧源码路由装饰器：{len(rows)}", f"- 同方法同规范路径：{counts['covered']}",
        f"- 新契约替代：{counts['replaced']}", f"- 明确退役：{counts['retired']}", f"- 尚无契约：{counts['missing']}", "",
        "| 状态 | 方法 | 旧路径 | Go 契约/预期目标 | 来源 |", "|---|---|---|---|---|",
    ]
    labels = {"covered": "已覆盖", "replaced": "新契约替代", "retired": "明确退役", "missing": "缺口"}
    for route, status, target in rows:
        lines.append(f"| {labels[status]} | `{route.method}` | `{route.path or '/'}` | `{target}` | `{route.source}:{route.line}` |")
    destination.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--legacy-root", type=Path, default=Path("/home/ben/ai-gateway-work"))
    parser.add_argument("--openapi", type=Path, default=Path("api/openapi/gateway.yaml"))
    parser.add_argument("--output", type=Path, default=Path("docs/route-contract-matrix.md"))
    parser.add_argument("--fail-on-missing", action="store_true", help="return a non-zero status when an undecided route remains")
    args = parser.parse_args()
    rows = compare(legacy_routes(args.legacy_root), current_routes(args.openapi))
    args.output.parent.mkdir(parents=True, exist_ok=True)
    write_markdown(rows, args.output)
    counts = {status: sum(1 for _, value, _ in rows if value == status) for status in ("covered", "replaced", "retired", "missing")}
    print(f"legacy={len(rows)} covered={counts['covered']} replaced={counts['replaced']} retired={counts['retired']} missing={counts['missing']}")
    if args.fail_on_missing and counts["missing"]:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
