"""Async sessionmaker + tenant-scoping helper.

Two helpers covering the request-handling lifecycle:

  - :func:`create_sessionmaker` — wraps :class:`async_sessionmaker`
    with mgf defaults (``expire_on_commit=False`` — SQLAlchemy 2's
    recommended default for async; consumers can pass
    ``expire_on_commit=True`` to opt into the legacy behavior).
  - :func:`tenant_session` — context manager that runs
    ``SET LOCAL app.current_tenant = '<uuid>'`` for Postgres RLS-based
    multi-tenancy. The ``LOCAL`` qualifier scopes to the current
    transaction; auto-resets on commit / rollback. Tenant id is
    UUID-validated at the call site to prevent SQL injection.

The FastAPI-Depends-shaped ``get_session`` previously co-located here
was moved to :mod:`mgf.fastapi.db` in mgf-fastapi v0.2 — it depends
on Starlette/FastAPI's ``Request`` / ``app.state`` which would pull a
heavy framework dep into this otherwise-pure sibling.
"""

from __future__ import annotations

from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from uuid import UUID

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from sqlalchemy.ext.asyncio import AsyncEngine


# Named kwargs whose values `create_sessionmaker` controls explicitly.
# RA-03: `**extra` MUST NOT collide with these — see the collision check
# below.
_RESERVED_SESSIONMAKER_KEYS = frozenset({"class_", "expire_on_commit"})


def create_sessionmaker(
    engine: AsyncEngine,
    *,
    expire_on_commit: bool = False,
    **extra: Any,
) -> async_sessionmaker[AsyncSession]:
    """Return an :class:`async_sessionmaker` bound to ``engine``.

    Default ``expire_on_commit=False`` matches SQLAlchemy 2's
    recommended default for async sessions (objects stay usable
    after commit instead of being silently expired). Pass
    ``expire_on_commit=True`` to revert to the legacy synchronous
    semantics.

    ``**extra`` is forwarded to :class:`async_sessionmaker` for
    consumers needing options not named in this signature
    (``autoflush=False``, ``info={...}``, a custom ``class_``-shaped
    subclass via ``info``, etc.). RA-03: keys in ``**extra`` MUST NOT
    collide with the explicit parameters (``class_``,
    ``expire_on_commit``) — a collision raises ``ValueError`` at
    construction. ``class_`` is reserved because mgf-sqlalchemy pins
    :class:`AsyncSession` as the session class for the federation
    invariants documented in ``mgf-common/docs/design/multi_tenancy.md``;
    consumers needing a custom session class should build their own
    factory directly via :func:`async_sessionmaker`.
    """
    # RA-03: defensive collision check on `**extra`. Without this, a
    # key already named in the explicit signature would SILENTLY
    # override our default — a consumer who passes `expire_on_commit=
    # True, **{"expire_on_commit": False}` ends up with False and
    # never notices. Python's own kwarg routing handles the literal
    # call-site collision (raises TypeError), but a programmatically-
    # assembled `extra` dict that includes a reserved key would slip
    # through; raise loudly so the misuse surfaces at construction.
    collisions = _RESERVED_SESSIONMAKER_KEYS & extra.keys()
    if collisions:
        raise ValueError(
            f"create_sessionmaker: `**extra` collides with explicit "
            f"parameter(s) {sorted(collisions)!r}. Use the explicit "
            f"parameter for documented kwargs; `**extra` is for "
            f"options that aren't named in this function's signature."
        )

    return async_sessionmaker(
        engine,
        class_=AsyncSession,
        expire_on_commit=expire_on_commit,
        **extra,
    )


@asynccontextmanager
async def tenant_session(
    session: AsyncSession,
    tenant_id: str | UUID,
) -> AsyncIterator[AsyncSession]:
    """Run ``SET LOCAL app.current_tenant = '<tenant_id>'`` for ``session``.

    Postgres RLS-based multi-tenancy pattern. The ``LOCAL`` qualifier
    scopes the setting to the current transaction; auto-resets on
    commit / rollback so cross-tenant leakage between requests is
    impossible at the SQL layer.

    ``tenant_id`` is UUID-validated before interpolation. Postgres
    ``SET LOCAL`` does NOT accept bind parameters, so we have to
    interpolate the value into the SQL — which is safe iff the
    input is shape-validated (UUIDs are alphanumeric + dashes,
    no SQL-meaningful chars). A non-UUID input raises ``ValueError``.

    Raises ``ValueError`` if ``tenant_id`` isn't a UUID-shaped
    string; raises whatever SQLAlchemy raises if the database
    rejects the SET (e.g., custom GUC not declared in
    ``postgresql.conf``).

    Security note (SH-01)
    ---------------------

    The UUID validation IS the SQL-injection boundary. A Hypothesis-
    driven property test in
    ``tests/unit/test_tenant_session_security.py`` exercises that
    boundary across SQL meta-characters, control bytes, unicode
    homoglyphs, and adversarial whitespace; every non-UUID input
    raises ``ValueError`` BEFORE any SQL is emitted (the mock
    session's ``execute`` is never called on the invalid path).
    A future refactor that accepts a raw ``str`` without the
    ``UUID(...)`` round-trip — even "to skip the validation cost"
    — would fail the property test. Do NOT relax this.

    NIL UUID policy (RA-04 / SH-03)
    -------------------------------

    The NIL UUID ``00000000-0000-0000-0000-000000000000`` IS accepted
    by this helper — it is a syntactically valid UUID and the
    library does NOT reserve it for any special meaning. The same
    holds for the other RFC 4122 variants (time-based v1, name-based
    v3/v5, etc.) — all accepted.

    Some applications use the NIL UUID as a "no tenant" /
    "unassigned" / "system rows" sentinel. If yours does, you are
    responsible for ensuring your Postgres RLS policy handles it
    appropriately. The most common policy shape
    (``tenant_id = current_setting('app.current_tenant')::uuid``)
    will happily return rows tagged with the NIL UUID when the GUC
    is set to NIL — which is typically test data, fixtures, or
    administrative records that SHOULD NOT be exposed to user-
    tenant-scoped queries. If your application defaults
    ``tenant_id`` to NIL on misconfiguration (some ORM patterns do),
    every request would silently read NIL-tagged rows.

    Two safe shapes:

      - **Reject NIL at the application layer** before calling
        ``tenant_session``. Treat a NIL tenant as an authentication
        failure or a 4xx response.
      - **Add an RLS predicate** that explicitly excludes the NIL
        UUID from user-scoped reads — e.g.,
        ``USING (tenant_id = current_setting(...)::uuid AND
        tenant_id <> '00000000-0000-0000-0000-000000000000')``.

    See ``mgf-common/docs/design/multi_tenancy.md`` for the broader
    RLS-policy contract.

    Transaction-state contract (RA-01)
    ----------------------------------

    The ``LOCAL`` qualifier on ``SET LOCAL`` scopes the GUC to the
    **active transaction** — auto-resets on commit / rollback. This
    helper relies on SQLAlchemy 2.x's autobegin behaviour: the
    ``SET LOCAL`` execute() call itself starts a transaction if the
    session isn't already in one. Subsequent queries inside the
    ``async with tenant_session(...)`` block run in that
    transaction and are tenant-scoped.

    What this means in practice:

      - **Read-only shape** (the common case)::

            async with sessionmaker() as session:
                async with tenant_session(session, tid) as scoped:
                    rows = await scoped.execute(text("SELECT ..."))
                # session.close() runs here; implicit rollback
                # resets the GUC. Cross-request leakage impossible.

      - **Read-write with explicit commit** (caution: commit
        resets the GUC)::

            async with sessionmaker() as session:
                async with tenant_session(session, tid) as scoped:
                    await scoped.execute(text("INSERT ..."))
                    await session.commit()   # <- ALSO resets GUC
                    # Queries here are NOT tenant-scoped.
                    # Re-enter tenant_session if you need them.

    A future SQLAlchemy version that disables autobegin by default
    would break the read-only shape silently — the ``SET LOCAL``
    would attach to no transaction. The helper runtime-checks
    ``session.in_transaction()`` after the ``SET LOCAL`` execute()
    and raises ``RuntimeError`` if the session isn't in a
    transaction, so the failure is loud rather than silently
    insecure. (RA-01.)

    Use as::

        async with sessionmaker() as session:
            async with tenant_session(session, tenant_id) as scoped:
                # All queries on `scoped` (same session, just
                # tenant-scoped) get app.current_tenant.
                rows = await scoped.execute(text("SELECT ..."))
    """
    if isinstance(tenant_id, UUID):
        tenant_str = str(tenant_id)
    else:
        # Validate that the string is a real UUID; UUID() raises
        # ValueError on malformed input. After validation, the
        # canonical hex+dash form is safe to interpolate.
        validated = UUID(tenant_id)
        tenant_str = str(validated)

    # SET LOCAL accepts only literal values; bind params don't work.
    # Safe because tenant_str came from UUID() — alphanumeric+dashes.
    await session.execute(text(f"SET LOCAL app.current_tenant = '{tenant_str}'"))
    # RA-01: SET LOCAL only takes effect inside a transaction.
    # SQLAlchemy 2.x's AsyncSession autobegins one on the first
    # execute() above, so we should be in a transaction now. If a
    # future SQLA version or a consumer's autobegin-disabled config
    # breaks that assumption, the GUC has no effect AND the tenant
    # scoping is silently bypassed — a security-relevant failure
    # mode. Raise loudly instead.
    if not session.in_transaction():
        raise RuntimeError(
            "tenant_session: session is not in a transaction after "
            "SET LOCAL — the GUC has no effect and tenant scoping "
            "is bypassed. Ensure SQLAlchemy autobegin is on, or wrap "
            "the call in `async with session.begin(): ...`. (RA-01.)"
        )
    yield session


__all__ = [
    "create_sessionmaker",
    "tenant_session",
]
