import errno
import os
import runpy
from pathlib import Path

import anyio
import pytest
from fastapi import APIRouter, FastAPI, HTTPException, Request, WebSocket
from fastapi.testclient import TestClient
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import PlainTextResponse, Response
from starlette.routing import BaseRoute, Match, NoMatchFound, Route


def write_file(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content)


def test_frontend_exact_prefix_path_serves_index(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app")
    app = FastAPI()
    app.frontend("/app", directory=dist)

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

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


def test_apirouter_frontend_with_router_prefix_and_frontend_subpath(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "asset.txt", "asset")
    router = APIRouter(prefix="/internal")
    router.frontend("/ui", directory=dist)
    app = FastAPI()
    app.include_router(router, prefix="/prefix")

    response = TestClient(app).get("/prefix/internal/ui/asset.txt")

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


def test_frontend_fallback_rejects_invalid_fallback(tmp_path: Path):
    dist = tmp_path / "dist"
    dist.mkdir()
    app = FastAPI()

    with pytest.raises(AssertionError, match="fallback"):
        app.frontend("/", directory=dist, fallback="invalid")  # type: ignore[arg-type]  # ty: ignore[invalid-argument-type]


def test_index_fallback_ignores_invalid_q_value(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get(
        "/dashboard/settings", headers={"accept": "text/html; q=wat"}
    )

    assert response.status_code == 200
    assert response.text == "app shell"


def test_frontend_static_files_lookup_errors(monkeypatch, tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app")
    app = FastAPI()
    app.frontend("/", directory=dist)
    frontend_routes = app.router._frontend_routes
    assert frontend_routes is not None
    static_files = frontend_routes.routes[0].app

    def raise_permission_error(path: str):
        raise PermissionError

    monkeypatch.setattr(static_files, "lookup_path", raise_permission_error)
    response = TestClient(app).get("/asset.txt")
    assert response.status_code == 401

    def raise_value_error(path: str):
        raise ValueError

    monkeypatch.setattr(static_files, "lookup_path", raise_value_error)
    response = TestClient(app).get("/asset.txt")
    assert response.status_code == 404

    def raise_name_too_long(path: str):
        raise OSError(errno.ENAMETOOLONG, "name too long")

    monkeypatch.setattr(static_files, "lookup_path", raise_name_too_long)
    response = TestClient(app).get("/asset.txt")
    assert response.status_code == 404

    def raise_os_error(path: str):
        raise OSError(5, "other")

    monkeypatch.setattr(static_files, "lookup_path", raise_os_error)
    with pytest.raises(OSError):
        TestClient(app).get("/asset.txt")


def test_frontend_route_group_helpers(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app")
    app = FastAPI()
    app.frontend("/", directory=dist)
    route_group = app.router._frontend_routes
    assert route_group is not None

    match, child_scope = route_group.matches({"type": "websocket", "path": "/"})
    assert match == Match.NONE
    assert child_scope == {}

    with pytest.raises(StarletteHTTPException) as exc_info:
        anyio.run(
            route_group.with_prefix("/app").handle,
            {"type": "http", "path": "/missing", "method": "GET"},
            None,
            None,
        )
    assert exc_info.value.status_code == 404

    with pytest.raises(NoMatchFound):
        route_group.url_path_for("frontend")
    with pytest.raises(NoMatchFound):
        route_group.routes[0].url_path_for("frontend")


def test_included_low_priority_routes_cache_is_reused():
    async def low_priority_endpoint(request: Request):
        return PlainTextResponse("low")

    router = APIRouter()
    router._low_priority_routes.append(Route("/low", low_priority_endpoint))
    router._mark_routes_changed()
    app = FastAPI()
    app.include_router(router, prefix="/prefix")
    included_router = next(
        route
        for route in app.router.routes
        if hasattr(route, "effective_low_priority_routes")
    )

    first = included_router.effective_low_priority_routes()  # ty: ignore[call-non-callable]
    second = included_router.effective_low_priority_routes()  # ty: ignore[call-non-callable]
    response = TestClient(app).get("/prefix/low")

    assert first is second
    assert response.status_code == 200
    assert response.text == "low"


def test_low_priority_api_route_handles_with_context():
    app = FastAPI()

    async def endpoint(request: Request) -> Response:
        return PlainTextResponse(request.scope["path_params"]["item_id"])

    route = app.router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"])
    app.router._low_priority_routes.append(route)
    app.router._mark_routes_changed()

    response = TestClient(app).get("/low/abc")

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


def test_included_low_priority_api_route_handles_with_context():
    router = APIRouter()

    async def endpoint(request: Request) -> Response:
        return PlainTextResponse(request.scope["path_params"]["item_id"])

    route = router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"])
    router._low_priority_routes.append(route)
    router._mark_routes_changed()
    app = FastAPI()
    app.include_router(router, prefix="/prefix")

    response = TestClient(app).get("/prefix/low/abc")

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


def test_normal_route_partial_match_returns_before_frontend(tmp_path: Path):
    class PartialRoute(BaseRoute):
        def matches(self, scope):
            return Match.PARTIAL, {}

        async def handle(self, scope, receive, send):
            response = PlainTextResponse("partial", status_code=405)
            await response(scope, receive, send)

    dist = tmp_path / "dist"
    write_file(dist / "index.html", "frontend")
    app = FastAPI()
    app.router.routes.append(PartialRoute())
    app.frontend("/", directory=dist)

    response = TestClient(app).get("/anything")

    assert response.status_code == 405
    assert response.text == "partial"


def test_normal_route_partial_match_wins_before_frontend(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "api", "frontend")
    app = FastAPI()

    @app.get("/api")
    def read_api():
        return {"source": "api"}

    app.frontend("/", directory=dist)

    client = TestClient(app)

    response = client.get("/api")
    assert response.status_code == 200
    assert response.json() == {"source": "api"}

    response = client.post("/api")
    assert response.status_code == 405


def test_basic_file_serving(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "assets" / "app.js", "console.log('ok')")
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).get("/assets/app.js")

    assert response.status_code == 200
    assert response.text == "console.log('ok')"
    assert "etag" in response.headers
    assert "last-modified" in response.headers


def test_existing_api_route_wins_over_frontend(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "api" / "users", "frontend")
    app = FastAPI()

    @app.get("/api/users")
    def read_users():
        return {"source": "api"}

    app.frontend("/", directory=dist)

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

    assert response.status_code == 200
    assert response.json() == {"source": "api"}


def test_api_route_404_is_not_replaced_by_frontend_fallback(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "frontend")
    app = FastAPI()

    @app.get("/api/users")
    def read_users():
        raise HTTPException(status_code=404, detail="api missing")

    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get("/api/users", headers={"accept": "text/html"})

    assert response.status_code == 404
    assert response.json() == {"detail": "api missing"}


def test_index_fallback_for_navigation_request(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get(
        "/dashboard/settings", headers={"accept": "text/html"}
    )

    assert response.status_code == 200
    assert response.text == "app shell"


def test_index_fallback_parses_accept_parameters(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get(
        "/dashboard/settings", headers={"accept": "text/html; q=0.8"}
    )

    assert response.status_code == 200
    assert response.text == "app shell"


def test_index_fallback_ignores_q_zero_accept(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get(
        "/dashboard/settings", headers={"accept": "text/html; q=0.0"}
    )

    assert response.status_code == 404


def test_index_fallback_respects_explicit_html_rejection_with_wildcard(
    tmp_path: Path,
):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get(
        "/dashboard/settings",
        headers={"accept": "text/html; q=0, */*; q=1"},
    )

    assert response.status_code == 404


def test_index_fallback_respects_explicit_xhtml_rejection_with_wildcard(
    tmp_path: Path,
):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get(
        "/dashboard/settings",
        headers={"accept": "application/xhtml+xml; q=0, */*; q=1"},
    )

    assert response.status_code == 404


@pytest.mark.parametrize(
    ("path", "accept"),
    [
        ("/assets/missing.js", "*/*"),
        ("/assets/missing.css", "text/css"),
        ("/assets/missing.png", "image/png"),
        ("/api/missing", "application/json"),
        ("/users/jane.doe", "text/html"),
    ],
)
def test_index_fallback_does_not_handle_asset_like_or_non_html_requests(
    tmp_path: Path, path: str, accept: str
):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="index.html")

    response = TestClient(app).get(path, headers={"accept": accept})

    assert response.status_code == 404
    assert response.text != "app shell"


def test_404_fallback_handles_missing_assets(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "404.html", "missing")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback="404.html")

    response = TestClient(app).get("/assets/missing.js")

    assert response.status_code == 404
    assert response.text == "missing"


def test_auto_fallback_prefers_404_over_index(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    write_file(dist / "404.html", "missing")
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})

    assert response.status_code == 404
    assert response.text == "missing"


def test_auto_fallback_uses_index_when_404_is_missing(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})

    assert response.status_code == 200
    assert response.text == "app shell"


def test_auto_fallback_returns_normal_404_without_fallback_files(tmp_path: Path):
    dist = tmp_path / "dist"
    dist.mkdir()
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})

    assert response.status_code == 404
    assert response.json() == {"detail": "Not Found"}


def test_no_fallback_returns_normal_404(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app shell")
    app = FastAPI()
    app.frontend("/", directory=dist, fallback=None)

    response = TestClient(app).get("/dashboard", headers={"accept": "text/html"})

    assert response.status_code == 404
    assert response.json() == {"detail": "Not Found"}


def test_directory_index_and_redirect(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "about" / "index.html", "about")
    app = FastAPI()
    app.frontend("/", directory=dist)
    client = TestClient(app)

    redirect = client.get("/about", follow_redirects=False)
    response = client.get("/about/")

    assert redirect.status_code == 307
    assert redirect.headers["location"] == "http://testserver/about/"
    assert response.status_code == 200
    assert response.text == "about"


def test_path_validation_and_trailing_slash_normalization(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "asset.txt", "ok")
    app = FastAPI()

    with pytest.raises(AssertionError):
        app.frontend("", directory=dist)
    with pytest.raises(AssertionError):
        app.frontend("app", directory=dist)

    app.frontend("/app/", directory=dist)
    response = TestClient(app).get("/app/asset.txt")

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


def test_frontend_path_matching_uses_segment_boundaries(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app")
    app = FastAPI()
    app.frontend("/app", directory=dist, fallback="index.html")

    response = TestClient(app).get("/application", headers={"accept": "text/html"})

    assert response.status_code == 404


def test_multiple_frontends_use_longest_matching_prefix(tmp_path: Path):
    site = tmp_path / "site"
    admin = tmp_path / "admin"
    write_file(site / "index.html", "site")
    write_file(admin / "index.html", "admin")
    app = FastAPI()
    app.frontend("/", directory=site, fallback="index.html")
    app.frontend("/admin", directory=admin, fallback="index.html")

    response = TestClient(app).get("/admin/settings", headers={"accept": "text/html"})

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


def test_apirouter_frontend_uses_include_prefix(tmp_path: Path):
    dist = tmp_path / "admin"
    write_file(dist / "index.html", "admin")
    router = APIRouter()
    router.frontend("/", directory=dist, fallback="index.html")
    app = FastAPI()
    app.include_router(router, prefix="/admin")

    response = TestClient(app).get("/admin/settings", headers={"accept": "text/html"})

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


def test_global_priority_across_included_routers(tmp_path: Path):
    dist = tmp_path / "site"
    write_file(dist / "index.html", "site")
    site_router = APIRouter()
    site_router.frontend("/", directory=dist, fallback="index.html")
    api_router = APIRouter()

    @api_router.get("/api/users")
    def read_users():
        return {"source": "api"}

    app = FastAPI()
    app.include_router(site_router)
    app.include_router(api_router)

    response = TestClient(app).get("/api/users", headers={"accept": "text/html"})

    assert response.status_code == 200
    assert response.json() == {"source": "api"}


def test_nested_apirouter_frontend_uses_all_include_prefixes(tmp_path: Path):
    dist = tmp_path / "admin"
    write_file(dist / "index.html", "admin")
    child_router = APIRouter()
    child_router.frontend("/", directory=dist, fallback="index.html")
    parent_router = APIRouter()
    parent_router.include_router(child_router, prefix="/child")
    app = FastAPI()
    app.include_router(parent_router, prefix="/parent")

    response = TestClient(app).get(
        "/parent/child/settings", headers={"accept": "text/html"}
    )

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


def test_low_priority_cache_updates_after_route_added_to_included_router(
    tmp_path: Path,
):
    dist = tmp_path / "site"
    write_file(dist / "index.html", "site")
    router = APIRouter()
    router.frontend("/", directory=dist, fallback="index.html")
    app = FastAPI()
    app.include_router(router, prefix="/app")
    client = TestClient(app)

    frontend_response = client.get("/app/dashboard", headers={"accept": "text/html"})

    @router.get("/dashboard")
    def read_dashboard():
        return {"source": "api"}

    api_response = client.get("/app/dashboard", headers={"accept": "text/html"})

    assert frontend_response.status_code == 200
    assert frontend_response.text == "site"
    assert api_response.status_code == 200
    assert api_response.json() == {"source": "api"}


def test_normal_route_slash_redirect_wins_before_frontend_redirect(tmp_path: Path):
    dist = tmp_path / "site"
    write_file(dist / "api" / "index.html", "frontend")
    app = FastAPI()

    @app.get("/api/")
    def read_api():
        return {"source": "api"}

    app.frontend("/", directory=dist)

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

    assert response.status_code == 307
    assert response.headers["location"] == "http://testserver/api/"

    followed = TestClient(app).get("/api/")
    assert followed.status_code == 200
    assert followed.json() == {"source": "api"}


def test_frontend_respects_root_path(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "assets" / "app.js", "console.log('ok')")
    app = FastAPI()
    app.frontend("/app", directory=dist)

    response = TestClient(app, root_path="/proxy").get("/app/assets/app.js")

    assert response.status_code == 200
    assert response.text == "console.log('ok')"


def test_websocket_route_wins_over_frontend(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "ws", "frontend")
    app = FastAPI()

    @app.websocket("/ws")
    async def websocket_endpoint(websocket: WebSocket):
        await websocket.accept()
        await websocket.send_text("websocket")
        await websocket.close()

    app.frontend("/", directory=dist)

    with TestClient(app).websocket_connect("/ws") as websocket:
        data = websocket.receive_text()

    assert data == "websocket"


def test_head_requests_work(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "asset.txt", "ok")
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).head("/asset.txt")

    assert response.status_code == 200
    assert response.text == ""
    assert response.headers["content-length"] == "2"


def test_unsupported_methods_return_405(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "asset.txt", "ok")
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).post("/asset.txt")

    assert response.status_code == 405


@pytest.mark.parametrize(
    "path",
    [
        "/../secret.txt",
        "/%2e%2e/secret.txt",
        "/..%2fsecret.txt",
        "/%5c..%5csecret.txt",
        "/..%5csecret.txt",
    ],
)
def test_path_traversal_cannot_escape_directory(tmp_path: Path, path: str):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app")
    write_file(tmp_path / "secret.txt", "secret")
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).get(path)

    assert response.status_code == 404
    assert response.text != "secret"


def test_symlink_outside_directory_is_not_served(tmp_path: Path):
    dist = tmp_path / "dist"
    dist.mkdir()
    outside = tmp_path / "secret.txt"
    outside.write_text("secret")
    link = dist / "secret.txt"
    try:
        os.symlink(outside, link)
    except (OSError, NotImplementedError):  # pragma: no cover
        pytest.skip("symlinks are not supported")
    app = FastAPI()
    app.frontend("/", directory=dist)

    response = TestClient(app).get("/secret.txt")

    assert response.status_code == 404
    assert response.text != "secret"


def test_check_dir_true_fails_early_for_missing_directory(monkeypatch, tmp_path: Path):
    app = FastAPI()
    monkeypatch.chdir(tmp_path)

    with pytest.raises(RuntimeError, match="does not exist") as exc_info:
        app.frontend("/", directory="missing")

    message = str(exc_info.value)
    assert "'missing'" in message
    assert str(tmp_path / "missing") in message


def test_check_dir_false_allows_missing_directory_and_fails_on_request(tmp_path: Path):
    app = FastAPI()
    app.frontend("/", directory=tmp_path / "missing", check_dir=False)

    with pytest.raises(RuntimeError, match="does not exist"):
        TestClient(app).get("/asset.txt")


def test_explicit_fallback_files_fail_clearly_when_missing(monkeypatch, tmp_path: Path):
    dist = tmp_path / "dist"
    dist.mkdir()
    monkeypatch.chdir(tmp_path)
    app = FastAPI()

    with pytest.raises(RuntimeError, match="index.html") as exc_info:
        app.frontend("/", directory="dist", fallback="index.html")

    message = str(exc_info.value)
    assert "directory 'dist'" in message
    assert str(dist) in message

    app = FastAPI()
    app.frontend("/", directory="dist", fallback="404.html", check_dir=False)

    with pytest.raises(RuntimeError, match="404.html") as exc_info:
        TestClient(app).get("/missing.js")

    message = str(exc_info.value)
    assert "directory 'dist'" in message
    assert str(dist) in message


def test_frontend_routes_are_not_in_openapi(tmp_path: Path):
    dist = tmp_path / "dist"
    write_file(dist / "index.html", "app")
    app = FastAPI()

    @app.get("/api")
    def read_api():
        return {"ok": True}

    app.frontend("/", directory=dist, fallback="index.html")

    schema = TestClient(app).get("/openapi.json").json()

    assert set(schema["paths"]) == {"/api"}

    response = TestClient(app).get("/api")
    assert response.status_code == 200
    assert response.json() == {"ok": True}


@pytest.mark.parametrize(
    ("example", "files", "path", "status_code", "body"),
    [
        (
            "tutorial001_py310.py",
            {"asset.txt": "asset"},
            "/asset.txt",
            200,
            "asset",
        ),
        (
            "tutorial002_py310.py",
            {"index.html": "index"},
            "/dashboard",
            200,
            "index",
        ),
        (
            "tutorial003_py310.py",
            {"404.html": "missing"},
            "/missing",
            404,
            "missing",
        ),
        (
            "tutorial004_py310.py",
            {"index.html": "index"},
            "/app/dashboard",
            200,
            "index",
        ),
        (
            "tutorial005_py310.py",
            {"index.html": "index"},
            "/dashboard",
            404,
            '{"detail":"Not Found"}',
        ),
        (
            "tutorial006_py310.py",
            {"asset.txt": "asset"},
            "/asset.txt",
            200,
            "asset",
        ),
    ],
)
def test_docs_frontend_examples(
    tmp_path: Path,
    monkeypatch,
    example: str,
    files: dict[str, str],
    path: str,
    status_code: int,
    body: str,
):
    dist = tmp_path / "dist"
    for file, content in files.items():
        write_file(dist / file, content)
    monkeypatch.chdir(tmp_path)

    namespace = runpy.run_path(
        str(Path(__file__).parents[1] / "docs_src" / "frontend" / example)
    )

    app = namespace["app"]
    assert isinstance(app, FastAPI)
    response = TestClient(app).get(path, headers={"accept": "text/html"})
    assert response.status_code == status_code
    assert response.text == body


def test_low_priority_routes_can_store_non_frontend_routes():
    async def low_priority_endpoint(request):
        return PlainTextResponse("low")

    app = FastAPI()
    app.router._low_priority_routes.append(Route("/low", low_priority_endpoint))
    app.router._mark_routes_changed()

    response = TestClient(app).get("/low")

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


def test_included_low_priority_routes_can_store_non_frontend_routes():
    async def low_priority_endpoint(request):
        return PlainTextResponse("low")

    router = APIRouter()
    router._low_priority_routes.append(Route("/low", low_priority_endpoint))
    router._mark_routes_changed()
    app = FastAPI()
    app.include_router(router, prefix="/prefix")

    response = TestClient(app).get("/prefix/low")

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