import importlib
import os
import uuid

import psycopg2
from psycopg2 import sql
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations


E6_MIGRATION = "database.versions.e6a1c4b8d2f0_2_2_13"
F7_MIGRATION = "database.versions.f7b2d5c9a301_2_2_14"

IDENTITY_COLUMN_TYPES = {
    "mediaid": sa.String,
    "tmdbid": sa.Integer,
    "doubanid": sa.String,
    "bangumiid": sa.Integer,
    "anilistid": sa.Integer,
    "media_source": sa.String,
    "media_id": sa.String,
}
F7_INDEX_SIGNATURES = {
    "subscribe": {
        "ix_subscribe_anilistid": (("anilistid",), False),
        "ix_subscribe_media_source": (("media_source",), False),
        "ix_subscribe_media_id": (("media_id",), False),
        "ix_subscribe_media_identity": (
            ("media_source", "media_id"), False,
        ),
    },
    "subscribehistory": {
        "ix_subscribehistory_anilistid": (("anilistid",), False),
        "ix_subscribehistory_media_source": (("media_source",), False),
        "ix_subscribehistory_media_id": (("media_id",), False),
        "ix_subscribehistory_media_identity": (
            ("media_source", "media_id"), False,
        ),
    },
    "downloadhistory": {
        "ix_downloadhistory_bangumiid": (("bangumiid",), False),
        "ix_downloadhistory_anilistid": (("anilistid",), False),
        "ix_downloadhistory_media_source": (("media_source",), False),
        "ix_downloadhistory_media_id": (("media_id",), False),
        "ix_downloadhistory_media_identity": (
            ("media_source", "media_id"), False,
        ),
    },
    "transferhistory": {
        "ix_transferhistory_bangumiid": (("bangumiid",), False),
        "ix_transferhistory_anilistid": (("anilistid",), False),
        "ix_transferhistory_media_identity": (
            ("media_source", "media_id"), False,
        ),
    },
    "downloadfailure": {
        "ix_downloadfailure_media_identity_site": (
            ("type", "media_source", "media_id", "site"), False,
        ),
    },
}


def _bind_migration(monkeypatch, connection, module_name: str):
    """把历史 revision 绑定到当前 disposable connection。"""
    migration = importlib.import_module(module_name)
    context = MigrationContext.configure(connection)
    monkeypatch.setattr(migration, "op", Operations(context))
    return migration


def _column_names(connection, table_name: str) -> set[str]:
    """返回指定测试表的字段集合。"""
    return {
        column["name"]
        for column in sa.inspect(connection).get_columns(table_name)
    }


def _index_signatures(
        connection,
        table_name: str,
) -> dict[str, tuple[tuple[str, ...], bool]]:
    """返回索引名称到字段顺序及唯一性的映射。"""
    return {
        index["name"]: (
            tuple(index.get("column_names") or ()),
            bool(index.get("unique")),
        )
        for index in sa.inspect(connection).get_indexes(table_name)
    }


def _assert_index_signatures(
        actual: dict[str, tuple[tuple[str, ...], bool]],
        expected: dict[str, tuple[tuple[str, ...], bool]],
) -> None:
    """断言关键索引的字段顺序和唯一性均符合迁移契约。"""
    for index_name, signature in expected.items():
        assert actual.get(index_name) == signature, (index_name, actual)


def _rows(connection, table_name: str) -> list[dict]:
    """按主键读取迁移后的测试数据。"""
    table = sa.Table(table_name, sa.MetaData(), autoload_with=connection)
    return list(
        connection.execute(sa.select(table).order_by(table.c.id)).mappings()
    )


def _create_transferhistory(
        connection,
        columns: tuple[str, ...],
        indexes: tuple[tuple[str, tuple[str, ...]], ...] = (),
) -> sa.Table:
    """创建具有指定兼容字段和索引的最小整理历史表。"""
    metadata = sa.MetaData()
    table = sa.Table(
        "transferhistory",
        metadata,
        sa.Column("id", sa.Integer(), primary_key=True),
        *(
            sa.Column(name, IDENTITY_COLUMN_TYPES[name]())
            for name in columns
        ),
    )
    for name, fields in indexes:
        sa.Index(name, *(table.c[field] for field in fields))
    metadata.create_all(connection)
    return table


def test_e6_backfill_preserves_priority_existing_identity_and_indexes(
        monkeypatch,
) -> None:
    """e6 应保持 TMDB 优先级、豆瓣兜底和已有规范身份。"""
    engine = sa.create_engine("sqlite://")
    with engine.begin() as connection:
        table = _create_transferhistory(
            connection,
            ("tmdbid", "doubanid", "media_source", "media_id"),
            (("ix_transferhistory_media_source", ("media_source",)),),
        )
        connection.execute(table.insert(), [
            {
                "id": 1, "tmdbid": 123, "doubanid": "ignored",
                "media_source": None, "media_id": None,
            },
            {
                "id": 2, "tmdbid": None, "doubanid": "456",
                "media_source": None, "media_id": None,
            },
            {
                "id": 3,
                "tmdbid": 789,
                "doubanid": "also-ignored",
                "media_source": "plugin_source",
                "media_id": "custom-1",
            },
        ])
        migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)

        migration.upgrade()
        migration.upgrade()

        migrated_rows = _rows(connection, "transferhistory")
        indexes = _index_signatures(connection, "transferhistory")

    assert (migrated_rows[0]["media_source"], migrated_rows[0]["media_id"]) == (
        "themoviedb", "123",
    )
    assert (migrated_rows[1]["media_source"], migrated_rows[1]["media_id"]) == (
        "douban", "456",
    )
    assert (migrated_rows[2]["media_source"], migrated_rows[2]["media_id"]) == (
        "plugin_source", "custom-1",
    )
    _assert_index_signatures(indexes, {
        "ix_transferhistory_media_source": (("media_source",), False),
        "ix_transferhistory_media_id": (("media_id",), False),
    })


@pytest.mark.parametrize(
    ("legacy_columns", "values", "expected_identity"),
    (
        (("tmdbid",), {"tmdbid": 123}, ("themoviedb", "123")),
        (("doubanid",), {"doubanid": "456"}, ("douban", "456")),
        ((), {}, (None, None)),
    ),
)
def test_e6_skips_only_missing_legacy_sources(
        monkeypatch,
        legacy_columns,
        values,
        expected_identity,
) -> None:
    """e6 应仅执行物理存在的旧来源字段回填。"""
    engine = sa.create_engine("sqlite://")
    with engine.begin() as connection:
        table = _create_transferhistory(connection, legacy_columns)
        connection.execute(table.insert(), {"id": 1, **values})
        migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)

        migration.upgrade()
        migration.upgrade()

        migrated = _rows(connection, "transferhistory")[0]

    assert (migrated["media_source"], migrated["media_id"]) == expected_identity


def test_e6_retry_completes_interrupted_column_and_index_state(
        monkeypatch,
) -> None:
    """e6 重试应独立补齐缺失的 revision-owned 字段和索引。"""
    engine = sa.create_engine("sqlite://")
    with engine.begin() as connection:
        table = _create_transferhistory(
            connection,
            ("tmdbid", "doubanid", "media_source"),
            (("ix_transferhistory_media_source", ("media_source",)),),
        )
        connection.execute(
            table.insert(),
            {"id": 1, "tmdbid": None, "doubanid": "456"},
        )
        migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)

        migration.upgrade()
        migration.upgrade()

        columns = _column_names(connection, "transferhistory")
        indexes = _index_signatures(connection, "transferhistory")
        migrated = _rows(connection, "transferhistory")[0]

    assert {"media_source", "media_id"}.issubset(columns)
    _assert_index_signatures(indexes, {
        "ix_transferhistory_media_source": (("media_source",), False),
        "ix_transferhistory_media_id": (("media_id",), False),
    })
    assert (migrated["media_source"], migrated["media_id"]) == (
        "douban", "456",
    )


def _create_f7_tables(
        connection,
        layouts: dict[str, tuple[str, ...]],
        indexes: dict[str, tuple[tuple[str, tuple[str, ...]], ...]] | None = None,
        transferhistory_identity: bool = True,
) -> dict[str, sa.Table]:
    """创建五张具有不同历史字段状态的最小媒体表。"""
    metadata = sa.MetaData()
    indexes = indexes or {}
    transferhistory_columns = (
        ("media_source", "media_id") if transferhistory_identity else ()
    )
    base_columns = {
        "subscribe": (),
        "subscribehistory": (),
        "downloadhistory": (),
        "transferhistory": transferhistory_columns,
        "downloadfailure": (),
    }
    tables = {}
    for table_name, required_columns in base_columns.items():
        column_names = tuple(dict.fromkeys(required_columns + layouts.get(table_name, ())))
        columns = [sa.Column("id", sa.Integer(), primary_key=True)]
        if table_name == "downloadfailure":
            columns.extend((
                sa.Column("type", sa.String()),
                sa.Column("site", sa.Integer()),
            ))
        columns.extend(
            sa.Column(name, IDENTITY_COLUMN_TYPES[name]())
            for name in column_names
        )
        table = sa.Table(table_name, metadata, *columns)
        for index_name, fields in indexes.get(table_name, ()):
            sa.Index(index_name, *(table.c[field] for field in fields))
        tables[table_name] = table
    metadata.create_all(connection)
    return tables


def test_f7_backfill_uses_physical_columns_and_preserves_priority(
        monkeypatch,
) -> None:
    """f7 应先用带前缀 ID，再按四类来源字段顺序回填。"""
    engine = sa.create_engine("sqlite://")
    layouts = {
        "subscribe": (
            "mediaid", "tmdbid", "doubanid", "bangumiid", "anilistid",
            "media_source", "media_id",
        ),
        "subscribehistory": ("mediaid",),
        "downloadhistory": ("doubanid",),
        "transferhistory": ("tmdbid",),
        "downloadfailure": (),
    }
    with engine.begin() as connection:
        tables = _create_f7_tables(connection, layouts)
        connection.execute(tables["subscribe"].insert(), [
            {
                "id": 1, "mediaid": "douban:prefix-1", "tmdbid": 101,
                "doubanid": "201", "bangumiid": 301, "anilistid": 401,
                "media_source": None, "media_id": None,
            },
            {
                "id": 2, "mediaid": None, "tmdbid": 102, "doubanid": "202",
                "bangumiid": 302, "anilistid": 402,
                "media_source": None, "media_id": None,
            },
            {
                "id": 3, "mediaid": None, "tmdbid": None,
                "doubanid": "203", "bangumiid": 303,
                "anilistid": 403,
                "media_source": None, "media_id": None,
            },
            {
                "id": 4, "mediaid": None, "tmdbid": None,
                "doubanid": None, "bangumiid": 304, "anilistid": 404,
                "media_source": None, "media_id": None,
            },
            {
                "id": 5, "mediaid": None, "tmdbid": None,
                "doubanid": None, "bangumiid": None, "anilistid": 405,
                "media_source": None, "media_id": None,
            },
            {
                "id": 6, "mediaid": "tmdb:106", "tmdbid": 106,
                "doubanid": None, "bangumiid": None, "anilistid": None,
                "media_source": "plugin_source", "media_id": "custom-6",
            },
        ])
        connection.execute(
            tables["subscribehistory"].insert(),
            {"id": 1, "mediaid": "anilist:154587"},
        )
        connection.execute(
            tables["downloadhistory"].insert(),
            {"id": 1, "doubanid": "35209731"},
        )
        connection.execute(
            tables["transferhistory"].insert(),
            {"id": 1, "tmdbid": 209867},
        )
        connection.execute(tables["downloadfailure"].insert(), {
            "id": 1, "type": "电视剧", "site": 1,
        })
        migration = _bind_migration(monkeypatch, connection, F7_MIGRATION)

        migration.upgrade()
        migration.upgrade()

        subscribe_rows = _rows(connection, "subscribe")
        partial_rows = {
            name: _rows(connection, name)[0]
            for name in (
                "subscribehistory", "downloadhistory", "transferhistory",
                "downloadfailure",
            )
        }

    assert [
        (row["media_source"], row["media_id"])
        for row in subscribe_rows
    ] == [
        ("douban", "prefix-1"),
        ("themoviedb", "102"),
        ("douban", "203"),
        ("bangumi", "304"),
        ("anilist", "405"),
        ("plugin_source", "custom-6"),
    ]
    assert (
        partial_rows["subscribehistory"]["media_source"],
        partial_rows["subscribehistory"]["media_id"],
    ) == ("anilist", "154587")
    assert (
        partial_rows["downloadhistory"]["media_source"],
        partial_rows["downloadhistory"]["media_id"],
    ) == ("douban", "35209731")
    assert (
        partial_rows["transferhistory"]["media_source"],
        partial_rows["transferhistory"]["media_id"],
    ) == ("themoviedb", "209867")
    assert partial_rows["downloadfailure"]["media_id"] is None


def test_f7_retry_completes_mixed_partial_table_states(monkeypatch) -> None:
    """f7 重试应跨表补齐混合缺失的字段和 revision-owned 索引。"""
    engine = sa.create_engine("sqlite://")
    layouts = {
        "subscribe": (
            "mediaid", "tmdbid", "anilistid", "media_source", "media_id",
        ),
        "subscribehistory": ("mediaid", "doubanid", "media_source"),
        "downloadhistory": ("bangumiid", "media_id"),
        "transferhistory": ("tmdbid", "bangumiid"),
        "downloadfailure": ("anilistid", "media_source", "media_id"),
    }
    existing_indexes = {
        "subscribe": (("ix_subscribe_anilistid", ("anilistid",)),),
        "subscribehistory": (
            ("ix_subscribehistory_media_source", ("media_source",)),
        ),
        "downloadhistory": (
            ("ix_downloadhistory_bangumiid", ("bangumiid",)),
        ),
        "transferhistory": (
            (
                "ix_transferhistory_media_identity",
                ("media_source", "media_id"),
            ),
        ),
        "downloadfailure": (
            (
                "ix_downloadfailure_media_identity_site",
                ("type", "media_source", "media_id", "site"),
            ),
        ),
    }
    with engine.begin() as connection:
        tables = _create_f7_tables(connection, layouts, existing_indexes)
        connection.execute(tables["subscribe"].insert(), {
            "id": 1,
            "mediaid": "douban:88",
            "tmdbid": 11,
            "media_source": "plugin_source",
            "media_id": "preserved-1",
        })
        connection.execute(tables["subscribehistory"].insert(), {
            "id": 1, "mediaid": "tmdb:22", "doubanid": "33",
        })
        connection.execute(tables["downloadhistory"].insert(), [
            {"id": 1, "bangumiid": 44, "media_id": None},
            {"id": 2, "bangumiid": 45, "media_id": "preserved-2"},
        ])
        connection.execute(
            tables["transferhistory"].insert(),
            {"id": 1, "tmdbid": 55, "bangumiid": 56},
        )
        connection.execute(tables["downloadfailure"].insert(), {
            "id": 1, "type": "动画", "site": 1, "anilistid": 66,
        })
        migration = _bind_migration(monkeypatch, connection, F7_MIGRATION)

        migration.upgrade()
        migration.upgrade()

        columns = {
            name: _column_names(connection, name)
            for name in tables
        }
        indexes = {
            name: _index_signatures(connection, name)
            for name in tables
        }
        rows = {name: _rows(connection, name) for name in tables}

    assert {"anilistid", "media_source", "media_id"}.issubset(columns["subscribe"])
    assert {"anilistid", "media_source", "media_id"}.issubset(
        columns["subscribehistory"]
    )
    assert {
        "bangumiid", "anilistid", "media_source", "media_id",
    }.issubset(columns["downloadhistory"])
    assert {"bangumiid", "anilistid"}.issubset(columns["transferhistory"])
    assert {
        "bangumiid", "anilistid", "media_source", "media_id",
    }.issubset(columns["downloadfailure"])

    for table_name, expected in F7_INDEX_SIGNATURES.items():
        _assert_index_signatures(indexes[table_name], expected)

    assert (rows["subscribe"][0]["media_source"], rows["subscribe"][0]["media_id"]) == (
        "plugin_source", "preserved-1",
    )
    assert (
        rows["subscribehistory"][0]["media_source"],
        rows["subscribehistory"][0]["media_id"],
    ) == ("themoviedb", "22")
    assert (
        rows["downloadhistory"][0]["media_source"],
        rows["downloadhistory"][0]["media_id"],
    ) == ("bangumi", "44")
    assert rows["downloadhistory"][1]["media_source"] is None
    assert rows["downloadhistory"][1]["media_id"] == "preserved-2"
    assert (
        rows["transferhistory"][0]["media_source"],
        rows["transferhistory"][0]["media_id"],
    ) == ("themoviedb", "55")
    assert (
        rows["downloadfailure"][0]["media_source"],
        rows["downloadfailure"][0]["media_id"],
    ) == ("anilist", "66")


def _exercise_e6_f7_round_trip(monkeypatch, engine: sa.Engine) -> None:
    """验证两个历史 revision 可升级、降级并再次升级。"""
    layouts = {
        "subscribe": ("mediaid", "tmdbid", "doubanid", "bangumiid"),
        "subscribehistory": ("mediaid", "tmdbid", "doubanid", "bangumiid"),
        "downloadhistory": ("tmdbid", "doubanid"),
        "transferhistory": ("tmdbid", "doubanid"),
        "downloadfailure": ("tmdbid", "doubanid"),
    }
    with engine.begin() as connection:
        tables = _create_f7_tables(
            connection,
            layouts,
            transferhistory_identity=False,
        )
        connection.execute(tables["subscribe"].insert(), {
            "id": 1, "mediaid": "douban:88", "tmdbid": 11,
        })
        connection.execute(tables["subscribehistory"].insert(), {
            "id": 1, "mediaid": None, "tmdbid": 22,
        })
        connection.execute(tables["downloadhistory"].insert(), {
            "id": 1, "tmdbid": None, "doubanid": "33",
        })
        connection.execute(tables["transferhistory"].insert(), {
            "id": 1, "tmdbid": 55, "doubanid": "ignored",
        })
        connection.execute(tables["downloadfailure"].insert(), {
            "id": 1, "type": "动画", "site": 1,
            "tmdbid": None, "doubanid": "66",
        })

        e6_migration = _bind_migration(monkeypatch, connection, E6_MIGRATION)
        e6_migration.upgrade()
        _assert_index_signatures(
            _index_signatures(connection, "transferhistory"),
            {
                "ix_transferhistory_media_source": (
                    ("media_source",), False,
                ),
                "ix_transferhistory_media_id": (("media_id",), False),
            },
        )
        e6_migration.downgrade()
        assert "media_source" not in _column_names(connection, "transferhistory")
        assert "media_id" not in _column_names(connection, "transferhistory")
        assert "ix_transferhistory_media_source" not in _index_signatures(
            connection, "transferhistory"
        )
        e6_migration.upgrade()
        transfer_row = _rows(connection, "transferhistory")[0]
        assert (transfer_row["media_source"], transfer_row["media_id"]) == (
            "themoviedb", "55",
        )

        pre_f7_columns = {
            "subscribe": {
                "id", "mediaid", "tmdbid", "doubanid", "bangumiid",
            },
            "subscribehistory": {
                "id", "mediaid", "tmdbid", "doubanid", "bangumiid",
            },
            "downloadhistory": {"id", "tmdbid", "doubanid"},
            "transferhistory": {
                "id", "tmdbid", "doubanid", "media_source", "media_id",
            },
            "downloadfailure": {
                "id", "type", "site", "tmdbid", "doubanid",
            },
        }
        for table_name, expected_columns in pre_f7_columns.items():
            assert _column_names(connection, table_name) == expected_columns

        f7_migration = _bind_migration(monkeypatch, connection, F7_MIGRATION)
        f7_migration.upgrade()
        for table_name, expected in F7_INDEX_SIGNATURES.items():
            _assert_index_signatures(
                _index_signatures(connection, table_name),
                expected,
            )
        f7_migration.downgrade()
        for table_name, expected_columns in pre_f7_columns.items():
            assert _column_names(connection, table_name) == expected_columns
        for table_name, expected in F7_INDEX_SIGNATURES.items():
            indexes = _index_signatures(connection, table_name)
            assert set(expected).isdisjoint(indexes), (table_name, indexes)

        f7_migration.upgrade()
        for table_name, expected in F7_INDEX_SIGNATURES.items():
            _assert_index_signatures(
                _index_signatures(connection, table_name),
                expected,
            )
        rows = {
            table_name: _rows(connection, table_name)[0]
            for table_name in layouts
        }

    assert (rows["subscribe"]["media_source"], rows["subscribe"]["media_id"]) == (
        "douban", "88",
    )
    assert (
        rows["subscribehistory"]["media_source"],
        rows["subscribehistory"]["media_id"],
    ) == ("themoviedb", "22")
    assert (
        rows["downloadhistory"]["media_source"],
        rows["downloadhistory"]["media_id"],
    ) == ("douban", "33")
    assert (
        rows["transferhistory"]["media_source"],
        rows["transferhistory"]["media_id"],
    ) == ("themoviedb", "55")
    assert (
        rows["downloadfailure"]["media_source"],
        rows["downloadfailure"]["media_id"],
    ) == ("douban", "66")


def test_e6_f7_round_trip_on_sqlite(monkeypatch) -> None:
    """SQLite 应支持两个 revision 的 upgrade/downgrade/re-upgrade。"""
    _exercise_e6_f7_round_trip(monkeypatch, sa.create_engine("sqlite://"))


def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
    """已配置的 PostgreSQL 15 应执行与 SQLite 相同的往返路径。"""
    prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
    host = os.getenv(f"{prefix}HOST")
    database = os.getenv(f"{prefix}DATABASE")
    username = os.getenv(f"{prefix}USERNAME")
    if not host or not database or not username:
        pytest.skip("未配置隔离 PostgreSQL migration 测试库")

    port = os.getenv(f"{prefix}PORT", "5432")
    password = os.getenv(f"{prefix}PASSWORD", "")
    schema = f"p1_db1_roundtrip_{uuid.uuid4().hex}"
    with psycopg2.connect(
        host=host,
        port=port,
        dbname=database,
        user=username,
        password=password,
    ) as connection:
        assert connection.server_version // 10000 == 15, connection.server_version
        connection.autocommit = True
        with connection.cursor() as cursor:
            cursor.execute(
                sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
            )

    engine = None
    try:
        engine = sa.create_engine(
            sa.URL.create(
                "postgresql+psycopg2",
                username=username,
                password=password,
                host=host,
                port=int(port),
                database=database,
            ),
            connect_args={"options": f"-csearch_path={schema}"},
        )
        _exercise_e6_f7_round_trip(monkeypatch, engine)
    finally:
        if engine is not None:
            engine.dispose()
        with psycopg2.connect(
            host=host,
            port=port,
            dbname=database,
            user=username,
            password=password,
        ) as connection:
            connection.autocommit = True
            with connection.cursor() as cursor:
                cursor.execute(
                    sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
                        sql.Identifier(schema)
                    )
                )
