from typing import Annotated, cast

import pytest
from fastapi import APIRouter, Body, Depends, FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.routing import (
    APIRoute,
    _IncludedRouter,
    _iter_included_route_candidates,
    _restore_fastapi_scope_key,
)
from fastapi.testclient import TestClient
from starlette.routing import BaseRoute, Host, Match, Mount, NoMatchFound, Route, Router


def dependency_a():
    return "a"


def dependency_b():
    return "b"


def dependency_c():
    return "c"


def unique_id_b(route: APIRoute) -> str:
    return f"b_{route.name}"


def test_router_include_context_matches_flattened_include_metadata():
    callback_router = APIRouter()

    @callback_router.post("/callback")
    def callback():  # pragma: no cover
        return {"ok": True}

    callback_route = callback_router.routes[0]

    parent_router = APIRouter()
    included_router = APIRouter(
        prefix="/items",
        tags=["router"],
        dependencies=[Depends(dependency_a)],
        responses={401: {"description": "Unauthorized"}},
        callbacks=[callback_route],
        default_response_class=HTMLResponse,
        strict_content_type=False,
    )

    @included_router.get(
        "/{item_id}",
        tags=["route"],
        dependencies=[Depends(dependency_b)],
        responses={404: {"description": "Missing"}},
        callbacks=[callback_route],
        generate_unique_id_function=unique_id_b,
    )
    def read_item(item_id: str, request: Request):
        context = request.scope["fastapi"]["effective_route_context"]
        return JSONResponse(
            {
                "path": context.path,
                "tags": context.tags,
                "dependency_count": len(context.dependencies),
                "response_codes": sorted(context.responses),
                "callback_count": len(context.callbacks or []),
                "deprecated": context.deprecated,
                "include_in_schema": context.include_in_schema,
                "response_class": context.response_class.__name__,
                "generate_unique_id": context.generate_unique_id_function(context),
                "strict_content_type": context.strict_content_type,
                "has_dependency_overrides_provider": (
                    context.dependency_overrides_provider
                    is app.router.dependency_overrides_provider
                ),
            }
        )

    parent_router.include_router(
        included_router,
        prefix="/api",
        tags=["include"],
        dependencies=[Depends(dependency_c)],
        responses={400: {"description": "Bad request"}},
        callbacks=[callback_route],
        deprecated=True,
        include_in_schema=False,
    )

    app = FastAPI()
    app.include_router(parent_router)
    response = TestClient(app).get("/api/items/foo")

    assert response.status_code == 200
    assert response.json() == {
        "path": "/api/items/{item_id}",
        "tags": ["include", "router", "route"],
        "dependency_count": 3,
        "response_codes": [400, 401, 404],
        "callback_count": 3,
        "deprecated": True,
        "include_in_schema": False,
        "response_class": "HTMLResponse",
        "generate_unique_id": "b_read_item",
        "strict_content_type": False,
        "has_dependency_overrides_provider": True,
    }


def test_live_route_addition_uses_include_metadata_for_runtime_and_openapi():
    calls: list[str] = []

    def included_dependency():
        calls.append("dependency")

    router = APIRouter()
    app = FastAPI()
    app.include_router(
        router,
        prefix="/api",
        tags=["included"],
        dependencies=[Depends(included_dependency)],
        responses={418: {"description": "Teapot"}},
    )

    @router.get("/later")
    def read_later():
        return {"later": True}

    client = TestClient(app)
    response = client.get("/api/later")

    assert response.status_code == 200
    assert response.json() == {"later": True}
    assert calls == ["dependency"]
    operation = client.get("/openapi.json").json()["paths"]["/api/later"]["get"]
    assert operation["tags"] == ["included"]
    assert operation["responses"]["418"] == {"description": "Teapot"}


def test_openapi_cache_updates_after_live_route_addition():
    router = APIRouter()
    app = FastAPI()
    app.include_router(router, prefix="/api")
    client = TestClient(app)

    first_schema = client.get("/openapi.json").json()
    assert "/api/later" not in first_schema["paths"]

    @router.get("/later")
    def read_later():  # pragma: no cover
        return {"later": True}

    second_schema = client.get("/openapi.json").json()
    assert "/api/later" in second_schema["paths"]


def test_nested_router_added_after_parent_inclusion_is_live():
    parent_router = APIRouter()
    child_router = APIRouter()
    app = FastAPI()
    app.include_router(parent_router, prefix="/api")
    parent_router.include_router(child_router, prefix="/child", tags=["child"])

    @child_router.get("/items")
    def read_items():
        return ["item"]

    client = TestClient(app)
    response = client.get("/api/child/items")

    assert response.status_code == 200
    assert response.json() == ["item"]
    operation = client.get("/openapi.json").json()["paths"]["/api/child/items"]["get"]
    assert operation["tags"] == ["child"]


def test_repeated_deep_inclusions_handle_all_concrete_paths():
    shared_router = APIRouter()

    @shared_router.get("/items")
    def read_items():
        return []

    parent_router = APIRouter()
    parent_router.include_router(shared_router, prefix="/a")
    parent_router.include_router(shared_router, prefix="/b")

    app = FastAPI()
    app.include_router(parent_router, prefix="/v1")
    app.include_router(parent_router, prefix="/v2")

    client = TestClient(app)
    paths = ["/v1/a/items", "/v1/b/items", "/v2/a/items", "/v2/b/items"]
    for path in paths:
        response = client.get(path)
        assert response.status_code == 200
        assert response.json() == []
    assert set(client.get("/openapi.json").json()["paths"]) == set(paths)


def test_url_path_for_uses_effective_context_for_live_included_route():
    router = APIRouter()
    app = FastAPI()
    app.include_router(router, prefix="/api")

    @router.get("/items/{item_id}", name="read_item")
    def read_item(item_id: str):  # pragma: no cover
        return {"item_id": item_id}

    assert app.url_path_for("read_item", item_id="abc") == "/api/items/abc"


def test_url_path_for_uses_distinct_repeated_inclusion_contexts():
    router = APIRouter()

    @router.get("/items/{item_id}", name="read_item")
    def read_item(item_id: str):  # pragma: no cover
        return {"item_id": item_id}

    parent_router = APIRouter()
    parent_router.include_router(router, prefix="/v1")
    parent_router.include_router(router, prefix="/v2")

    assert parent_router.url_path_for("read_item", item_id="abc") == "/v1/items/abc"
    assert (
        parent_router.routes[1].url_path_for("read_item", item_id="abc")
        == "/v2/items/abc"
    )


def test_indirect_router_inclusion_cycles_are_rejected():
    parent_router = APIRouter()
    child_router = APIRouter()

    parent_router.include_router(child_router, prefix="/child")

    with pytest.raises(AssertionError, match="already includes this router"):
        child_router.include_router(parent_router, prefix="/parent")

    parent_router = APIRouter()
    child_router = APIRouter()
    grandchild_router = APIRouter()

    parent_router.include_router(child_router, prefix="/child")
    child_router.include_router(grandchild_router, prefix="/grandchild")

    with pytest.raises(AssertionError, match="already includes this router"):
        grandchild_router.include_router(parent_router, prefix="/parent")


def test_original_api_route_subclass_instance_is_called_after_inclusion():
    class TrackingRoute(APIRoute):
        calls = 0

        async def handle(self, scope, receive, send):
            self.calls += 1
            await super().handle(scope, receive, send)

    router = APIRouter(route_class=TrackingRoute)

    @router.get("/items")
    def read_items():
        return []

    original_route = router.routes[0]
    assert isinstance(original_route, TrackingRoute)

    app = FastAPI()
    app.include_router(router, prefix="/api")

    response = TestClient(app).get("/api/items")

    assert response.status_code == 200
    assert original_route.calls == 1


def test_original_api_route_get_route_handler_is_called_after_inclusion():
    class TrackingRoute(APIRoute):
        calls = 0

        def get_route_handler(self):
            handler = super().get_route_handler()

            async def custom_handler(request):
                self.calls += 1
                return await handler(request)

            return custom_handler

    router = APIRouter(route_class=TrackingRoute)

    @router.get("/items")
    def read_items():
        return []

    original_route = router.routes[0]
    assert isinstance(original_route, TrackingRoute)
    original_route.calls = 0

    app = FastAPI()
    app.include_router(router, prefix="/api")

    response = TestClient(app).get("/api/items")

    assert response.status_code == 200
    assert original_route.calls == 1


def test_original_api_route_matches_is_called_after_inclusion():
    class HeaderRoute(APIRoute):
        calls = 0

        def matches(self, scope):
            self.calls += 1
            headers = dict(scope.get("headers", []))
            if headers.get(b"x-match") != b"yes":
                return Match.NONE, {}
            return super().matches(scope)

    router = APIRouter(route_class=HeaderRoute)

    @router.get("/items")
    def read_items():
        return []

    original_route = router.routes[0]
    assert isinstance(original_route, HeaderRoute)
    original_route.calls = 0

    app = FastAPI()
    app.include_router(router, prefix="/api")
    client = TestClient(app)

    assert client.get("/api/items").status_code == 404
    assert client.get("/api/items", headers={"x-match": "yes"}).status_code == 200
    assert original_route.calls >= 2


def test_effective_route_context_is_available_in_scope_during_request():
    router = APIRouter()

    @router.get("/items")
    def read_items(request: Request):
        fastapi_scope = request.scope.get("fastapi")
        assert isinstance(fastapi_scope, dict)
        return {
            "has_context": "effective_route_context" in fastapi_scope,
            "path": fastapi_scope["effective_route_context"].path,
        }

    app = FastAPI()
    app.include_router(router, prefix="/api")

    response = TestClient(app).get("/api/items")

    assert response.status_code == 200
    assert response.json() == {"has_context": True, "path": "/api/items"}


def test_original_api_router_matches_is_called_after_inclusion():
    class HeaderRouter(APIRouter):
        calls = 0

        def matches(self, scope):
            self.calls += 1
            headers = dict(scope.get("headers", []))
            if headers.get(b"x-router-match") != b"yes":
                return Match.NONE, {}
            return super().matches(scope)

    router = HeaderRouter()

    @router.get("/items")
    def read_items():
        return []

    app = FastAPI()
    app.include_router(router, prefix="/api")
    client = TestClient(app)

    assert client.get("/api/items").status_code == 404
    assert (
        client.get("/api/items", headers={"x-router-match": "yes"}).status_code == 200
    )
    assert router.calls >= 2


def test_original_nested_api_router_subclasses_are_called_after_inclusion():
    class TrackingRouter(APIRouter):
        calls = 0

        async def handle(self, scope, receive, send):
            self.calls += 1
            await super().handle(scope, receive, send)

    parent_router = TrackingRouter()
    child_router = TrackingRouter()

    @child_router.get("/items")
    def read_items():
        return []

    parent_router.include_router(child_router, prefix="/child")
    app = FastAPI()
    app.include_router(parent_router, prefix="/api")

    response = TestClient(app).get("/api/child/items")

    assert response.status_code == 200
    assert parent_router.calls == 1
    assert child_router.calls == 1


def test_router_and_include_prefix_path_params_reach_endpoint_and_openapi():
    router = APIRouter(prefix="/tenants/{tenant_id}")

    @router.get("/items/{item_id}")
    def read_item(version: int, tenant_id: int, item_id: int):
        return {"version": version, "tenant_id": tenant_id, "item_id": item_id}

    app = FastAPI()
    app.include_router(router, prefix="/api/{version}")

    client = TestClient(app)
    response = client.get("/api/1/tenants/2/items/3")

    assert response.status_code == 200
    assert response.json() == {"version": 1, "tenant_id": 2, "item_id": 3}

    operation = client.get("/openapi.json").json()["paths"][
        "/api/{version}/tenants/{tenant_id}/items/{item_id}"
    ]["get"]
    assert {parameter["name"] for parameter in operation["parameters"]} == {
        "version",
        "tenant_id",
        "item_id",
    }


def test_effective_body_fields_from_app_router_include_and_route_match_openapi():
    def app_body_dependency(app_body: Annotated[str, Body()]):
        return app_body

    def router_body_dependency(router_body: Annotated[int, Body()]):
        return router_body

    def include_body_dependency(include_body: Annotated[bool, Body()]):
        return include_body

    app = FastAPI(dependencies=[Depends(app_body_dependency)])
    router = APIRouter(dependencies=[Depends(router_body_dependency)])

    @router.post("/items")
    def create_item(route_body: Annotated[float, Body()]):
        return {"route_body": route_body}

    app.include_router(
        router,
        prefix="/api",
        dependencies=[Depends(include_body_dependency)],
    )

    client = TestClient(app)
    response = client.post(
        "/api/items",
        json={
            "app_body": "app",
            "router_body": 1,
            "include_body": True,
            "route_body": 2.5,
        },
    )

    assert response.status_code == 200
    assert response.json() == {"route_body": 2.5}

    schema = client.get("/openapi.json").json()
    request_body_schema = schema["paths"]["/api/items"]["post"]["requestBody"][
        "content"
    ]["application/json"]["schema"]
    body_ref = request_body_schema["$ref"].removeprefix("#/components/schemas/")
    body_schema = schema["components"]["schemas"][body_ref]
    assert set(body_schema["required"]) == {
        "app_body",
        "router_body",
        "include_body",
        "route_body",
    }
    assert set(body_schema["properties"]) == {
        "app_body",
        "router_body",
        "include_body",
        "route_body",
    }


def test_later_full_match_wins_over_earlier_included_partial_match():
    get_router = APIRouter()
    post_router = APIRouter()

    @get_router.get("/items")
    def read_items():  # pragma: no cover
        return {"method": "get"}

    @post_router.post("/items")
    def create_item():
        return {"method": "post"}

    app = FastAPI()
    app.include_router(get_router, prefix="/api")
    app.include_router(post_router, prefix="/api")

    response = TestClient(app).post("/api/items")

    assert response.status_code == 200
    assert response.json() == {"method": "post"}


def test_included_partial_match_returns_405_when_no_later_full_match_exists():
    router = APIRouter()

    @router.get("/items")
    def read_items():  # pragma: no cover
        return []

    app = FastAPI()
    app.include_router(router, prefix="/api")

    response = TestClient(app).post("/api/items")

    assert response.status_code == 405
    assert response.headers["allow"] == "GET"


def test_included_slash_redirect_does_not_block_later_exact_match():
    redirect_router = APIRouter()
    exact_router = APIRouter()

    @redirect_router.get("/items/")
    def read_items_with_slash():  # pragma: no cover
        return {"path": "slash"}

    @exact_router.get("/items")
    def read_items_without_slash():
        return {"path": "exact"}

    app = FastAPI()
    app.include_router(redirect_router, prefix="/api")
    app.include_router(exact_router, prefix="/api")

    response = TestClient(app).get("/api/items", follow_redirects=False)

    assert response.status_code == 200
    assert response.json() == {"path": "exact"}


def test_failed_included_match_does_not_leak_effective_context_to_later_route():
    class RejectingRoute(APIRoute):
        def matches(self, scope):
            return Match.NONE, {}

    rejecting_router = APIRouter(route_class=RejectingRoute)
    fallback_router = APIRouter()

    @rejecting_router.get("/items")
    def rejected_item():  # pragma: no cover
        return {"source": "rejected"}

    @fallback_router.get("/items")
    def fallback_item(request: Request):
        fastapi_scope = request.scope.get("fastapi", {})
        context = fastapi_scope.get("effective_route_context")
        return {
            "source": "fallback",
            "context_path": getattr(context, "path", None),
        }

    app = FastAPI()
    app.include_router(rejecting_router, prefix="/api")
    app.include_router(fallback_router, prefix="/api")

    response = TestClient(app).get("/api/items")

    assert response.status_code == 200
    assert response.json() == {"source": "fallback", "context_path": "/api/items"}


def test_included_starlette_mount_keeps_prefix_runtime_and_url_path_for():
    def mounted_endpoint(request):
        return PlainTextResponse("mounted")

    router = APIRouter(
        routes=[
            Mount(
                "/mounted",
                routes=[Route("/items/{item_id}", mounted_endpoint, name="read_item")],
                name="mounted",
            )
        ]
    )
    app = FastAPI()
    app.include_router(router, prefix="/api")

    client = TestClient(app)
    response = client.get("/api/mounted/items/abc")

    assert response.status_code == 200
    assert response.text == "mounted"
    assert (
        app.url_path_for("mounted:read_item", item_id="abc") == "/api/mounted/items/abc"
    )


def test_included_starlette_host_keeps_prefix_runtime_and_url_path_for():
    def hosted_endpoint(request):
        return PlainTextResponse("hosted")

    hosted_app = Router(
        routes=[Route("/items/{item_id}", hosted_endpoint, name="read_item")]
    )
    router = APIRouter(
        routes=[Host("{subdomain}.example.com", hosted_app, name="hosted")]
    )
    app = FastAPI()
    app.include_router(router, prefix="/api")

    client = TestClient(app, base_url="http://api.example.com")
    response = client.get("/api/items/abc")

    assert response.status_code == 200
    assert response.text == "hosted"
    url = app.url_path_for("hosted:read_item", subdomain="api", item_id="abc")
    assert str(url) == "/api/items/abc"
    assert url.host == "api.example.com"


def test_restore_fastapi_scope_key_ignores_non_dict_fastapi_scope():
    scope = {"fastapi": "not-a-dict"}

    _restore_fastapi_scope_key(scope, "effective_route_context", object())

    assert scope == {"fastapi": "not-a-dict"}


@pytest.mark.anyio
async def test_included_api_route_without_app_scope_returns_405_response():
    router = APIRouter()

    @router.get("/items")
    def read_items():  # pragma: no cover
        return {"items": []}

    app = FastAPI()
    app.include_router(router, prefix="/api")
    included_router = cast(_IncludedRouter, app.router.routes[-1])
    effective_context = next(included_router.effective_route_contexts())
    route = effective_context.original_route
    messages = []

    async def receive():  # pragma: no cover
        return {"type": "http.request", "body": b"", "more_body": False}

    async def send(message):
        messages.append(message)

    scope = {
        "type": "http",
        "method": "POST",
        "path": "/api/items",
        "raw_path": b"/api/items",
        "root_path": "",
        "scheme": "http",
        "query_string": b"",
        "headers": [],
        "fastapi": {"effective_route_context": effective_context},
    }

    await route.handle(scope, receive, send)

    assert messages[0]["type"] == "http.response.start"
    assert messages[0]["status"] == 405
    assert dict(messages[0]["headers"])[b"allow"] == b"GET"


def test_effective_api_route_context_does_not_match_websocket_scope():
    router = APIRouter()

    @router.get("/items")
    def read_items():  # pragma: no cover
        return {"items": []}

    app = FastAPI()
    app.include_router(router, prefix="/api")
    included_router = cast(_IncludedRouter, app.router.routes[-1])
    effective_context = next(included_router.effective_route_contexts())

    match, child_scope = effective_context.matches(
        {
            "type": "websocket",
            "path": "/api/items",
            "root_path": "",
        }
    )

    assert match == Match.NONE
    assert child_scope == {}


def test_effective_api_route_context_url_path_for_no_match():
    router = APIRouter()

    @router.get("/items/{item_id}")
    def read_item(item_id: str):  # pragma: no cover
        return {"item_id": item_id}

    app = FastAPI()
    app.include_router(router, prefix="/api")
    included_router = cast(_IncludedRouter, app.router.routes[-1])
    effective_context = next(included_router.effective_route_contexts())

    with pytest.raises(NoMatchFound):
        effective_context.url_path_for("missing", item_id="abc")

    with pytest.raises(NoMatchFound):
        included_router.url_path_for("missing", item_id="abc")


def test_included_starlette_host_without_prefix_keeps_original_app():
    def hosted_endpoint(request):
        return PlainTextResponse("hosted")

    hosted_app = Router(
        routes=[Route("/items/{item_id}", hosted_endpoint, name="read_item")]
    )
    router = APIRouter(
        routes=[Host("{subdomain}.example.com", hosted_app, name="hosted")]
    )
    app = FastAPI()
    app.include_router(router)

    client = TestClient(app, base_url="http://api.example.com")
    response = client.get("/items/abc")

    assert response.status_code == 200
    assert response.text == "hosted"


class UnknownRoute(BaseRoute):
    def matches(self, scope):  # pragma: no cover
        return Match.NONE, {}

    async def handle(self, scope, receive, send):  # pragma: no cover
        raise AssertionError("UnknownRoute should not be handled")

    def url_path_for(self, name, /, **path_params):  # pragma: no cover
        raise NoMatchFound(name, path_params)


@pytest.mark.anyio
async def test_included_unknown_route_is_ignored_and_can_return_default_404():
    router = APIRouter(routes=[UnknownRoute()])
    app = FastAPI()
    app.include_router(router, prefix="/api")
    included_router = cast(_IncludedRouter, app.router.routes[-1])

    assert included_router.effective_candidates() == []

    messages = []

    async def receive():  # pragma: no cover
        return {"type": "http.request", "body": b"", "more_body": False}

    async def send(message):
        messages.append(message)

    scope = {
        "type": "http",
        "method": "GET",
        "path": "/api/missing",
        "raw_path": b"/api/missing",
        "root_path": "",
        "scheme": "http",
        "query_string": b"",
        "headers": [],
        "fastapi": {},
    }

    await included_router._handle_selected(scope, receive, send)

    assert messages[0]["type"] == "http.response.start"
    assert messages[0]["status"] == 404


def test_no_prefix_include_validation_sees_effective_starlette_route_candidates():
    def endpoint(request):  # pragma: no cover
        return PlainTextResponse("ok")

    child_router = APIRouter(routes=[Route("/items", endpoint, name="read_items")])
    parent_router = APIRouter()
    parent_router.include_router(child_router, prefix="/child")

    candidates = list(_iter_included_route_candidates(parent_router.routes))

    assert cast(Route, candidates[0]).path == "/child/items"


def test_apirouter_matches_fallback_without_include_context():
    router = APIRouter()

    def read_items(request):  # pragma: no cover
        return PlainTextResponse("items")

    router.add_route("/items", read_items)

    assert router.matches({"type": "http", "path": "/items", "root_path": ""}) == (
        Match.NONE,
        {},
    )


@pytest.mark.anyio
async def test_apirouter_handle_fallback_without_include_context():
    router = APIRouter()

    def read_items(request):
        return PlainTextResponse("items")

    router.add_route("/items", read_items)
    messages = []

    async def receive():  # pragma: no cover
        return {"type": "http.request", "body": b"", "more_body": False}

    async def send(message):
        messages.append(message)

    scope = {
        "type": "http",
        "method": "GET",
        "path": "/items",
        "raw_path": b"/items",
        "root_path": "",
        "scheme": "http",
        "query_string": b"",
        "headers": [],
    }

    await router.handle(scope, receive, send)

    assert messages[0]["type"] == "http.response.start"
    assert messages[0]["status"] == 200
    assert messages[1]["body"] == b"items"
