from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Protocol, Union

from app.application.configuration import TransferRetryConfig, get_transfer_retry_config
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.media import resolve_media_identity
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.foundation.text import cut as jieba_cut
from app.runtime.cache import TTLCache
from app.runtime.log import logger
from app.schemas.history import (
    DownloadHistory as DownloadHistoryView,
    TransferHistory as TransferHistoryView,
    TransferHistoryPage,
)
from app.schemas.workflow import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MUSIC_ENTITY_RECORDING

# 失败重试次数的合法区间。下界为 1：一次瞬时故障（网络抖动、TMDB 瞬断、移动失败）
# 不该让文件永久漏整理，所以不允许关闭重试；上界为 10：永远识别不出的文件重试再多
# 也不会成功，只会重复推送失败通知，批量导入场景下会刷屏，所以不允许无限重试
MIN_FAILED_RETRIES = 1
MAX_FAILED_RETRIES = 10

# 同一源路径的连续整理失败状态。整理链在写失败历史时累计、整理成功或删除历史时清零，
# 查重闸只读不写，避免监控层与整理链对同一个事件重复计数。缓存值会同时保存文件指纹，
# 因此同一路径的新版本天然获得独立预算；内存缓存会随进程重启清空，Redis 后端则保留到 TTL 到期。
FAILED_RETRY_TTL = 24 * 3600
_failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL)


class TransferHistoryRecord(Protocol):
    """整理历史用例读取的最小记录投影。"""

    id: int
    status: bool
    src: Optional[str]
    src_storage: Optional[str]
    src_fileitem: Optional[dict]


class TransferHistoryWriter(Protocol):
    """整理历史写入和查重端口。"""

    def get_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]:
        """按源路径读取记录。"""

    def get_success_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]:
        """按源路径读取成功记录。"""

    def add_force(self, **payload: Any) -> Optional[TransferHistoryRecord]:
        """强制写入整理历史。"""


_configured_transfer_history_provider: Callable[[], TransferHistoryWriter] | None = None


def configure_transfer_history_provider(
    provider: Callable[[], TransferHistoryWriter],
) -> None:
    """由启动组合根登记整理历史数据端口提供器。"""
    global _configured_transfer_history_provider
    _configured_transfer_history_provider = provider


def _get_transfer_history_writer(
    writer: Optional[TransferHistoryWriter],
) -> TransferHistoryWriter:
    """获取显式传入或组合根登记的整理历史数据端口。"""
    if writer is not None:
        return writer
    if _configured_transfer_history_provider is None:
        raise RuntimeError("整理历史数据端口尚未配置")
    return _configured_transfer_history_provider()


class TransferHistoryPort:
    """把监控等宿主用例的存量构造形态转发到整理历史端口。"""

    def __getattr__(self, name: str) -> Any:
        """转发整理历史读写方法，避免上层直接导入数据库操作器。"""
        return getattr(_get_transfer_history_writer(None), name)


@dataclass(frozen=True, slots=True)
class HistoryMutationResult:
    """描述历史记录维护操作是否成功及兼容提示。"""

    success: bool
    message: str = ""


class AsyncDownloadHistoryQueryRepository(Protocol):
    """下载历史只读用例需要的最小异步持久化端口。"""

    async def async_list_by_page(
        self,
        page: int = 1,
        count: int = 30,
    ) -> list[Any]:
        """按下载时间倒序分页读取历史记录。"""
        ...


class AsyncTransferHistoryQueryRepository(Protocol):
    """整理历史列表和详情查询需要的最小异步持久化端口。"""

    async def async_get(self, historyid: int) -> Optional[Any]:
        """按主键读取单条整理历史。"""
        ...

    async def async_list_by_title(
        self,
        title: str,
        page: int = 1,
        count: int = 30,
        status: Optional[bool] = None,
        wildcard: bool = False,
    ) -> list[Any]:
        """按标题或路径分页读取整理历史。"""
        ...

    async def async_list_by_page(
        self,
        page: int = 1,
        count: int = 30,
        status: Optional[bool] = None,
    ) -> list[Any]:
        """按时间倒序分页读取整理历史。"""
        ...

    async def async_count(self, status: Optional[bool] = None) -> Optional[int]:
        """统计指定状态的整理历史数量。"""
        ...

    async def async_count_by_title(
        self,
        title: str,
        status: Optional[bool] = None,
        wildcard: bool = False,
    ) -> Optional[int]:
        """统计匹配标题或路径的整理历史数量。"""
        ...


@dataclass(frozen=True, slots=True)
class ManualTransferHistory:
    """手动整理准备阶段需要的稳定历史投影。"""

    id: int
    status: bool
    mode: Optional[str]
    src_fileitem: Optional[dict]
    dest_fileitem: Optional[dict]
    downloader: Optional[str]
    download_hash: Optional[str]
    type: Optional[str]
    media_source: Optional[str]
    media_id: Optional[str]
    music_type: Optional[str]
    seasons: Optional[str]
    episodes: Optional[str]
    episode_group: Optional[str]


class TransferHistoryLookupRepository(Protocol):
    """手动整理历史投影所需的同步查询端口。"""

    def get(self, history_id: int) -> Optional[Any]:
        """按主键读取整理历史。"""
        ...


class TransferHistoryLookupService:
    """向同步整理用例提供脱离 ORM 会话的历史投影。"""

    def __init__(self, repository: TransferHistoryLookupRepository) -> None:
        """保存整理历史只读端口。"""
        self._repository = repository

    def get(self, history_id: int) -> Optional[ManualTransferHistory]:
        """按主键读取手动整理所需字段。"""
        record = self._repository.get(history_id)
        if record is None:
            return None
        return ManualTransferHistory(
            id=record.id,
            status=bool(record.status),
            mode=record.mode,
            src_fileitem=record.src_fileitem,
            dest_fileitem=record.dest_fileitem,
            downloader=record.downloader,
            download_hash=record.download_hash,
            type=record.type,
            media_source=record.media_source,
            media_id=record.media_id,
            music_type=getattr(record, "music_type", None),
            seasons=record.seasons,
            episodes=record.episodes,
            episode_group=record.episode_group,
        )


class HistoryQueryService:
    """提供历史列表和详情 DTO，隔离 API 与数据库模型。"""

    def __init__(
        self,
        *,
        download_repository: AsyncDownloadHistoryQueryRepository,
        transfer_repository: AsyncTransferHistoryQueryRepository,
    ) -> None:
        """保存下载历史和整理历史的只读端口。"""
        self._download_repository = download_repository
        self._transfer_repository = transfer_repository

    async def list_download(
        self,
        *,
        page: int = 1,
        count: int = 30,
    ) -> list[DownloadHistoryView]:
        """分页读取下载历史并转换为稳定的接口 DTO。"""
        records = await self._download_repository.async_list_by_page(page, count)
        return [DownloadHistoryView.model_validate(record) for record in records]

    async def list_transfer(
        self,
        *,
        title: Optional[str] = None,
        page: int = 1,
        count: int = 30,
        status: Optional[bool] = None,
    ) -> TransferHistoryPage:
        """应用历史筛选规则并返回整理历史分页 DTO。"""
        if title == "失败":
            title = None
            status = False
        elif title == "成功":
            title = None
            status = True

        if title:
            wildcard = "*" in title or "?" in title
            if wildcard:
                pattern = self._glob_to_like(title)
            else:
                pattern = "%".join(jieba_cut(title, HMM=False))
            total = await self._transfer_repository.async_count_by_title(
                pattern,
                status=status,
                wildcard=wildcard,
            )
            records = await self._transfer_repository.async_list_by_title(
                pattern,
                page=page,
                count=count,
                status=status,
                wildcard=wildcard,
            )
        else:
            records = await self._transfer_repository.async_list_by_page(
                page=page,
                count=count,
                status=status,
            )
            total = await self._transfer_repository.async_count(status=status)

        return TransferHistoryPage(
            list=[TransferHistoryView.model_validate(record) for record in records],
            total=int(total or 0),
        )

    async def get_transfer(self, history_id: int) -> Optional[TransferHistoryView]:
        """读取单条整理历史 DTO，不向调用方泄漏 ORM 实例。"""
        record = await self._transfer_repository.async_get(history_id)
        if record is None:
            return None
        return TransferHistoryView.model_validate(record)

    async def get_transfers(
        self,
        history_ids: list[int],
    ) -> tuple[list[TransferHistoryView], list[int]]:
        """按输入顺序读取多条整理历史，并同时返回缺失 ID。"""
        records: list[TransferHistoryView] = []
        missing_ids: list[int] = []
        for history_id in history_ids:
            record = await self.get_transfer(history_id)
            if record is None:
                missing_ids.append(history_id)
            else:
                records.append(record)
        return records, missing_ids

    @staticmethod
    def _glob_to_like(pattern: str) -> str:
        """将 glob 通配符转换为使用反斜杠转义的 SQL LIKE 模式。"""
        result = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
        return result.replace("*", "%").replace("?", "_")


class DownloadHistoryMutationRepository(Protocol):
    """下载历史删除用例需要的最小持久化端口。"""

    def stage_delete_history(self, history_id: int) -> None:
        """暂存下载历史删除。"""
        ...


class TransferHistoryMutationRepository(Protocol):
    """整理历史删除与清理用例需要的最小持久化端口。"""

    def get(self, history_id: int) -> Optional[Any]:
        """读取整理历史。"""
        ...

    def stage_delete(self, history_id: int) -> None:
        """暂存整理历史删除。"""
        ...

    def stage_truncate(self) -> None:
        """暂存全部整理历史删除。"""
        ...


class DownloadFileMutationRepository(Protocol):
    """整理历史删除时关联下载文件状态更新端口。"""

    def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
        """暂存下载文件删除状态。"""
        ...


class HistoryUnitOfWork(Protocol):
    """同步历史维护用例使用的事务端口。"""

    def commit(self) -> None:
        """提交当前事务。"""
        ...

    def rollback(self) -> None:
        """回滚当前事务。"""
        ...


class DownloadHistoryMutationCommand:
    """统一提交下载历史删除，避免 API 直接持有数据库事务。"""

    def __init__(
        self,
        *,
        repository: DownloadHistoryMutationRepository,
        unit_of_work: HistoryUnitOfWork,
    ) -> None:
        """保存下载历史持久化和事务端口。"""
        self._repository = repository
        self._unit_of_work = unit_of_work

    def delete(self, history_id: int) -> HistoryMutationResult:
        """暂存并提交单条下载历史删除。"""
        self._repository.stage_delete_history(history_id)
        self._commit()
        return HistoryMutationResult(True)

    def _commit(self) -> None:
        """提交事务，失败时回滚。"""
        try:
            self._unit_of_work.commit()
        except Exception:
            self._unit_of_work.rollback()
            raise


class TransferHistoryMutationCommand:
    """协调整理历史、关联文件状态和外部存储删除。"""

    def __init__(
        self,
        *,
        repository: TransferHistoryMutationRepository,
        download_repository: DownloadFileMutationRepository,
        unit_of_work: HistoryUnitOfWork,
        file_item_factory: Callable[[dict], Any],
        delete_media_file: Callable[[Any], bool],
        publish_download_file_deleted: Callable[[dict], None],
        clear_failures: Callable[[Optional[str], Optional[str]], None],
    ) -> None:
        """保存历史事务、存储删除、事件和失败状态清理端口。"""
        self._repository = repository
        self._download_repository = download_repository
        self._unit_of_work = unit_of_work
        self._file_item_factory = file_item_factory
        self._delete_media_file = delete_media_file
        self._publish_download_file_deleted = publish_download_file_deleted
        self._clear_failures = clear_failures

    def delete(
        self,
        history_id: int,
        *,
        delete_source: bool = False,
        delete_destination: bool = False,
    ) -> HistoryMutationResult:
        """删除整理记录，并保持源文件失败时不提交数据库变更。"""
        history = self._repository.get(history_id)
        if not history:
            return HistoryMutationResult(False, "记录不存在")

        if delete_destination and history.dest_fileitem:
            destination = self._file_item_factory(history.dest_fileitem)
            self._delete_media_file(destination)

        source_deleted = False
        if delete_source and history.src_fileitem:
            source = self._file_item_factory(history.src_fileitem)
            if not self._delete_media_file(source):
                return HistoryMutationResult(False, f"{source.path} 删除失败")
            self._download_repository.stage_delete_file_by_fullpath(
                Path(source.path).as_posix()
            )
            source_deleted = True

        self._repository.stage_delete(history_id)
        self._commit()
        if source_deleted:
            self._publish_download_file_deleted({
                "src": history.src,
                "hash": history.download_hash,
            })
        self._clear_failures(history.src, history.src_storage)
        return HistoryMutationResult(True)

    def truncate(self) -> HistoryMutationResult:
        """在单一事务中清空全部整理历史。"""
        self._repository.stage_truncate()
        self._commit()
        return HistoryMutationResult(True)

    def _commit(self) -> None:
        """提交历史事务，失败时回滚且不发布事件或清缓存。"""
        try:
            self._unit_of_work.commit()
        except Exception:
            self._unit_of_work.rollback()
            raise


class HistoryGateAction:
    """
    整理历史查重闸的判定结果。

    监控分发（app/monitor/dispatcher.py）与整理链计划整理段（app/chain/transfer.py）
    共用本模块，避免两处各写一套去重策略后互相对冲：上游放行的文件被下游按
    「存在记录即拦」全额收回，等于放行逻辑完全失效。
    """
    # 没有整理记录
    PASS_NO_RECORD = "pass_no_record"
    # 上次整理失败且重试次数未用尽，放行重试
    PASS_FAILED = "pass_failed"
    # 上次整理失败但源文件已变为新版本，放行并重置该版本的重试预算
    PASS_FAILED_VERSION_CHANGED = "pass_failed_version_changed"
    # 已整理成功但源文件已变化，放行交由 overwrite_mode 决断
    PASS_SIZE_CHANGED = "pass_size_changed"
    # 上次整理失败且重试次数已用尽，跳过
    SKIP_RETRY_EXHAUSTED = "skip_retry_exhausted"
    # 已整理成功且源文件未变化，跳过
    SKIP = "skip"


def is_skip_action(action: str) -> bool:
    """
    判断查重闸判定是否为跳过整理。
    :param action: HistoryGateAction 之一
    :return: True 表示跳过
    """
    return action in (HistoryGateAction.SKIP, HistoryGateAction.SKIP_RETRY_EXHAUSTED)


def max_failed_retries(config: TransferRetryConfig | None = None) -> int:
    """
    读取失败重试上限并钳制到合法区间。

    配置为负数、0 或超过上界时都会被钳制并记录 warn：关闭重试会让瞬时故障造成
    永久漏件，无限重试会让永久失败的文件反复刷通知，两端都不接受。
    :return: 合法的最大重试次数
    """
    raw = (config or get_transfer_retry_config()).max_failed_retries
    try:
        value = int(raw)
    except (TypeError, ValueError):
        logger.warn(f"TRANSFER_MAX_FAILED_RETRIES 配置非法（{raw!r}），"
                    f"已回退为 {MIN_FAILED_RETRIES}")
        return MIN_FAILED_RETRIES
    if value < MIN_FAILED_RETRIES:
        logger.warn(f"TRANSFER_MAX_FAILED_RETRIES 不能小于 {MIN_FAILED_RETRIES}"
                    f"（当前 {value}），已按 {MIN_FAILED_RETRIES} 处理")
        return MIN_FAILED_RETRIES
    if value > MAX_FAILED_RETRIES:
        logger.warn(f"TRANSFER_MAX_FAILED_RETRIES 不能大于 {MAX_FAILED_RETRIES}"
                    f"（当前 {value}），已按 {MAX_FAILED_RETRIES} 处理")
        return MAX_FAILED_RETRIES
    return value


def failed_retry_key(src_path: Optional[str], storage: Optional[str] = None) -> Optional[str]:
    """
    生成失败重试计数的缓存键。
    :param src_path: 整理记录使用的源路径
    :param storage: 源存储
    :return: 缓存键，源路径为空时返回 None
    """
    if not src_path:
        return None
    return f"{storage or 'local'}:{src_path}"


def coerce_modify_time(modify_time: Any) -> Optional[float]:
    """
    统一转换文件修改时间，无法转换时返回 None。
    :param modify_time: 原始修改时间值
    :return: 文件修改时间
    """
    if modify_time is None:
        return None
    try:
        return float(modify_time)
    except (TypeError, ValueError):
        return None


def coerce_fileid(fileid: Any) -> Optional[str]:
    """
    统一转换文件唯一标识，空值视为不可比对。
    :param fileid: 原始文件唯一标识
    :return: 非空文件唯一标识
    """
    if fileid is None:
        return None
    value = str(fileid).strip()
    return value or None


def file_fingerprint(
        file_size: Any = None,
        file_modify_time: Any = None,
        fileid: Any = None,
) -> Dict[str, Any]:
    """
    生成用于区分同一路径文件版本的稳定指纹。

    大小是所有存储器都尽量提供的最小指纹；两端均有数据时，修改时间和文件 ID 还可
    识别“同大小替换”。只保留可比较字段，避免缺失元数据把同一文件误判成新版本。
    :param file_size: 文件大小
    :param file_modify_time: 文件修改时间
    :param fileid: 存储器文件唯一标识
    :return: 非空且可比较的指纹字段
    """
    fingerprint = {}
    size = coerce_size(file_size)
    if size is not None:
        fingerprint["size"] = size
    modify_time = coerce_modify_time(file_modify_time)
    if modify_time is not None:
        fingerprint["modify_time"] = modify_time
    normalized_fileid = coerce_fileid(fileid)
    if normalized_fileid is not None:
        fingerprint["fileid"] = normalized_fileid
    return fingerprint


def _retry_state(value: Any) -> tuple[int, Dict[str, Any]]:
    """将新旧缓存值统一转换为失败次数与文件指纹。"""
    if isinstance(value, dict):
        raw_count = value.get("count", 0)
        raw_fingerprint = value.get("fingerprint")
    else:
        # 兼容已写入 Redis 或内存的旧整数计数；下次带指纹写入时会自动升级结构。
        raw_count = value
        raw_fingerprint = None
    try:
        count = max(int(raw_count or 0), 0)
    except (TypeError, ValueError):
        count = 0
    fingerprint = (
        file_fingerprint(
            file_size=raw_fingerprint.get("size"),
            file_modify_time=raw_fingerprint.get("modify_time"),
            fileid=raw_fingerprint.get("fileid"),
        )
        if isinstance(raw_fingerprint, dict)
        else {}
    )
    return count, fingerprint


def _is_file_version_changed(
        recorded_fingerprint: Dict[str, Any],
        current_fingerprint: Dict[str, Any],
) -> bool:
    """判断两个可比文件指纹是否指向不同版本。"""
    for field in ("fileid", "modify_time", "size"):
        recorded_value = recorded_fingerprint.get(field)
        current_value = current_fingerprint.get(field)
        if (
                recorded_value is not None
                and current_value is not None
                and recorded_value != current_value
        ):
            return True
    return False


def failed_retry_count(src_path: Optional[str], storage: Optional[str] = None,
                       file_size: Any = None, file_modify_time: Any = None,
                       fileid: Any = None) -> int:
    """
    读取同一源路径已累计的连续整理失败次数。
    :param src_path: 整理记录使用的源路径
    :param storage: 源存储
    :param file_size: 当前文件大小
    :param file_modify_time: 当前文件修改时间
    :param fileid: 当前文件唯一标识
    :return: 当前文件版本已失败次数，无记录时为 0
    """
    key = failed_retry_key(src_path, storage)
    if not key:
        return 0
    count, recorded_fingerprint = _retry_state(_failed_retry_counts.get(key))
    current_fingerprint = file_fingerprint(
        file_size=file_size,
        file_modify_time=file_modify_time,
        fileid=fileid,
    )
    if (
            recorded_fingerprint
            and current_fingerprint
            and _is_file_version_changed(recorded_fingerprint, current_fingerprint)
    ):
        return 0
    return count


def record_transfer_failure(src_path: Optional[str], storage: Optional[str] = None,
                            file_size: Any = None, file_modify_time: Any = None,
                            fileid: Any = None) -> int:
    """
    累计一次整理失败。
    :param src_path: 整理记录使用的源路径
    :param storage: 源存储
    :param file_size: 当前文件大小
    :param file_modify_time: 当前文件修改时间
    :param fileid: 当前文件唯一标识
    :return: 当前文件版本累计后的失败次数
    """
    key = failed_retry_key(src_path, storage)
    if not key:
        return 0
    count, recorded_fingerprint = _retry_state(_failed_retry_counts.get(key))
    current_fingerprint = file_fingerprint(
        file_size=file_size,
        file_modify_time=file_modify_time,
        fileid=fileid,
    )
    if current_fingerprint and (
            not recorded_fingerprint
            or _is_file_version_changed(recorded_fingerprint, current_fingerprint)
    ):
        count = 0
    count += 1
    if current_fingerprint:
        _failed_retry_counts[key] = {
            "count": count,
            "fingerprint": current_fingerprint,
        }
    elif recorded_fingerprint:
        _failed_retry_counts[key] = {
            "count": count,
            "fingerprint": recorded_fingerprint,
        }
    else:
        _failed_retry_counts[key] = count
    return count


def clear_transfer_failures(src_path: Optional[str], storage: Optional[str] = None) -> None:
    """
    清空同一源路径的失败计数。整理成功、或用户删除整理记录（显式要求重来）时调用。
    :param src_path: 整理记录使用的源路径
    :param storage: 源存储
    """
    key = failed_retry_key(src_path, storage)
    if key:
        # 缺省值必须是 0 而不是 None：CacheBackend.pop 把「default 为 None」当成「未提供
        # default」，键不存在时会抛 KeyError。整理成功路径上绝大多数文件从未失败过，
        # 传 None 会让每一次首次成功整理都炸掉成功回调
        _failed_retry_counts.pop(key, 0)


def coerce_size(size: Any) -> Optional[int]:
    """
    统一转换文件大小，无法转换时返回 None（视为不可比对）。
    :param size: 原始大小值
    :return: 文件大小
    """
    if size is None:
        return None
    try:
        return int(size)
    except (TypeError, ValueError):
        return None


def history_src_size(history: TransferHistoryRecord) -> Optional[int]:
    """
    读取整理记录中的源文件大小。
    src_fileitem 是 JSON 列，历史数据可能为空、缺 size 键甚至不是字典，
    取不到时统一返回 None 交由调用方保守处理。
    :param history: 整理记录
    :return: 源文件大小，取不到时为 None
    """
    return history_src_fingerprint(history).get("size")


def history_src_fingerprint(history: TransferHistoryRecord) -> Dict[str, Any]:
    """
    读取整理记录中的源文件版本指纹。
    :param history: 整理记录
    :return: 源文件的可比较指纹字段
    """
    src_fileitem = getattr(history, "src_fileitem", None)
    if not isinstance(src_fileitem, dict):
        return {}
    return file_fingerprint(
        file_size=src_fileitem.get("size"),
        file_modify_time=src_fileitem.get("modify_time"),
        fileid=src_fileitem.get("fileid"),
    )


def resolve_history(src_path: str, storage: Optional[str] = None,
                    transfer_history_oper: Optional[TransferHistoryWriter] = None
                    ) -> Optional[TransferHistoryRecord]:
    """
    查询源路径对应的整理记录。

    新表通过 (src, src_storage) 唯一索引保证单条记录；仍保留对成功记录的二次确认，
    兼容升级前可能残留的重复数据，避免把已整理成功的文件重复整理。查询异常不在
    此处吞掉，由调用方按各自的重试策略处理。
    :param src_path: 整理记录使用的源路径
    :param storage: 存储
    :param transfer_history_oper: 复用的历史操作对象，未传时新建
    :return: 命中的整理记录，未命中时为 None
    """
    oper = _get_transfer_history_writer(transfer_history_oper)
    history = oper.get_by_src(src_path, storage=storage)
    if history is not None and not history.status:
        history = oper.get_success_by_src(src_path, storage=storage) or history
    return history


def evaluate_history_gate(history: Optional[TransferHistoryRecord],
                          file_size: Optional[float] = None,
                          file_modify_time: Optional[float] = None,
                          fileid: Optional[str] = None,
                          retry_count: Optional[int] = None) -> str:
    """
    依据整理历史判断本次是否跳过整理。

    成功记录不能简单地「存在即跳过」：同路径重新上传的新版本会因此没有机会走到
    整理链的 overwrite_mode 判定，升级永远无法入库，故任一可比文件指纹变化时一律放行。
    失败记录按文件版本使用有界重试：新版本先放行并在下一次失败时从 1 重新计数，
    同一版本未达上限时继续重试，让瞬时故障（网络/识别/移动）自愈；达到上限后跳过，
    避免永久失败的文件反复刷失败通知。
    :param history: 整理记录，未命中时为 None
    :param file_size: 当前文件大小，蓝光目录等场景可能为 None
    :param file_modify_time: 当前文件修改时间
    :param fileid: 当前文件唯一标识
    :param retry_count: 已累计的失败次数，None 表示按记录源路径实时查询
    :return: HistoryGateAction 之一
    """
    if history is None:
        return HistoryGateAction.PASS_NO_RECORD
    recorded_fingerprint = history_src_fingerprint(history)
    current_fingerprint = file_fingerprint(
        file_size=file_size,
        file_modify_time=file_modify_time,
        fileid=fileid,
    )
    if not history.status:
        if _is_file_version_changed(recorded_fingerprint, current_fingerprint):
            return HistoryGateAction.PASS_FAILED_VERSION_CHANGED
        if retry_count is None:
            retry_count = failed_retry_count(
                getattr(history, "src", None),
                getattr(history, "src_storage", None),
                file_size=file_size,
                file_modify_time=file_modify_time,
                fileid=fileid,
            )
        if retry_count >= max_failed_retries():
            return HistoryGateAction.SKIP_RETRY_EXHAUSTED
        # 监控事件是稀疏驱动的（落地事件/延迟重扫/补偿扫描），入口还有 TTL 去重兜底，
        # 配合失败次数上限，重试频率与总量都可控
        return HistoryGateAction.PASS_FAILED
    if _is_file_version_changed(recorded_fingerprint, current_fingerprint):
        # 同路径换成了另一个版本（如升级为更高码率），是否覆盖交给整理链的
        # overwrite_mode 决断，查重闸不做替代判断
        return HistoryGateAction.PASS_SIZE_CHANGED
    # 无法比对大小（蓝光目录、历史记录缺 size）时保守跳过，避免重复整理
    return HistoryGateAction.SKIP


def describe_history_gate(history: Optional[TransferHistoryRecord],
                          file_size: Optional[float] = None,
                          file_modify_time: Optional[float] = None,
                          fileid: Optional[str] = None) -> str:
    """
    生成查重闸判定的可读说明，供日志定位「到底是哪条记录在拦」。
    :param history: 整理记录
    :param file_size: 当前文件大小
    :param file_modify_time: 当前文件修改时间
    :param fileid: 当前文件唯一标识
    :return: 说明文本
    """
    if history is None:
        return "无整理记录"
    recorded_fingerprint = history_src_fingerprint(history)
    current_fingerprint = file_fingerprint(
        file_size=file_size,
        file_modify_time=file_modify_time,
        fileid=fileid,
    )
    if not history.status:
        count = failed_retry_count(
            getattr(history, "src", None),
            getattr(history, "src_storage", None),
            file_size=file_size,
            file_modify_time=file_modify_time,
            fileid=fileid,
        )
        if _is_file_version_changed(recorded_fingerprint, current_fingerprint):
            return f"失败记录 #{history.id}，文件版本已变化，重试预算将重置"
        return f"失败记录 #{history.id}，已重试 {count}/{max_failed_retries()} 次"
    recorded_size = recorded_fingerprint.get("size")
    current_size = current_fingerprint.get("size")
    if recorded_size is None and current_size is None:
        return f"成功记录 #{history.id}，大小不可比对"
    return f"成功记录 #{history.id}，大小 {recorded_size} -> {current_size}"


# --------------------------------------------------------------------------- #
# 整理历史的写入路径
#
# 这两个函数把 FileItem / MetaBase / MediaInfo / TransferInfo 四个领域对象翻译成
# 一行整理历史，是整理历史表的唯一写入口。它们此前长在 TransferHistoryOper 上，但
# 拼标题、拆季集、取海报、判音乐字段都是整理链的业务规则而非数据访问——Oper 只该
# 收敛查询，领域对象不该出现在它的入参里。搬到本模块与查重闸（读侧）作伴：同一张
# 表的读写规则放在一起，字段含义只有一处需要维护。
# --------------------------------------------------------------------------- #

def _history_title(meta: MetaBase,
                   mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None) -> Optional[str]:
    """音乐文件优先记录曲目标题，其它媒体保持识别标题。"""
    if isinstance(meta, MetaMusic) and meta.title:
        return meta.title
    if mediainfo and mediainfo.title:
        return mediainfo.title
    return meta.name


def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase,
                         mediainfo: Union[MediaInfo, MusicInfo], transferinfo: TransferInfo,
                         downloader: Optional[str] = None,
                         download_hash: Optional[str] = None,
                         transfer_history_oper: Optional[TransferHistoryWriter] = None
                         ) -> Optional[TransferHistoryRecord]:
    """
    新增转移成功历史记录。
    :param fileitem: 源文件项
    :param mode: 整理方式
    :param meta: 文件名识别结果
    :param mediainfo: 媒体识别结果
    :param transferinfo: 整理结果
    :param downloader: 下载器
    :param download_hash: 种子 hash
    :param transfer_history_oper: 复用的历史操作对象，未传时新建
    :return: 落库后的整理记录
    """
    oper = _get_transfer_history_writer(transfer_history_oper)
    media_source, media_id = resolve_media_identity(media=mediainfo)
    return oper.add_force(
        src=fileitem.path,
        src_storage=fileitem.storage,
        src_fileitem=fileitem.model_dump(),
        dest=transferinfo.target_item.path if transferinfo.target_item else None,
        dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,
        dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None,
        mode=mode,
        type=mediainfo.type.value,
        category=mediainfo.category,
        title=_history_title(meta, mediainfo),
        year=mediainfo.year,
        media_source=media_source,
        media_id=media_id,
        music_type=getattr(mediainfo, "music_type", None),
        total_tracks=getattr(mediainfo, "total_tracks", None),
        audio_format=getattr(meta, "audio_format", None),
        audio_lossless=getattr(meta, "audio_lossless", None),
        bit_depth=getattr(meta, "bit_depth", None),
        sample_rate=getattr(meta, "sample_rate", None),
        bitrate=getattr(meta, "bitrate", None),
        seasons=meta.season,
        episodes=meta.episode,
        image=mediainfo.get_poster_image(),
        downloader=downloader,
        download_hash=download_hash,
        status=1,
        files=transferinfo.file_list
    )


def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
                      mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
                      transferinfo: Optional[TransferInfo] = None,
                      downloader: Optional[str] = None,
                      download_hash: Optional[str] = None,
                      transfer_history_oper: Optional[TransferHistoryWriter] = None
                      ) -> Optional[TransferHistoryRecord]:
    """
    新增转移失败历史记录。

    识别结果与整理结果齐备时按完整字段落库；缺任一项则走「未识别到媒体信息」分支，
    此时只有文件名解析出的元数据可用，不写目标路径。
    :param fileitem: 源文件项
    :param mode: 整理方式
    :param meta: 文件名识别结果
    :param mediainfo: 媒体识别结果，未识别时为 None
    :param transferinfo: 整理结果，未进入整理时为 None
    :param downloader: 下载器
    :param download_hash: 种子 hash
    :param transfer_history_oper: 复用的历史操作对象，未传时新建
    :return: 落库后的整理记录
    """
    oper = _get_transfer_history_writer(transfer_history_oper)
    if mediainfo and transferinfo:
        media_source, media_id = resolve_media_identity(media=mediainfo)
        his = oper.add_force(
            src=fileitem.path,
            src_storage=fileitem.storage,
            src_fileitem=fileitem.model_dump(),
            dest=transferinfo.target_item.path if transferinfo.target_item else None,
            dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,
            dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None,
            mode=mode,
            type=mediainfo.type.value,
            category=mediainfo.category,
            title=_history_title(meta, mediainfo),
            year=mediainfo.year or meta.year,
            media_source=media_source,
            media_id=media_id,
            music_type=getattr(mediainfo, "music_type", None),
            total_tracks=getattr(mediainfo, "total_tracks", None),
            audio_format=getattr(meta, "audio_format", None),
            audio_lossless=getattr(meta, "audio_lossless", None),
            bit_depth=getattr(meta, "bit_depth", None),
            sample_rate=getattr(meta, "sample_rate", None),
            bitrate=getattr(meta, "bitrate", None),
            seasons=meta.season,
            episodes=meta.episode,
            image=mediainfo.get_poster_image(),
            downloader=downloader,
            download_hash=download_hash,
            episode_group=mediainfo.episode_group,
            status=0,
            errmsg=transferinfo.message or '未知错误',
            files=transferinfo.file_list
        )
    else:
        media_source, media_id = resolve_media_identity(media=meta)
        his = oper.add_force(
            type=meta.type.value if meta.type else None,
            title=_history_title(meta),
            year=meta.year,
            media_source=media_source,
            media_id=media_id,
            music_type=MUSIC_ENTITY_RECORDING if isinstance(meta, MetaMusic) else None,
            audio_format=getattr(meta, "audio_format", None),
            audio_lossless=getattr(meta, "audio_lossless", None),
            bit_depth=getattr(meta, "bit_depth", None),
            sample_rate=getattr(meta, "sample_rate", None),
            bitrate=getattr(meta, "bitrate", None),
            src=fileitem.path,
            src_storage=fileitem.storage,
            src_fileitem=fileitem.model_dump(),
            mode=mode,
            seasons=meta.season,
            episodes=meta.episode,
            downloader=downloader,
            download_hash=download_hash,
            status=0,
            errmsg="未识别到媒体信息"
        )
    return his
