from unittest.mock import Mock, patch
from types import SimpleNamespace

import pytest

from app.chain import transfer as transfer_module
from app.chain.transfer import TransferChain
from app.application.transfer import (
    TransferFailureNotification,
    TransferFailureNotificationAggregator,
    TransferTask,
    build_transfer_failure_group_key,
)
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo
from app.runtime.config import ConfigModel
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource, MediaType


class _Timer:
    """记录静默窗口是否因新失败到达而取消。"""

    def __init__(self, callback, args):
        """保存定时回调与参数。"""
        self.callback = callback
        self.args = args
        self.cancelled = False

    def cancel(self):
        """标记当前定时器已取消。"""
        self.cancelled = True


class _Loop:
    """同步执行线程安全入队，并保留延迟回调供测试触发。"""

    def __init__(self):
        """初始化定时器清单。"""
        self.timers = []

    def call_soon_threadsafe(self, callback, *args):
        """同步执行本应投递到事件循环的回调。"""
        callback(*args)

    def call_later(self, _delay, callback, *args):
        """保存延迟回调并返回可取消句柄。"""
        timer = _Timer(callback, args)
        self.timers.append(timer)
        return timer

    @staticmethod
    def is_running() -> bool:
        """该替身代表由生命周期持有的运行中循环。"""
        return True

    @staticmethod
    def is_closed() -> bool:
        """该替身在用例期间保持可用。"""
        return False


class _DeferredLoop(_Loop):
    """延迟执行线程安全回调，用于覆盖关闭与入环之间的竞态。"""

    def __init__(self):
        """初始化延迟回调和定时器清单。"""
        super().__init__()
        self.soon_callbacks = []

    def call_soon_threadsafe(self, callback, *args):
        """保存线程安全回调，直到测试显式执行。"""
        self.soon_callbacks.append((callback, args))

    def run_soon_callbacks(self):
        """执行并清空已保存的线程安全回调。"""
        callbacks = list(self.soon_callbacks)
        self.soon_callbacks.clear()
        for callback, args in callbacks:
            callback(*args)


def _task(*, episode: int, download_hash: str = "hash-1") -> TransferTask:
    """构造同一媒体不同剧集的整理任务。"""
    return TransferTask(
        fileitem=FileItem(
            storage="local",
            path=f"/downloads/Show/Show.S01E{episode:02d}.mkv",
            type="file",
            name=f"Show.S01E{episode:02d}.mkv",
        ),
        meta=MetaInfo(f"Show S01E{episode:02d}"),
        mediainfo=MediaInfo(
            media_source=MediaSource.TMDB,
            media_id="100",
            tmdb_id=100,
            title="测试剧",
            type=MediaType.TV,
            year="2026",
        ),
        download_hash=download_hash,
        username="tester",
    )


def test_failure_group_key_prefers_media_identity_and_season():
    """同一媒体同一季应跨文件共享分组键。"""
    first = build_transfer_failure_group_key(_task(episode=1, download_hash="hash-a"))
    second = build_transfer_failure_group_key(_task(episode=2, download_hash="hash-b"))

    assert first == second
    assert first == "media:themoviedb:100:season:1:user:tester"


def test_failure_notification_aggregation_defaults_on():
    """整理失败通知聚合默认开启。"""
    field = ConfigModel.model_fields["TRANSFER_FAILURE_NOTIFICATION_AGGREGATION"]

    assert field.default is True


def test_aggregator_debounces_same_group_and_flushes_once():
    """同组失败应重置定时器并一次性回调全部快照。"""
    loop = _Loop()
    aggregator = TransferFailureNotificationAggregator()
    callback = Mock()
    notices = [
        TransferFailureNotification("测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"),
        TransferFailureNotification("测试剧 (2026)", "S01E02", "原因B", 2, None, "tester"),
    ]

    for notice in notices:
        aggregator.schedule(
            group_key="media:test",
            notification=notice,
            callback=callback,
            loop=loop,
        )

    assert loop.timers[0].cancelled is True
    assert loop.timers[1].cancelled is False
    loop.timers[1].callback(*loop.timers[1].args)
    callback.assert_called_once_with(notices)


def test_aggregator_old_timer_cannot_flush_before_renewal_is_armed():
    """新通知已接收时，旧 timer 不得抢在事件循环重置静默窗前发送。"""
    loop = _DeferredLoop()
    aggregator = TransferFailureNotificationAggregator()
    callback = Mock()
    first = TransferFailureNotification(
        "测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
    )
    second = TransferFailureNotification(
        "测试剧 (2026)", "S01E02", "原因B", 2, None, "tester"
    )

    aggregator.schedule(
        group_key="media:test",
        notification=first,
        callback=callback,
        loop=loop,
    )
    loop.run_soon_callbacks()
    old_timer = loop.timers[0]

    aggregator.schedule(
        group_key="media:test",
        notification=second,
        callback=callback,
        loop=loop,
    )
    old_timer.callback(*old_timer.args)
    callback.assert_not_called()

    loop.run_soon_callbacks()
    renewed_timer = loop.timers[1]
    assert old_timer.cancelled is True
    renewed_timer.callback(*renewed_timer.args)
    callback.assert_called_once_with([first, second])


def test_aggregator_close_flushes_accepted_notification_before_timer_is_armed():
    """关闭应发送已接收但尚未进入事件循环的通知，且延迟回调不能重新建 timer。"""
    loop = _DeferredLoop()
    aggregator = TransferFailureNotificationAggregator()
    callback = Mock()
    notice = TransferFailureNotification(
        "测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
    )

    aggregator.schedule(
        group_key="media:test",
        notification=notice,
        callback=callback,
        loop=loop,
    )
    aggregator.close()
    aggregator.close()
    loop.run_soon_callbacks()

    callback.assert_called_once_with([notice])
    assert loop.timers == []


def test_aggregator_close_cancels_timer_and_rejects_new_notification():
    """关闭应取消已建 timer，并让调用方明确感知后续投递被拒绝。"""
    loop = _Loop()
    aggregator = TransferFailureNotificationAggregator()
    callback = Mock()
    notice = TransferFailureNotification(
        "测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
    )
    aggregator.schedule(
        group_key="media:test",
        notification=notice,
        callback=callback,
        loop=loop,
    )

    aggregator.close()

    assert loop.timers[0].cancelled is True
    callback.assert_called_once_with([notice])
    with pytest.raises(RuntimeError, match="正在关闭"):
        aggregator.schedule(
            group_key="media:test",
            notification=notice,
            callback=callback,
            loop=loop,
        )


def test_aggregator_close_observes_flush_callback_error():
    """关闭阶段同步刷新失败时应记录异常而不是让通知静默丢失。"""
    loop = _DeferredLoop()
    aggregator = TransferFailureNotificationAggregator()
    callback = Mock(side_effect=RuntimeError("send failed"))
    notice = TransferFailureNotification(
        "测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
    )
    aggregator.schedule(
        group_key="media:test",
        notification=notice,
        callback=callback,
        loop=loop,
    )

    with patch("app.application.transfer.logger.error") as log_error:
        aggregator.close()

    callback.assert_called_once_with([notice])
    log_error.assert_called_once()


def test_aggregated_message_contains_count_reason_stats_and_batch_entry():
    """聚合消息应给出失败数、原因统计、历史 ID 和批量处理入口。"""
    chain = object.__new__(TransferChain)
    chain.runtime_config = SimpleNamespace(history_url="#/history")
    sent = []
    chain.post_message = sent.append
    notices = [
        TransferFailureNotification("测试剧 (2026)", "S01E01", "未识别到媒体信息", 11, None, "tester"),
        TransferFailureNotification("测试剧 (2026)", "S01E02", "目标已存在", 12, None, "tester"),
        TransferFailureNotification("测试剧 (2026)", "S01E03", "目标已存在", 13, None, "tester"),
    ]

    chain._send_transfer_failure_notifications(notices)

    assert len(sent) == 1
    message = sent[0]
    assert message.title == "测试剧 (2026) 入库失败（3 个文件）"
    assert "失败文件：3 个" in message.text
    assert "- 目标已存在 × 2" in message.text
    assert "整理记录：#11、#12、#13" in message.text
    assert message.buttons == [[{
        "text": "批量处理",
        "url": "#/history",
    }]]


def test_enabled_queue_uses_shared_group_key():
    """开启聚合后公开通知入口应投递到聚合器而不是立即发送。"""
    chain = object.__new__(TransferChain)
    chain.runtime_config = SimpleNamespace(
        transfer_failure_notification_aggregation=True,
    )
    chain.failure_notification_aggregator = Mock()
    chain.post_message = Mock()
    task = _task(episode=1)
    transferinfo = TransferInfo(
        success=False,
        fileitem=task.fileitem,
        message="整理失败",
        transfer_type="copy",
    )
    loop = _Loop()
    with patch.object(transfer_module.global_vars, "CURRENT_EVENT_LOOP", loop):
        chain.queue_failed_transfer_notification(
            task=task,
            transferinfo=transferinfo,
            history_id=22,
        )

    chain.failure_notification_aggregator.schedule.assert_called_once()
    kwargs = chain.failure_notification_aggregator.schedule.call_args.kwargs
    assert kwargs["group_key"] == build_transfer_failure_group_key(task)
    assert kwargs["loop"] is loop
    chain.post_message.assert_not_called()
