"""Route-level security contracts (tasks 8.2, 8.3, 8.5).

These assert properties of the API surface itself, so they run without a database and fail
loudly if someone later adds a convenient-but-dangerous route.

They read the generated OpenAPI document rather than walking `app.routes`, because that
document is the contract the frontend and any client actually see — and because FastAPI's
internal route representation has changed shape between versions.
"""

from functools import lru_cache
from typing import Any

from app.main import app
from app.schemas.invitation import OpenEventRead, TokenInvitationRead


@lru_cache
def _openapi() -> dict[str, Any]:
    return app.openapi()


def _paths() -> dict[str, Any]:
    return _openapi()["paths"]


def _methods_for(path: str) -> set[str]:
    entry = _paths().get(path, {})
    return {method.upper() for method in entry if method != "parameters"}


def test_cancel_get_is_inert() -> None:
    """FR-3.3: a WhatsApp or email link-preview crawler issues a GET.

    If a GET route for cancellation ever exists, link scanners will silently cancel real
    guests' RSVPs — so the absence of that route is the whole defence.
    """
    methods = _methods_for("/api/rsvp/{token}/cancel")
    assert "POST" in methods
    assert "GET" not in methods
    assert "HEAD" not in methods


def test_every_state_changing_rsvp_route_is_post_only() -> None:
    mutating = [
        "/api/rsvp/{token}/accept",
        "/api/rsvp/{token}/decline",
        "/api/rsvp/{token}/cancel",
        "/api/rsvp/{token}/reaccept",
        "/api/events/{slug}/rsvp",
    ]
    for path in mutating:
        methods = _methods_for(path)
        assert methods, f"{path} is not registered"
        assert methods <= {"POST"}, f"{path} exposes non-POST methods: {methods}"


def test_guest_facing_reads_are_get_only() -> None:
    for path in ("/api/invitations/by-token/{token}", "/api/events/{slug}", "/api/ics/{token}"):
        assert _methods_for(path) <= {"GET"}


def test_open_event_payload_carries_no_personal_data() -> None:
    """Task 8.2: `/e/{slug}` is reachable by anyone who scans a printed QR.

    The open payload must not be able to carry guest contact details at all — this checks
    the schema rather than one response, so adding a leaky field breaks the build.
    """
    forbidden = {"guest", "email", "phone", "phone_e164", "guests", "prefill"}
    assert not (set(OpenEventRead.model_fields) & forbidden)

    nested_fields: set[str] = set()
    for name in ("event", "couple"):
        annotation = OpenEventRead.model_fields[name].annotation
        if hasattr(annotation, "model_fields"):
            nested_fields |= set(annotation.model_fields)
    assert not (nested_fields & {"email", "phone_e164", "full_name"})


def test_token_payload_may_carry_prefill_because_the_token_authorizes_it() -> None:
    """The counterpart to the rule above: holding the token is what permits pre-fill."""
    assert "guest" in TokenInvitationRead.model_fields


def test_no_route_places_a_token_in_a_query_string() -> None:
    """Tokens are bearer secrets. In a path segment they still reach logs, but a query
    string additionally leaks through Referer and analytics."""
    for path in _paths():
        assert "?" not in path
        assert "token=" not in path


def test_health_is_reachable_without_authentication() -> None:
    """Uptime monitoring must not need a credential."""
    assert "GET" in _methods_for("/api/health")


def test_openapi_document_builds() -> None:
    """The generated TypeScript client is derived from this document (design D12), so a
    schema that fails to build breaks the frontend build too."""
    document = _openapi()
    assert document["openapi"].startswith("3.")
    assert len(document["paths"]) >= 8
