#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

"""Four-way native-sol conformance check."""

from __future__ import annotations

import ast
import importlib
import inspect
import sys
import tomllib
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any

from flask import Flask

import solstone.convey.reasons as reasons
from solstone.apps.activities.routes import activities_bp
from solstone.apps.awareness.routes import awareness_bp
from solstone.apps.body.routes import body_bp
from solstone.apps.curation.routes import curation_bp
from solstone.apps.entities.routes import entities_bp
from solstone.apps.network.routes import network_bp
from solstone.apps.settings.routes import settings_bp
from solstone.apps.sol.routes import sol_bp
from solstone.apps.speakers.routes import speakers_bp
from solstone.apps.support.routes import support_bp
from solstone.apps.thinking.routes import thinking_bp
from solstone.apps.transcripts.routes import transcripts_bp
from solstone.convey.chat import chat_bp
from solstone.convey.contract.assemble import build_document, rule_to_openapi_path
from solstone.convey.health import bp as health_bp
from solstone.convey.ledger import bp as ledger_bp
from solstone.convey.profile import bp as profile_bp
from solstone.convey.profile import profiles_bp
from solstone.convey.reasons import Reason
from solstone.convey.root import bp as root_bp

try:
    from scripts.build_native_sol_inventory import (
        AuthorityEntry,
        discover,
        is_private_app_authority,
    )
except ModuleNotFoundError:  # pragma: no cover - direct script execution path.
    from build_native_sol_inventory import (  # type: ignore[no-redef]
        AuthorityEntry,
        discover,
        is_private_app_authority,
    )

REPO_ROOT = Path(__file__).resolve().parents[1]
import_bp = importlib.import_module("solstone.apps.import.routes").import_bp
REASON_CODES_BY_NAME = {
    name: value.code
    for name, value in vars(reasons).items()
    if isinstance(value, Reason)
}


@dataclass(frozen=True)
class ContractOperation:
    operation_id: str
    method: str
    route: str
    reason_codes: frozenset[str]


@dataclass(frozen=True)
class RawAuthorityEntry:
    authority: Path
    raw: dict[str, Any]


def check_conformance(
    *,
    root: Path = REPO_ROOT,
    document: dict[str, Any] | None = None,
    authorities: list[AuthorityEntry] | None = None,
    route_map: dict[tuple[str, str], Callable[..., Any]] | None = None,
) -> list[str]:
    root = root.resolve()
    document = document if document is not None else build_document()
    authorities = authorities if authorities is not None else discover(root)
    route_map = route_map if route_map is not None else collect_flask_routes()

    errors: list[str] = []
    raw_authority_by_operation = load_raw_authority_entries(root)
    contract_by_operation = collect_contract_operations(document)

    if not authorities:
        errors.append("native sol conformance discovered zero authorities")
    if not route_map:
        errors.append("native sol conformance discovered zero Flask routes")
    if not contract_by_operation:
        errors.append("native sol conformance discovered zero OpenAPI operations")
    if not any(authority.entry_type == "top-level-import" for authority in authorities):
        errors.append("native sol conformance missing top-level import authority")
    if not any(authority.entry_type == "top-level-notify" for authority in authorities):
        errors.append("native sol conformance missing top-level notify authority")

    for authority in sorted(authorities, key=lambda entry: entry.operation_id):
        raw_authority = raw_authority_by_operation.get(authority.operation_id)
        if authority.entry_type == "http":
            errors.extend(check_http_entry(authority, contract_by_operation, route_map))
        elif authority.entry_type in {"moved-stub", "local"}:
            errors.extend(check_non_http_entry(authority, contract_by_operation))
        elif authority.entry_type == "top-level-chat":
            errors.extend(
                check_top_level_backing_contracts(
                    authority,
                    raw_authority,
                    contract_by_operation,
                    route_map,
                    "top-level-chat",
                    "chat",
                )
            )
        elif authority.entry_type == "top-level-import":
            errors.extend(
                check_top_level_backing_contracts(
                    authority,
                    raw_authority,
                    contract_by_operation,
                    route_map,
                    "top-level-import",
                    "import",
                )
            )
        elif authority.entry_type == "top-level-notify":
            errors.extend(check_non_http_entry(authority, contract_by_operation))
        else:
            errors.append(
                f"{authority.operation_id}: unsupported entry_type "
                f"{authority.entry_type!r}"
            )
    return errors


def check_http_entry(
    authority: AuthorityEntry,
    contract_by_operation: dict[str, ContractOperation],
    route_map: dict[tuple[str, str], Callable[..., Any]],
) -> list[str]:
    operation_id = authority.operation_id
    errors: list[str] = []
    if authority.method is None or authority.route is None:
        errors.append(f"{operation_id}: HTTP authority must declare method and route")
        return errors
    if authority.contract_operation_id is None:
        errors.append(
            f"{operation_id}: HTTP authority must declare contract_operation_id"
        )
        return errors

    route_key = (authority.method, authority.route)
    view = route_map.get(route_key)
    if view is None:
        errors.append(
            f"{operation_id}: no Flask route for {authority.method} {authority.route}"
        )

    contract = contract_by_operation.get(authority.contract_operation_id)
    if contract is None:
        errors.append(
            f"{operation_id}: no contract operation {authority.contract_operation_id}"
        )
        return errors
    if contract.method != authority.method:
        errors.append(
            f"{operation_id}: contract method {contract.method!r} "
            f"!= authority {authority.method!r}"
        )
    if contract.route != authority.route:
        errors.append(
            f"{operation_id}: contract route {contract.route!r} "
            f"!= authority {authority.route!r}"
        )
    unknown_reasons = sorted(contract.reason_codes - set(REASON_CODES_BY_NAME.values()))
    if unknown_reasons:
        errors.append(
            f"{operation_id}: contract declares unknown reason codes {unknown_reasons!r}"
        )

    if view is not None:
        route_reason_codes = route_error_reason_codes(view)
        if route_reason_codes != contract.reason_codes:
            errors.append(
                f"{operation_id}: route reason codes {sorted(route_reason_codes)!r} "
                f"!= contract {sorted(contract.reason_codes)!r}"
            )
    return errors


def check_non_http_entry(
    authority: AuthorityEntry,
    contract_by_operation: dict[str, ContractOperation],
) -> list[str]:
    errors: list[str] = []
    operation_id = authority.operation_id
    if any(
        value is not None
        for value in (
            authority.method,
            authority.route,
            authority.contract_operation_id,
        )
    ):
        errors.append(
            f"{operation_id}: non-HTTP authority must not declare HTTP fields"
        )
    if operation_id in contract_by_operation:
        errors.append(f"{operation_id}: non-HTTP authority must not have a contract")
    return errors


def check_top_level_backing_contracts(
    authority: AuthorityEntry,
    raw_authority: RawAuthorityEntry | None,
    contract_by_operation: dict[str, ContractOperation],
    route_map: dict[tuple[str, str], Callable[..., Any]],
    entry_type: str,
    label: str,
) -> list[str]:
    errors = check_non_http_entry(authority, contract_by_operation)
    operation_id = authority.operation_id
    authority_ids = (
        raw_authority.raw.get("backing_contract_operation_ids")
        if raw_authority is not None
        else None
    )
    if not isinstance(authority_ids, list) or not authority_ids:
        errors.append(f"{operation_id}: {entry_type} must declare backing contracts")
        return errors
    for backing_id in authority_ids:
        if not isinstance(backing_id, str) or not backing_id:
            errors.append(f"{operation_id}: backing contract id must be a string")
            continue
        contract = contract_by_operation.get(backing_id)
        if contract is None:
            errors.append(
                f"{operation_id}: missing {label} backing contract {backing_id}"
            )
            continue
        if (contract.method, contract.route) not in route_map:
            errors.append(
                f"{operation_id}: missing {label} backing route "
                f"{contract.method} {contract.route}"
            )
    return errors


def collect_contract_operations(
    document: dict[str, Any],
) -> dict[str, ContractOperation]:
    output: dict[str, ContractOperation] = {}
    for route, methods in document["paths"].items():
        for raw_method, operation in methods.items():
            operation_id = operation["operationId"]
            reason_codes = {
                reason_code
                for response in operation.get("responses", {}).values()
                for reason_code in response.get("x-reason-codes", [])
            }
            output[operation_id] = ContractOperation(
                operation_id=operation_id,
                method=raw_method.upper(),
                route=route,
                reason_codes=frozenset(reason_codes),
            )
    return output


def register_native_blueprints(app: Flask) -> None:
    """Register blueprints needed by currently ported native authorities."""

    for blueprint in (
        activities_bp,
        support_bp,
        health_bp,
        chat_bp,
        root_bp,
        body_bp,
        curation_bp,
        entities_bp,
        sol_bp,
        awareness_bp,
        ledger_bp,
        profile_bp,
        profiles_bp,
        import_bp,
        transcripts_bp,
        network_bp,
        settings_bp,
        speakers_bp,
        thinking_bp,
    ):
        app.register_blueprint(blueprint)


def collect_flask_routes() -> dict[tuple[str, str], Callable[..., Any]]:
    app = Flask(__name__)
    register_native_blueprints(app)
    routes: dict[tuple[str, str], Callable[..., Any]] = {}
    for rule in app.url_map.iter_rules():
        view = app.view_functions[rule.endpoint]
        route = rule_to_openapi_path(rule.rule)
        for method in sorted(rule.methods or ()):
            if method in {"HEAD", "OPTIONS"}:
                continue
            routes[(method, route)] = view
    return routes


def route_error_reason_codes(view: Callable[..., Any]) -> frozenset[str]:
    source = inspect.getsourcefile(view)
    if source is None:
        return frozenset()
    functions = module_functions(Path(source))
    discovered = collect_function_reason_codes(view.__name__, functions, set())
    return frozenset(discovered | annotated_route_reason_codes(view))


def annotated_route_reason_codes(view: Callable[..., Any]) -> set[str]:
    module = inspect.getmodule(view)
    annotations = getattr(module, "NATIVE_SOL_ROUTE_REASON_CODES", None)
    if not isinstance(annotations, dict):
        return set()
    values = annotations.get(view.__name__, set())
    if not isinstance(values, (set, frozenset, list, tuple)):
        return set()
    return {str(value) for value in values}


@lru_cache(maxsize=None)
def module_functions(source: Path) -> dict[str, ast.FunctionDef]:
    tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
    return {node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)}


def collect_function_reason_codes(
    function_name: str,
    functions: dict[str, ast.FunctionDef],
    seen: set[str],
) -> set[str]:
    if function_name in seen:
        return set()
    seen.add(function_name)
    function = functions.get(function_name)
    if function is None:
        return set()
    reason_codes: set[str] = set()
    for node in ast.walk(function):
        if not isinstance(node, ast.Call):
            continue
        name = call_name(node.func)
        if name == "error_response" and node.args:
            reason_code = reason_code_from_ast(node.args[0])
            if reason_code is not None:
                reason_codes.add(reason_code)
        elif (
            isinstance(node.func, ast.Name)
            and node.func.id in functions
            and node.func.id != function_name
        ):
            reason_codes.update(
                collect_function_reason_codes(node.func.id, functions, seen)
            )
    return reason_codes


def call_name(node: ast.expr) -> str | None:
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.Attribute):
        return node.attr
    return None


def reason_code_from_ast(node: ast.expr) -> str | None:
    if isinstance(node, ast.Name):
        return REASON_CODES_BY_NAME.get(node.id)
    if isinstance(node, ast.Attribute):
        return REASON_CODES_BY_NAME.get(node.attr)
    return None


def load_raw_authority_entries(root: Path) -> dict[str, RawAuthorityEntry]:
    output: dict[str, RawAuthorityEntry] = {}
    authority_paths = sorted(
        set((root / "solstone").glob("**/native/authority.toml"))
        | set((root / "solstone").glob("**/native/**/authority.toml"))
    )
    for authority in authority_paths:
        if is_private_app_authority(authority, root):
            continue
        data = tomllib.loads(authority.read_text(encoding="utf-8"))
        for raw in data.get("entries", []):
            if not isinstance(raw, dict):
                continue
            operation_id = raw.get("operation_id")
            if isinstance(operation_id, str):
                output[operation_id] = RawAuthorityEntry(authority=authority, raw=raw)
    return output


def format_errors(errors: Iterable[str]) -> str:
    return "\n".join(f"- {error}" for error in errors)


def main() -> int:
    errors = check_conformance()
    if errors:
        print("native sol conformance failed:", file=sys.stderr)
        print(format_errors(errors), file=sys.stderr)
        return 1
    print("native sol conformance ok")
    return 0


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