"""tools/build_trees.py 单元测试。

覆盖纯函数（完整性校验、待建清单发现、SRT 查找）与编排集成（Task 3 追加）。
"""

from __future__ import annotations

import argparse
import asyncio
import json
import sys
from pathlib import Path

import pytest

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from app.tree.index import IndexMeta, L1Card, L1Node, TreeIndex  # noqa: E402
from tools.build_trees import (  # noqa: E402
    _discover_pending,
    _find_srt_entries,
    _tree_is_complete,
    load_progress,
    main_async,
)


def _write_valid_tree(tree_path: Path) -> None:
    """写入一棵最小合法树。"""
    l1 = L1Node(
        id="vid_L1_000",
        card=L1Card("场景", "室内", ["实体"], ["动作"], ["关键词"], [], "线性"),
        time_range=(0.0, 10.0),
        children=[],
    )
    index = TreeIndex(metadata=IndexMeta("/v.mp4", "video"), roots=[l1])
    tree_path.parent.mkdir(parents=True, exist_ok=True)
    index.save_json(str(tree_path))


class TestTreeIsComplete:
    """_tree_is_complete 测试。"""

    def test_valid_tree(self, tmp_path: Path) -> None:
        """合法 tree.json 判定完整。"""
        tree_path = tmp_path / "vid" / "tree.json"
        _write_valid_tree(tree_path)
        assert _tree_is_complete(tree_path) is True

    def test_missing_file(self, tmp_path: Path) -> None:
        """文件不存在判定不完整。"""
        assert _tree_is_complete(tmp_path / "nope" / "tree.json") is False

    def test_corrupt_json(self, tmp_path: Path) -> None:
        """损坏 JSON 判定不完整（不抛异常）。"""
        p = tmp_path / "vid" / "tree.json"
        p.parent.mkdir(parents=True)
        p.write_text("{broken", encoding="utf-8")
        assert _tree_is_complete(p) is False


class TestDiscoverPending:
    """_discover_pending 测试。"""

    def _touch_videos(self, videos_dir: Path, names: list[str]) -> None:
        videos_dir.mkdir(parents=True, exist_ok=True)
        for n in names:
            (videos_dir / n).write_bytes(b"")

    def test_all_pending_when_fresh(self, tmp_path: Path) -> None:
        """无进度无产物时全部待建，按名排序。"""
        videos = tmp_path / "videos"
        self._touch_videos(videos, ["b.mp4", "a.mkv", "c.txt"])
        pending = _discover_pending(videos, tmp_path / "out", set())
        assert [p.name for p in pending] == ["a.mkv", "b.mp4"]  # 非视频扩展名被忽略

    def test_skips_finished_and_complete(self, tmp_path: Path) -> None:
        """progress 已记录或 tree.json 完整的视频被跳过。"""
        videos = tmp_path / "videos"
        out = tmp_path / "out"
        self._touch_videos(videos, ["a.mp4", "b.mp4", "c.mp4"])
        _write_valid_tree(out / "b" / "tree.json")  # b 已有完整树
        pending = _discover_pending(videos, out, {"a"})  # a 在 progress 中
        assert [p.name for p in pending] == ["c.mp4"]

    def test_incomplete_tree_not_skipped(self, tmp_path: Path) -> None:
        """tree.json 损坏的视频仍待建（重建覆盖）。"""
        videos = tmp_path / "videos"
        out = tmp_path / "out"
        self._touch_videos(videos, ["a.mp4"])
        (out / "a").mkdir(parents=True)
        (out / "a" / "tree.json").write_text("{broken", encoding="utf-8")
        pending = _discover_pending(videos, out, set())
        assert [p.name for p in pending] == ["a.mp4"]


class TestFindSrtEntries:
    """_find_srt_entries 测试。"""

    def test_found(self, tmp_path: Path) -> None:
        """同名 .srt 存在时解析返回条目。"""
        srt = tmp_path / "vid.srt"
        srt.write_text(
            "1\n00:00:01,000 --> 00:00:03,000\nhello world\n\n",
            encoding="utf-8",
        )
        entries = _find_srt_entries(tmp_path / "vid.mp4", tmp_path)
        assert entries is not None
        assert len(entries) == 1

    def test_missing_returns_none(self, tmp_path: Path) -> None:
        """无同名 .srt 返回 None。"""
        assert _find_srt_entries(tmp_path / "vid.mp4", tmp_path) is None


# ── 编排集成（桩 builder，无真实视频/LLM）──────────────────────


class _StubBuilder:
    """记录并发与注入信号量的桩 builder。"""

    instances: list[_StubBuilder] = []
    inflight = 0
    max_inflight = 0

    def __init__(self, vlm, llm, config, *, api_semaphore=None) -> None:
        """记录注入的 api_semaphore 并登记实例。"""
        self.api_semaphore = api_semaphore
        _StubBuilder.instances.append(self)

    async def build_async(self, video_path: str, srt_entries=None) -> TreeIndex:
        """模拟建树：短暂 sleep 并统计并发峰值，返回最小合法树。"""
        _StubBuilder.inflight += 1
        _StubBuilder.max_inflight = max(_StubBuilder.max_inflight, _StubBuilder.inflight)
        await asyncio.sleep(0.02)
        _StubBuilder.inflight -= 1

        l1 = L1Node(
            id="x_L1_000",
            card=L1Card("s", "室内", [], [], [], [], "线性"),
            time_range=(0.0, 1.0),
            children=[],
        )
        return TreeIndex(metadata=IndexMeta(video_path, "video"), roots=[l1])


@pytest.fixture()
def batch_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
    """5 个假视频 + 桩 builder + 隔离的 progress 路径。"""
    import tools.build_trees as bt

    _StubBuilder.instances = []
    _StubBuilder.inflight = 0
    _StubBuilder.max_inflight = 0
    monkeypatch.setattr(bt, "VideoTreeBuilder", _StubBuilder)
    monkeypatch.setattr(bt, "_build_clients", lambda api_concurrency: (None, None))

    videos = tmp_path / "videos"
    videos.mkdir()
    for i in range(5):
        (videos / f"v{i}.mp4").write_bytes(b"")

    return {
        "videos": videos,
        "out": tmp_path / "out",
        "progress": tmp_path / "build_progress.json",
    }


def _make_args(env: dict, video_concurrency: int = 2, limit: int = 0) -> argparse.Namespace:
    """构造 main_async 所需的 CLI 参数命名空间。"""
    return argparse.Namespace(
        videos_dir=str(env["videos"]),
        out_dir=str(env["out"]),
        srt_dir=str(env["videos"]),
        video_concurrency=video_concurrency,
        limit=limit,
        progress_path=str(env["progress"]),
    )


class TestOrchestration:
    """main_async 编排行为（桩 builder）。"""

    @pytest.mark.asyncio
    async def test_video_concurrency_capped(self, batch_env: dict) -> None:
        """同时在建视频数不得超过 video_concurrency。"""
        await main_async(_make_args(batch_env, video_concurrency=2))
        assert _StubBuilder.max_inflight <= 2
        assert len(_StubBuilder.instances) == 5

    @pytest.mark.asyncio
    async def test_shared_api_semaphore(self, batch_env: dict) -> None:
        """全部 builder 实例共享同一个 API Semaphore 对象。"""
        await main_async(_make_args(batch_env))
        sems = {id(b.api_semaphore) for b in _StubBuilder.instances}
        assert len(sems) == 1
        assert _StubBuilder.instances[0].api_semaphore is not None

    @pytest.mark.asyncio
    async def test_trees_saved_and_progress_recorded(self, batch_env: dict) -> None:
        """每个视频产出 tree.json 且 progress 记录全部完成。"""
        await main_async(_make_args(batch_env))
        for i in range(5):
            assert (batch_env["out"] / f"v{i}" / "tree.json").exists()

        assert load_progress(batch_env["progress"]) == {f"v{i}" for i in range(5)}

    @pytest.mark.asyncio
    async def test_resume_skips_finished(self, batch_env: dict) -> None:
        """第二次运行跳过全部已完成视频。"""
        await main_async(_make_args(batch_env))
        n_first = len(_StubBuilder.instances)
        await main_async(_make_args(batch_env))
        assert len(_StubBuilder.instances) == n_first  # 无新建

    @pytest.mark.asyncio
    async def test_partial_completion_resume(self, batch_env: dict) -> None:
        """部分完成后重跑只建剩余视频（模拟中断后恢复）。"""
        batch_env["progress"].write_text(
            json.dumps({"finished_video_ids": ["v0", "v1"]}),
            encoding="utf-8",
        )
        await main_async(_make_args(batch_env))
        assert len(_StubBuilder.instances) == 3  # 仅 v2/v3/v4

    @pytest.mark.asyncio
    async def test_limit(self, batch_env: dict) -> None:
        """--limit 2 只建前两个（烟测入口）。"""
        await main_async(_make_args(batch_env, limit=2))
        assert len(_StubBuilder.instances) == 2
