"""Audit guard: every registered route MUST have a matching PERMISSIONS entry.

This is the inverse of ``test_auth_permissions_matrix.py``: that one pins
the fail-closed invariant for a specific dead prefix; this one pins the
opposite — for every route that actually exists, the matrix MUST grant
some role (otherwise operators hitting the route would get 403s in
production but the tests would still pass because ``TestClient`` bypasses
the ``matrix_gate`` middleware).

Found via a one-shot audit on 2026-07-07, three production bugs surfaced:

  - DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id}
    (used by the Inbox "Matched claims" operator flow to unlink a wrong
    match)
  - GET  /api/dashboard/kpis (dashboard summary cards)
  - GET  /api/inbox/ack-orphans (Inbox "Ack orphans" lane — already
    wired into the frontend via ``src/hooks/useAckOrphans.ts`` and
    ``src/components/inbox/AckOrphansLane.tsx``)

All three had registered routes + tests + (for #3) frontend callers,
yet ``allowed_roles(method, path)`` returned ``None`` because no matrix
entry covered the path. Fail-closed denied every role, so the operators
saw 403s in production but every CI test passed.

This test enumerates the real FastAPI route table (after all
``include_router`` calls) and asserts each route has coverage. If a
contributor adds a new route without a matching matrix entry, this test
fires.
"""
from __future__ import annotations

import pytest
from fastapi.routing import APIRoute

from cyclone.api import app
from cyclone.auth.permissions import allowed_roles


# Endpoints mounted by FastAPI itself (docs, openapi schema) — not part
# of the application surface. Skipped from the coverage assertion because
# they aren't protected by ``matrix_gate``.
_INTERNAL_PATHS = frozenset(("/docs", "/openapi.json", "/redoc", "/docs/oauth2-redirect"))


def _collect_routes(fastapi_app):
    """Recursively collect (method, full_path) for every registered route.

    FastAPI mounts sub-routers via ``_IncludedRouter`` wrappers; their
    ``original_router`` exposes the routes mounted at the include point.
    We recurse into those so every endpoint appears exactly once with its
    full path (e.g. ``/api/auth/me``), no prefix doubling.
    """
    out: list[tuple[str, str]] = []
    for r in fastapi_app.routes:
        if isinstance(r, APIRoute):
            for m in r.methods or set():
                if m in {"GET", "POST", "PATCH", "DELETE", "PUT"}:
                    out.append((m, r.path))
        elif hasattr(r, "original_router"):
            out.extend(_collect_routes(r.original_router))
    return out


ALL_ROUTES = _collect_routes(app)


def test_internal_paths_not_in_collection():
    """Sanity: docs/openapi/redoc are NOT in the collected routes because
    we skip them via the APIRoute isinstance check (they're mounted as
    Starlette Routes, not APIRoutes)."""
    paths = {p for _, p in ALL_ROUTES}
    assert not any(p in _INTERNAL_PATHS for p in paths), (
        "FastAPI internal docs paths leaked into route collection — "
        "fix _collect_routes() to filter on APIRoute, not method set"
    )


def test_every_route_has_matrix_coverage():
    """Every registered (method, path) MUST match at least one PERMISSIONS entry.

    Fail-closed means missing entries silently deny the route in
    production. The frontend may have a working caller + the test suite
    may be green (TestClient bypasses auth) but operators will see 403s.

    To fix an uncovered route, add a ``("METHOD", "/api/foo"): ROLES``
    entry to ``cyclone.auth.permissions.PERMISSIONS``. Prefix entries
    (e.g. ``/api/inbox``) match child paths, so adding a prefix is
    usually the lightest-touch fix.
    """
    uncovered = [
        (m, p) for m, p in ALL_ROUTES if allowed_roles(m, p) is None
    ]
    assert not uncovered, (
        "These routes are registered but have NO matching PERMISSIONS "
        "entry — they will be denied (403) in production:\n  "
        + "\n  ".join(f"{m} {p}" for m, p in sorted(uncovered))
    )


@pytest.mark.parametrize("verb,path", sorted(ALL_ROUTES))
def test_each_route_individually_has_coverage(verb, path):
    """Per-route parametrised coverage — failure output points at the
    exact missing route. Complements the aggregate test by surfacing
    each gap as its own test report entry."""
    assert allowed_roles(verb, path) is not None, (
        f"{verb} {path} has no PERMISSIONS entry — fail-closed will deny it"
    )