"""Contract-driven dynamic route and semantic handler registry."""

from __future__ import annotations

import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import parse_qsl

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from app.api.errors import ApiError, ContractValidationError
from app.api.openapi import contract_openapi_tags
from app.config import Settings
from app.contracts.examples import schema_example
from app.contracts.model import Method, Parameter, Schema, Snapshot
from app.db.pool import AsyncpgDatabase
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket

Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
FallbackMode = Literal["error", "schema-default", "fixture"]


class RouteCollisionError(ValueError):
    pass


@dataclass(slots=True)
class HandlerRegistry:
    _handlers: dict[tuple[str, str], Handler] = field(default_factory=dict)

    def register(self, path: str, verb: str, handler: Handler) -> None:
        key = (path, verb.upper())
        if key in self._handlers:
            raise RouteCollisionError(f"duplicate semantic handler: {verb} {path}")
        self._handlers[key] = handler

    def get(self, path: str, verb: str) -> Handler | None:
        return self._handlers.get((path, verb.upper()))

    def keys(self) -> frozenset[tuple[str, str]]:
        return frozenset(self._handlers)


def register_contract_routes(
    app: FastAPI,
    snapshot: Snapshot,
    handlers: HandlerRegistry,
    fallback: FallbackMode = "error",
    *,
    existing: set[tuple[str, str, str]] | None = None,
    require_handler: bool = False,
    allow_existing: bool = False,
) -> set[tuple[str, str, str]]:
    """Register `/api2/{json,extjs}` routes for a contract snapshot.

    When ``allow_existing`` is true, path/verb pairs already present in
    ``existing`` are skipped (used to merge older majors onto a primary contract).
    When ``require_handler`` is true, only methods with a registered semantic
    handler are added — used for legacy-path aliases.
    """

    seen = existing if existing is not None else set()
    for contract_path in snapshot.paths:
        for contract_method in contract_path.methods:
            if require_handler and handlers.get(contract_path.path, contract_method.verb) is None:
                continue
            for renderer in ("json", "extjs"):
                route = f"/api2/{renderer}{contract_path.path}"
                key = (route, contract_method.verb, renderer)
                if key in seen:
                    if allow_existing:
                        continue
                    raise RouteCollisionError(
                        f"duplicate contract route: {contract_method.verb} {route}"
                    )
                seen.add(key)
                implemented = handlers.get(contract_path.path, contract_method.verb) is not None
                endpoint = _endpoint(
                    contract_path.path,
                    contract_method,
                    renderer,
                    handlers,
                    fallback,
                )
                app.add_api_route(
                    route,
                    endpoint,
                    methods=[contract_method.verb],
                    name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
                    tags=cast(
                        list[str | Enum], contract_openapi_tags(contract_path.path, renderer)
                    ),
                    openapi_extra={
                        "x-proxmox-method-checksum": contract_method.checksum,
                        "x-proxmox-implementation": "implemented" if implemented else "unsupported",
                    },
                )
    return seen


def register_legacy_handler_routes(
    app: FastAPI,
    handlers: HandlerRegistry,
    store_root: Path,
    fallback: FallbackMode = "error",
    *,
    primary_version: str | None = None,
    existing: set[tuple[str, str, str]] | None = None,
) -> set[tuple[str, str, str]]:
    """Expose handler-backed paths declared only in older cached contracts."""

    seen = existing if existing is not None else set()
    if not store_root.is_dir():
        return seen
    for revision_dir in sorted(store_root.iterdir()):
        snapshot_path = revision_dir / "snapshot.json"
        if not snapshot_path.is_file():
            continue
        snapshot = Snapshot.model_validate_json(snapshot_path.read_bytes())
        if primary_version and snapshot.source_version == primary_version:
            continue
        seen = register_contract_routes(
            app,
            snapshot,
            handlers,
            fallback,
            existing=seen,
            require_handler=True,
            allow_existing=True,
        )
    return seen


_PATH_PARAM_RE = re.compile(r"\{([^{}]+)\}")


def _unbound_method(path: str, verb: str) -> Method:
    """Minimal contract method so handler-only paths can be mounted."""

    parameters = tuple(
        Parameter(name=name, definition=Schema(type="string", optional=True))
        for name in _PATH_PARAM_RE.findall(path)
    )
    return Method(
        verb=verb.upper(),
        name=f"unbound-{verb.lower()}-{path}",
        description="Handler-backed route not present in the active contract snapshot",
        parameters=parameters,
        returns=Schema(),
        permissions=None,
        checksum="0" * 64,
    )


def register_unbound_handler_routes(
    app: FastAPI,
    handlers: HandlerRegistry,
    fallback: FallbackMode = "error",
    *,
    existing: set[tuple[str, str, str]] | None = None,
) -> set[tuple[str, str, str]]:
    """Mount semantic handlers that no cached contract currently declares.

    Keeps DB-backed handlers reachable (no FastAPI 404) when Proxmox schema
    omits a path that the simulator still implements.
    """

    seen = existing if existing is not None else set()
    for path, verb in sorted(handlers.keys()):
        for renderer in ("json", "extjs"):
            route = f"/api2/{renderer}{path}"
            key = (route, verb, renderer)
            if key in seen:
                continue
            method = _unbound_method(path, verb)
            seen.add(key)
            app.add_api_route(
                route,
                _endpoint(path, method, renderer, handlers, fallback),
                methods=[verb],
                name=f"unbound:{renderer}:{verb}:{path}",
                tags=cast(list[str | Enum], contract_openapi_tags(path, renderer)),
                openapi_extra={
                    "x-proxmox-method-checksum": method.checksum,
                    "x-proxmox-implementation": "implemented",
                    "x-proxmox-unbound-handler": True,
                },
            )
    return seen


def _endpoint(
    semantic_path: str,
    method: Method,
    renderer: str,
    handlers: HandlerRegistry,
    fallback: FallbackMode,
) -> Callable[[Request], Awaitable[JSONResponse]]:
    async def dispatch(request: Request) -> JSONResponse:
        inputs = await _parse_inputs(request, method)
        await _authenticate(request, semantic_path, method, inputs)
        handler = handlers.get(semantic_path, method.verb)
        if handler is not None:
            data = await handler(request, inputs)
        elif fallback == "schema-default":
            data = schema_example(method.returns)
        elif fallback == "fixture" and "fixture" in method.extra:
            data = method.extra["fixture"]
        else:
            return JSONResponse(
                status_code=501,
                content={"data": None, "errors": "handler pending for this contract method"},
            )
        content = {"data": data, "success": True} if renderer == "extjs" else {"data": data}
        response = JSONResponse(content)
        if semantic_path == "/access/ticket" and isinstance(data, dict):
            ticket = data.get("ticket")
            if isinstance(ticket, str):
                response.set_cookie(
                    "PVEAuthCookie", ticket, httponly=True, samesite="strict", path="/"
                )
        return response

    return dispatch


async def _authenticate(
    request: Request, semantic_path: str, method: Method, inputs: dict[str, Any]
) -> None:
    if semantic_path in {"/version", "/access/ticket"}:
        return
    authorization = request.headers.get("Authorization", "")
    token_privileges: frozenset[str] | None = None
    principal: str
    if authorization.startswith("PVEAPIToken="):
        database = cast(AsyncpgDatabase, request.app.state.database)
        try:
            parsed_token = parse_api_token(authorization)
        except ValueError as error:
            raise ApiError(401, "authentication failure") from error
        row = await database.pool.fetchrow(
            """SELECT p.name, t.secret_hash, t.privileges, t.privilege_separation
            FROM api_tokens t JOIN principals p ON p.id=t.principal_id
            WHERE p.name=$1 AND t.token_id=$2
              AND (t.expires_at IS NULL OR t.expires_at > now())""",
            parsed_token.principal,
            parsed_token.token_id,
        )
        if row is None or not verify_secret(parsed_token.secret, str(row["secret_hash"])):
            raise ApiError(401, "authentication failure")
        principal = str(row["name"])
        token_privileges = (
            frozenset(str(item) for item in row["privileges"])
            if bool(row["privilege_separation"])
            else None
        )
    else:
        ticket = request.cookies.get("PVEAuthCookie")
        if ticket is None:
            raise ApiError(401, "authentication required")
        settings = cast(Settings, request.app.state.settings)
        key = settings.ticket_signing_key.get_secret_value().encode()
        try:
            claims = verify_ticket(ticket, key)
        except ValueError as error:
            raise ApiError(401, "authentication failure") from error
        principal = claims.principal
        if request.method not in {"GET", "HEAD", "OPTIONS"}:
            csrf_value = request.headers.get("CSRFPreventionToken", "")
            if not verify_csrf(ticket, csrf_value, key):
                raise ApiError(403, "invalid CSRF prevention token")
    request.state.principal = principal
    if principal == "root@pam" and token_privileges is None:
        return
    database = cast(AsyncpgDatabase, request.app.state.database)
    await _authorize(database, principal, token_privileges, semantic_path, method, inputs)


async def _authorize(
    database: AsyncpgDatabase,
    principal: str,
    token_privileges: frozenset[str] | None,
    semantic_path: str,
    method: Method,
    inputs: dict[str, Any],
) -> None:
    values = cast(dict[str, Any], inputs["values"])
    requirement = requirement_from_contract(
        method.permissions, {name: str(value) for name, value in values.items()}
    )
    if requirement is None and semantic_path == "/nodes/{node}/qemu" and method.verb == "POST":
        requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
    if requirement is None and semantic_path == "/nodes/{node}/lxc" and method.verb == "POST":
        requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
    if requirement is None:
        return
    rows = await database.pool.fetch(
        """SELECT a.path, a.propagate, r.privileges
        FROM acl_entries a JOIN roles r ON r.name=a.role_name
        JOIN principals p ON p.id=a.principal_id WHERE p.name=$1
        UNION ALL
        SELECT a.path, a.propagate, r.privileges
        FROM group_acl_entries a JOIN roles r ON r.name=a.role_name
        JOIN identity_group_members m ON m.group_id=a.group_id
        JOIN principals p ON p.id=m.principal_id WHERE p.name=$1""",
        principal,
    )
    entries = tuple(
        AclEntry(
            principal,
            str(row["path"]),
            frozenset(str(item) for item in row["privileges"]),
            bool(row["propagate"]),
        )
        for row in rows
    )
    if not authorize(
        principal,
        requirement.path,
        requirement.privileges,
        entries,
        token_privileges=token_privileges,
        require_all=requirement.require_all,
    ):
        raise ApiError(403, "permission check failed")


async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
    supplied: dict[str, Any] = dict(request.query_params)
    supplied.update(request.path_params)
    if request.method not in {"GET", "DELETE"}:
        content_type = request.headers.get("content-type", "").split(";", 1)[0].strip()
        if content_type == "application/json":
            try:
                body = await request.json()
            except ValueError as exc:
                raise ContractValidationError({"body": "invalid JSON"}) from exc
            if not isinstance(body, dict):
                raise ContractValidationError({"body": "expected an object"})
            supplied.update(body)
        elif content_type == "application/x-www-form-urlencoded":
            supplied.update(dict(parse_qsl((await request.body()).decode())))

    definitions = {parameter.name: parameter.definition for parameter in method.parameters}
    indexed = {
        re.compile("^" + re.escape(name).replace(r"\[n\]", r"\d+") + "$"): definition
        for name, definition in definitions.items()
        if "[n]" in name
    }
    errors: dict[str, str] = {}
    parsed: dict[str, Any] = {}
    for name, definition in definitions.items():
        if "[n]" in name:
            continue
        if name not in supplied:
            if definition.optional:
                # Proxmox schema ``default`` is often UI documentation text (e.g.
                # bwlimit default = "restore limit from datacenter…"), not a value
                # to materialize. Only inject defaults that coerce to the type.
                if definition.default is not None:
                    try:
                        parsed[name] = _coerce(definition.default, definition)
                    except (TypeError, ValueError):
                        pass
                continue
            errors[name] = "property is missing and it is not optional"
            continue
        try:
            parsed[name] = _coerce(supplied[name], definition)
        except (TypeError, ValueError) as exc:
            errors[name] = str(exc)
    indexed_names: set[str] = set()
    for name in supplied.keys() - definitions.keys():
        indexed_definition = next(
            (candidate for pattern, candidate in indexed.items() if pattern.fullmatch(name)), None
        )
        if indexed_definition is not None:
            indexed_names.add(name)
            try:
                parsed[name] = _coerce(supplied[name], indexed_definition)
            except (TypeError, ValueError) as exc:
                errors[name] = str(exc)
    for name in supplied.keys() - definitions.keys() - indexed_names:
        if name not in request.path_params:
            errors[name] = "property is not defined in schema"
    if errors:
        raise ContractValidationError(dict(sorted(errors.items())))
    # Path params are always available to handlers even when omitted from the
    # method property schema (common for Proxmox nested resources).
    for name, value in request.path_params.items():
        parsed.setdefault(name, value)
    return {
        "values": parsed,
        "path": dict(request.path_params),
        "provided": tuple(sorted(supplied)),
    }


def _coerce(value: Any, schema: Schema) -> Any:
    if schema.type == "integer":
        parsed: Any = int(value)
    elif schema.type == "number":
        parsed = float(value)
    elif schema.type == "boolean":
        if isinstance(value, bool):
            parsed = value
        elif str(value).lower() in {"1", "true", "yes", "on"}:
            parsed = True
        elif str(value).lower() in {"0", "false", "no", "off"}:
            parsed = False
        else:
            raise ValueError("expected a boolean")
    elif schema.type == "string" or schema.type is None:
        parsed = str(value)
    else:
        parsed = value
    if schema.enum and parsed not in schema.enum:
        raise ValueError("value is not in the allowed enumeration")
    if isinstance(parsed, int | float):
        if schema.minimum is not None and parsed < schema.minimum:
            raise ValueError(f"value must be at least {schema.minimum}")
        if schema.maximum is not None and parsed > schema.maximum:
            raise ValueError(f"value must be at most {schema.maximum}")
    if isinstance(parsed, str):
        if schema.min_length is not None and len(parsed) < schema.min_length:
            raise ValueError(f"value is shorter than {schema.min_length}")
        if schema.max_length is not None and len(parsed) > schema.max_length:
            raise ValueError(f"value is longer than {schema.max_length}")
    return parsed


def _schema_default(schema: Schema) -> Any:
    return schema_example(schema)
