"""Schema-driven OpenStack API engine — surface-complete ops from contract packs."""

from __future__ import annotations

import json
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid4

from asyncpg import Connection
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse

from app.db.pool import AsyncpgDatabase
from app.openstack.auth import TokenContext, extract_token, validate_token
from app.openstack.contract_loader import ensure_loaded, get_runtime
from app.openstack.errors import OpenStackError
from app.openstack.opspec import OperationSpec, ServicePack

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


def _singular(collection_key: str) -> str:
    if collection_key.endswith("ies"):
        return collection_key[:-3] + "y"
    if collection_key.endswith("ses"):
        return collection_key[:-2]
    if collection_key.endswith("s") and not collection_key.endswith("ss"):
        return collection_key[:-1]
    return collection_key


def _fastapi_path(path: str) -> str:
    """Convert {param} to FastAPI {param} (already compatible)."""
    return path if path.startswith("/") else f"/{path}"


def _parent_scope(path: str, path_params: dict[str, str]) -> dict[str, str]:
    parent = {k: v for k, v in path_params.items() if k != "id"}
    # Nested collections like /resource_providers/{id}/inventories keep the parent
    # id under useful aliases so list filters can match seeded child rows.
    if "id" in path_params:
        match = re.search(r"/([^/]+)/\{id\}(?:/|$)", path)
        if match:
            segment = match.group(1)
            singular = (
                segment[:-1] if segment.endswith("s") and not segment.endswith("ss") else segment
            )
            parent.setdefault(f"{singular}_id", path_params["id"])
            parent.setdefault(singular, path_params["id"])
            parent.setdefault("parent_id", path_params["id"])
            parent.setdefault("resource_provider", path_params["id"])
            parent.setdefault("resource_provider_id", path_params["id"])
            parent.setdefault("server_id", path_params["id"])
    return parent


def _path_ends_with_item_param(path: str) -> bool:
    """True for item show paths (/x/{id}), false for nested collections (/x/{id}/ys)."""

    trimmed = path.rstrip("/")
    return bool(re.search(r"/\{[^{}/]+\}$", trimmed))


def _row_item(row: Any) -> dict[str, Any]:
    data = row["data"]
    if isinstance(data, str):
        data = json.loads(data)
    item = dict(data or {})
    item.setdefault("id", str(row["id"]))
    item.setdefault("name", row["name"])
    item.setdefault("status", row["status"])
    if row["project_id"] is not None:
        item.setdefault("project_id", str(row["project_id"]))
        item.setdefault("tenant_id", str(row["project_id"]))
    item.setdefault("created_at", row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
    item.setdefault("updated_at", row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
    return item


def _paginate(
    items: list[dict[str, Any]], request: Request
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    try:
        limit = int(request.query_params.get("limit") or 0)
    except ValueError:
        limit = 0
    marker = request.query_params.get("marker")
    start = 0
    if marker:
        for i, item in enumerate(items):
            if str(item.get("id")) == marker or str(item.get("name")) == marker:
                start = i + 1
                break
    page = items[start:]
    links: dict[str, Any] = {}
    if limit > 0:
        page = page[:limit]
        if start + limit < len(items):
            last = page[-1] if page else None
            if last:
                links["next"] = str(last.get("id") or last.get("name"))
    return page, links


def _check_microversion(request: Request, op: OperationSpec, pack: ServicePack) -> None:
    if not op.microversion_min and not pack.max_microversion:
        return
    requested = getattr(request.state, "microversion", None)
    runtime = get_runtime()
    override = runtime.active_microversion(pack.name)
    chosen = requested or override or pack.default_microversion
    if not chosen:
        return
    maximum = pack.max_microversion or op.microversion_max
    minimum = op.microversion_min or pack.default_microversion
    if maximum and _mv_tuple(chosen) > _mv_tuple(maximum):
        raise OpenStackError(
            "VersionNotFound",
            f"Microversion {chosen} exceeds max {maximum}",
            status_code=406,
        )
    if minimum and _mv_tuple(chosen) < _mv_tuple(minimum):
        raise OpenStackError(
            "VersionNotFound",
            f"Microversion {chosen} below min {minimum}",
            status_code=406,
        )


def _mv_tuple(value: str) -> tuple[int, ...]:
    parts = []
    for piece in value.split("."):
        try:
            parts.append(int(piece))
        except ValueError:
            parts.append(0)
    return tuple(parts)


def _fixture_or_item(
    op: OperationSpec, item: dict[str, Any] | None, *, list_mode: bool = False
) -> Any:
    if op.response_fixture is not None:
        return op.response_fixture
    key = op.collection_key
    if list_mode and key:
        return {key: item if isinstance(item, list) else []}
    if op.item_key:
        return {op.item_key: item or {}}
    if key:
        return {_singular(key): item or {}}
    return item or {}


async def _list_objects(
    conn: Connection,
    *,
    service: str,
    resource_type: str,
    project_id: Any,
    parent: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
    if project_id is None:
        rows = await conn.fetch(
            """SELECT * FROM os_api_objects
               WHERE service=$1 AND resource_type=$2
               ORDER BY created_at""",
            service,
            resource_type,
        )
    else:
        rows = await conn.fetch(
            """SELECT * FROM os_api_objects
               WHERE service=$1 AND resource_type=$2
                 AND (project_id=$3 OR project_id IS NULL)
               ORDER BY created_at""",
            service,
            resource_type,
            project_id,
        )
    items = [_row_item(r) for r in rows]
    if parent:
        filtered = []
        for item in items:
            ok = True
            for pk, pv in parent.items():
                if str(item.get(pk) or item.get("parent_id") or "") not in {pv, str(item.get(pk))}:
                    # soft filter: keep if parent key absent
                    if pk in item and str(item[pk]) != pv:
                        ok = False
                        break
            if ok:
                filtered.append(item)
        return filtered
    return items


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 build_schema_router(pack: ServicePack) -> APIRouter:
    router = APIRouter(tags=[f"Schema:{pack.name}"])
    # Deduplicate by method+path so FastAPI does not register twice.
    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)
        _register_operation(router, pack, op)
    return router


def _register_operation(router: APIRouter, pack: ServicePack, op: OperationSpec) -> None:
    path = _fastapi_path(op.path)
    name = f"schema-{pack.name}-{op.operation_id}"

    async def endpoint(request: Request) -> Response:
        return await _dispatch(request, pack, op)

    router.add_api_route(
        path,
        endpoint,
        methods=[op.method],
        name=name,
        include_in_schema=True,
    )


async def _resolve_ctx(request: Request, *, need_project: bool) -> TokenContext:
    database = request.app.state.database
    assert isinstance(database, AsyncpgDatabase)
    token = extract_token({k: v for k, v in request.headers.items()})
    if not token:
        raise OpenStackError(
            "Unauthorized",
            "The request you have made requires authentication.",
            status_code=401,
        )
    async with database.pool.acquire() as conn:
        ctx = await validate_token(conn, token)
    if need_project and ctx.project_id is None:
        raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401)
    return ctx


def _has_id_param(path: str) -> bool:
    return "{id}" in path or "{name}" in path


async def _dispatch(request: Request, pack: ServicePack, op: OperationSpec) -> Response:
    _check_microversion(request, op, pack)

    ctx: TokenContext | None = None
    if op.requires_auth:
        ctx = await _resolve_ctx(request, need_project=op.requires_project)

    database = request.app.state.database
    assert isinstance(database, AsyncpgDatabase)

    async with database.pool.acquire() as conn:
        path_params = dict(request.path_params)
        if op.kind == "action" or op.path.rstrip("/").endswith("/action"):
            return await _handle_action(request, conn, pack, op, ctx, path_params)
        if op.method == "GET":
            # Literal "/detail" list views must not be treated as item show.
            if op.kind == "detail" or str(path_params.get("id") or "").lower() == "detail":
                return await _handle_list(request, conn, pack, op, ctx, path_params)
            # Nested collection paths contain {id} but list children, not show the parent id.
            if op.kind == "collection" or (
                op.collection_key
                and _has_id_param(op.path)
                and not _path_ends_with_item_param(op.path)
            ):
                return await _handle_list(request, conn, pack, op, ctx, path_params)
            if _path_ends_with_item_param(op.path) or op.kind == "item":
                return await _handle_show(request, conn, pack, op, ctx, path_params)
            return await _handle_list(request, conn, pack, op, ctx, path_params)
        if op.method == "POST":
            if _path_ends_with_item_param(op.path) and op.kind != "collection":
                return await _handle_action(request, conn, pack, op, ctx, path_params)
            if _has_id_param(op.path) and op.kind == "collection":
                return await _handle_create(request, conn, pack, op, ctx, path_params)
            if _has_id_param(op.path) and op.kind != "collection":
                return await _handle_action(request, conn, pack, op, ctx, path_params)
            return await _handle_create(request, conn, pack, op, ctx, path_params)
        if op.method in {"PUT", "PATCH"}:
            return await _handle_update(request, conn, pack, op, ctx, path_params)
        if op.method == "DELETE":
            return await _handle_delete(request, conn, pack, op, ctx, path_params)
        raise OpenStackError(
            "BadRequest", f"Unsupported operation {op.method} {op.path}", status_code=400
        )


async def _handle_list(
    request: Request,
    conn: Connection,
    pack: ServicePack,
    op: OperationSpec,
    ctx: TokenContext | None,
    path_params: dict[str, str],
) -> Response:
    if op.response_fixture is not None:
        return JSONResponse(op.response_fixture)
    # Discovery docs live in PostgreSQL (seed_discovery_documents).
    from app.openstack.db_docs import require_doc

    if op.resource_type == "ping":
        return JSONResponse(
            await require_doc(conn, service=pack.name, resource_type="ping", name="default")
        )
    if op.resource_type == "health":
        return JSONResponse(
            await require_doc(conn, service=pack.name, resource_type="health", name="default")
        )
    if op.resource_type == "limit" and op.collection_key == "limits":
        doc = await require_doc(conn, service=pack.name, resource_type="limits", name="default")
        # Overlay live usage for cinder when volumes table is present.
        if pack.name == "cinder" and ctx and ctx.project_id is not None:
            used = await conn.fetchrow(
                """SELECT count(*)::int AS volumes,
                          coalesce(sum(size), 0)::int AS gigabytes
                   FROM os_volumes WHERE project_id=$1""",
                ctx.project_id,
            )
            absolute = dict((doc.get("limits") or {}).get("absolute") or {})
            if used:
                absolute["totalVolumesUsed"] = int(used["volumes"])
                absolute["totalGigabytesUsed"] = int(used["gigabytes"])
            return JSONResponse(
                {
                    "limits": {
                        "rate": (doc.get("limits") or {}).get("rate") or [],
                        "absolute": absolute,
                    }
                }
            )
        return JSONResponse(doc)
    if op.resource_type == "version":
        return JSONResponse(
            await require_doc(
                conn, service=pack.name, resource_type="discovery_version", name="default"
            )
        )
    project_id = ctx.project_id if ctx else None
    items = await _list_objects(
        conn,
        service=pack.name,
        resource_type=op.resource_type,
        project_id=project_id,
        parent=_parent_scope(op.path, path_params) or None,
    )
    # soft filter query params
    for qk, qv in request.query_params.items():
        if qk in {"limit", "marker", "sort_key", "sort_dir", "fields"}:
            continue
        items = [i for i in items if str(i.get(qk, qv)) == qv or qk not in i]
    # Nested soft-filter may hide parent-scoped rows — re-read without parent filter
    # but still only from PostgreSQL (no synthetic templates).
    if not items and op.method == "GET" and op.kind in {"collection", "detail", "custom"}:
        parent = _parent_scope(op.path, path_params) or None
        if parent:
            items = await _list_objects(
                conn,
                service=pack.name,
                resource_type=op.resource_type,
                project_id=project_id,
                parent=None,
            )
    page, links = _paginate(items, request)
    key = op.collection_key or "items"
    body: dict[str, Any] = {key: page}
    if links:
        body[f"{key}_links"] = links
    return JSONResponse(body, status_code=op.status_code)


async def _handle_create(
    request: Request,
    conn: Connection,
    pack: ServicePack,
    op: OperationSpec,
    ctx: TokenContext | None,
    path_params: dict[str, str],
) -> Response:
    if ctx is None or ctx.project_id is None:
        raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401)
    try:
        payload = await request.json()
    except Exception:
        payload = {}
    key = op.item_key or (op.collection_key and _singular(op.collection_key)) or "resource"
    body = payload.get(key) if isinstance(payload, dict) else None
    if body is None and isinstance(payload, dict):
        body = payload.get(op.collection_key) or payload
    if not isinstance(body, dict):
        body = {"value": body}
    item_id = uuid4()
    name = str(body.get("name") or body.get("stack_name") or op.resource_type)
    status = str(body.get("status") or body.get("stack_status") or "ACTIVE")
    data = {**body, "id": str(item_id), "name": name, "status": status, **path_params}
    row = await conn.fetchrow(
        """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
           VALUES($1,$2,$3,$4,$5,$6,$7::jsonb) RETURNING *""",
        item_id,
        pack.name,
        op.resource_type,
        ctx.project_id,
        name,
        status,
        json.dumps(data),
    )
    content = _fixture_or_item(op, _row_item(row))
    return JSONResponse(
        content, status_code=op.create_status if op.method == "POST" else op.status_code
    )


async def _handle_show(
    request: Request,
    conn: Connection,
    pack: ServicePack,
    op: OperationSpec,
    ctx: TokenContext | None,
    path_params: dict[str, str],
) -> Response:
    item_id = (
        path_params.get("id") or path_params.get("name") or next(iter(path_params.values()), None)
    )
    if not item_id:
        # custom GET without id — fall back to list-like empty / fixture
        return await _handle_list(request, conn, pack, op, ctx, path_params)
    row = await conn.fetchrow(
        """SELECT * FROM os_api_objects
           WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3)
           LIMIT 1""",
        pack.name,
        op.resource_type,
        item_id,
    )
    if row is None:
        raise OpenStackError("NotFound", f"{op.resource_type} {item_id} not found", status_code=404)
    return JSONResponse(_fixture_or_item(op, _row_item(row)), status_code=op.status_code)


async def _handle_update(
    request: Request,
    conn: Connection,
    pack: ServicePack,
    op: OperationSpec,
    ctx: TokenContext | None,
    path_params: dict[str, str],
) -> Response:
    if ctx is None or ctx.project_id is None:
        raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401)
    item_id = path_params.get("id") or next(iter(path_params.values()), None)
    if not item_id:
        raise OpenStackError("BadRequest", "Missing id", status_code=400)
    try:
        payload = await request.json()
    except Exception:
        payload = {}
    key = op.item_key or (op.collection_key and _singular(op.collection_key))
    body = payload.get(key, payload) if isinstance(payload, dict) else {}
    if not isinstance(body, dict):
        body = {}
    row = await conn.fetchrow(
        """SELECT * FROM os_api_objects
           WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3) AND project_id=$4""",
        pack.name,
        op.resource_type,
        item_id,
        ctx.project_id,
    )
    if row is None:
        # Lab upsert: pack PUT/PATCH against unknown ids still succeed (surface-complete).
        try:
            new_id = UUID(str(item_id))
        except Exception:
            new_id = uuid4()
        data = {
            "id": str(new_id),
            "name": str(body.get("name") or op.resource_type),
            "status": "ACTIVE",
            **body,
            **path_params,
        }
        created = await conn.fetchrow(
            """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
               VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
               ON CONFLICT (id) DO UPDATE SET
                 name=EXCLUDED.name, status=EXCLUDED.status, data=EXCLUDED.data, updated_at=now()
               RETURNING *""",
            new_id,
            pack.name,
            op.resource_type,
            ctx.project_id,
            str(data.get("name") or op.resource_type),
            str(data.get("status") or "ACTIVE"),
            json.dumps(data),
        )
        if op.status_code == 204:
            return Response(status_code=204)
        return JSONResponse(_fixture_or_item(op, _row_item(created)), status_code=200)
    data = row["data"]
    if isinstance(data, str):
        data = json.loads(data)
    data = {**(data or {}), **body, "id": str(row["id"])}
    updated = await conn.fetchrow(
        """UPDATE os_api_objects
           SET name=$1, status=$2, data=$3::jsonb, updated_at=now()
           WHERE id=$4 RETURNING *""",
        str(data.get("name") or row["name"]),
        str(data.get("status") or row["status"]),
        json.dumps(data),
        row["id"],
    )
    if op.status_code == 204:
        return Response(status_code=204)
    return JSONResponse(_fixture_or_item(op, _row_item(updated)), status_code=200)


async def _handle_delete(
    request: Request,
    conn: Connection,
    pack: ServicePack,
    op: OperationSpec,
    ctx: TokenContext | None,
    path_params: dict[str, str],
) -> Response:
    item_id = path_params.get("id") or next(iter(path_params.values()), None)
    if not item_id:
        return Response(status_code=204)
    project_id = ctx.project_id if ctx else None
    if project_id is not None:
        await conn.execute(
            """DELETE FROM os_api_objects
               WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3) AND project_id=$4""",
            pack.name,
            op.resource_type,
            item_id,
            project_id,
        )
    else:
        await conn.execute(
            """DELETE FROM os_api_objects
               WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3)""",
            pack.name,
            op.resource_type,
            item_id,
        )
    return Response(status_code=op.status_code if op.status_code in {202, 204} else 204)


async def _handle_action(
    request: Request,
    conn: Connection,
    pack: ServicePack,
    op: OperationSpec,
    ctx: TokenContext | None,
    path_params: dict[str, str],
) -> Response:
    try:
        payload = await request.json()
    except Exception:
        payload = {}
    action = op.action_name if op.action_name and op.action_name != "*" else None
    if action is None and isinstance(payload, dict) and payload:
        action = next(iter(payload.keys()))
    item_id = path_params.get("id") or path_params.get("server_id")
    # record action history for nova-like resources
    if ctx and item_id:
        await conn.execute(
            """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
               VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
               ON CONFLICT DO NOTHING""",
            uuid4(),
            pack.name,
            "instance_action" if pack.name == "nova" else f"{op.resource_type}_action",
            ctx.project_id,
            action or "action",
            "DONE",
            json.dumps(
                {
                    "action": action,
                    "instance_uuid": item_id,
                    "request_id": request.headers.get("x-openstack-request-id") or str(uuid4()),
                    "message": None,
                    "start_time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
                }
            ),
        )
        # update parent status for common power actions
        if action in {"os-start", "unshelve", "resume", "unpause", "unrescue"}:
            new_status = "ACTIVE"
        elif action in {"os-stop", "shelve", "shelveOffload"}:
            new_status = "SHUTOFF"
        elif action in {"pause"}:
            new_status = "PAUSED"
        elif action in {"suspend"}:
            new_status = "SUSPENDED"
        else:
            new_status = None
        if new_status and pack.name == "nova":
            await conn.execute(
                "UPDATE os_servers SET status=$1, updated_at=now() WHERE id::text=$2",
                new_status,
                item_id,
            )
            await conn.execute(
                """UPDATE os_api_objects SET status=$1, data = jsonb_set(data, '{status}', to_jsonb($1::text)), updated_at=now()
                   WHERE service=$2 AND resource_type='server' AND id::text=$3""",
                new_status,
                pack.name,
                item_id,
            )
    if op.status_code == 204:
        return Response(status_code=204)
    if action in {"os-getConsoleOutput"} and item_id:
        from app.openstack.db_docs import require_doc

        row = await conn.fetchrow(
            """SELECT data FROM os_api_objects
               WHERE service='nova' AND resource_type='console_output'
                 AND (name=$1 OR data->>'server_id'=$1)
               ORDER BY updated_at DESC LIMIT 1""",
            item_id,
        )
        if row is not None:
            data = row["data"]
            if isinstance(data, str):
                data = json.loads(data)
            output = str((data or {}).get("output") or "")
        else:
            template = await require_doc(
                conn, service="nova", resource_type="console_output_template", name="default"
            )
            output = str(template.get("output") or "")
            await conn.execute(
                """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
                   VALUES($1,'nova','console_output',$2,$3,'ACTIVE',$4::jsonb)""",
                uuid4(),
                ctx.project_id if ctx else None,
                item_id,
                json.dumps({"server_id": item_id, "output": output}),
            )
        return JSONResponse({"output": output})
    if action in {"os-getVNCConsole", "remote-consoles"} or "console" in (action or "").lower():
        from app.openstack.db_docs import require_doc

        console_type = ""
        console_url = ""
        if item_id:
            row = await conn.fetchrow(
                """SELECT data FROM os_api_objects
                   WHERE service='nova' AND resource_type='console'
                     AND (name=$1 OR data->>'server_id'=$1)
                   ORDER BY updated_at DESC LIMIT 1""",
                item_id,
            )
            if row is not None:
                data = row["data"]
                if isinstance(data, str):
                    data = json.loads(data)
                console_type = str((data or {}).get("type") or "")
                console_url = str((data or {}).get("url") or "")
            else:
                template = await require_doc(
                    conn, service="nova", resource_type="console_template", name="default"
                )
                console_type = str(template.get("type") or "")
                console_url = str(template.get("url") or "").replace("__SERVER_ID__", item_id)
                await conn.execute(
                    """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
                       VALUES($1,'nova','console',$2,$3,'ACTIVE',$4::jsonb)""",
                    uuid4(),
                    ctx.project_id if ctx else None,
                    item_id,
                    json.dumps({"server_id": item_id, "type": console_type, "url": console_url}),
                )
        return JSONResponse({"console": {"type": console_type, "url": console_url}})
    if action == "createImage" and item_id and ctx is not None:
        from app.openstack.db_docs import fetch_doc

        image_id = uuid4()
        body = payload.get("createImage") if isinstance(payload, dict) else None
        defaults = (
            await fetch_doc(conn, service="glance", resource_type="image_defaults", name="default")
            or {}
        )
        name = "snapshot"
        if isinstance(body, dict):
            name = str(body.get("name") or name)
        await conn.execute(
            """INSERT INTO os_images(id, name, status, visibility, size, disk_format,
                   container_format, owner_project_id)
               VALUES($1,$2,'active',$3,0,$4,$5,$6)""",
            image_id,
            name,
            defaults.get("visibility") or "private",
            defaults.get("disk_format") or "qcow2",
            defaults.get("container_format") or "bare",
            ctx.project_id,
        )
        return JSONResponse({"image_id": str(image_id)}, status_code=202)
    return Response(status_code=op.status_code)


def mount_schema_services(
    app: Any,
    *,
    series: str = "dalmatian",
    handlers: Any | None = None,
) -> int:
    """Register one FastAPI route per contract (method, path). Returns route count."""

    from app.openstack.registry import HandlerRegistry, mount_contract_services

    runtime = ensure_loaded(series)
    registry = handlers if isinstance(handlers, HandlerRegistry) else HandlerRegistry()
    count = mount_contract_services(
        app,
        packs=runtime.packs,
        handlers=registry,
        dispatch_fn=_dispatch,
    )
    app.state.openstack_contract = runtime
    app.state.openstack_handlers = registry
    return count


def remount_schema_services(app: Any, series: str) -> dict[str, Any]:
    """Reload pack metadata and rebuild per-path contract routes on the app router."""

    from app.openstack.registry import HandlerRegistry, mount_contract_services

    runtime = get_runtime()
    summary = runtime.reload(series)
    handlers = getattr(app.state, "openstack_handlers", None)
    if not isinstance(handlers, HandlerRegistry):
        handlers = HandlerRegistry()
        app.state.openstack_handlers = handlers
    count = mount_contract_services(
        app,
        packs=runtime.packs,
        handlers=handlers,
        dispatch_fn=_dispatch,
    )
    app.state.openstack_contract = runtime
    app.state.openstack_schema_ops = count
    summary = {**summary, "routes_mounted": count}
    return summary
