"""
tests/test_migration_runner.py — Unit + integration tests for the migration framework.

All tests use in-memory SQLite (:memory:) so they run fast and leave
no artifacts on disk.

Coverage:
  - MigrationRunner.get_current_version() reads schema_version correctly
  - MigrationRunner.get_pending_since() filters correctly
  - MigrationRunner.apply_all() applies in order and returns count
  - MigrationRunner.apply_one() updates schema_version after success
  - Rollback on failure: a bad migration leaves the DB unchanged
  - Idempotency: apply_all() called twice applies migrations only once
  - SQLiteAdapter with auto_migrate=True wires through correctly
  - SQLiteAdapter with auto_migrate=False skips migration
"""
from __future__ import annotations

import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch

import aiosqlite
import pytest
import pytest_asyncio

from cortex.storage import SQLiteAdapter
from cortex.storage.migrations import MigrationRunner


# ---------------------------------------------------------------------------
# Helpers: build minimal in-memory connections
# ---------------------------------------------------------------------------

async def _make_conn(*, with_config: bool = True) -> aiosqlite.Connection:
    """Open an :memory: connection with optional cortex_config bootstrapping."""
    conn = await aiosqlite.connect(":memory:")
    conn.row_factory = aiosqlite.Row
    if with_config:
        await conn.execute(
            """
            CREATE TABLE cortex_config (
                key       TEXT PRIMARY KEY,
                value     TEXT NOT NULL,
                updated_at TEXT NOT NULL DEFAULT (datetime('now'))
            )
            """
        )
        await conn.execute(
            "INSERT INTO cortex_config (key, value) VALUES ('schema_version', '3')"
        )
        await conn.commit()
    return conn


def _make_migration(version: int, sql: str = "", description: str = "test") -> types.ModuleType:
    """Produce a minimal migration module object."""
    mod = types.ModuleType(f"v{version}_test")
    mod.VERSION = version
    mod.DESCRIPTION = description
    mod.SQL_UP = sql
    mod.SQL_DOWN = "-- no-op"
    return mod


# ---------------------------------------------------------------------------
# get_current_version
# ---------------------------------------------------------------------------

class TestGetCurrentVersion:
    @pytest.mark.asyncio
    async def test_returns_version_from_config(self):
        conn = await _make_conn(with_config=True)
        runner = MigrationRunner(conn)
        assert await runner.get_current_version() == 3
        await conn.close()

    @pytest.mark.asyncio
    async def test_returns_zero_when_table_missing(self):
        conn = await aiosqlite.connect(":memory:")
        runner = MigrationRunner(conn)
        assert await runner.get_current_version() == 0
        await conn.close()

    @pytest.mark.asyncio
    async def test_returns_zero_when_row_missing(self):
        conn = await aiosqlite.connect(":memory:")
        await conn.execute(
            "CREATE TABLE cortex_config (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT)"
        )
        await conn.commit()
        runner = MigrationRunner(conn)
        assert await runner.get_current_version() == 0
        await conn.close()


# ---------------------------------------------------------------------------
# get_pending_since
# ---------------------------------------------------------------------------

class TestGetPendingSince:
    @pytest.mark.asyncio
    async def test_returns_migrations_above_cutoff(self):
        conn = await _make_conn()
        runner = MigrationRunner(conn)

        m4 = _make_migration(4)
        m5 = _make_migration(5)
        m3 = _make_migration(3)

        fake_all = [m3, m4, m5]  # already sorted

        with patch.object(MigrationRunner, "_load_all_migrations", return_value=fake_all):
            pending = await runner.get_pending_since(3)

        assert [m.VERSION for m in pending] == [4, 5]
        await conn.close()

    @pytest.mark.asyncio
    async def test_returns_empty_when_up_to_date(self):
        conn = await _make_conn()
        runner = MigrationRunner(conn)

        m3 = _make_migration(3)
        with patch.object(MigrationRunner, "_load_all_migrations", return_value=[m3]):
            pending = await runner.get_pending_since(3)

        assert pending == []
        await conn.close()


# ---------------------------------------------------------------------------
# apply_one
# ---------------------------------------------------------------------------

class TestApplyOne:
    @pytest.mark.asyncio
    async def test_applies_sql_and_updates_schema_version(self):
        conn = await _make_conn()
        runner = MigrationRunner(conn)

        migration = _make_migration(
            4,
            sql="CREATE TABLE IF NOT EXISTS test_v4 (id INTEGER PRIMARY KEY)",
            description="create test_v4",
        )
        result = await runner.apply_one(migration)

        assert result is True
        # schema_version updated
        async with conn.execute(
            "SELECT value FROM cortex_config WHERE key = 'schema_version'"
        ) as cur:
            row = await cur.fetchone()
        assert row[0] == "4"

        # table exists
        async with conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='test_v4'"
        ) as cur:
            table = await cur.fetchone()
        assert table is not None

        await conn.close()

    @pytest.mark.asyncio
    async def test_rollback_on_bad_sql(self):
        """A migration with invalid SQL must not advance schema_version."""
        conn = await _make_conn()
        runner = MigrationRunner(conn)

        # executescript raises OperationalError on invalid SQL.
        migration = _make_migration(
            4,
            sql="INVALID SQL THAT CANNOT PARSE !!!@@@",
            description="bad migration",
        )

        with pytest.raises(Exception):
            await runner.apply_one(migration)

        # schema_version must remain at 3
        async with conn.execute(
            "SELECT value FROM cortex_config WHERE key = 'schema_version'"
        ) as cur:
            row = await cur.fetchone()
        assert row[0] == "3"

        await conn.close()

    @pytest.mark.asyncio
    async def test_multi_statement_sql(self):
        """Multiple semicolon-separated statements in SQL_UP all apply."""
        conn = await _make_conn()
        runner = MigrationRunner(conn)

        sql = (
            "CREATE TABLE IF NOT EXISTS t_alpha (id INTEGER PRIMARY KEY);\n"
            "CREATE TABLE IF NOT EXISTS t_beta (id INTEGER PRIMARY KEY);\n"
        )
        migration = _make_migration(4, sql=sql)
        await runner.apply_one(migration)

        for table_name in ("t_alpha", "t_beta"):
            async with conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
                (table_name,),
            ) as cur:
                assert await cur.fetchone() is not None, f"{table_name} missing"

        await conn.close()


# ---------------------------------------------------------------------------
# apply_all
# ---------------------------------------------------------------------------

class TestApplyAll:
    @pytest.mark.asyncio
    async def test_applies_in_ascending_order(self):
        conn = await _make_conn()  # schema_version = 3
        runner = MigrationRunner(conn)

        order: list[int] = []

        m4 = _make_migration(4, sql="SELECT 1")
        m5 = _make_migration(5, sql="SELECT 1")
        m6 = _make_migration(6, sql="SELECT 1")

        # Patch apply_one to track call order
        original_apply_one = runner.apply_one
        applied_versions: list[int] = []

        async def recording_apply_one(migration):
            applied_versions.append(migration.VERSION)
            return await original_apply_one(migration)

        runner.apply_one = recording_apply_one

        with patch.object(
            MigrationRunner, "_load_all_migrations", return_value=[m4, m5, m6]
        ):
            count = await runner.apply_all()

        assert count == 3
        assert applied_versions == [4, 5, 6]

        # Final schema_version should be the last applied
        async with conn.execute(
            "SELECT value FROM cortex_config WHERE key = 'schema_version'"
        ) as cur:
            row = await cur.fetchone()
        assert row[0] == "6"

        await conn.close()

    @pytest.mark.asyncio
    async def test_returns_zero_when_already_up_to_date(self):
        conn = await _make_conn()  # schema_version = 3
        runner = MigrationRunner(conn)

        m3 = _make_migration(3)
        with patch.object(MigrationRunner, "_load_all_migrations", return_value=[m3]):
            count = await runner.apply_all()

        assert count == 0
        await conn.close()

    @pytest.mark.asyncio
    async def test_idempotent_double_run(self):
        """Calling apply_all twice should apply migrations only once."""
        conn = await _make_conn()  # schema_version = 3
        runner = MigrationRunner(conn)

        m4 = _make_migration(4, sql="CREATE TABLE IF NOT EXISTS idempotent_test (x INT)")

        with patch.object(MigrationRunner, "_load_all_migrations", return_value=[m4]):
            first = await runner.apply_all()
            second = await runner.apply_all()

        assert first == 1
        assert second == 0  # already at version 4

        await conn.close()

    @pytest.mark.asyncio
    async def test_stops_on_first_failure(self):
        """If migration N fails, N+1 is NOT applied."""
        conn = await _make_conn()  # schema_version = 3
        runner = MigrationRunner(conn)

        m4_bad = _make_migration(4, sql="INVALID SQL ###", description="bad")
        m5_good = _make_migration(5, sql="SELECT 1", description="good")

        with patch.object(
            MigrationRunner, "_load_all_migrations", return_value=[m4_bad, m5_good]
        ):
            with pytest.raises(Exception):
                await runner.apply_all()

        # version stayed at 3
        async with conn.execute(
            "SELECT value FROM cortex_config WHERE key = 'schema_version'"
        ) as cur:
            row = await cur.fetchone()
        assert row[0] == "3"

        await conn.close()


# ---------------------------------------------------------------------------
# SQLiteAdapter integration
# ---------------------------------------------------------------------------

class TestSQLiteAdapterAutoMigrate:
    @pytest_asyncio.fixture
    async def adapter(self):
        a = SQLiteAdapter(db_path=":memory:", busy_timeout_ms=1000, enable_wal=False)
        await a.initialize()
        yield a
        await a.close()

    @pytest.mark.asyncio
    async def test_schema_version_is_set_after_initialize(self, adapter):
        version = await adapter.get_config("schema_version")
        assert version is not None
        assert int(version) >= 3

    @pytest.mark.asyncio
    async def test_auto_migrate_false_skips_migration(self):
        """With auto_migrate=False, initialize() does NOT run migrations.

        The DB is left in a bare state (no schema applied), so
        cortex_config does not exist and querying it raises OperationalError.
        We verify initialize() completes without error and that the schema
        was indeed not applied.
        """
        import aiosqlite as _aiosqlite

        a = SQLiteAdapter(
            db_path=":memory:",
            busy_timeout_ms=1000,
            enable_wal=False,
            auto_migrate=False,
        )
        # initialize() itself must not raise
        await a.initialize()

        # cortex_config should NOT exist — any query should raise
        conn = await a._get_conn()
        with pytest.raises(_aiosqlite.OperationalError, match="no such table"):
            async with conn.execute(
                "SELECT value FROM cortex_config WHERE key = 'schema_version'"
            ) as cur:
                await cur.fetchone()

        await a.close()

    @pytest.mark.asyncio
    async def test_initialize_is_idempotent(self):
        """Calling initialize() twice on the same adapter path is safe."""
        import tempfile, os

        with tempfile.TemporaryDirectory() as tmpdir:
            db_path = os.path.join(tmpdir, "idempotent.db")
            a = SQLiteAdapter(db_path=db_path, enable_wal=False)
            await a.initialize()
            await a.close()

            # Re-open the same DB — should not fail or re-apply migrations
            b = SQLiteAdapter(db_path=db_path, enable_wal=False)
            await b.initialize()
            version = await b.get_config("schema_version")
            assert version is not None
            await b.close()
