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

from app.runtime.config import settings
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metavideo import MetaVideo
from app.chain.transfer import JobManager, TransferChain
from app.application.history import (
    clear_transfer_failures,
    failed_retry_count,
    record_transfer_failure,
)
from app.modules.filemanager.transhandler import TransHandler
from app.schemas import EpisodeFormat, FileItem, TransferInfo
from app.application.transfer import TransferTask
from app.schemas.types import EventType, MediaSource, MediaType


def _reset_failed_retries(src_path, storage=None):
    """清空失败重试计数，隔离用例之间共享的模块级计数缓存。"""
    clear_transfer_failures(src_path, storage)


class FakeMeta(MetaBase):
    """
    整理任务分组需要的最小剧集元数据。

    继承 MetaBase 而非鸭子类型：TransferTask.meta 已标注为 MetaBase，pydantic 会做
    isinstance 校验。下面三个类级属性用来遮蔽 MetaBase 上的同名 property，好让
    __init__ 能直接赋值。
    """

    name = None
    episode_list = None
    season_episode = None

    def __init__(self, episode: int, season: int = 1):
        super().__init__(title=f"Test Show S{season:02d}E{episode:02d}")
        self.name = "Test Show"
        self.title = f"Test Show S{season:02d}E{episode:02d}"
        self.year = "2026"
        self.type = MediaType.TV
        self.begin_season = season
        self.end_season = None
        self.total_season = 1
        self.begin_episode = episode
        self.end_episode = None
        self.total_episode = 1
        self.episode_list = [episode]
        self.season_episode = f"S01E{episode:02d}"
        self.part = None

    @property
    def season(self):
        return f"S{self.begin_season:02d}"

    @property
    def episode(self):
        return f"E{self.begin_episode:02d}"

    def to_dict(self):
        return {
            "title": self.title,
            "name": self.name,
            "year": self.year,
            "type": self.type.value,
            "begin_season": self.begin_season,
            "end_season": self.end_season,
            "total_season": self.total_season,
            "begin_episode": self.begin_episode,
            "end_episode": self.end_episode,
            "total_episode": self.total_episode,
            "season_episode": self.season_episode,
            "episode_list": self.episode_list,
            "part": self.part,
        }


class FakeMedia(MediaInfo):
    """
    与正式 MediaInfo 身份字段一致的测试媒体对象。

    继承 MediaInfo 而非鸭子类型：TransferTask.mediainfo 已标注为 MusicInfo | MediaInfo，
    pydantic 会做 isinstance 校验。
    """

    def __init__(self, tmdb_id: int = 12345):
        """构造与正式 MediaInfo 身份字段一致的测试媒体对象。"""
        super().__init__()
        self.tmdb_id = tmdb_id
        self.douban_id = None
        self.bangumi_id = None
        self.anilist_id = None
        self.media_source = MediaSource.TMDB
        self.media_id = str(tmdb_id)
        self.type = MediaType.TV
        self.title_year = "Test Show (2026)"

    def clear(self):
        """模拟正式媒体对象的清理接口。"""
        pass

    def to_dict(self):
        """返回测试媒体对象的序列化字段。"""
        return {
            "type": MediaType.TV.value,
            "title": "Test Show",
            "year": "2026",
            "title_year": "Test Show (2026)",
            "media_source": self.media_source,
            "media_id": self.media_id,
            "tmdb_id": self.tmdb_id,
            "douban_id": self.douban_id,
            "bangumi_id": self.bangumi_id,
            "anilist_id": self.anilist_id,
        }


def make_media_info() -> MediaInfo:
    media = MediaInfo()
    media.type = MediaType.TV
    media.title = "Test Show"
    media.title_year = "Test Show (2026)"
    media.year = "2026"
    media.tmdb_id = 12345
    media.category = ""
    media.actors = []
    media.season_years = {}
    media.vote_average = 0
    return media


def make_task(episode: int, season: int = 1) -> TransferTask:
    name = f"Test.Show.S{season:02d}E{episode:02d}.mkv"
    return TransferTask(
        fileitem=FileItem(
            storage="local",
            path=f"/downloads/Test Show/{name}",
            type="file",
            name=name,
            basename=name.removesuffix(".mkv"),
            extension="mkv",
            size=1024,
        ),
        meta=FakeMeta(episode),
    )


def make_transfer_chain() -> TransferChain:
    chain = object.__new__(TransferChain)
    chain.jobview = JobManager()
    chain._media_exts = settings.RMT_MEDIAEXT
    chain._subtitle_exts = settings.RMT_SUBEXT
    chain._audio_exts = settings.RMT_AUDIOEXT
    chain._allowed_exts = (
        chain._media_exts + chain._audio_exts + chain._subtitle_exts
    )
    chain._success_target_files = {}
    chain._scrape_batches = {}
    return chain


def make_fileitem(path: str, size: int = 1024) -> FileItem:
    file_path = path
    name = file_path.rsplit("/", 1)[-1]
    suffix = name.rsplit(".", 1)[-1] if "." in name else ""
    basename = name[: -(len(suffix) + 1)] if suffix else name
    return FileItem(
        storage="local",
        path=file_path,
        type="file",
        name=name,
        basename=basename,
        extension=suffix,
        size=size,
    )


def migrate_to_media_job(jobview: JobManager, task: TransferTask):
    task.mediainfo = FakeMedia()
    jobview.migrate_task(task)
    jobview.running_task(task)
    jobview.finish_task(task)
    jobview.try_remove_job(task)


class TransferJobManagerTest(unittest.TestCase):
    def test_same_storage_success_uses_target_path_when_metadata_is_delayed(self):
        """
        网盘操作已成功但目标元数据暂不可见时，整理结果应按成功路径落库。
        """
        source_item = FileItem(
            storage="alist",
            path="/downloads/Test.Show.S01E01.mkv",
            type="file",
            name="Test.Show.S01E01.mkv",
            basename="Test.Show.S01E01",
            extension="mkv",
            size=1024,
            modify_time=1715939275.0,
        )
        target_path = Path(
            "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
        )
        target_folder = FileItem(
            storage="alist",
            path=f"{target_path.parent.as_posix()}/",
            type="dir",
            name=target_path.parent.name,
            basename=target_path.parent.stem,
        )
        source_oper = SimpleNamespace(
            is_support_transtype=lambda transfer_type: True,
            move=lambda fileitem, path, name: True,
        )
        target_oper = SimpleNamespace(
            get_folder=lambda path: target_folder,
            get_item=lambda path: None,
            get_item_strict=lambda path: None,
        )

        new_item, errmsg = TransHandler._TransHandler__transfer_command(
            fileitem=source_item,
            target_storage="alist",
            source_oper=source_oper,
            target_oper=target_oper,
            target_file=target_path,
            transfer_type="move",
        )

        self.assertEqual("", errmsg)
        self.assertIsNotNone(new_item)
        self.assertEqual(target_path.as_posix(), new_item.path)
        self.assertEqual("alist", new_item.storage)
        self.assertEqual("file", new_item.type)
        self.assertEqual(1024, new_item.size)

    def test_transfer_media_uses_target_folder_returned_by_storage(self):
        """
        整理成功时直接使用存储层返回的目标目录项，回调和事件不再二次拼装。
        """
        handler = TransHandler()
        source_item = FileItem(
            storage="alist",
            path="/downloads/Test.Show.S01E01.mkv",
            type="file",
            name="Test.Show.S01E01.mkv",
            basename="Test.Show.S01E01",
            extension="mkv",
            size=1024,
            modify_time=1715939275.0,
        )
        target_path = Path("/library")
        target_file = Path(
            "/library/Test.Show.S01E01.mkv"
        )
        target_folder = FileItem(
            storage="alist",
            type="dir",
            path="/library/",
            name="library",
            basename="library",
        )
        target_item = FileItem(
            storage="alist",
            path=target_file.as_posix(),
            type="file",
            name=target_file.name,
            basename=target_file.stem,
            extension="mkv",
            size=1024,
        )
        source_oper = SimpleNamespace(
            is_support_transtype=lambda transfer_type: True,
            move=lambda fileitem, path, name: True,
        )
        target_oper = SimpleNamespace(
            get_folder=lambda path: target_folder,
            get_item=lambda path: None,
            get_item_strict=lambda path: None,
        )

        with patch.object(
                TransHandler, "get_rename_path", return_value=target_file
        ), patch(
                "app.modules.filemanager.transhandler.DirectoryHelper.get_media_root_path",
                return_value=Path("/library"),
        ), patch.object(
                TransHandler,
                "_TransHandler__transfer_command",
                return_value=(target_item, ""),
        ), patch("app.modules.filemanager.transhandler.eventmanager") as eventmanager_mock:
            eventmanager_mock.send_event.return_value = None
            transferinfo = handler.transfer_media(
                fileitem=source_item,
                in_meta=MetaVideo("Test.Show.S01E01"),
                mediainfo=make_media_info(),
                target_storage="alist",
                target_path=target_path,
                transfer_type="move",
                source_oper=source_oper,
                target_oper=target_oper,
                need_scrape=True,
                need_notify=True,
            )

        self.assertTrue(transferinfo.success)
        self.assertEqual(target_item, transferinfo.target_item)
        self.assertEqual(target_folder, transferinfo.target_diritem)

    def test_single_file_transfer_intercept_event_carries_file_meta(self):
        """
        单文件整理拦截事件应携带元数据，便于事件处理器按季集匹配。
        """
        handler = TransHandler()
        source_item = FileItem(
            storage="alist",
            path="/downloads/Test.Show.S02E03.mkv",
            type="file",
            name="Test.Show.S02E03.mkv",
            basename="Test.Show.S02E03",
            extension="mkv",
            size=1024,
            modify_time=1715939275.0,
        )
        target_path = Path("/library")
        target_file = Path("/library/Test.Show.S02E03.mkv")
        target_folder = FileItem(
            storage="alist",
            type="dir",
            path="/library/",
            name="library",
            basename="library",
        )
        target_item = FileItem(
            storage="alist",
            path=target_file.as_posix(),
            type="file",
            name=target_file.name,
            basename=target_file.stem,
            extension="mkv",
            size=1024,
        )
        source_oper = SimpleNamespace(
            is_support_transtype=lambda transfer_type: True,
            move=lambda fileitem, path, name: True,
        )
        target_oper = SimpleNamespace(
            get_folder=lambda path: target_folder,
            get_item=lambda path: None,
            get_item_strict=lambda path: None,
        )
        in_meta = MetaVideo("Test.Show.S02E03")

        with patch.object(
                TransHandler, "get_rename_path", return_value=target_file
        ), patch(
                "app.modules.filemanager.transhandler.DirectoryHelper.get_media_root_path",
                return_value=Path("/library"),
        ), patch.object(
                TransHandler,
                "_TransHandler__transfer_command",
                return_value=(target_item, ""),
        ), patch(
                "app.modules.filemanager.transhandler.eventmanager.send_event",
                return_value=None,
        ) as send_event:
            transferinfo = handler.transfer_media(
                fileitem=source_item,
                in_meta=in_meta,
                mediainfo=make_media_info(),
                target_storage="alist",
                target_path=target_path,
                transfer_type="move",
                source_oper=source_oper,
                target_oper=target_oper,
                need_scrape=True,
                need_notify=True,
            )

        self.assertTrue(transferinfo.success)
        event_data = send_event.call_args.args[1]
        self.assertIs(in_meta, event_data.meta)
        self.assertEqual(2, event_data.meta.begin_season)
        self.assertEqual(3, event_data.meta.begin_episode)

    def test_success_callback_uses_transfer_result_target_diritem(self):
        """
        回调发送刮削事件时应直接使用整理结果里的目标目录项。
        """
        chain = make_transfer_chain()
        chain.eventmanager = MagicMock()
        chain.transfer_completed = lambda *args, **kwargs: None

        task = make_task(1)
        task.mediainfo = FakeMedia()
        task.background = False
        task.manual = True
        self.assertTrue(chain._TransferChain__put_to_jobview(task))

        target_diritem = FileItem(
            storage="alist",
            path="/library/Test Show (2026)/Season 1/",
            type="dir",
            name="Season 1",
        )
        target_item = FileItem(
            storage="alist",
            path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
            type="file",
            name="Test.Show.S01E01.mkv",
            extension="mkv",
        )
        transferinfo = TransferInfo(
            success=True,
            fileitem=task.fileitem,
            target_item=target_item,
            target_diritem=target_diritem,
            file_list_new=[target_item.path],
            transfer_type="copy",
            need_scrape=True,
            need_notify=False,
        )

        with patch(
            "app.chain.transfer.TransferHistoryOper", return_value=SimpleNamespace()
        ), patch(
            "app.chain.transfer.add_transfer_success",
            lambda **kwargs: SimpleNamespace(id=1),
        ):
            state, errmsg = chain._TransferChain__default_callback(task, transferinfo)

        self.assertTrue(state)
        self.assertEqual("", errmsg)
        metadata_calls = [
            call
            for call in chain.eventmanager.send_event.call_args_list
            if call.args[0] == EventType.MetadataScrape
        ]
        self.assertEqual(1, len(metadata_calls))
        event_data = metadata_calls[0].args[1]
        self.assertEqual(target_diritem, event_data["fileitem"])
        self.assertEqual([target_item.path], event_data["file_list"])

    def test_manual_episode_offset_applies_once(self):
        chain = make_transfer_chain()
        source_fileitem = make_fileitem("/downloads/Test.Show.2026.S01E14.mkv")
        planned_episodes = []

        chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
            (source_fileitem, False)
        ]
        chain._TransferChain__put_to_jobview = lambda task: True
        chain._register_scrape_batch_task = lambda task: None
        chain._close_scrape_batch = lambda batch_id: None

        def fake_handle_transfer(task, callback=None):
            planned_episodes.append(task.meta.begin_episode)
            return True, ""

        chain._TransferChain__handle_transfer = fake_handle_transfer

        transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
        download_history_oper = SimpleNamespace(
            get_by_hash=lambda download_hash: None,
            get_file_by_fullpath=lambda fullpath: None,
            get_files_by_savepath=lambda savepath: [],
            get_by_path=lambda path: None,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)

        with patch("app.chain.transfer.TransferHistoryOper", return_value=transfer_history_oper), \
                patch("app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper), \
                patch("app.chain.transfer.get_configured_system_config", return_value=system_config_oper), \
                patch("app.chain.transfer.MetaInfoPath", lambda *args, **kwargs: FakeMeta(14)):
            state, errmsg = chain.do_transfer(
                fileitem=source_fileitem,
                mediainfo=FakeMedia(),
                target_path=Path("/library"),
                epformat=EpisodeFormat(offset="-1"),
                background=False,
            )

        self.assertTrue(state, errmsg)
        # 手动集数偏移只能应用一次，避免 E14 + (-1) 被二次处理成 E12。
        self.assertEqual([13], planned_episodes)

    def test_completed_media_job_is_removed_after_last_meta_task_fails(self):
        jobview = JobManager()
        tasks = [make_task(episode) for episode in range(1, 4)]
        for task in tasks:
            self.assertTrue(jobview.add_task(task))

        migrate_to_media_job(jobview, tasks[0])
        migrate_to_media_job(jobview, tasks[1])

        # 还有一个 meta 任务未处理时，media 组虽然已完成也不能提前清理。
        self.assertEqual(2, len(jobview.list_jobs()))

        # 最后一个仍在 meta 组中的任务未识别，__handle_transfer 会直接 remove_task 后 return。
        jobview.remove_task(tasks[2].fileitem)
        jobview.try_remove_job(tasks[2])

        self.assertEqual([], jobview.list_jobs())

    def test_completed_media_job_is_removed_after_all_meta_tasks_migrate(self):
        jobview = JobManager()
        tasks = [make_task(episode) for episode in range(1, 3)]
        for task in tasks:
            self.assertTrue(jobview.add_task(task))

        migrate_to_media_job(jobview, tasks[0])
        self.assertEqual(2, len(jobview.list_jobs()))

        migrate_to_media_job(jobview, tasks[1])
        self.assertEqual([], jobview.list_jobs())

    def test_exception_marks_unfinished_meta_task_failed_and_cleans_jobs(self):
        jobview = JobManager()
        tasks = [make_task(episode) for episode in range(1, 3)]
        for task in tasks:
            self.assertTrue(jobview.add_task(task))

        migrate_to_media_job(jobview, tasks[0])
        jobview.running_task(tasks[1])

        jobview.fail_unfinished_task(tasks[1])
        jobview.try_remove_job(tasks[1])

        self.assertEqual([], jobview.list_jobs())

    def test_exception_marks_unfinished_media_task_failed_and_cleans_jobs(self):
        jobview = JobManager()
        task = make_task(1)
        self.assertTrue(jobview.add_task(task))

        task.mediainfo = FakeMedia()
        jobview.migrate_task(task)
        jobview.running_task(task)

        jobview.fail_unfinished_task(task)
        jobview.try_remove_job(task)

        self.assertEqual([], jobview.list_jobs())

    def test_pre_recognized_jobs_with_same_meta_do_not_block_each_other(self):
        jobview = JobManager()
        task1 = make_task(1)
        task2 = make_task(2)
        task1.mediainfo = FakeMedia(100)
        task2.mediainfo = FakeMedia(200)

        self.assertTrue(jobview.add_task(task1))
        self.assertTrue(jobview.add_task(task2))

        jobview.running_task(task1)
        jobview.finish_task(task1)
        jobview.try_remove_job(task1)

        jobs = jobview.list_jobs()
        self.assertEqual(1, len(jobs))
        self.assertEqual(task2.fileitem, jobs[0].tasks[0].fileitem)

    def test_same_source_file_is_deduped_across_media_jobs(self):
        """
        同一个源文件即使识别到不同媒体作业，也不能重复加入整理视图。
        """
        jobview = JobManager()
        task1 = make_task(1)
        task2 = make_task(1)
        task1.mediainfo = FakeMedia(100)
        task2.mediainfo = FakeMedia(200)

        self.assertTrue(jobview.add_task(task1))
        self.assertFalse(jobview.add_task(task2))

        jobs = jobview.list_jobs()
        self.assertEqual(1, len(jobs))
        self.assertEqual(task1.fileitem, jobs[0].tasks[0].fileitem)

    def test_pre_recognized_migrations_with_same_meta_do_not_link_jobs(self):
        jobview = JobManager()
        task1 = make_task(1)
        task2 = make_task(2)
        task1.mediainfo = FakeMedia(100)
        task2.mediainfo = FakeMedia(200)

        self.assertTrue(jobview.add_task(task1))
        self.assertTrue(jobview.add_task(task2))

        self.assertTrue(jobview.migrate_task(task1))
        self.assertTrue(jobview.migrate_task(task2))
        jobview.running_task(task1)
        jobview.finish_task(task1)
        jobview.try_remove_job(task1)

        jobs = jobview.list_jobs()
        self.assertEqual(1, len(jobs))
        self.assertEqual(task2.fileitem, jobs[0].tasks[0].fileitem)

    def test_exception_failure_does_not_mark_downloader_without_history(self):
        chain = make_transfer_chain()
        completed = []

        def fake_transfer_completed(hashs, downloader):
            completed.append((hashs, downloader))

        chain.transfer_completed = fake_transfer_completed
        task = make_task(1)
        task.downloader = "qbittorrent"
        task.download_hash = "abc123"
        self.assertTrue(chain.jobview.add_task(task))
        chain.jobview.running_task(task)

        chain._TransferChain__fail_transfer_task(task)

        self.assertEqual([], completed)
        self.assertEqual([], chain.jobview.list_jobs())

    def test_successful_history_skip_marks_downloader_hash_completed(self):
        chain = make_transfer_chain()
        completed = []

        def fake_transfer_completed(hashs, downloader):
            completed.append((hashs, downloader))

        chain.transfer_completed = fake_transfer_completed
        chain.list_torrents = lambda **kwargs: [SimpleNamespace(progress=100)]
        chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
            (fileitem, False)
        ]

        fileitem = make_task(1).fileitem
        history = SimpleNamespace(
            id=1,
            status=True,
            download_hash="abc123",
            downloader="qbittorrent",
        )
        transfer_history_oper = SimpleNamespace(
            get_by_src=lambda src, storage=None: history,
            get_success_by_src=lambda src, storage=None: history,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=transfer_history_oper,
        ), patch(
            "app.chain.transfer.get_configured_system_config",
            return_value=system_config_oper,
        ):
            state, errmsg = TransferChain.do_transfer(
                chain,
                fileitem=fileitem,
                downloader="qbittorrent",
                download_hash="abc123",
                background=False,
            )

        self.assertTrue(state)
        self.assertEqual("Test.Show.S01E01.mkv 已整理过", errmsg)
        self.assertEqual([("abc123", "qbittorrent")], completed)

    def test_failed_history_is_retried_within_retry_budget(self):
        """
        失败重试次数未达上限（默认计数为 0）时，失败记录不再跳过整理，
        会放行重新送入整理链；由于种子还没有真正整理完成，也不能连带把种子标记为已整理。
        """
        chain = make_transfer_chain()
        completed = []

        def fake_transfer_completed(hashs, downloader):
            completed.append((hashs, downloader))

        chain.transfer_completed = fake_transfer_completed
        chain.list_torrents = lambda **kwargs: [SimpleNamespace(progress=100)]
        chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
            (fileitem, False)
        ]

        planned = []

        def fake_handle_transfer(task, callback=None):
            """记录放行重试后实际进入整理执行阶段的文件。"""
            planned.append(task.fileitem.path)
            return True, ""

        chain._TransferChain__handle_transfer = fake_handle_transfer

        fileitem = make_task(1).fileitem
        history = SimpleNamespace(
            id=2,
            status=False,
            download_hash="abc123",
            downloader="qbittorrent",
            src=fileitem.path,
            src_storage=fileitem.storage,
        )
        transfer_history_oper = SimpleNamespace(
            get_by_src=lambda src, storage=None: history,
            get_success_by_src=lambda src, storage=None: None,
        )
        download_history_oper = SimpleNamespace(
            get_by_hash=lambda download_hash: None,
            get_file_by_fullpath=lambda fullpath: None,
            get_files_by_savepath=lambda savepath: [],
            get_by_path=lambda path: None,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)

        _reset_failed_retries(fileitem.path, fileitem.storage)
        try:
            with patch(
                "app.chain.transfer.TransferHistoryOper",
                return_value=transfer_history_oper,
            ), patch(
                "app.chain.transfer.DownloadHistoryOper",
                return_value=download_history_oper,
            ), patch(
                "app.chain.transfer.get_configured_system_config",
                return_value=system_config_oper,
            ):
                state, errmsg = TransferChain.do_transfer(
                    chain,
                    fileitem=fileitem,
                    downloader="qbittorrent",
                    download_hash="abc123",
                    background=False,
                )

            self.assertTrue(state)
            self.assertEqual("", errmsg)
            self.assertEqual([fileitem.path], planned)
            self.assertEqual([], completed)
        finally:
            _reset_failed_retries(fileitem.path, fileitem.storage)

    def test_failed_history_skip_marks_downloader_hash_when_retry_budget_exhausted(self):
        """
        失败重试次数已达上限时，失败记录仍会拦截整理；但拦截意味着不再重试，
        此时仍要给种子打已整理标签，让种子退出下载器轮询，否则下载器每一轮都会
        重新扫描到同一个失败记录，空转且刷屏。
        """
        chain = make_transfer_chain()
        completed = []

        def fake_transfer_completed(hashs, downloader):
            completed.append((hashs, downloader))

        chain.transfer_completed = fake_transfer_completed
        chain.list_torrents = lambda **kwargs: [SimpleNamespace(progress=100)]
        chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
            (fileitem, False)
        ]

        planned = []

        def fake_handle_transfer(task, callback=None):
            """达到重试上限时不应有文件进入实际整理执行阶段。"""
            planned.append(task.fileitem.path)
            return True, ""

        chain._TransferChain__handle_transfer = fake_handle_transfer

        fileitem = make_task(1).fileitem
        history = SimpleNamespace(
            id=3,
            status=False,
            download_hash="abc123",
            downloader="qbittorrent",
            src=fileitem.path,
            src_storage=fileitem.storage,
        )
        transfer_history_oper = SimpleNamespace(
            get_by_src=lambda src, storage=None: history,
            get_success_by_src=lambda src, storage=None: None,
        )
        download_history_oper = SimpleNamespace(
            get_by_hash=lambda download_hash: None,
            get_file_by_fullpath=lambda fullpath: None,
            get_files_by_savepath=lambda savepath: [],
            get_by_path=lambda path: None,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)

        _reset_failed_retries(fileitem.path, fileitem.storage)
        try:
            with patch.object(
                settings, "TRANSFER_MAX_FAILED_RETRIES", 1,
            ):
                record_transfer_failure(fileitem.path, fileitem.storage)
                with patch(
                    "app.chain.transfer.TransferHistoryOper",
                    return_value=transfer_history_oper,
                ), patch(
                    "app.chain.transfer.DownloadHistoryOper",
                    return_value=download_history_oper,
                ), patch(
                    "app.chain.transfer.get_configured_system_config",
                    return_value=system_config_oper,
                ):
                    state, errmsg = TransferChain.do_transfer(
                        chain,
                        fileitem=fileitem,
                        downloader="qbittorrent",
                        download_hash="abc123",
                        background=False,
                    )

            self.assertFalse(state)
            self.assertEqual("Test.Show.S01E01.mkv 已整理过", errmsg)
            self.assertEqual([], planned)
            self.assertEqual([("abc123", "qbittorrent")], completed)
        finally:
            _reset_failed_retries(fileitem.path, fileitem.storage)

    def test_default_callback_failure_and_success_track_retry_counter(self):
        """
        __default_callback 应在整理失败时累计连续失败次数、整理成功时清零，
        避免瞬时故障与长期失败混用同一份计数导致误判上限。
        """
        chain = make_transfer_chain()
        chain.eventmanager = MagicMock()
        chain.post_message = MagicMock()
        chain.transfer_completed = lambda *args, **kwargs: None

        task = make_task(1)
        task.mediainfo = FakeMedia()
        # __default_callback 失败通知路径需要读取海报图，FakeMedia 本身不提供该接口
        task.mediainfo.get_message_image = lambda: "poster.jpg"
        task.background = False
        task.manual = True

        src_path = task.fileitem.path
        storage = task.fileitem.storage
        _reset_failed_retries(src_path, storage)
        try:
            self.assertEqual(0, failed_retry_count(src_path, storage))

            failed_transferinfo = TransferInfo(
                success=False,
                fileitem=task.fileitem,
                message="整理失败测试",
                transfer_type="copy",
                need_notify=False,
            )
            failed_history_oper = SimpleNamespace()
            with patch(
                "app.chain.transfer.TransferHistoryOper",
                return_value=failed_history_oper,
            ), patch(
                "app.chain.transfer.add_transfer_fail",
                lambda **kwargs: SimpleNamespace(id=1),
            ), patch(
                "app.runtime.config.settings.AI_AGENT_ENABLE", False
            ), patch(
                "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False
            ):
                state, _ = chain._TransferChain__default_callback(task, failed_transferinfo)

            self.assertFalse(state)
            self.assertEqual(1, failed_retry_count(src_path, storage))

            self.assertTrue(chain._TransferChain__put_to_jobview(task))
            success_transferinfo = TransferInfo(
                success=True,
                fileitem=task.fileitem,
                target_item=FileItem(
                    storage=storage,
                    path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
                    type="file",
                    name="Test.Show.S01E01.mkv",
                    extension="mkv",
                ),
                target_diritem=FileItem(
                    storage=storage,
                    path="/library/Test Show (2026)/Season 1/",
                    type="dir",
                    name="Season 1",
                ),
                file_list_new=[
                    "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
                ],
                transfer_type="copy",
                need_scrape=False,
                need_notify=False,
            )
            with patch(
                "app.chain.transfer.TransferHistoryOper", return_value=SimpleNamespace()
            ), patch(
                "app.chain.transfer.add_transfer_success",
                lambda **kwargs: SimpleNamespace(id=2),
            ):
                state, _ = chain._TransferChain__default_callback(task, success_transferinfo)

            self.assertTrue(state)
            self.assertEqual(0, failed_retry_count(src_path, storage))
        finally:
            _reset_failed_retries(src_path, storage)

    def test_unrecognized_task_marks_downloader_hash_completed(self):
        chain = make_transfer_chain()
        chain.post_message = lambda *_args, **_kwargs: None
        completed = []

        def fake_transfer_completed(hashs, downloader):
            completed.append((hashs, downloader))

        chain.transfer_completed = fake_transfer_completed
        chain.list_torrents = lambda **kwargs: [SimpleNamespace(progress=100)]
        task = make_task(1)
        task.downloader = "qbittorrent"
        task.download_hash = "abc123"
        self.assertTrue(chain.jobview.add_task(task))

        transfer_history_oper = SimpleNamespace()

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=transfer_history_oper,
        ), patch(
            "app.chain.transfer.add_transfer_fail",
            lambda **kwargs: SimpleNamespace(id=1),
        ), patch(
            "app.chain.transfer.MediaChain"
        ) as media_chain_cls, patch(
            "app.runtime.config.settings.AI_AGENT_ENABLE", False
        ), patch(
            "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False
        ):
            media_chain_cls.return_value.recognize_by_meta.return_value = None
            state, errmsg = chain._TransferChain__handle_transfer(task)

        self.assertFalse(state)
        self.assertEqual("未识别到媒体信息", errmsg)
        self.assertEqual([("abc123", "qbittorrent")], completed)
        self.assertEqual([], chain.jobview.list_jobs())

    def test_unrecognized_task_survives_missing_failure_history(self):
        """
        写整理历史失败（``add_transfer_fail`` 返回 None）时，未识别分支仍须走完
        通知、作业清理与种子完成标记：历史落库是通知的附属信息，不是前置条件。
        通知正文只省去 ``/redo`` 指引，不得因读取 ``his.id`` 抛 NoneType。
        """
        chain = make_transfer_chain()
        notifications = []
        chain.post_message = lambda message, **_kwargs: notifications.append(message)
        completed = []

        def fake_transfer_completed(hashs, downloader):
            completed.append((hashs, downloader))

        chain.transfer_completed = fake_transfer_completed
        chain.list_torrents = lambda **kwargs: [SimpleNamespace(progress=100)]
        task = make_task(1)
        task.downloader = "qbittorrent"
        task.download_hash = "abc123"
        self.assertTrue(chain.jobview.add_task(task))

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=SimpleNamespace(),
        ), patch(
            "app.chain.transfer.add_transfer_fail",
            lambda **kwargs: None,
        ), patch(
            "app.chain.transfer.MediaChain"
        ) as media_chain_cls, patch(
            "app.runtime.config.settings.AI_AGENT_ENABLE", False
        ), patch(
            "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False
        ):
            media_chain_cls.return_value.recognize_by_meta.return_value = None
            state, errmsg = chain._TransferChain__handle_transfer(task)

        self.assertFalse(state)
        self.assertEqual("未识别到媒体信息", errmsg)
        # 种子完成标记与作业清理都排在通知之后，通知崩掉会把它们一并跳过
        self.assertEqual([("abc123", "qbittorrent")], completed)
        self.assertEqual([], chain.jobview.list_jobs())
        # 通知照发，但不含无法使用的 /redo 指引
        self.assertEqual(1, len(notifications))
        notification = notifications[0]
        self.assertIn("未识别到媒体信息", notification.text)
        self.assertNotIn("/redo", notification.text)
        self.assertIsNone(notification.buttons)

    def test_unrecognized_task_keeps_redo_hint_when_history_written(self):
        """
        整理历史正常落库时，未识别通知须保留两条 ``/redo`` 指引与操作按钮，
        防止上一条用例被「一律删掉 /redo」这种偷懒实现蒙混过关。
        """
        chain = make_transfer_chain()
        notifications = []
        chain.post_message = lambda message, **_kwargs: notifications.append(message)
        chain.transfer_completed = lambda *args, **kwargs: None
        chain.list_torrents = lambda **kwargs: [SimpleNamespace(progress=100)]
        task = make_task(1)
        task.downloader = "qbittorrent"
        task.download_hash = "abc123"
        self.assertTrue(chain.jobview.add_task(task))

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=SimpleNamespace(),
        ), patch(
            "app.chain.transfer.add_transfer_fail",
            lambda **kwargs: SimpleNamespace(id=77),
        ), patch(
            "app.chain.transfer.MediaChain"
        ) as media_chain_cls, patch(
            "app.runtime.config.settings.AI_AGENT_ENABLE", False
        ), patch(
            "app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False
        ):
            media_chain_cls.return_value.recognize_by_meta.return_value = None
            chain._TransferChain__handle_transfer(task)

        self.assertEqual(1, len(notifications))
        notification = notifications[0]
        self.assertIn("/redo 77\n", notification.text)
        self.assertIn("/redo 77 [media_source]|[media_id]|[类型]", notification.text)
        self.assertEqual(
            [
                [
                    {"text": "重试", "callback_data": "transfer_retry_77"},
                    {
                        "text": "智能助手接管",
                        "callback_data": "transfer_ai_retry_77",
                    },
                ]
            ],
            notification.buttons,
        )

    def test_do_transfer_syncs_same_stem_extra_files_by_default(self):
        chain = make_transfer_chain()
        planned = []
        main_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv"
        )
        subtitle_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Test.Show.S01E01.2026.zh-cn.srt"
        )

        chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
            (main_fileitem, False)
        ]
        chain._TransferChain__put_to_jobview = lambda task: True
        chain._register_scrape_batch_task = lambda task: None
        chain._close_scrape_batch = lambda batch_id: None

        def fake_handle_transfer(task, callback=None):
            planned.append(task.fileitem.path)
            return True, ""

        chain._TransferChain__handle_transfer = fake_handle_transfer
        transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
        download_history_oper = SimpleNamespace(
            get_by_hash=lambda download_hash: None,
            get_file_by_fullpath=lambda fullpath: None,
            get_files_by_savepath=lambda savepath: [],
            get_by_path=lambda path: None,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)
        storage_chain = SimpleNamespace(
            get_parent_item=lambda fileitem: FileItem(
                storage="local",
                path="/downloads/Test Show (2026)/",
                type="dir",
                name="Test Show (2026)",
            ),
            list_files=lambda fileitem, recursion=False: [
                main_fileitem,
                subtitle_fileitem,
            ],
        )

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=transfer_history_oper,
        ), patch(
            "app.chain.transfer.DownloadHistoryOper",
            return_value=download_history_oper,
        ), patch(
            "app.chain.transfer.get_configured_system_config",
            return_value=system_config_oper,
        ), patch(
            "app.chain.transfer.StorageChain",
            return_value=storage_chain,
        ):
            state, errmsg = TransferChain.do_transfer(
                chain,
                fileitem=main_fileitem,
                background=False,
            )

        self.assertTrue(state)
        self.assertEqual("", errmsg)
        self.assertEqual(
            [
                main_fileitem.path,
                subtitle_fileitem.path,
            ],
            planned,
        )

    def test_manual_transfer_enables_sync_extra_files(self):
        chain = make_transfer_chain()
        captured = {}
        fileitem = make_fileitem("/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv")

        def fake_do_transfer(**kwargs):
            captured.update(kwargs)
            return True, ""

        chain.do_transfer = fake_do_transfer

        state, errmsg = TransferChain.manual_transfer(
            chain,
            fileitem=fileitem,
            preview=True,
        )

        self.assertTrue(state)
        self.assertEqual("", errmsg)
        self.assertTrue(captured["manual"])
        self.assertTrue(captured["sync_extra_files"])

    def test_manual_transfer_respects_sync_extra_files_argument(self):
        chain = make_transfer_chain()
        captured = {}
        fileitem = make_fileitem("/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv")

        def fake_do_transfer(**kwargs):
            captured.update(kwargs)
            return True, ""

        chain.do_transfer = fake_do_transfer

        state, errmsg = TransferChain.manual_transfer(
            chain,
            fileitem=fileitem,
            preview=True,
            sync_extra_files=False,
        )

        self.assertTrue(state)
        self.assertEqual("", errmsg)
        self.assertFalse(captured["sync_extra_files"])

    def test_do_transfer_skips_manual_single_file_when_epformat_misses(self):
        chain = make_transfer_chain()
        planned = []
        subtitle_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Show - 01.sc.ass"
        )

        chain._TransferChain__put_to_jobview = lambda task: True
        chain._register_scrape_batch_task = lambda task: None
        chain._close_scrape_batch = lambda batch_id: None

        def fake_handle_transfer(task, callback=None):
            planned.append((task.fileitem.path, task.meta.begin_episode))
            return True, ""

        chain._TransferChain__handle_transfer = fake_handle_transfer
        transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
        download_history_oper = SimpleNamespace(
            get_by_hash=lambda download_hash: None,
            get_file_by_fullpath=lambda fullpath: None,
            get_files_by_savepath=lambda savepath: [],
            get_by_path=lambda path: None,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)
        storage_chain = SimpleNamespace(get_item=lambda fileitem: subtitle_fileitem)

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=transfer_history_oper,
        ), patch(
            "app.chain.transfer.DownloadHistoryOper",
            return_value=download_history_oper,
        ), patch(
            "app.chain.transfer.get_configured_system_config",
            return_value=system_config_oper,
        ), patch(
            "app.chain.transfer.StorageChain",
            return_value=storage_chain,
        ), patch(
            "app.chain.transfer.MetaInfoPath",
            side_effect=lambda path, custom_words=None, **kwargs: FakeMeta(1),
        ):
            state, errmsg = TransferChain.do_transfer(
                chain,
                fileitem=subtitle_fileitem,
                background=False,
                manual=True,
                preview=True,
                sync_extra_files=True,
                epformat=EpisodeFormat(format="Show - {ep}.mkv"),
            )

        self.assertTrue(state)
        self.assertEqual(
            {
                "summary": {"total": 0, "success": 0, "failed": 0},
                "items": [],
                "message": "",
            },
            errmsg,
        )
        self.assertEqual([], planned)

    def test_do_transfer_syncs_extra_files_when_epformat_only_matches_main_video(self):
        chain = make_transfer_chain()
        planned = []
        main_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Show - 01.mkv"
        )
        subtitle_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Show - 01.sc.ass"
        )
        parent_fileitem = FileItem(
            storage="local",
            path="/downloads/Test Show (2026)/",
            type="dir",
            name="Test Show (2026)",
        )

        chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
            (main_fileitem, False)
        ]
        chain._TransferChain__put_to_jobview = lambda task: True
        chain._register_scrape_batch_task = lambda task: None
        chain._close_scrape_batch = lambda batch_id: None

        def fake_handle_transfer(task, callback=None):
            planned.append((task.fileitem.path, task.meta.begin_episode))
            return True, ""

        chain._TransferChain__handle_transfer = fake_handle_transfer
        transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
        download_history_oper = SimpleNamespace(
            get_by_hash=lambda download_hash: None,
            get_file_by_fullpath=lambda fullpath: None,
            get_files_by_savepath=lambda savepath: [],
            get_by_path=lambda path: None,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)
        storage_chain = SimpleNamespace(
            get_parent_item=lambda fileitem: parent_fileitem,
            list_files=lambda fileitem, recursion=False: [
                main_fileitem,
                subtitle_fileitem,
            ],
        )

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=transfer_history_oper,
        ), patch(
            "app.chain.transfer.DownloadHistoryOper",
            return_value=download_history_oper,
        ), patch(
            "app.chain.transfer.get_configured_system_config",
            return_value=system_config_oper,
        ), patch(
            "app.chain.transfer.StorageChain",
            return_value=storage_chain,
        ), patch(
            "app.chain.transfer.MetaInfoPath",
            side_effect=lambda path, custom_words=None, **kwargs: FakeMeta(1),
        ):
            state, errmsg = TransferChain.do_transfer(
                chain,
                fileitem=main_fileitem,
                background=False,
                manual=True,
                sync_extra_files=True,
                epformat=EpisodeFormat(format="Show - {ep}.mkv"),
            )

        self.assertTrue(state)
        self.assertEqual("", errmsg)
        self.assertEqual(
            [
                (main_fileitem.path, 1),
            ],
            planned,
        )

    def test_do_transfer_syncs_matching_extra_files_for_each_main_video(self):
        chain = make_transfer_chain()
        planned = []
        main_ep1_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv"
        )
        main_ep2_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Test.Show.S01E02.2026.mkv"
        )
        ep1_subtitle_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Test.Show.S01E01.2026.zh-cn.srt"
        )
        ep1_audio_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Test.Show.S01E01.2026.commentary.mka"
        )
        ep2_subtitle_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Test.Show.S01E02.2026.zh-cn.srt"
        )
        other_title_fileitem = make_fileitem(
            "/downloads/Test Show (2026)/Other.Show.S01E01.2026.zh-cn.srt"
        )
        parent_fileitem = FileItem(
            storage="local",
            path="/downloads/Test Show (2026)/",
            type="dir",
            name="Test Show (2026)",
        )

        chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
            (main_ep1_fileitem, False),
            (main_ep2_fileitem, False),
            (ep1_subtitle_fileitem, False),
            (ep1_audio_fileitem, False),
            (ep2_subtitle_fileitem, False),
            (other_title_fileitem, False),
        ]
        chain._TransferChain__put_to_jobview = lambda task: True
        chain._register_scrape_batch_task = lambda task: None
        chain._close_scrape_batch = lambda batch_id: None

        def fake_handle_transfer(task, callback=None):
            planned.append((task.fileitem.path, task.meta.begin_episode))
            return True, ""

        chain._TransferChain__handle_transfer = fake_handle_transfer
        transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
        download_history_oper = SimpleNamespace(
            get_by_hash=lambda download_hash: None,
            get_file_by_fullpath=lambda fullpath: None,
            get_files_by_savepath=lambda savepath: [],
            get_by_path=lambda path: None,
        )
        system_config_oper = SimpleNamespace(get=lambda key: None)
        list_files_calls = []

        def fake_list_files(fileitem, recursion=False):
            list_files_calls.append((fileitem.path, recursion))
            return [
                main_ep1_fileitem,
                main_ep2_fileitem,
                ep1_subtitle_fileitem,
                ep1_audio_fileitem,
                ep2_subtitle_fileitem,
                other_title_fileitem,
            ]

        storage_chain = SimpleNamespace(
            get_parent_item=lambda fileitem: parent_fileitem,
            list_files=fake_list_files,
        )

        with patch(
            "app.chain.transfer.TransferHistoryOper",
            return_value=transfer_history_oper,
        ), patch(
            "app.chain.transfer.DownloadHistoryOper",
            return_value=download_history_oper,
        ), patch(
            "app.chain.transfer.get_configured_system_config",
            return_value=system_config_oper,
        ), patch(
            "app.chain.transfer.StorageChain",
            return_value=storage_chain,
        ):
            state, errmsg = TransferChain.do_transfer(
                chain,
                fileitem=parent_fileitem,
                background=False,
                sync_extra_files=True,
            )

        self.assertTrue(state)
        self.assertEqual("", errmsg)
        self.assertEqual(
            [
                (main_ep1_fileitem.path, 1),
                (ep1_subtitle_fileitem.path, 1),
                (ep1_audio_fileitem.path, 1),
                (main_ep2_fileitem.path, 2),
                (ep2_subtitle_fileitem.path, 2),
                (other_title_fileitem.path, 1),
            ],
            planned,
        )
        self.assertEqual([], list_files_calls)

    def test_scrape_event_is_aggregated_by_transfer_batch_across_seasons(self):
        chain = make_transfer_chain()
        chain.eventmanager = MagicMock()
        chain.transfer_completed = lambda *args, **kwargs: None

        tasks = [make_task(1, season=1), make_task(1, season=2)]
        target_diritem = FileItem(
            storage="local",
            path="/library/Test Show (2026)",
            type="dir",
            name="Test Show (2026)",
        )
        batch_id = "batch-tv-multi-season"

        for task in tasks:
            task.mediainfo = FakeMedia()
            task.transfer_batch_id = batch_id
            task.background = False
            task.manual = True
            self.assertTrue(chain._TransferChain__put_to_jobview(task))
            chain._register_scrape_batch_task(task)

        chain._close_scrape_batch(batch_id)

        transferinfos = [
            TransferInfo(
                success=True,
                fileitem=tasks[0].fileitem,
                target_diritem=target_diritem,
                target_item=FileItem(
                    storage="local",
                    path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
                    type="file",
                    name="Test.Show.S01E01.mkv",
                    extension="mkv",
                ),
                file_list_new=[
                    "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
                ],
                transfer_type="copy",
                need_scrape=True,
                need_notify=False,
            ),
            TransferInfo(
                success=True,
                fileitem=tasks[1].fileitem,
                target_diritem=target_diritem,
                target_item=FileItem(
                    storage="local",
                    path="/library/Test Show (2026)/Season 2/Test.Show.S02E01.mkv",
                    type="file",
                    name="Test.Show.S02E01.mkv",
                    extension="mkv",
                ),
                file_list_new=[
                    "/library/Test Show (2026)/Season 2/Test.Show.S02E01.mkv"
                ],
                transfer_type="copy",
                need_scrape=True,
                need_notify=False,
            ),
        ]

        with patch(
            "app.chain.transfer.TransferHistoryOper", return_value=SimpleNamespace()
        ), patch(
            "app.chain.transfer.add_transfer_success",
            lambda **kwargs: SimpleNamespace(id=1),
        ), patch(
            "app.chain.transfer.StorageChain"
        ) as storage_chain_cls:
            storage_chain_cls.return_value.is_bluray_folder.return_value = False
            for task, transferinfo in zip(tasks, transferinfos):
                chain._TransferChain__default_callback(task, transferinfo)
                chain._finish_scrape_batch_task(task)

        metadata_calls = [
            call
            for call in chain.eventmanager.send_event.call_args_list
            if call.args[0] == EventType.MetadataScrape
        ]
        self.assertEqual(1, len(metadata_calls))
        event_data = metadata_calls[0].args[1]
        self.assertEqual(target_diritem, event_data["fileitem"])
        self.assertEqual(
            [
                "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
                "/library/Test Show (2026)/Season 2/Test.Show.S02E01.mkv",
            ],
            event_data["file_list"],
        )
        self.assertEqual({}, chain._scrape_batches)

    def test_scrape_event_keeps_immediate_behavior_without_transfer_batch(self):
        chain = make_transfer_chain()
        chain.eventmanager = MagicMock()
        chain.transfer_completed = lambda *args, **kwargs: None

        task = make_task(1)
        task.mediainfo = FakeMedia()
        task.background = False
        task.manual = True
        self.assertTrue(chain._TransferChain__put_to_jobview(task))

        target_diritem = FileItem(
            storage="local",
            path="/library/Test Show (2026)",
            type="dir",
            name="Test Show (2026)",
        )
        transferinfo = TransferInfo(
            success=True,
            fileitem=task.fileitem,
            target_diritem=target_diritem,
            target_item=FileItem(
                storage="local",
                path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
                type="file",
                name="Test.Show.S01E01.mkv",
                extension="mkv",
            ),
            file_list_new=[
                "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
            ],
            transfer_type="copy",
            need_scrape=True,
            need_notify=False,
        )

        with patch(
            "app.chain.transfer.TransferHistoryOper", return_value=SimpleNamespace()
        ), patch(
            "app.chain.transfer.add_transfer_success",
            lambda **kwargs: SimpleNamespace(id=1),
        ), patch(
            "app.chain.transfer.StorageChain"
        ) as storage_chain_cls:
            storage_chain_cls.return_value.is_bluray_folder.return_value = False
            chain._TransferChain__default_callback(task, transferinfo)

        metadata_calls = [
            call
            for call in chain.eventmanager.send_event.call_args_list
            if call.args[0] == EventType.MetadataScrape
        ]
        self.assertEqual(1, len(metadata_calls))
        event_data = metadata_calls[0].args[1]
        self.assertEqual(target_diritem, event_data["fileitem"])
        self.assertEqual(
            ["/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"],
            event_data["file_list"],
        )
