"""Tests for app/utils/db_migrate.py module."""

from unittest.mock import MagicMock, patch

import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.database import Base
from app.utils.db_migrate import (
    _make_engine,
    _ordered_tables,
    _stamp_alembic_head,
    migrate_data,
    preview_migration,
)


@pytest.mark.unit
class TestMakeEngine:
    """Tests for _make_engine helper function."""

    def test_sqlite_engine_has_check_same_thread(self):
        """Test that SQLite engine has check_same_thread set."""
        engine = _make_engine("sqlite:///:memory:")
        assert engine is not None
        engine.dispose()

    def test_non_sqlite_engine_created(self):
        """Test that non-SQLite engine can be created (even if driver is missing)."""
        # _make_engine only creates the engine object; it doesn't connect.
        # If the driver isn't installed, create_engine raises at creation time.
        try:
            engine = _make_engine("postgresql://u:p@localhost:5432/test")
            assert engine is not None
            engine.dispose()
        except Exception:
            # Driver not installed in test environment — acceptable
            pass


@pytest.mark.unit
class TestOrderedTables:
    """Tests for _ordered_tables helper function."""

    def test_known_tables_come_first(self):
        """Test that known tables from _TABLE_ORDER come first."""
        mock_inspector = MagicMock()
        mock_inspector.get_table_names.return_value = [
            "webhook_configs",
            "documents",
            "files",
            "custom_table",
            "alembic_version",
        ]
        result = _ordered_tables(mock_inspector)
        # alembic_version should be skipped
        assert "alembic_version" not in result
        # Known tables should come first in their predefined order
        assert result.index("documents") < result.index("files")
        assert result.index("files") < result.index("webhook_configs")
        # custom_table is not in _TABLE_ORDER so comes after known tables
        assert "custom_table" in result

    def test_skips_alembic_version(self):
        """Test that alembic_version table is always skipped."""
        mock_inspector = MagicMock()
        mock_inspector.get_table_names.return_value = ["alembic_version", "documents"]
        result = _ordered_tables(mock_inspector)
        assert "alembic_version" not in result
        assert "documents" in result

    def test_unknown_tables_appended_alphabetically(self):
        """Test that tables not in _TABLE_ORDER are appended alphabetically."""
        mock_inspector = MagicMock()
        mock_inspector.get_table_names.return_value = ["zebra", "apple", "documents"]
        result = _ordered_tables(mock_inspector)
        assert result[0] == "documents"
        # apple and zebra should be after documents, in alpha order
        remaining = result[1:]
        assert remaining == sorted(remaining)

    def test_empty_database(self):
        """Test with an empty database returns empty list."""
        mock_inspector = MagicMock()
        mock_inspector.get_table_names.return_value = []
        result = _ordered_tables(mock_inspector)
        assert result == []


@pytest.mark.unit
class TestPreviewMigration:
    """Tests for preview_migration function."""

    def test_preview_in_memory_sqlite(self):
        """Test previewing an in-memory SQLite database."""
        # Create a temporary source DB with some data
        src_engine = create_engine(
            "sqlite:///:memory:",
            connect_args={"check_same_thread": False},
            poolclass=StaticPool,
        )
        Base.metadata.create_all(bind=src_engine)

        # Insert a test row
        Session = sessionmaker(bind=src_engine)
        session = Session()
        session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
        session.commit()
        session.close()

        # Preview using the engine's URL won't work for :memory:,
        # but we can test the error path
        result = preview_migration("sqlite:///:memory:")
        # For :memory: this creates a new empty DB, so tables are empty
        assert result["success"] is True
        assert isinstance(result["tables"], list)

    def test_preview_invalid_url(self):
        """Test preview with invalid URL returns error."""
        result = preview_migration("invalid://not-a-db")
        assert result["success"] is False
        assert "error" in result

    def test_preview_with_patched_source_shows_tables(self):
        """Test preview with source that has tables and data."""
        real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_src)

        # Insert test data
        Session = sessionmaker(bind=real_src)
        session = Session()
        session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
        session.commit()
        session.close()

        with patch("app.utils.db_migrate._make_engine", return_value=real_src):
            result = preview_migration("sqlite:///:memory:")

        assert result["success"] is True
        assert result["total_rows"] >= 1
        # At least the documents table should be in results
        table_names = [t["name"] for t in result["tables"]]
        assert "documents" in table_names
        doc_table = next(t for t in result["tables"] if t["name"] == "documents")
        assert doc_table["row_count"] >= 1


@pytest.mark.unit
class TestMigrateData:
    """Tests for migrate_data function."""

    def test_migrate_empty_sqlite_to_sqlite(self):
        """Test migrating an empty SQLite DB to another SQLite DB."""
        # Both are file-based temp databases for this test
        src_url = "sqlite:///:memory:"
        tgt_url = "sqlite://"  # Another in-memory DB

        # Create source schema
        src_engine = create_engine(src_url, connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=src_engine)
        src_engine.dispose()

        # Run migration from empty source
        with patch("app.utils.db_migrate._make_engine") as mock_make:
            # Create real engines for both
            real_src = create_engine(
                "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool
            )
            Base.metadata.create_all(bind=real_src)
            real_tgt = create_engine(
                "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool
            )
            mock_make.side_effect = [real_src, real_tgt]

            with patch("app.utils.db_migrate._stamp_alembic_head"):
                result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")

        assert result["success"] is True
        assert result["rows_copied"] == 0

    def test_migrate_with_data(self):
        """Test migrating a SQLite DB with actual data."""
        real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_src)

        # Insert test data
        Session = sessionmaker(bind=real_src)
        session = Session()
        session.execute(text("INSERT INTO documents (filename) VALUES ('invoice.pdf')"))
        session.execute(text("INSERT INTO documents (filename) VALUES ('receipt.pdf')"))
        session.commit()
        session.close()

        real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)

        with patch("app.utils.db_migrate._make_engine") as mock_make:
            mock_make.side_effect = [real_src, real_tgt]
            with patch("app.utils.db_migrate._stamp_alembic_head"):
                result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")

        assert result["success"] is True
        assert result["rows_copied"] >= 2  # At least the 2 documents rows

    def test_migrate_with_progress_callback(self):
        """Test that progress callback is invoked during migration."""
        real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_src)

        Session = sessionmaker(bind=real_src)
        session = Session()
        session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
        session.commit()
        session.close()

        real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        callback = MagicMock()

        with patch("app.utils.db_migrate._make_engine") as mock_make:
            mock_make.side_effect = [real_src, real_tgt]
            with patch("app.utils.db_migrate._stamp_alembic_head"):
                result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:", progress_callback=callback)

        assert result["success"] is True
        # Callback should have been called at least once for the non-empty table
        if result["rows_copied"] > 0:
            assert callback.call_count > 0

    def test_migrate_global_exception(self):
        """Test that a global exception is caught gracefully."""
        with patch("app.utils.db_migrate._make_engine", side_effect=Exception("boom")):
            result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
        assert result["success"] is False
        assert len(result["errors"]) > 0

    def test_migrate_stamp_failure_is_recorded(self):
        """Test that Alembic stamp failure is recorded as an error."""
        real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_src)
        real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)

        with patch("app.utils.db_migrate._make_engine") as mock_make:
            mock_make.side_effect = [real_src, real_tgt]
            with patch("app.utils.db_migrate._stamp_alembic_head", side_effect=Exception("stamp failed")):
                result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")

        # Data copy succeeds but stamp fails — errors list non-empty
        assert len(result["errors"]) > 0
        assert any("stamp" in e.lower() for e in result["errors"])

    def test_migrate_table_copy_exception(self):
        """Test that per-table copy exception is recorded but migration continues."""
        real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_src)

        # Insert data so the table isn't empty
        Session = sessionmaker(bind=real_src)
        session = Session()
        session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
        session.commit()
        session.close()

        real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_tgt)

        # Make the target reflect fail for one table to trigger the error path
        original_reflect = MagicMock(side_effect=Exception("reflect error"))

        with patch("app.utils.db_migrate._make_engine") as mock_make:
            mock_make.side_effect = [real_src, real_tgt]
            with patch("app.utils.db_migrate._stamp_alembic_head"):
                # Patch MetaData so that reflecting target raises for the first table
                with patch("app.utils.db_migrate.MetaData") as mock_meta_cls:
                    # First MetaData() is for source reflect (should work)
                    src_meta = MagicMock()
                    src_table = MagicMock()
                    src_table.columns = []
                    src_table.select.return_value = text("SELECT 1")
                    src_meta.tables = {"documents": src_table}
                    src_meta.reflect = MagicMock()

                    # Second MetaData() is for target reflect (should fail)
                    tgt_meta = MagicMock()
                    tgt_meta.reflect.side_effect = Exception("target reflect error")

                    mock_meta_cls.side_effect = [src_meta, tgt_meta]
                    result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")

        assert any("Error copying table" in e for e in result["errors"])

    def test_migrate_target_table_not_found(self):
        """Test that missing target table after reflect is recorded."""
        real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_src)

        Session = sessionmaker(bind=real_src)
        session = Session()
        session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
        session.commit()
        session.close()

        real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        # Create schema in target so reflect works but returns empty
        Base.metadata.create_all(bind=real_tgt)

        with patch("app.utils.db_migrate._make_engine") as mock_make:
            mock_make.side_effect = [real_src, real_tgt]
            with patch("app.utils.db_migrate._stamp_alembic_head"):
                result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")

        # With schema in target, migration should succeed normally
        assert result["success"] is True

    def test_migrate_returns_tables_and_rows_counts(self):
        """Test that successful migration returns expected count fields."""
        real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
        Base.metadata.create_all(bind=real_src)
        real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)

        with patch("app.utils.db_migrate._make_engine") as mock_make:
            mock_make.side_effect = [real_src, real_tgt]
            with patch("app.utils.db_migrate._stamp_alembic_head"):
                result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")

        assert "tables_copied" in result
        assert "rows_copied" in result
        assert "errors" in result
        assert isinstance(result["errors"], list)


@pytest.mark.unit
class TestStampAlembicHead:
    """Tests for _stamp_alembic_head helper function."""

    def test_stamp_calls_alembic_command(self):
        """Test that stamping calls alembic command.stamp with 'head'."""
        mock_engine = MagicMock()
        mock_connection = MagicMock()
        mock_engine.begin.return_value.__enter__ = MagicMock(return_value=mock_connection)
        mock_engine.begin.return_value.__exit__ = MagicMock(return_value=False)

        with patch("alembic.command.stamp") as mock_stamp:
            _stamp_alembic_head(mock_engine)
            mock_stamp.assert_called_once()
            # Verify it stamps to "head"
            args = mock_stamp.call_args
            assert args[0][1] == "head"

    def test_stamp_raises_on_error(self):
        """Test that stamp propagates exceptions."""
        mock_engine = MagicMock()
        mock_connection = MagicMock()
        mock_engine.begin.return_value.__enter__ = MagicMock(return_value=mock_connection)
        mock_engine.begin.return_value.__exit__ = MagicMock(return_value=False)

        with patch("alembic.command.stamp", side_effect=Exception("stamp error")):
            with pytest.raises(Exception, match="stamp error"):
                _stamp_alembic_head(mock_engine)
