"""Emit the REST surface as a machine-readable inventory.

Hangar's REST API is consumed across repositories -- the operator builds URLs
against it by hand. When core renamed `MCPProvider` to `MCPServer` and moved
`/api/v1/*` to `/api/*`, the operator kept calling the old paths. Every remote
`MCPServer` sat `Degraded` for months while working perfectly, and its own tests
stayed green throughout: they assert against an `httptest` mock, and a mock
answers whatever it is asked (operator#91).

A live server is not needed to catch that. What is needed is an authoritative
list of paths, which this produces.

Per ADR-011, the REST surface is a cross-repo fact and belongs to one owner.
This is that owner's copy: generated from the routing table itself, so it cannot
describe an endpoint that does not exist, and cannot miss one that does.

    python scripts/dump_api_routes.py            # print
    python scripts/dump_api_routes.py --write    # regenerate api-routes.json

`tests/unit/test_api_route_inventory.py` fails when the checked-in file drifts
from the live app, so a route change either updates it or breaks the build.

## What this deliberately does not capture

Only method and path. Not request or response shapes -- a consumer decoding
`consecutive_failures` still breaks silently if that field is renamed, and no
amount of path checking will say so. Shape drift needs a smoke test against a
running core. Stating the limit here so the inventory is not mistaken for a
guarantee it does not make.
"""

from __future__ import annotations

import argparse
import json
import pathlib
import re
import sys
from typing import Any

#: Where the generated inventory lives. Consumers vendor a copy of this file.
INVENTORY_PATH = pathlib.Path(__file__).resolve().parent.parent / "api-routes.json"

#: Prefix the API router is mounted under (`server/lifecycle.py`). The router
#: itself knows nothing about it, so it is applied here.
API_MOUNT = "/api"


def _walk(routes: Any, prefix: str = "") -> list[dict[str, Any]]:
    """Flatten Starlette's route tree into (methods, path) pairs."""
    found: list[dict[str, Any]] = []
    for route in routes:
        path = prefix + getattr(route, "path", "")
        nested = getattr(route, "routes", None)
        if nested:
            found.extend(_walk(nested, path))
            continue
        methods = getattr(route, "methods", None)
        if not methods:
            continue
        # HEAD is auto-added alongside GET and says nothing about the surface.
        found.append({"path": _normalise(path), "methods": sorted(m for m in methods if m != "HEAD")})
    return found


def _normalise(path: str) -> str:
    """Drop Starlette's converter suffixes: ``{id:str}`` -> ``{id}``.

    Consumers match concrete URLs against these templates, and the converter is
    an implementation detail of this framework rather than part of the contract.
    """
    return re.sub(r"\{([^:}]+):[^}]+\}", r"{\1}", path)


def collect_routes() -> dict[str, Any]:
    """Build the inventory from the live routing table."""
    from mcp_hangar.server.api.router import create_api_router

    app = create_api_router(auth_components=None)
    routes = sorted(_walk(app.routes, API_MOUNT), key=lambda r: (r["path"], r["methods"]))
    return {
        "note": "Generated by scripts/dump_api_routes.py. Do not edit by hand.",
        "mount": API_MOUNT,
        "count": len(routes),
        "routes": routes,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--write", action="store_true", help=f"regenerate {INVENTORY_PATH.name}")
    args = parser.parse_args()

    inventory = collect_routes()
    rendered = json.dumps(inventory, indent=2) + "\n"

    if args.write:
        INVENTORY_PATH.write_text(rendered)
        print(f"wrote {INVENTORY_PATH} ({inventory['count']} routes)")
    else:
        sys.stdout.write(rendered)
    return 0


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