"""``mgf.fastapi.testing`` — context-manager test API server.

Closes FEEDBACK PAPER-28 (v0.27.0): :func:`run_test_app`. Pulls the
"start uvicorn on a free port for Playwright / e2e tests" dance into
one helper. Replaces ~120 LOC per consumer (free-port discovery +
``server.started`` race + clean-shutdown choreography).

The server-lifecycle is generic; consumer-specific seeding (test DB
reset, fixture loading) stays in consumer code — ``run_test_app``
takes a fully-built :class:`fastapi.FastAPI` and yields the base
URL.
"""

from __future__ import annotations

import asyncio
import contextlib
import logging
import socket
from typing import TYPE_CHECKING

import uvicorn

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from fastapi import FastAPI

__all__ = ["run_test_app"]

LOG = logging.getLogger(__name__)


def _free_port(host: str = "127.0.0.1") -> int:
    """Pick a free TCP port by binding ephemerally on ``host``.

    Race-prone in theory (the kernel could reassign the port between
    bind-and-release here and uvicorn's bind below) but reliable in
    practice — every consumer's test fixture uses this pattern.
    """
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind((host, 0))
        return int(s.getsockname()[1])


@contextlib.asynccontextmanager
async def run_test_app(
    app: FastAPI,
    *,
    port: int | None = None,
    host: str = "127.0.0.1",
    log_level: str = "warning",
    startup_timeout_seconds: float = 5.0,
) -> AsyncIterator[str]:
    """Start ``app`` under uvicorn; yield the base URL; tear down on exit.

    Usage in a pytest-asyncio test::

        from mgf.fastapi.testing import run_test_app

        async def test_my_endpoint():
            app = build_my_test_app()  # consumer-side fixture
            async with run_test_app(app) as base_url:
                async with httpx.AsyncClient(base_url=base_url, timeout=30.0) as client:
                    response = await client.get("/health")
                    assert response.status_code == 200

    :param app: A :class:`fastapi.FastAPI` instance ready to serve.
        Lifespan handlers fire as part of uvicorn startup.
    :param port: Port to bind. ``None`` (default) picks a free
        ephemeral port — the right answer for parallel test runs.
    :param host: Bind host. Default ``127.0.0.1`` — never expose
        a test server externally. Override only for container-bridge
        scenarios where the test runner reaches the server through a
        non-loopback address.
    :param log_level: uvicorn log level. Default ``warning`` — uvicorn's
        info-level chatter ("Started server process", etc.) crowds
        test output.
    :param startup_timeout_seconds: Max wait for uvicorn's
        ``server.started`` flag. Default 5 s. Raises
        :class:`TimeoutError` on miss.

    Yields the base URL (``http://<host>:<port>``).

    On exit (success OR exception), uvicorn is signalled to shut
    down and the underlying task is awaited. ``CancelledError`` from
    the server task is suppressed — it's the expected outcome of a
    clean shutdown, not a failure.
    """
    bind_port = port if port is not None else _free_port(host=host)
    base_url = f"http://{host}:{bind_port}"

    config = uvicorn.Config(
        app,
        host=host,
        port=bind_port,
        log_level=log_level,
        lifespan="on",
    )
    server = uvicorn.Server(config)

    serve_task = asyncio.create_task(server.serve())
    # Poll for uvicorn's started flag with a sensible bound.
    deadline = asyncio.get_event_loop().time() + startup_timeout_seconds
    while not server.started:
        if asyncio.get_event_loop().time() > deadline:
            server.should_exit = True
            with contextlib.suppress(asyncio.CancelledError):
                await serve_task
            msg = (
                f"run_test_app: server failed to start within "
                f"{startup_timeout_seconds}s on {base_url}"
            )
            raise TimeoutError(msg)
        # If the serve task has already crashed (e.g. port collision),
        # surface its exception now instead of timing out silently.
        if serve_task.done():
            exc = serve_task.exception()
            if exc is not None:
                raise exc
            msg = "run_test_app: server task exited before reaching started"
            raise RuntimeError(msg)
        await asyncio.sleep(0.02)

    try:
        yield base_url
    finally:
        server.should_exit = True
        with contextlib.suppress(asyncio.CancelledError):
            await serve_task
