"""``mgf.fastapi.db`` — FastAPI-shaped helpers around mgf-sqlalchemy.

Two helpers that previously lived under ``mgf.common.db.*`` (mgf-common
≤ v0.29) and moved here at the v0.30 federation paired-extraction:

  - :func:`get_session` — FastAPI-Depends-compatible async generator.
    Yields one :class:`AsyncSession` per request from the sessionmaker
    that ``setup_db`` stashed on ``app.state.mgf_db_sessionmaker``.
  - :func:`setup_db` — async context manager for use inside a FastAPI
    lifespan. Creates the engine + sessionmaker via
    :func:`mgf.sqlalchemy.create_engine` /
    :func:`mgf.sqlalchemy.create_sessionmaker`, stashes them on
    ``app.state``, disposes the engine cleanly at lifespan exit.

Why this submodule lives in ``mgf-fastapi`` (not ``mgf-sqlalchemy``):
both helpers need Starlette/FastAPI types at runtime
(``starlette.requests.Request`` for ``get_session``'s Depends
signature, ``fastapi.FastAPI`` for ``setup_db``'s ``app`` parameter).
Pulling Starlette into a "pure-SQLAlchemy" sibling would break the
roadmap's framework-agnostic intent for mgf-sqlalchemy. Splitting by
shape — pure helpers in mgf-sqlalchemy, FastAPI helpers here — keeps
each sibling's dep graph honest.

Optional extra: ``pip install 'mgf-fastapi[sqlalchemy]'`` (pulls
``mgf-sqlalchemy>=0.1``). Without the extra, importing
:mod:`mgf.fastapi.db` raises :class:`ImportError` at module load
time — the failure is loud and points at the missing extra.
"""

from __future__ import annotations

import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any

try:
    from mgf.sqlalchemy import create_engine, create_sessionmaker
except ImportError as exc:  # pragma: no cover - depends on extras install
    raise ImportError(
        "mgf.fastapi.db requires the [sqlalchemy] extra: "
        "`pip install mgf-fastapi[sqlalchemy]`. "
        "This pulls in mgf-sqlalchemy, which provides "
        "create_engine / create_sessionmaker."
    ) from exc

from mgf.common.exceptions import AppConfigError
from starlette.requests import (
    Request,  # noqa: TC002 — FastAPI needs runtime type for Depends introspection
)

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from sqlalchemy.ext.asyncio import AsyncSession

    from fastapi import FastAPI


_LOGGER = logging.getLogger(__name__)


async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
    """FastAPI-Depends generator that yields one session per request.

    Reads the sessionmaker from
    ``request.app.state.mgf_db_sessionmaker`` — populated by
    :func:`setup_db` inside the FastAPI lifespan.

    The session is closed via its context manager when the
    handler returns (or raises). Commit / rollback is the
    consumer's responsibility — we don't presume a global policy.

    Use as::

        from typing import Annotated
        from fastapi import Depends
        from sqlalchemy.ext.asyncio import AsyncSession
        from mgf.fastapi.db import get_session

        @app.get("/users")
        async def list_users(
            session: Annotated[AsyncSession, Depends(get_session)],
        ) -> list[dict]:
            ...

    Raises :class:`AppConfigError` if the lifespan didn't run
    (no sessionmaker on ``app.state``). The
    ``ExceptionTranslationMiddleware`` (when installed) maps that
    to a 500 with an actionable message.
    """
    sessionmaker = getattr(request.app.state, "mgf_db_sessionmaker", None)
    if sessionmaker is None:
        raise AppConfigError(
            "mgf.fastapi.db: app.state.mgf_db_sessionmaker is unset. "
            "Did you wrap your lifespan with `setup_db(app, "
            "database_url=...)` (see "
            "docs/recipes/db.md)?"
        )

    async with sessionmaker() as session:
        yield session


@asynccontextmanager
async def setup_db(
    app: FastAPI,
    *,
    database_url: str,
    **engine_kwargs: Any,
) -> AsyncIterator[None]:
    """Create engine + sessionmaker; stash on ``app.state``; dispose at exit.

    Inside the context:

      - ``app.state.mgf_db_engine`` → :class:`AsyncEngine`.
      - ``app.state.mgf_db_sessionmaker`` → factory.

    On exit, the engine's connection pool is disposed cleanly (via
    :meth:`AsyncEngine.dispose`).

    Compose inside a larger lifespan that also runs
    :func:`mgf.common.bootstrap`::

        from contextlib import asynccontextmanager
        from fastapi import FastAPI
        from mgf.common import bootstrap
        from mgf.fastapi.db import setup_db


        @asynccontextmanager
        async def my_lifespan(app: FastAPI):
            with bootstrap(app_name="myapp", app_version="1.0.0"):
                async with setup_db(
                    app,
                    database_url="postgresql+asyncpg://...",
                ):
                    yield


        app = FastAPI(lifespan=my_lifespan)

    Forward extra engine kwargs::

        async with setup_db(
            app,
            database_url=settings.database_url,
            echo=settings.db_echo,
            pool_size=20,
        ):
            yield
    """
    _LOGGER.info(
        "mgf.fastapi.db.setup started",
        extra={"event": "mgf.fastapi.db.setup", "operation": "db.setup", "phase": "start"},
    )
    engine = create_engine(database_url, **engine_kwargs)
    sessionmaker = create_sessionmaker(engine)
    app.state.mgf_db_engine = engine
    app.state.mgf_db_sessionmaker = sessionmaker
    _LOGGER.info(
        "mgf.fastapi.db.setup complete",
        extra={"event": "mgf.fastapi.db.setup", "operation": "db.setup", "phase": "complete"},
    )
    try:
        yield
    finally:
        # RA-06: defensive log-and-continue. `engine.dispose()`
        # can raise on transient DB errors during shutdown (a
        # closed socket, an asyncpg cancellation while flushing
        # the last connection). If we let it propagate, the rest
        # of the lifespan teardown stack (other context managers
        # composed around setup_db) unwinds incompletely — leaked
        # OTel exporters, half-flushed log queues, etc. The
        # canonical pattern is to log and swallow so the surrounding
        # cleanup completes.
        try:
            await engine.dispose()
        except Exception:
            _LOGGER.warning(
                "mgf.fastapi.db.dispose_failed",
                extra={
                    "event": "mgf.fastapi.db.dispose_failed",
                    "database_url_present": True,
                },
                exc_info=True,
            )
        else:
            _LOGGER.info(
                "mgf.fastapi.db.teardown complete",
                extra={
                    "event": "mgf.fastapi.db.teardown",
                    "operation": "db.setup",
                    "phase": "teardown",
                },
            )


__all__ = [
    "get_session",
    "setup_db",
]
