"""
挂载级(block 型)故障下的监控自愈测试。

背景：CloudDrive2/115 等 FUSE 挂载会进入「请求不返回错误、永不返回」的挂死
状态。此时监控自愈体系的检测环节仍然有效，但恢复环节（重建监控线程、重试
整理队列）本身要访问挂载，一旦内联执行就会把全局自愈的单点——健康检查——
冻死在它自己要修复的挂载上，随后停滞检测、告警、重试驱动全部静默失效。

crash 型（挂载抛 Transport endpoint is not connected）的自愈由
test_monitor_resilience.py 覆盖；这些测试固定 block 型的两项不变量：
「看门狗零挂载访问」与「挂载级故障隔离/探测/恢复」。
"""
import threading
import time
from pathlib import Path
from threading import Lock, Thread
from unittest.mock import MagicMock

import pytest

from app.monitor.monitor import Monitor
from app.monitor.recovery import RecoveryExecutor, RecoveryState, probe_path
from app.monitor.watcher import LocalDirectoryWatcher


def _build_monitor(monkeypatch, put_recorder=None):
    """
    构造测试用 Monitor 骨架，绕过单例初始化。
    :param monkeypatch: pytest monkeypatch
    :param put_recorder: 消息推送记录器
    :return: Monitor 骨架
    """
    put_recorder = put_recorder or MagicMock()
    monkeypatch.setattr("app.monitor.monitor.MessageHelper", MagicMock(return_value=put_recorder))
    monitor = object.__new__(Monitor)
    monitor._dispatcher = MagicMock()
    monitor._lifecycle_lock = threading.RLock()
    monitor._owner_lock = threading.Lock()
    monitor._work_stop_event = threading.Event()
    monitor._shutdown_event = threading.Event()
    monitor._closed = False
    monitor._compensation_threads = {}
    monitor._scheduler_shutdown_thread = None
    monitor._scheduler_shutdown_succeeded = False
    monitor._scheduler = None
    monitor._watchers = []
    monitor._retired_watchers = []
    monitor._watcher_lock = Lock()
    monitor._pending_locals = []
    monitor._alerted_paths = {}
    monitor._restart_marks = {}
    monitor._stable_cycles = {}
    monitor._isolated = {}
    monitor._pending_rebuild = {}
    monitor._recovery = RecoveryExecutor()
    return monitor


def _fake_watcher(mon_path, alive=True, stalled=False, restart_count=0):
    """
    构造测试用监控线程替身。
    :param mon_path: 监控目录
    :param alive: 线程是否存活
    :param stalled: 是否静默失效
    :param restart_count: 自动重启次数
    :return: 监控线程替身
    """
    watcher = MagicMock()
    watcher.watch_path = mon_path
    watcher.is_alive.return_value = alive
    watcher.is_stalled.return_value = stalled
    watcher.restart_count = restart_count
    watcher.last_activity_time = time.time()
    return watcher


def _run_watchdog(monitor, timeout=10.0):
    """
    在独立线程里跑一次健康检查，返回它是否在限定时间内结束。
    :param monitor: Monitor 骨架
    :param timeout: 最长等待秒数
    :return: 健康检查是否已返回
    """
    done = threading.Event()

    def runner():
        """
        执行一次健康检查并标记结束。
        """
        try:
            monitor.watchdog()
        finally:
            done.set()

    thread = Thread(target=runner, daemon=True, name="test-watchdog")
    thread.start()
    finished = done.wait(timeout=timeout)
    if finished:
        thread.join(timeout=timeout)
    return finished and not thread.is_alive()


def _release_and_join(release, threads, timeout=10.0):
    """
    释放测试注入的阻塞，并等待受控工作线程结束。

    真实故障下的恢复线程允许随进程退出，但测试主动解除阻塞后必须完成回收，
    避免仍在执行应用代码的守护线程与解释器关闭过程并发。
    :param release: 控制阻塞动作的事件
    :param threads: 需要等待的线程集合
    :param timeout: 每个线程的最长等待秒数
    """
    release.set()
    for thread in threads:
        thread.join(timeout=timeout)
        assert not thread.is_alive(), f"受控线程未能结束: {thread.name}"


def _stop_monitor_threads(monitor, timeout=10.0):
    """
    停止测试期间重建的目录监控，并等待其派生任务结束。

    目录 watcher 会进入 watchfiles 原生扩展，必须在解释器关闭前退出；补偿扫描
    由重建流程派生，也要在测试边界内完成。
    :param monitor: Monitor 骨架
    :param timeout: 每个线程的最长等待秒数
    """
    for watcher in (*monitor._watchers, *monitor._retired_watchers):
        if isinstance(watcher, LocalDirectoryWatcher):
            watcher.stop()
            watcher.join(timeout=timeout)
            assert not watcher.is_alive(), f"目录监控线程未能结束: {watcher.watch_path}"
    for thread in threading.enumerate():
        if thread.name.startswith("MoviePilot-MonitorCompensation-"):
            thread.join(timeout=timeout)
            assert not thread.is_alive(), f"补偿扫描线程未能结束: {thread.name}"


# --------------------------------------------------------------------------- #
# P0：看门狗线程零挂载访问
# --------------------------------------------------------------------------- #

def test_watchdog_returns_while_rebuild_blocks_forever(tmp_path, monkeypatch):
    """
    根因回归：重建动作卡在死挂载上永不返回时，健康检查本身必须在有限时间内
    返回。事故中看门狗内联执行重建，冻死后 13 个目录的停滞检测与告警全部失效。
    """
    monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
    monitor = _build_monitor(monkeypatch)
    watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
    monitor._watchers = [watcher]

    entered = threading.Event()
    release = threading.Event()

    def blocking_rebuild(_target):
        """
        模拟 block 型挂载：进入后永不返回。
        """
        entered.set()
        release.wait()

    setattr(monitor, "_Monitor__rebuild_watcher", blocking_rebuild)

    try:
        assert _run_watchdog(monitor), "看门狗被重建动作冻死，未能在限定时间内返回"
        assert entered.is_set(), "重建动作没有被真正发起"
    finally:
        _release_and_join(release, monitor._recovery._running.values())


def test_watchdog_returns_while_pending_retry_blocks_forever(tmp_path, monkeypatch):
    """
    重试驱动（整理重试队列的 stat）同样会卡在死挂载上，也必须移出看门狗线程。
    """
    monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
    monitor = _build_monitor(monkeypatch)
    release = threading.Event()
    monitor._dispatcher.retry_pending.side_effect = lambda: release.wait()

    try:
        assert _run_watchdog(monitor), "看门狗被整理重试驱动冻死，未能在限定时间内返回"
    finally:
        _release_and_join(release, monitor._recovery._running.values())


def test_watchdog_returns_while_local_retry_blocks_forever(tmp_path, monkeypatch):
    """
    启动失败目录的重试会走 decide_monitor_mode 的 os.walk 与 watcher.start()
    的 exists()，同样触碰挂载，必须移出看门狗线程。
    """
    monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
    monitor = _build_monitor(monkeypatch)
    monitor._pending_locals = [{"mon_path": tmp_path, "monitor_mode": "compatibility"}]
    release = threading.Event()

    def blocking_start(**_kwargs):
        """
        模拟启动重试卡在死挂载的目录遍历上。
        """
        release.wait()
        return False

    setattr(monitor, "_Monitor__start_local_monitor", blocking_start)

    try:
        assert _run_watchdog(monitor), "看门狗被本地监控重试冻死，未能在限定时间内返回"
    finally:
        _release_and_join(release, monitor._recovery._running.values())


def test_check_watchers_performs_no_filesystem_access(tmp_path, monkeypatch):
    """
    检测与判定必须是纯内存运算：只读线程存活标志、心跳与重启计数，
    不做任何文件系统访问，否则检测环节本身也会被 block 型故障拖死。
    """
    monitor = _build_monitor(monkeypatch)
    watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
    monitor._watchers = [watcher]

    def forbidden(*_args, **_kwargs):
        """
        任何真实的文件系统调用都应视为检测环节的缺陷。
        """
        raise AssertionError("检测环节不允许访问文件系统")

    for name in ("stat", "exists", "is_dir", "iterdir", "rglob"):
        monkeypatch.setattr(Path, name, forbidden, raising=False)

    broken = monitor._Monitor__check_watchers()

    assert broken == [watcher]


def test_watchdog_survives_blocking_exists_in_real_rebuild(tmp_path, monkeypatch):
    """
    端到端回归：不替换任何恢复逻辑，只让 Path.exists() 永不返回（等价于对 FUSE
    守护进程 kill -STOP 后挂载的表现），走真实的
    __rebuild_watcher -> LocalDirectoryWatcher.start() -> exists() 调用链。

    事故当天冻结的就是这一行（watcher.py 的 start() 入口校验）。修复后看门狗
    必须照常返回，并把该目录转入隔离。
    """
    monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.5)
    monitor = _build_monitor(monkeypatch)

    # 用真实 watcher：只把底层线程换成替身，让它被判定为「存活但静默失效」
    watcher = LocalDirectoryWatcher(tmp_path, callback=MagicMock(), force_polling=True)
    watcher._thread = MagicMock()
    watcher._thread.is_alive.return_value = True
    watcher._mark_activity()
    watcher._last_activity -= LocalDirectoryWatcher.STALL_TIMEOUT + 1
    monitor._watchers = [watcher]

    release = threading.Event()

    def blocking_stat(path, *_args, **_kwargs):
        """
        模拟 block 型挂载：监控目录的属性读取永不返回。
        入口校验已改走子进程代理，因此在代理这一层注入阻塞。
        """
        if Path(path) == tmp_path:
            release.wait()
        return {"size": 0, "mtime": 0.0, "is_dir": True, "is_file": False}

    monkeypatch.setattr(
        "app.adapters.system.fsproxy.fsproxy.stat", blocking_stat
    )
    monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)

    try:
        assert _run_watchdog(monitor), "看门狗冻死在真实的重建调用链上"
        assert str(tmp_path) in monitor._isolated, "重建无响应后目录没有转入隔离"
    finally:
        _release_and_join(release, monitor._recovery._running.values())
        assert watcher in monitor._retired_watchers
        # 重建后的旧 watcher 仍由 retired owner 表持有；测试替身不会自行切换
        # is_alive，释放模拟阻塞后显式推进到真实线程应有的退出终态。
        watcher._thread.is_alive.return_value = False
        _stop_monitor_threads(monitor)


# --------------------------------------------------------------------------- #
# P0.5：挂载级故障隔离
# --------------------------------------------------------------------------- #

def test_rebuild_timeout_isolates_directory(tmp_path, monkeypatch):
    """
    重建在挂载上超时未返回 = 挂载级故障：该目录转入隔离，后续周期不再对它
    发起任何新的挂载访问，避免每 60 秒泄漏一个冻死的线程。
    """
    monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 0.3)
    monitor = _build_monitor(monkeypatch)
    watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
    monitor._watchers = [watcher]

    calls = []
    release = threading.Event()

    def blocking_rebuild(target):
        """
        模拟 block 型挂载上的重建：进入后永不返回。
        """
        calls.append(target)
        release.wait()

    setattr(monitor, "_Monitor__rebuild_watcher", blocking_rebuild)
    monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)

    try:
        assert _run_watchdog(monitor)
        assert str(tmp_path) in monitor._isolated, "重建超时后目录没有转入隔离"

        # 第二个周期：隔离中的目录不得再被提交重建
        assert _run_watchdog(monitor)
        assert len(calls) == 1, "隔离中的目录仍在被反复重建，会持续泄漏冻死的线程"
    finally:
        _release_and_join(release, monitor._recovery._running.values())


def test_isolated_directory_recovers_after_probe_succeeds(tmp_path, monkeypatch):
    """
    隔离期间用可放弃的子进程探测挂载；探测通过即解除隔离、重建监控，
    并复用既有的补偿扫描补回停摆期间落地的文件。
    """
    monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 5.0)
    monitor = _build_monitor(monkeypatch)
    watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
    monitor._watchers = [watcher]
    monitor._isolated = {str(tmp_path): {"watcher": watcher, "since": time.time(), "failures": 3}}

    rebuilt = []
    setattr(monitor, "_Monitor__rebuild_watcher", rebuilt.append)
    monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: True)

    assert _run_watchdog(monitor)

    assert str(tmp_path) not in monitor._isolated, "探测通过后没有解除隔离"
    # 不得在探测线程里内联重建：重建会触碰挂载，若此时再次挂死，全局 PROBE_KEY
    # 将永久 BUSY，其余隔离目录连探测机会都没有
    assert rebuilt == [], "探测线程内联执行了重建，会饿死其他隔离目录"
    assert str(tmp_path) in monitor._pending_rebuild, "没有登记待重建"

    # 下一周期应把它送去按目录隔离的重建路径
    assert _run_watchdog(monitor)
    assert rebuilt == [watcher], "下一周期没有重建该监控"
    assert not monitor._pending_rebuild, "重建后未清空待重建登记"


def test_isolated_directory_stays_isolated_while_probe_fails(tmp_path, monkeypatch):
    """
    探测未通过说明挂载仍未恢复应答，必须保持隔离并累计失败次数。
    """
    monkeypatch.setattr(Monitor, "RECOVERY_TIMEOUT", 5.0)
    monitor = _build_monitor(monkeypatch)
    watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
    monitor._watchers = [watcher]
    monitor._isolated = {str(tmp_path): {"watcher": watcher, "since": time.time(), "failures": 0}}

    rebuilt = []
    setattr(monitor, "_Monitor__rebuild_watcher", rebuilt.append)
    monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)

    assert _run_watchdog(monitor)

    assert monitor._isolated[str(tmp_path)]["failures"] == 1
    assert rebuilt == [], "挂载尚未恢复就重建监控，只会再冻死一个线程"


def test_pending_locals_skip_isolated_directories(tmp_path, monkeypatch):
    """
    隔离中的目录不能再走启动重试路径，否则又会在同一个挂载上冻死。
    """
    monitor = _build_monitor(monkeypatch)
    monitor._pending_locals = [{"mon_path": tmp_path, "monitor_mode": "compatibility"}]
    monitor._isolated = {str(tmp_path): {"watcher": _fake_watcher(tmp_path), "since": time.time(),
                                         "failures": 0}}
    start = MagicMock(return_value=False)
    setattr(monitor, "_Monitor__start_local_monitor", start)

    monitor._Monitor__retry_pending_locals()

    start.assert_not_called()


def test_isolation_alert_is_pushed_after_fault_alert(tmp_path, monkeypatch):
    """
    故障→隔离是状态升级，必须再推一条告警：沿用「同目录只告警一次」会让
    用户完全看不到「监控已暂停访问、正在等待挂载恢复」这个关键状态变化。
    """
    put_recorder = MagicMock()
    monitor = _build_monitor(monkeypatch, put_recorder)

    monitor._Monitor__send_alert(tmp_path, "目录监控异常")
    monitor._Monitor__send_alert(tmp_path, "目录监控异常")
    assert put_recorder.put.call_count == 1

    monitor._Monitor__send_alert(tmp_path, "挂载无响应，已隔离", stage="isolated")
    assert put_recorder.put.call_count == 2

    monitor._Monitor__clear_alert(tmp_path, "已恢复")
    assert put_recorder.put.call_count == 3
    assert str(tmp_path) not in monitor._alerted_paths


# --------------------------------------------------------------------------- #
# 整理分发器：一个文件卡死不得锁死整条整理链
# --------------------------------------------------------------------------- #

def test_stuck_transfer_does_not_block_other_files(monkeypatch):
    """
    根因回归：整理的规划阶段（do_transfer 里的 get_parent_item / list_files）会
    访问挂载，在 block 型故障下永不返回。若分发器的互斥锁包住这段调用，这把锁
    就会被永久持有，把 13 个 watcher 的事件派发、补偿扫描和重试队列一起锁死
    ——监控层即使自愈成功也送不进任何文件，漏件永远补不回来。

    锁只应保护 TTL 去重的 check-and-set。
    """
    from app.monitor.dispatcher import TransferDispatcher

    dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
    monkeypatch.setattr(dispatcher, "_should_skip_by_history", MagicMock(return_value=False))

    release = threading.Event()
    stuck_entered = threading.Event()
    transferred = []

    class FakeChain:
        """
        整理链替身：指定文件的整理进入后永不返回。
        """

        @staticmethod
        def do_transfer(fileitem, **_kwargs):
            """
            模拟规划阶段卡在死挂载的 list_files 上。
            """
            transferred.append(fileitem.path)
            if fileitem.path.endswith("stuck.mkv"):
                stuck_entered.set()
                release.wait()

    monkeypatch.setattr("app.monitor.dispatcher.TransferChain", FakeChain)

    def feed(name):
        """
        向分发器送入一个文件。
        """
        dispatcher.handle_file(storage="local", event_path=Path(f"/mnt/cd2/{name}"), file_size=1)

    stuck_thread = Thread(target=feed, args=("stuck.mkv",), daemon=True)
    stuck_thread.start()
    assert stuck_entered.wait(timeout=5), "卡死的整理没有真正进入"

    done = threading.Event()
    other_thread = Thread(target=lambda: (feed("other.mkv"), done.set()), daemon=True)
    other_thread.start()

    try:
        assert done.wait(timeout=5), "一个文件卡在死挂载上就锁死了整条整理链"
        assert "/mnt/cd2/other.mkv" in transferred
    finally:
        _release_and_join(release, (stuck_thread, other_thread))


# --------------------------------------------------------------------------- #
# 恢复执行器与挂载探测
# --------------------------------------------------------------------------- #

def test_recovery_executor_reports_timeout_without_blocking():
    """
    永不返回的动作只应消耗一次 timeout，执行器必须结束本轮等待并如实报告。
    """
    executor = RecoveryExecutor()
    release = threading.Event()

    started = time.monotonic()
    results = executor.run({"stuck": release.wait}, timeout=0.3)
    elapsed = time.monotonic() - started

    try:
        assert results == {"stuck": RecoveryState.TIMEOUT}
        assert elapsed < 3.0, "执行器没有在超时后结束本轮等待"
    finally:
        _release_and_join(release, executor._running.values())


def test_recovery_executor_skips_key_with_running_task():
    """
    同一个 key 的上一个动作还冻着时，不能再提交新线程——否则每个健康检查
    周期都会在同一个死挂载上泄漏一个线程。
    """
    executor = RecoveryExecutor()
    release = threading.Event()
    calls = []

    def stuck():
        """
        模拟永不返回的恢复动作。
        """
        calls.append(1)
        release.wait()

    try:
        assert executor.run({"k": stuck}, timeout=0.2) == {"k": RecoveryState.TIMEOUT}
        assert executor.run({"k": stuck}, timeout=0.2) == {"k": RecoveryState.BUSY}
        assert len(calls) == 1
    finally:
        _release_and_join(release, executor._running.values())


def test_recovery_executor_runs_actions_concurrently():
    """
    一个周期内多个目录的恢复动作必须并发执行，否则 13 个目录串行等待会把
    健康检查拖过下一个周期。
    """
    executor = RecoveryExecutor()
    release = threading.Event()

    started = time.monotonic()
    results = executor.run({f"k{i}": release.wait for i in range(5)}, timeout=0.4)
    elapsed = time.monotonic() - started

    try:
        assert set(results.values()) == {RecoveryState.TIMEOUT}
        assert elapsed < 1.5, "恢复动作是串行等待的，总耗时随目录数增长"
    finally:
        _release_and_join(release, executor._running.values())


def test_recovery_executor_reports_completion_and_swallows_errors():
    """
    正常完成的动作报告 COMPLETED；动作内部抛异常不能让执行器崩溃，
    否则一个目录的失败会连累整批恢复。
    """
    executor = RecoveryExecutor()
    done = []

    def boom():
        """
        模拟恢复动作内部异常。
        """
        raise RuntimeError("rebuild failed")

    results = executor.run({"ok": lambda: done.append(1), "bad": boom}, timeout=5)

    assert results == {"ok": RecoveryState.COMPLETED, "bad": RecoveryState.COMPLETED}
    assert done == [1]


def test_probe_path_succeeds_on_existing_directory(tmp_path):
    """
    挂载可用时探测应通过。
    """
    assert probe_path(tmp_path, timeout=30) is True


def test_probe_path_fails_on_missing_path(tmp_path):
    """
    路径不存在时探测应失败，而不是抛异常。
    """
    assert probe_path(tmp_path / "does-not-exist", timeout=30) is False


@pytest.mark.skipif(not Path("/bin/sh").exists(), reason="需要 POSIX 环境")
def test_probe_path_is_abandonable_on_timeout(tmp_path, monkeypatch):
    """
    探测的关键性质：卡住时可以被放弃。线程做的 stat 无法回收，只有子进程能在
    超时后被 kill——这正是隔离期间能持续探测而不泄漏资源的前提。
    """
    import app.monitor.recovery as recovery

    # 用一个必定超时的探测脚本替换真实探测逻辑，验证超时路径的可放弃性
    monkeypatch.setattr(recovery, "_PROBE_SCRIPT", "import time; time.sleep(60)")

    started = time.monotonic()
    assert probe_path(tmp_path, timeout=0.5) is False
    assert time.monotonic() - started < 10, "探测超时后没有及时放弃子进程"
