"""Shared pytest fixtures.

The async db fixture provides an AsyncSession bound to a transaction that
gets rolled back after each test. CI provisions a real Postgres + pgvector
(see .forgejo/workflows/ci.yml), so tests exercise the actual schema and
migration code paths.
"""

import os

# Audit 2026-06-02: CredentialCrypto now refuses to auto-generate a
# Fernet key without explicit opt-in (production safety against silent
# key regeneration on partial restore). The test environment never has
# a pre-seeded key file, so set the bootstrap flag here before any
# create_app() / CredentialCrypto() import path fires.
os.environ.setdefault("CURATOR_BOOTSTRAP_NEW_KEY", "1")

import pytest  # noqa: E402
import pytest_asyncio  # noqa: E402
from sqlalchemy import create_engine  # noqa: E402
from sqlalchemy.ext.asyncio import (  # noqa: E402
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)
from sqlalchemy.orm import sessionmaker  # noqa: E402

from backend.app import create_app  # noqa: E402
from backend.app.models import Base  # noqa: E402


def _async_database_url() -> str:
    user = os.environ.get("DB_USER", "fabledcurator")
    password = os.environ["DB_PASSWORD"]
    host = os.environ.get("DB_HOST", "postgres")
    port = os.environ.get("DB_PORT", "5432")
    name = os.environ.get("DB_NAME", "fabledcurator_test")
    return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"


def _sync_database_url() -> str:
    user = os.environ.get("DB_USER", "fabledcurator")
    password = os.environ["DB_PASSWORD"]
    host = os.environ.get("DB_HOST", "postgres")
    port = os.environ.get("DB_PORT", "5432")
    name = os.environ.get("DB_NAME", "fabledcurator_test")
    return f"postgresql+psycopg://{user}:{password}@{host}:{port}/{name}"


@pytest_asyncio.fixture
async def engine():
    e = create_async_engine(_async_database_url(), future=True)
    yield e
    await e.dispose()


@pytest_asyncio.fixture
async def db(engine) -> AsyncSession:
    Session = async_sessionmaker(engine, expire_on_commit=False)
    async with Session() as session:
        await session.begin()
        try:
            yield session
        finally:
            await session.rollback()


@pytest.fixture
def sync_engine():
    e = create_engine(_sync_database_url(), future=True)
    yield e
    e.dispose()


@pytest.fixture
def db_sync(sync_engine):
    """Synchronous Session bound to a savepoint — used by Importer tests
    (the Importer is sync-only by design)."""
    SyncSession = sessionmaker(sync_engine, expire_on_commit=False)
    with SyncSession() as session:
        session.begin()
        try:
            yield session
        finally:
            session.rollback()


@pytest_asyncio.fixture
async def app():
    return create_app()


@pytest_asyncio.fixture
async def client(app):
    async with app.test_client() as c:
        yield c


# Migration-seeded baseline (singleton config like import_settings /
# ml_settings), captured lazily at the FIRST integration test's setup —
# when the CI integration job has just run `alembic upgrade head` so the DB
# is pristine. Cached module-wide. Never populated in the DB-less unit job
# because only integration-marked tests trigger the connection.
_SEED_SNAPSHOT: dict[str, list[dict]] | None = None


@pytest.fixture(scope="session")
def _truncate_engine():
    """Session-scoped sync engine reused by the per-test DB-reset teardown.

    Creating a fresh engine + Postgres connection for EVERY test's teardown was
    the integration suite's dominant cost — `--durations` showed the 15 slowest
    operations in both long shards were all ~1.5-2s teardowns, i.e. the
    connect+SCRAM handshake, not test logic. A single pooled connection reused
    across teardowns cuts that to the TRUNCATE itself. `pool_pre_ping` guards
    against Postgres reaping the idle connection mid-suite (a stale-connection
    failure would be a nasty flaky bounce; the ping is sub-millisecond).

    `create_engine` is lazy — it opens no connection until first `.begin()` —
    so the no-DB unit job instantiates this object but never connects.
    """
    eng = create_engine(_sync_database_url(), future=True, pool_pre_ping=True)
    yield eng
    eng.dispose()


@pytest.fixture(autouse=True)
def _reset_db_after_integration(request, _truncate_engine):
    """Integration tests exercise app/celery code that commits on its own
    connection, which the rollback fixtures above can't undo — so data
    would leak between tests. Capture the seeded baseline at the first
    integration test, then after each integration-marked test truncate
    every model table and restore that baseline. Touches the DB ONLY for
    integration-marked tests, so the no-DB fast unit job is untouched.

    Uses the session-scoped `_truncate_engine` (pooled, reused) rather than
    building a fresh engine per test — see that fixture's docstring.
    """
    global _SEED_SNAPSHOT
    is_integration = request.node.get_closest_marker("integration") is not None

    if is_integration and _SEED_SNAPSHOT is None:
        snap: dict[str, list[dict]] = {}
        with _truncate_engine.connect() as conn:
            for t in Base.metadata.sorted_tables:
                rows = [
                    dict(m)
                    for m in conn.execute(t.select()).mappings().all()
                ]
                if rows:
                    snap[t.name] = rows
        _SEED_SNAPSHOT = snap

    yield

    if not is_integration:
        return
    tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
    if not tables:
        return
    with _truncate_engine.begin() as conn:
        conn.exec_driver_sql(
            f"TRUNCATE {tables} RESTART IDENTITY CASCADE"
        )
        for t in Base.metadata.sorted_tables:
            rows = (_SEED_SNAPSHOT or {}).get(t.name)
            if rows:
                conn.execute(t.insert(), rows)
                # Restored rows carry their explicit ids while RESTART
                # IDENTITY reset the sequence to 1, so the next ORM insert
                # would collide on the PK (bitten by migration 0075's seeded
                # system tags: every Tag insert failed with pk_tag id=1).
                # Resync any serial id sequence past the restored rows.
                if "id" in t.c:
                    conn.exec_driver_sql(
                        f"SELECT setval(pg_get_serial_sequence('{t.name}', 'id'), "
                        f"(SELECT COALESCE(MAX(id), 1) FROM {t.name})) "
                        f"WHERE pg_get_serial_sequence('{t.name}', 'id') IS NOT NULL"
                    )


@pytest_asyncio.fixture(autouse=True)
async def _dispose_app_engine(request):
    """The app's shared async engine (extensions.get_engine) is a process
    singleton, but pytest-asyncio gives each test a fresh event loop;
    asyncpg connections are loop-bound, so reuse across tests raises
    'attached to a different loop'. Dispose after each integration test so
    the next test rebuilds the engine on its own loop.
    """
    yield
    if request.node.get_closest_marker("integration") is None:
        return
    from backend.app import extensions
    await extensions.dispose_engine()
