#!/usr/bin/env python3
"""Lightweight API compatibility guard.

Checks a minimal set of OpenAPI contracts for critical public endpoints.
Intended to fail fast when key paths or response contracts drift unexpectedly.
"""

from __future__ import annotations

import ast
from pathlib import Path
from typing import Any

from grantflow.api.app import app

REPO_ROOT = Path(__file__).resolve().parents[1]
REQUIRED_SECURITY_SCHEME = "ApiKeyAuth"
FORBIDDEN_APP_MODULE_TOKENS = (
    "def _app_module",
    "_app_module().",
    "from grantflow.api import app",
    "import grantflow.api.app",
)
TEST_APP_PRIVATE_IMPORT_ALLOWLIST = {
    Path("grantflow/tests/test_import_contract.py"),
}

# path -> required methods
REQUIRED_PATHS: dict[str, set[str]] = {
    "/health": {"get"},
    "/generate": {"post"},
    "/generate/from-preset": {"post"},
    "/status/{job_id}": {"get"},
    "/status/{job_id}/quality": {"get"},
    "/status/{job_id}/critic": {"get"},
    "/status/{job_id}/review/workflow": {"get"},
    "/export": {"post"},
}

# path/method tuples that must document a 200 JSON response schema
REQUIRED_200_JSON_SCHEMA: set[tuple[str, str]] = {
    ("/generate", "post"),
    ("/generate/from-preset", "post"),
    ("/status/{job_id}", "get"),
    ("/status/{job_id}/quality", "get"),
    ("/status/{job_id}/critic", "get"),
    ("/status/{job_id}/review/workflow", "get"),
}


def _fail(msg: str) -> None:
    raise SystemExit(msg)


def _get_json_schema_for_200(op: dict[str, Any]) -> dict[str, Any] | None:
    responses = op.get("responses") if isinstance(op, dict) else None
    if not isinstance(responses, dict):
        return None
    ok = responses.get("200")
    if not isinstance(ok, dict):
        return None
    content = ok.get("content")
    if not isinstance(content, dict):
        return None
    app_json = content.get("application/json")
    if not isinstance(app_json, dict):
        return None
    schema = app_json.get("schema")
    return schema if isinstance(schema, dict) else None


def _forbidden_app_module_bridge_refs(api_root: Path) -> list[str]:
    findings: list[str] = []
    for path in sorted(api_root.rglob("*.py")):
        rel = path.relative_to(REPO_ROOT) if path.is_relative_to(REPO_ROOT) else path
        for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
            if any(token in line for token in FORBIDDEN_APP_MODULE_TOKENS):
                findings.append(f"{rel}:{lineno}: {line.strip()}")
    return findings


def _guard_no_app_module_bridges() -> None:
    findings = _forbidden_app_module_bridge_refs(REPO_ROOT / "grantflow" / "api")
    if findings:
        formatted = "\n".join(f"- {finding}" for finding in findings)
        _fail(
            "Forbidden app-module bridge references found. "
            "Import owner modules or runtime_state directly:\n"
            f"{formatted}"
        )


def _relative_path(path: Path) -> Path:
    return path.relative_to(REPO_ROOT) if path.is_relative_to(REPO_ROOT) else path


def _forbidden_test_app_private_refs(tests_root: Path) -> list[str]:
    findings: list[str] = []
    for path in sorted(tests_root.rglob("*.py")):
        rel = _relative_path(path)
        if rel in TEST_APP_PRIVATE_IMPORT_ALLOWLIST:
            continue
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(rel))
        for node in ast.walk(tree):
            if isinstance(node, ast.ImportFrom) and node.module == "grantflow.api.app":
                private_names = [alias.name for alias in node.names if alias.name.startswith("_") or alias.name == "*"]
                if private_names:
                    findings.append(f"{rel}:{node.lineno}: private app import(s): {', '.join(private_names)}")
            elif isinstance(node, ast.Import):
                for alias in node.names:
                    if alias.name == "grantflow.api.app":
                        findings.append(f"{rel}:{node.lineno}: app module import is reserved for import-contract tests")
    return findings


def _guard_no_test_private_app_imports() -> None:
    findings = _forbidden_test_app_private_refs(REPO_ROOT / "grantflow" / "tests")
    if findings:
        formatted = "\n".join(f"- {finding}" for finding in findings)
        _fail(
            "Forbidden private imports from grantflow.api.app found in tests. "
            "Import owner modules directly, or update the dedicated import contract test:\n"
            f"{formatted}"
        )


def main() -> int:
    _guard_no_app_module_bridges()
    _guard_no_test_private_app_imports()

    spec = app.openapi()

    components = spec.get("components") if isinstance(spec, dict) else None
    security_schemes = (components or {}).get("securitySchemes") if isinstance(components, dict) else None
    if not isinstance(security_schemes, dict) or REQUIRED_SECURITY_SCHEME not in security_schemes:
        _fail(f"Missing required security scheme: {REQUIRED_SECURITY_SCHEME}")

    paths = spec.get("paths") if isinstance(spec, dict) else None
    if not isinstance(paths, dict):
        _fail("OpenAPI spec missing paths")

    for path, methods in REQUIRED_PATHS.items():
        path_item = paths.get(path)
        if not isinstance(path_item, dict):
            _fail(f"Missing required path: {path}")
        for method in methods:
            op = path_item.get(method)
            if not isinstance(op, dict):
                _fail(f"Missing required operation: {method.upper()} {path}")

    for path, method in sorted(REQUIRED_200_JSON_SCHEMA):
        op = ((paths.get(path) or {}) if isinstance(paths.get(path), dict) else {}).get(method)
        if not isinstance(op, dict):
            _fail(f"Missing operation while checking schema: {method.upper()} {path}")
        schema = _get_json_schema_for_200(op)
        if not schema:
            _fail(f"Missing 200 application/json schema for: {method.upper()} {path}")

    print("API contract guard passed.")
    return 0


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