"""Tests for ``mgf.fastapi.testing.run_test_app`` (closes PAPER-28 in mgf-common v0.27.0)."""

from __future__ import annotations

import asyncio
import socket

import httpx
import pytest
from fastapi import FastAPI

from mgf.fastapi.testing import run_test_app


def _build_app() -> FastAPI:
    """A trivially small FastAPI app for lifecycle testing."""
    app = FastAPI()

    @app.get("/health")
    def health() -> dict[str, str]:
        return {"status": "ok"}

    @app.get("/echo/{value}")
    def echo(value: str) -> dict[str, str]:
        return {"echoed": value}

    return app


class TestRunTestApp:
    @pytest.mark.asyncio
    async def test_yields_reachable_base_url(self) -> None:
        async with run_test_app(_build_app()) as base_url:
            assert base_url.startswith("http://127.0.0.1:")
            async with httpx.AsyncClient(base_url=base_url) as client:
                response = await client.get("/health")
            assert response.status_code == 200
            assert response.json() == {"status": "ok"}

    @pytest.mark.asyncio
    async def test_routes_serve_correctly(self) -> None:
        async with run_test_app(_build_app()) as base_url:
            async with httpx.AsyncClient(base_url=base_url) as client:
                response = await client.get("/echo/hello")
            assert response.status_code == 200
            assert response.json() == {"echoed": "hello"}

    @pytest.mark.asyncio
    async def test_picks_free_port_by_default(self) -> None:
        # Two parallel servers should each grab their own free port
        # without colliding.
        async with run_test_app(_build_app()) as url1, run_test_app(_build_app()) as url2:
            assert url1 != url2
            port1 = int(url1.rsplit(":", 1)[1])
            port2 = int(url2.rsplit(":", 1)[1])
            assert port1 != port2

    @pytest.mark.asyncio
    async def test_explicit_port_used_when_passed(self) -> None:
        # Pre-grab a free port to use as the explicit value, so we don't
        # collide with whatever's running on the test runner.
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(("127.0.0.1", 0))
            chosen = int(s.getsockname()[1])
        async with run_test_app(_build_app(), port=chosen) as base_url:
            assert base_url == f"http://127.0.0.1:{chosen}"

    @pytest.mark.asyncio
    async def test_clean_shutdown_releases_port(self) -> None:
        # After the context exits, the bound port MUST be reusable.
        # The shutdown story is the load-bearing piece — a leaked
        # uvicorn task would tie up the port indefinitely.
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(("127.0.0.1", 0))
            chosen = int(s.getsockname()[1])
        async with run_test_app(_build_app(), port=chosen):
            pass
        # If shutdown didn't release the port, this rebind would
        # OSError. SO_REUSEADDR isn't on by default for our socket
        # so this is a real test of clean shutdown.
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(("127.0.0.1", chosen))

    @pytest.mark.asyncio
    async def test_exception_inside_context_still_shuts_down(self) -> None:
        # If the test body raises, the server must still tear down —
        # the finally branch in run_test_app guarantees this.
        with pytest.raises(RuntimeError, match="boom"):
            async with run_test_app(_build_app()) as base_url:
                # Confirm server is up
                async with httpx.AsyncClient(base_url=base_url) as client:
                    await client.get("/health")
                msg = "boom"
                raise RuntimeError(msg)
        # If shutdown didn't fire, asyncio.all_tasks() would still
        # contain the serve task. By the time we get here, the
        # finally branch has awaited it.
        active = [t for t in asyncio.all_tasks() if "serve" in t.get_name()]
        assert active == []

    @pytest.mark.asyncio
    @pytest.mark.timeout(120)
    async def test_startup_timeout_raises_timeout_error(self) -> None:
        # Construct an app whose lifespan blocks indefinitely. Uvicorn
        # never reaches "started"; run_test_app should TimeoutError.
        #
        # The 120s pytest-timeout override on this test reflects the
        # current implementation's behaviour: `startup_timeout_seconds=0.5`
        # fires correctly, but unwinding the `async with` waits for the
        # lifespan's `asyncio.sleep(60)` to honour CancelledError — the
        # current shape doesn't cleanly cancel the lifespan task on
        # startup timeout. Surfaced during the mgf-fastapi supervisor
        # adoption (2026-05-26); see FEEDBACK.md for the follow-up.
        from contextlib import asynccontextmanager

        @asynccontextmanager
        async def stuck_lifespan(_app: FastAPI):
            await asyncio.sleep(60)  # never returns within the test window
            yield

        stuck_app = FastAPI(lifespan=stuck_lifespan)

        with pytest.raises(TimeoutError, match="failed to start"):
            async with run_test_app(stuck_app, startup_timeout_seconds=0.5):
                pass

    @pytest.mark.asyncio
    async def test_log_level_default_is_warning(self) -> None:
        # Sanity: the default doesn't blow out test output with
        # uvicorn's INFO-level chatter. We can't easily assert log
        # level without intercepting logging; this test just confirms
        # the kwarg routes through (not raises) at the boundary.
        async with run_test_app(_build_app(), log_level="error") as base_url:
            async with httpx.AsyncClient(base_url=base_url) as client:
                response = await client.get("/health")
            assert response.status_code == 200
