"""3.0.2
统一通用媒体表的来源与原生 ID

Revision ID: 8a4c7e1d2f90
Revises: 6f9a1c2d3e4b
Create Date: 2026-08-12
"""

from collections.abc import Iterable
import re

from alembic import op
import sqlalchemy as sa


revision = "8a4c7e1d2f90"
down_revision = "6f9a1c2d3e4b"
branch_labels = None
depends_on = None


LEGACY_COLUMNS = {
    "subscribe": (
        "tmdbid", "imdbid", "tvdbid", "doubanid", "bangumiid",
        "anilistid", "mediaid",
    ),
    "subscribehistory": (
        "tmdbid", "imdbid", "tvdbid", "doubanid", "bangumiid",
        "anilistid", "mediaid",
    ),
    "downloadhistory": (
        "tmdbid", "imdbid", "tvdbid", "doubanid", "bangumiid",
        "anilistid",
    ),
    "transferhistory": (
        "tmdbid", "imdbid", "tvdbid", "doubanid", "bangumiid",
        "anilistid",
    ),
    "downloadfailure": ("tmdbid", "doubanid", "bangumiid", "anilistid"),
    "mediaserveritem": ("tmdbid", "imdbid", "tvdbid"),
}

SOURCE_COLUMNS = (
    ("themoviedb", "tmdbid"),
    ("douban", "doubanid"),
    ("bangumi", "bangumiid"),
    ("anilist", "anilistid"),
    ("imdb", "imdbid"),
    ("tvdb", "tvdbid"),
)

SOURCE_ALIASES = {
    "tmdb": "themoviedb",
    "themoviedb": "themoviedb",
    "douban": "douban",
    "bangumi": "bangumi",
    "anilist": "anilist",
    "imdb": "imdb",
    "tvdb": "tvdb",
    "musicbrainz": "musicbrainz",
    "theaudiodb": "theaudiodb",
    "audio_db": "theaudiodb",
    "doubanmusic": "doubanmusic",
    "douban_music": "doubanmusic",
    "bilibili": "bilibili",
    "mangguodiscover": "mangguodiscover",
    "mango_tv": "mangguodiscover",
    "migu": "migu",
    "migu_video": "migu",
    "tencentvideodiscover": "tencentvideodiscover",
    "tencent_video": "tencentvideodiscover",
}
MEDIA_IDENTITY_CHECK_SQL = (
    "(media_source IS NULL AND media_id IS NULL) OR "
    "(media_source IS NOT NULL AND "
    "trim(media_source) <> '' AND media_source = lower(trim(media_source)) AND "
    "length(media_source) <= 64 AND media_source NOT LIKE '%:%' AND "
    "media_source NOT LIKE '% %' AND "
    "media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')"
)
MEDIA_SOURCE_PATTERN = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")


def _inspector() -> sa.Inspector:
    """返回使用当前迁移连接的数据库检查器。"""
    return sa.inspect(op.get_bind())


def _has_table(table_name: str) -> bool:
    """检查数据表是否存在。"""
    return table_name in _inspector().get_table_names()


def _column_names(table_name: str) -> set[str]:
    """读取数据表当前全部字段名。"""
    if not _has_table(table_name):
        return set()
    return {column["name"] for column in _inspector().get_columns(table_name)}


def _ensure_identity_columns(table_name: str) -> None:
    """为旧库补齐规范媒体身份字段。"""
    columns = _column_names(table_name)
    if "media_source" not in columns:
        op.add_column(table_name, sa.Column("media_source", sa.String(), nullable=True))
    if "media_id" not in columns:
        op.add_column(table_name, sa.Column("media_id", sa.String(), nullable=True))


def _identity_missing(table: sa.TableClause):
    """返回任一规范身份字段为空的 SQL 条件。"""
    return sa.or_(
        table.c.media_source.is_(None),
        table.c.media_source == "",
        table.c.media_id.is_(None),
        table.c.media_id == "",
    )


def _normalize_existing_sources(table_name: str) -> None:
    """规范内置来源别名，并保留插件注册的扩展来源标识。"""
    table = sa.table(
        table_name,
        sa.column("media_source", sa.String()),
    )
    connection = op.get_bind()
    for alias, source in SOURCE_ALIASES.items():
        connection.execute(
            table.update()
            .where(sa.func.lower(sa.func.trim(table.c.media_source)) == alias)
            .values(media_source=source)
        )
    connection.execute(
        table.update()
        .where(table.c.media_source.is_not(None))
        .values(media_source=sa.func.lower(sa.func.trim(table.c.media_source)))
    )


def _clear_invalid_or_partial_identity(table_name: str) -> None:
    """清空无效或仅有一半的身份，允许后续从旧字段重新回填。"""
    table = sa.table(
        table_name,
        sa.column("media_source", sa.String()),
        sa.column("media_id", sa.String()),
    )
    invalid_identity = sa.or_(
        table.c.media_source.is_(None),
        sa.func.trim(table.c.media_source) == "",
        sa.func.length(sa.func.trim(table.c.media_source)) > 64,
        sa.func.trim(table.c.media_source).contains(":"),
        sa.func.trim(table.c.media_source).contains(" "),
        table.c.media_id.is_(None),
        sa.func.trim(table.c.media_id) == "",
        sa.func.trim(table.c.media_id) == "0",
    )
    op.get_bind().execute(
        table.update()
        .where(invalid_identity)
        .values(media_source=None, media_id=None)
    )
    op.get_bind().execute(
        table.update()
        .where(table.c.media_id.is_not(None))
        .values(media_id=sa.func.trim(table.c.media_id))
    )


def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None:
    """从旧的 ``prefix:id`` 组合字段回填内置或插件扩展身份。"""
    if "mediaid" not in columns:
        return
    table = sa.table(
        table_name,
        sa.column("id", sa.Integer()),
        sa.column("mediaid", sa.String()),
        sa.column("media_source", sa.String()),
        sa.column("media_id", sa.String()),
    )
    for prefix, source in SOURCE_ALIASES.items():
        op.get_bind().execute(
            table.update()
            .where(_identity_missing(table))
            .where(
                sa.func.lower(
                    sa.func.substr(
                        sa.func.trim(table.c.mediaid), 1, len(prefix) + 1
                    )
                ) == f"{prefix}:"
            )
            .where(
                sa.func.trim(
                    sa.func.substr(table.c.mediaid, len(prefix) + 2)
                ) != ""
            )
            .where(
                sa.func.trim(
                    sa.func.substr(table.c.mediaid, len(prefix) + 2)
                ) != "0"
            )
            .values(
                media_source=source,
                media_id=sa.func.trim(
                    sa.func.substr(table.c.mediaid, len(prefix) + 2)
                ),
            )
        )

    # 插件来源无法预先枚举，已知别名批量回填后再解析剩余合法前缀。
    connection = op.get_bind()
    rows = connection.execute(
        sa.select(table.c.id, table.c.mediaid)
        .where(_identity_missing(table))
        .where(table.c.mediaid.is_not(None))
    ).mappings().all()
    for row in rows:
        raw_media_id = str(row["mediaid"]).strip()
        raw_source, separator, raw_native_id = raw_media_id.partition(":")
        media_source = raw_source.strip().casefold()
        media_id = raw_native_id.strip()
        if (
                not separator
                or not MEDIA_SOURCE_PATTERN.fullmatch(media_source)
                or not media_id
                or media_id == "0"
        ):
            continue
        connection.execute(
            table.update()
            .where(table.c.id == row["id"])
            .where(_identity_missing(table))
            .values(media_source=media_source, media_id=media_id)
        )


def _backfill_source_columns(table_name: str, columns: set[str]) -> None:
    """按确定优先级从旧的来源专用字段回填规范身份。"""
    table_columns = [
        sa.column("media_source", sa.String()),
        sa.column("media_id", sa.String()),
    ] + [
        sa.column(field, sa.String())
        for _, field in SOURCE_COLUMNS
        if field in columns
    ]
    table = sa.table(table_name, *table_columns)
    for source, field in SOURCE_COLUMNS:
        if field not in columns:
            continue
        identity_column = table.c[field]
        op.get_bind().execute(
            table.update()
            .where(_identity_missing(table))
            .where(identity_column.is_not(None))
            .where(sa.func.trim(sa.cast(identity_column, sa.String())) != "")
            .where(sa.func.trim(sa.cast(identity_column, sa.String())) != "0")
            .values(
                media_source=source,
                media_id=sa.func.trim(sa.cast(identity_column, sa.String())),
            )
        )


def _drop_legacy_indexes(table_name: str, columns: Iterable[str]) -> None:
    """删除引用待移除字段的普通索引或唯一约束。"""
    legacy_columns = set(columns)
    inspector = _inspector()
    for index in inspector.get_indexes(table_name):
        if legacy_columns.intersection(index.get("column_names") or []):
            op.drop_index(index["name"], table_name=table_name)
    inspector = _inspector()
    for constraint in inspector.get_unique_constraints(table_name):
        if legacy_columns.intersection(constraint.get("column_names") or []):
            name = constraint.get("name")
            if name:
                op.drop_constraint(name, table_name, type_="unique")


def _drop_legacy_columns(table_name: str, columns: Iterable[str]) -> None:
    """以批处理方式移除旧媒体身份字段，兼容 SQLite。"""
    existing = _column_names(table_name)
    targets = [column for column in columns if column in existing]
    if not targets:
        return
    _drop_legacy_indexes(table_name, targets)
    with op.batch_alter_table(table_name) as batch_op:
        for column in targets:
            batch_op.drop_column(column)


def _ensure_identity_indexes() -> None:
    """为规范身份字段建立查询索引。"""
    for table_name in LEGACY_COLUMNS:
        if not _has_table(table_name):
            continue
        existing = {index["name"] for index in _inspector().get_indexes(table_name)}
        source_index = f"ix_{table_name}_media_source"
        id_index = f"ix_{table_name}_media_id"
        identity_index = f"ix_{table_name}_media_identity"
        if source_index not in existing:
            op.create_index(source_index, table_name, ["media_source"])
        if id_index not in existing:
            op.create_index(id_index, table_name, ["media_id"])
        existing = {index["name"] for index in _inspector().get_indexes(table_name)}
        if table_name != "downloadfailure" and identity_index not in existing:
            op.create_index(identity_index, table_name, ["media_source", "media_id"])
    if _has_table("downloadfailure"):
        existing = {
            index["name"] for index in _inspector().get_indexes("downloadfailure")
        }
        identity_site = "ix_downloadfailure_media_identity_site"
        if identity_site not in existing:
            op.create_index(
                identity_site,
                "downloadfailure",
                ["type", "media_source", "media_id", "site"],
            )
    if _has_table("mediaserveritem"):
        existing = {
            index["name"] for index in _inspector().get_indexes("mediaserveritem")
        }
        identity_type = "ix_mediaserveritem_media_identity_type"
        if identity_type not in existing:
            op.create_index(
                identity_type,
                "mediaserveritem",
                ["media_source", "media_id", "item_type"],
            )


def _ensure_identity_constraints() -> None:
    """为六张通用媒体表建立可扩展来源与身份成对数据库约束。"""
    for table_name in LEGACY_COLUMNS:
        if not _has_table(table_name):
            continue
        constraint_name = f"ck_{table_name}_media_identity"
        existing = {
            constraint.get("name")
            for constraint in _inspector().get_check_constraints(table_name)
        }
        if constraint_name in existing:
            continue
        with op.batch_alter_table(table_name) as batch_op:
            batch_op.create_check_constraint(
                constraint_name,
                MEDIA_IDENTITY_CHECK_SQL,
            )


def _drop_identity_constraints() -> None:
    """降级时移除本次迁移新增的媒体身份数据库约束。"""
    for table_name in LEGACY_COLUMNS:
        if not _has_table(table_name):
            continue
        constraint_name = f"ck_{table_name}_media_identity"
        existing = {
            constraint.get("name")
            for constraint in _inspector().get_check_constraints(table_name)
        }
        if constraint_name not in existing:
            continue
        with op.batch_alter_table(table_name) as batch_op:
            batch_op.drop_constraint(constraint_name, type_="check")


def upgrade() -> None:
    """回填规范媒体身份，并删除通用表中的全部来源专用 ID 字段。"""
    for table_name, legacy_columns in LEGACY_COLUMNS.items():
        if not _has_table(table_name):
            continue
        _ensure_identity_columns(table_name)
        _normalize_existing_sources(table_name)
        _clear_invalid_or_partial_identity(table_name)
        columns = _column_names(table_name)
        _backfill_prefixed_media_id(table_name, columns)
        _backfill_source_columns(table_name, columns)
        _drop_legacy_columns(table_name, legacy_columns)
    _ensure_identity_indexes()
    _ensure_identity_constraints()


def _restore_legacy_columns(table_name: str, columns: Iterable[str]) -> None:
    """降级时恢复旧字段，并从当前主身份回填能够确定的来源字段。"""
    if not _has_table(table_name):
        return
    existing = _column_names(table_name)
    with op.batch_alter_table(table_name) as batch_op:
        for column in columns:
            if column in existing:
                continue
            column_type = sa.Integer() if column in {
                "tmdbid", "tvdbid", "bangumiid", "anilistid"
            } else sa.String()
            batch_op.add_column(sa.Column(column, column_type, nullable=True))
    columns = _column_names(table_name)
    table = sa.table(
        table_name,
        sa.column("media_source", sa.String()),
        sa.column("media_id", sa.String()),
        *[sa.column(column, sa.String()) for column in columns if column in LEGACY_COLUMNS[table_name]],
    )
    for source, field in SOURCE_COLUMNS:
        if field not in columns:
            continue
        value = table.c.media_id
        if field in {"tmdbid", "tvdbid", "bangumiid", "anilistid"}:
            value = sa.cast(table.c.media_id, sa.Integer())
        op.get_bind().execute(
            table.update()
            .where(table.c.media_source == source)
            .where(table.c.media_id.is_not(None))
            .values({field: value})
        )


def downgrade() -> None:
    """恢复旧列；已被规范身份舍弃的辅助来源 ID 无法无损恢复。"""
    _drop_identity_constraints()
    for table_name, legacy_columns in LEGACY_COLUMNS.items():
        _restore_legacy_columns(table_name, legacy_columns)
    if _has_table("mediaserveritem"):
        existing_indexes = {
            index["name"] for index in _inspector().get_indexes("mediaserveritem")
        }
        for index_name in (
            "ix_mediaserveritem_media_identity_type",
            "ix_mediaserveritem_media_identity",
            "ix_mediaserveritem_media_source",
            "ix_mediaserveritem_media_id",
        ):
            if index_name in existing_indexes:
                op.drop_index(index_name, table_name="mediaserveritem")
        with op.batch_alter_table("mediaserveritem") as batch_op:
            batch_op.drop_column("media_id")
            batch_op.drop_column("media_source")
