"""Tests for ``mgf.sqlalchemy._session`` — sessionmaker + tenant_session.

Note: the FastAPI-Depends-shaped ``get_session`` previously co-located
with these helpers in mgf-common moved to ``mgf.fastapi.db.get_session``
in mgf-fastapi v0.2.0 — its tests live in mgf-fastapi/tests/.
"""

from __future__ import annotations

from uuid import UUID, uuid4

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

from mgf.sqlalchemy import (
    create_engine,
    create_sessionmaker,
    tenant_session,
)

# ---------------------------------------------------------------------------
# create_sessionmaker
# ---------------------------------------------------------------------------


class TestCreateSessionmaker:
    def test_returns_async_sessionmaker(self) -> None:
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine)
        assert isinstance(sm, async_sessionmaker)

    def test_default_expire_on_commit_false(self) -> None:
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine)
        # SQLAlchemy 2.x async-recommended default: expire_on_commit=False.
        # Inspect via the kw saved on the sessionmaker.
        assert sm.kw.get("expire_on_commit") is False

    def test_expire_on_commit_can_be_overridden(self) -> None:
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine, expire_on_commit=True)
        assert sm.kw.get("expire_on_commit") is True


class TestCreateSessionmakerExtraKwargs:
    """RA-03: `**extra` forwards non-named kwargs to `async_sessionmaker`.

    Consumers needing `autoflush=False`, `info={...}`, etc. can now
    pass them directly without rebuilding the factory from scratch
    (which would skip our `expire_on_commit=False` default).

    The collision-check shape mirrors RA-02 (create_engine):

      - The user-facing collision (literal call with the same
        kwarg twice, e.g. ``expire_on_commit=True,
        **{"expire_on_commit": False}``) is handled by Python's
        own kwarg routing at the CALL layer — TypeError before
        our function runs.
      - The check inside the function body is defensive against
        the programmatic case where a consumer passes a key
        through ``**extra`` that the named-signature also accepts
        BUT didn't get explicitly passed at the call site
        (impossible at the surface today; matters for refactor
        safety + the reserved-key shape below).
      - `class_` is the only reserved key not in the named
        signature; the check is what surfaces a consumer passing
        ``class_=CustomSession``. Without it, the consumer's
        custom class would silently override our pinned
        AsyncSession — a federation invariant violation.
    """

    def test_autoflush_forwarded(self) -> None:
        """`autoflush=False` reaches the underlying sessionmaker."""
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine, autoflush=False)
        assert sm.kw.get("autoflush") is False

    def test_info_dict_forwarded(self) -> None:
        """A consumer-supplied `info` dict reaches the sessionmaker."""
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        info = {"app": "test"}
        sm = create_sessionmaker(engine, info=info)
        assert sm.kw.get("info") == info

    def test_python_kwarg_collision_on_expire_on_commit_raises_typeerror(self) -> None:
        """Python's own kwarg routing catches the user-facing
        collision shape (literal kwarg + same key in **extra dict)
        with TypeError before our function runs. Pin the live
        behaviour."""
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        legacy_kwargs = {"expire_on_commit": True}
        with pytest.raises(TypeError, match="multiple values"):
            create_sessionmaker(engine, expire_on_commit=False, **legacy_kwargs)

    def test_collision_on_reserved_class_raises_valueerror(self) -> None:
        """RA-03: `class_` is reserved by mgf-sqlalchemy for the
        federation AsyncSession pin. A consumer programmatically
        passing ``class_`` through ``**extra`` (not literal — there's
        no named ``class_`` parameter so Python doesn't catch it)
        hits the defensive collision check and gets a loud
        ValueError instead of a silent class-override."""
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        legacy_kwargs = {"class_": AsyncSession}
        with pytest.raises(ValueError, match="class_"):
            create_sessionmaker(engine, **legacy_kwargs)

    def test_reserved_keyset_contains_both_named_params(self) -> None:
        """RA-03 refactor-safety guard: the reserved-keyset constant
        names both signature parameters (``class_``,
        ``expire_on_commit``).

        If someone removes the named ``expire_on_commit`` from the
        signature, the key would suddenly route into ``**extra``
        and silently use the underlying default. Pinning the
        reserved set here surfaces that refactor in review."""
        from mgf.sqlalchemy._session import _RESERVED_SESSIONMAKER_KEYS

        assert "class_" in _RESERVED_SESSIONMAKER_KEYS
        assert "expire_on_commit" in _RESERVED_SESSIONMAKER_KEYS

    def test_collision_check_present_in_source(self) -> None:
        """Documentation test (mirror of TestExtraCollisionCheck.
        test_collision_check_present_in_source on the engine
        side): the defensive collision check IS in
        `create_sessionmaker`'s body. A refactor that removes it
        without thinking about RA-03 fails this assertion."""
        import inspect

        from mgf.sqlalchemy import _session

        source = inspect.getsource(_session.create_sessionmaker)
        assert "_RESERVED_SESSIONMAKER_KEYS" in source, (
            "RA-03: defensive collision check removed from "
            "create_sessionmaker. If this is intentional, update "
            "the docstring's '`**extra` MUST NOT collide' clause too."
        )

    def test_no_extra_still_works(self) -> None:
        """Sanity: the original call shape (no `**extra`) still works."""
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine)
        assert isinstance(sm, async_sessionmaker)


# ---------------------------------------------------------------------------
# tenant_session
# ---------------------------------------------------------------------------


class TestTenantSessionValidation:
    """Pure validation logic — runs without a real DB."""

    @pytest.mark.asyncio
    async def test_rejects_non_uuid_string(self) -> None:
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine)
        try:
            async with sm() as session:
                with pytest.raises(ValueError):
                    async with tenant_session(session, "not-a-uuid"):
                        pass
        finally:
            await engine.dispose()

    @pytest.mark.asyncio
    async def test_accepts_uuid_object(self) -> None:
        # SQLite doesn't support GUC settings, so the SET LOCAL will
        # error at the DB layer. We're testing that VALIDATION passes
        # (no ValueError) — the SQL execution can fail, that's fine.
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine)
        tid = uuid4()
        try:
            async with sm() as session:
                with pytest.raises(Exception) as excinfo:
                    async with tenant_session(session, tid):
                        pass
                # The error is from SQLAlchemy / SQLite, NOT a ValueError.
                assert not isinstance(excinfo.value, ValueError)
        finally:
            await engine.dispose()

    @pytest.mark.asyncio
    async def test_accepts_uuid_string(self) -> None:
        engine = create_engine("sqlite+aiosqlite:///:memory:")
        sm = create_sessionmaker(engine)
        try:
            async with sm() as session:
                with pytest.raises(Exception) as excinfo:
                    async with tenant_session(
                        session,
                        "550e8400-e29b-41d4-a716-446655440000",
                    ):
                        pass
                # Validation passed; only DB-layer error remains.
                assert not isinstance(excinfo.value, ValueError)
        finally:
            await engine.dispose()


class TestTenantSessionSqlShape:
    """Pin the exact SQL emitted by tenant_session.

    Captures the fact that we use SET LOCAL with literal interpolation
    (Postgres' SET doesn't accept bind params). Critical that the
    UUID validation runs first.
    """

    @pytest.mark.asyncio
    async def test_emits_set_local_app_current_tenant(self) -> None:
        from unittest.mock import AsyncMock, MagicMock

        # Mock the session to capture executed SQL.
        mock_session = MagicMock(spec=AsyncSession)
        mock_session.execute = AsyncMock()
        tid = UUID("550e8400-e29b-41d4-a716-446655440000")

        async with tenant_session(mock_session, tid):
            pass

        # Captured exactly one execute() call.
        assert mock_session.execute.call_count == 1
        call_args = mock_session.execute.call_args
        sql_clause = call_args.args[0]
        # The SQL clause is a TextClause; render to string for inspection.
        rendered = str(sql_clause)
        assert "SET LOCAL" in rendered
        assert "app.current_tenant" in rendered
        assert "550e8400-e29b-41d4-a716-446655440000" in rendered

    @pytest.mark.asyncio
    async def test_uuid_object_rendered_as_canonical_form(self) -> None:
        from unittest.mock import AsyncMock, MagicMock

        mock_session = MagicMock(spec=AsyncSession)
        mock_session.execute = AsyncMock()
        # UUID with mixed case input — should render lowercase canonical.
        tid_str = "550E8400-E29B-41D4-A716-446655440000"

        async with tenant_session(mock_session, tid_str):
            pass

        rendered = str(mock_session.execute.call_args.args[0])
        # UUID() canonicalises to lowercase.
        assert "550e8400-e29b-41d4-a716-446655440000" in rendered
        assert "550E8400" not in rendered
