"""Contract-driven OpenStack route registry (Proxmox-style per-path registration)."""

from __future__ import annotations

import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any

from fastapi import FastAPI, Request, Response
from fastapi.routing import APIRoute, APIRouter

from app.api.openapi import service_openapi_tag
from app.openstack.opspec import OperationSpec, ServicePack

Handler = Callable[[Request], Awaitable[Response]]

_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
_ROUTE_NAME_PREFIX = "os-contract:"


class RouteCollisionError(ValueError):
    pass


def normalize_path_template(path: str) -> str:
    """Collapse `{param}` names so `/servers/{id}` matches `/servers/{server_id}`."""

    return _PATH_PARAM.sub("{}", path if path.startswith("/") else f"/{path}")


def _param_names(path: str) -> list[str]:
    """Path param names without FastAPI converters (``{object_name:path}`` → ``object_name``)."""

    return [name.split(":", 1)[0] for name in _PATH_PARAM.findall(path)]


@dataclass(slots=True)
class HandlerRegistry:
    """Semantic handlers keyed by (service, path, verb)."""

    _handlers: dict[tuple[str, str, str], Handler] = field(default_factory=dict)
    _normalized: dict[tuple[str, str, str], tuple[str, Handler]] = field(default_factory=dict)

    def register(self, service: str, path: str, verb: str, handler: Handler) -> None:
        key = (service, path, verb.upper())
        if key in self._handlers:
            raise RouteCollisionError(f"duplicate semantic handler: {verb} {service} {path}")
        self._handlers[key] = handler
        norm_key = (service, normalize_path_template(path), verb.upper())
        # First registration wins for structural lookup (prefer exact contract names).
        self._normalized.setdefault(norm_key, (path, handler))

    def get(self, service: str, path: str, verb: str) -> Handler | None:
        verb_u = verb.upper()
        exact = self._handlers.get((service, path, verb_u))
        if exact is not None:
            return exact
        hit = self._normalized.get((service, normalize_path_template(path), verb_u))
        return hit[1] if hit else None

    def get_specialized_path(self, service: str, path: str, verb: str) -> str | None:
        """Return the path template the handler was registered under (for param remap)."""

        verb_u = verb.upper()
        if (service, path, verb_u) in self._handlers:
            return path
        hit = self._normalized.get((service, normalize_path_template(path), verb_u))
        return hit[0] if hit else None

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


def _fastapi_path(path: str) -> str:
    return path if path.startswith("/") else f"/{path}"


def _route_priority(op: OperationSpec) -> tuple[int, int, str]:
    """Static paths before templated ones so /detail is not captured by /{id}."""

    path = op.path
    braces = path.count("{")
    detail_bias = 0 if path.rstrip("/").endswith("/detail") else 1
    return (braces, detail_bias, path)


def _remap_path_params(request: Request, contract_path: str, specialized_path: str) -> None:
    """Align request.path_params names with the specialized route template."""

    contract_names = _param_names(contract_path)
    specialized_names = _param_names(specialized_path)
    if not specialized_names:
        return
    current = dict(request.path_params)
    if set(specialized_names) <= set(current):
        return
    values: list[str] = []
    for name in contract_names:
        if name in current:
            values.append(str(current[name]))
    if len(values) != len(specialized_names):
        # Fall back to positional values already present.
        values = [str(v) for v in current.values()]
        if len(values) != len(specialized_names):
            return
    remapped = dict(zip(specialized_names, values, strict=True))
    # Keep any non-path extras (unlikely) under original keys.
    for key, value in current.items():
        if key not in remapped and key not in contract_names:
            remapped[key] = value
    request.scope["path_params"] = remapped


def _bridge_route_handler(specialized_path: str, route_handler: Handler) -> Handler:
    async def handler(request: Request) -> Response:
        contract_path = getattr(request.state, "os_contract_path", specialized_path)
        _remap_path_params(request, contract_path, specialized_path)
        return await route_handler(request)

    return handler


def register_specialized_handlers(
    registry: HandlerRegistry,
    service: str,
    router: APIRouter,
) -> int:
    """Import FastAPI router endpoints into the semantic handler registry."""

    count = 0
    for route in router.routes:
        if not isinstance(route, APIRoute):
            continue
        methods = route.methods or set()
        route_handler = route.get_route_handler()
        for method in methods:
            if method in {"HEAD", "OPTIONS"}:
                continue
            path = route.path
            key = (service, path, method.upper())
            if key in registry._handlers:
                continue
            registry.register(
                service,
                path,
                method,
                _bridge_route_handler(path, route_handler),
            )
            count += 1
    return count


def clear_os_contract_routes(app: FastAPI) -> None:
    """Drop previously registered ``os-contract:`` routes for rebuild / hot-swap."""

    app.router.routes = [
        route
        for route in app.router.routes
        if not (
            isinstance(getattr(route, "name", None), str)
            and str(route.name).startswith(_ROUTE_NAME_PREFIX)
        )
    ]
    app.openapi_schema = None


def register_openstack_contract_routes(
    app: FastAPI,
    packs: dict[str, ServicePack],
    handlers: HandlerRegistry,
    *,
    dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
) -> int:
    """Register one FastAPI route per unique (service, method, path) from packs.

    Endpoint looks up a semantic handler first; otherwise falls back to ``dispatch_fn``
    (schema engine generic CRUD/action behaviour).
    """

    registered = 0
    for pack in packs.values():
        seen: set[tuple[str, str]] = set()
        for op in sorted(pack.operations, key=_route_priority):
            key = (op.method, op.path)
            if key in seen:
                continue
            seen.add(key)
            path = _fastapi_path(op.path)
            full_path = f"/_os/{pack.name}{path}"
            name = f"{_ROUTE_NAME_PREFIX}{pack.name}:{op.method}:{op.path}"
            endpoint = _make_contract_endpoint(pack, op, handlers, dispatch_fn)

            app.add_api_route(
                full_path,
                endpoint,
                methods=[op.method],
                name=name,
                include_in_schema=True,
                tags=[service_openapi_tag(pack.name)],
            )
            registered += 1
    return registered


def _make_contract_endpoint(
    pack: ServicePack,
    op: OperationSpec,
    handlers: HandlerRegistry,
    dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
) -> Handler:
    async def endpoint(request: Request) -> Response:
        request.state.os_contract_path = op.path
        request.state.os_contract_op = op
        handler = handlers.get(pack.name, op.path, op.method)
        if handler is not None:
            return await handler(request)
        return await dispatch_fn(request, pack, op)

    return endpoint


def register_specialized_orphan_routes(
    app: FastAPI,
    packs: dict[str, ServicePack],
    handlers: HandlerRegistry,
) -> int:
    """Register specialized handler paths that are not declared in the contract pack.

    Keeps trailing-slash version roots, PUT collection aliases, etc. that exist on
    stateful routers but are missing from generated ``api.json`` packs.
    """

    declared: set[tuple[str, str, str]] = set()
    declared_norm: set[tuple[str, str, str]] = set()
    for pack in packs.values():
        for op in pack.operations:
            declared.add((pack.name, op.method.upper(), op.path))
            declared_norm.add((pack.name, op.method.upper(), normalize_path_template(op.path)))

    # Paths already mounted by the contract loop.
    mounted: set[tuple[str, str, str]] = set()
    for route in app.router.routes:
        name = getattr(route, "name", None)
        if not isinstance(name, str) or not name.startswith(_ROUTE_NAME_PREFIX):
            continue
        # os-contract:{service}:{METHOD}:{path}
        rest = name[len(_ROUTE_NAME_PREFIX) :]
        service, _, remainder = rest.partition(":")
        method, _, path = remainder.partition(":")
        mounted.add((service, method.upper(), path))

    registered = 0
    for service, path, verb in sorted(handlers.keys()):
        verb_u = verb.upper()
        if (service, verb_u, path) in declared or (service, verb_u, path) in mounted:
            continue
        if (service, verb_u, normalize_path_template(path)) in declared_norm:
            continue
        handler = handlers.get(service, path, verb_u)
        if handler is None:
            continue
        full_path = f"/_os/{service}{_fastapi_path(path)}"
        name = f"{_ROUTE_NAME_PREFIX}{service}:{verb_u}:{path}"
        endpoint = _make_handler_only_endpoint(path, handler)
        app.add_api_route(
            full_path,
            endpoint,
            methods=[verb_u],
            name=name,
            include_in_schema=True,
            tags=[service_openapi_tag(service)],
        )
        registered += 1
    return registered


def _make_handler_only_endpoint(specialized_path: str, handler: Handler) -> Handler:
    async def endpoint(request: Request) -> Response:
        request.state.os_contract_path = specialized_path
        return await handler(request)

    return endpoint


def mount_contract_services(
    app: FastAPI,
    *,
    packs: dict[str, ServicePack],
    handlers: HandlerRegistry,
    dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
) -> int:
    """Clear previous contract routes and register from packs. Returns route count."""

    # Preserve non-contract routes; insert contract routes before gen-* so static
    # schema paths are not stolen by generic /{item_id}.
    non_gen: list[Any] = []
    gen: list[Any] = []
    for route in app.router.routes:
        name = getattr(route, "name", "") or ""
        if isinstance(name, str) and name.startswith(_ROUTE_NAME_PREFIX):
            continue
        if isinstance(name, str) and name.startswith("schema-"):
            # Legacy schema-* routes from older mounts — drop on rebuild.
            continue
        if isinstance(name, str) and name.startswith("gen-"):
            gen.append(route)
        else:
            non_gen.append(route)
    app.router.routes = non_gen
    app.openapi_schema = None
    count = register_openstack_contract_routes(app, packs, handlers, dispatch_fn=dispatch_fn)
    count += register_specialized_orphan_routes(app, packs, handlers)
    app.router.routes.extend(gen)
    return count
