"""video_filter_builder 单元测试 — FFmpeg 滤镜构建纯逻辑层。

覆盖：
- ClipFilterChain 数据类
- 常量与映射表
- build_clip_filter：单片段滤镜链
- chain_filters：滤镜串联工具
- has_audio：音频流判断
- build_concat_filter：concat 滤镜
- build_xfade_filter：xfade 转场滤镜
- build_filter_complex：策略选择（空/单片段/concat/xfade）
"""

from __future__ import annotations

import unittest
from dataclasses import FrozenInstanceError

from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.template_clip_config import TransitionEffect
from packages.domain.video_filter_builder import (
    DEFAULT_CLIP_DURATION,
    DEFAULT_FPS,
    DEFAULT_OUTPUT_HEIGHT,
    DEFAULT_OUTPUT_WIDTH,
    DEFAULT_TRANSITION_DURATION,
    XFADE_TRANSITION_MAP,
    ClipFilterChain,
    build_clip_filter,
    build_concat_filter,
    build_filter_complex,
    build_xfade_filter,
    chain_filters,
    has_audio,
)

# ── 辅助：构造 EditPlanClip ──────────────────────────────────────────────────


def _make_clip(
    clip_id: str = "clip-1",
    duration: float = 5.0,
    start_time: float = 0.0,
    clip_type: str = "video",
    asset_id: str | None = "asset-1",
) -> EditPlanClip:
    """构造一个测试用 EditPlanClip。"""
    return EditPlanClip(
        id=clip_id,
        plan_id="plan-1",
        asset_id=asset_id,
        clip_type=clip_type,
        duration=duration,
        start_time=start_time,
        order=0,
        status=EditPlanClipStatus.READY,
    )


# ── ClipFilterChain 数据类测试 ──────────────────────────────────────────────


class TestClipFilterChain(unittest.TestCase):
    """ClipFilterChain 数据类测试。"""

    def test_immutable(self):
        """ClipFilterChain 是 frozen dataclass，不可修改。"""
        chain = ClipFilterChain(
            clip_id="c1",
            input_index=0,
            video_label="v0",
            audio_label="a0",
            filters=["scale=1280:720"],
            duration=5.0,
        )
        with self.assertRaises(FrozenInstanceError):
            chain.duration = 10.0  # type: ignore[misc]

    def test_fields(self):
        """所有字段正确存储。"""
        chain = ClipFilterChain(
            clip_id="c1",
            input_index=2,
            video_label="v2",
            audio_label=None,
            filters=["fps=25", "trim=0:3"],
            duration=3.0,
        )
        self.assertEqual(chain.clip_id, "c1")
        self.assertEqual(chain.input_index, 2)
        self.assertEqual(chain.video_label, "v2")
        self.assertIsNone(chain.audio_label)
        self.assertEqual(chain.filters, ["fps=25", "trim=0:3"])
        self.assertEqual(chain.duration, 3.0)


# ── 常量测试 ────────────────────────────────────────────────────────────────


class TestConstants(unittest.TestCase):
    """常量与映射表测试。"""

    def test_default_output_size(self):
        """默认输出分辨率 1280x720。"""
        self.assertEqual(DEFAULT_OUTPUT_WIDTH, 1280)
        self.assertEqual(DEFAULT_OUTPUT_HEIGHT, 720)

    def test_default_fps(self):
        """默认帧率 25。"""
        self.assertEqual(DEFAULT_FPS, 25)

    def test_default_transition_duration(self):
        """默认转场时长 0.5 秒。"""
        self.assertEqual(DEFAULT_TRANSITION_DURATION, 0.5)

    def test_default_clip_duration(self):
        """默认片段时长 5 秒。"""
        self.assertEqual(DEFAULT_CLIP_DURATION, 5.0)

    def test_xfade_transition_map_keys(self):
        """xfade 映射包含所有转场类型。"""
        self.assertIn(TransitionEffect.FADE, XFADE_TRANSITION_MAP)
        self.assertIn(TransitionEffect.SLIDE_LEFT, XFADE_TRANSITION_MAP)
        self.assertIn(TransitionEffect.SLIDE_RIGHT, XFADE_TRANSITION_MAP)
        self.assertIn(TransitionEffect.DISSOLVE, XFADE_TRANSITION_MAP)
        self.assertIn(TransitionEffect.WIPE, XFADE_TRANSITION_MAP)

    def test_xfade_transition_map_values(self):
        """xfade 映射值为 FFmpeg 合法 transition 名称。"""
        self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.FADE], "fade")
        self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.SLIDE_LEFT], "slideleft")
        self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.SLIDE_RIGHT], "slideright")
        self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.DISSOLVE], "dissolve")
        self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.WIPE], "wipeleft")


# ── chain_filters 测试 ──────────────────────────────────────────────────────


class TestChainFilters(unittest.TestCase):
    """chain_filters 滤镜串联工具测试。"""

    def test_single_filter(self):
        """单个滤镜。"""
        result = chain_filters(["scale=1280:720"], "v0")
        self.assertEqual(result, "[0:v]scale=1280:720[v0]")

    def test_multiple_filters(self):
        """多个滤镜用逗号串联。"""
        result = chain_filters(["scale=1280:720", "fps=25", "trim=0:5"], "v1")
        self.assertEqual(result, "[0:v]scale=1280:720,fps=25,trim=0:5[v1]")

    def test_empty_filters(self):
        """空滤镜列表。"""
        result = chain_filters([], "v0")
        self.assertEqual(result, "[0:v][v0]")

    def test_custom_input_label(self):
        """自定义输入标签。"""
        result = chain_filters(["fps=30"], "out", input_label="v0")
        self.assertEqual(result, "[v0]fps=30[out]")


# ── has_audio 测试 ──────────────────────────────────────────────────────────


class TestHasAudio(unittest.TestCase):
    """has_audio 音频流判断测试。"""

    def test_all_have_audio(self):
        """所有片段都有音频。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
            ClipFilterChain("c2", 1, "v1", "a1", [], 3.0),
        ]
        self.assertTrue(has_audio(chains))

    def test_some_have_audio(self):
        """部分片段有音频。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
        ]
        self.assertTrue(has_audio(chains))

    def test_none_have_audio(self):
        """没有片段有音频。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 5.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
        ]
        self.assertFalse(has_audio(chains))

    def test_empty_list(self):
        """空列表返回 False。"""
        self.assertFalse(has_audio([]))


# ── build_clip_filter 测试 ──────────────────────────────────────────────────


class TestBuildClipFilter(unittest.TestCase):
    """build_clip_filter 单片段滤镜链测试。"""

    def test_basic_video_clip(self):
        """普通视频片段生成完整滤镜链。"""
        clip = _make_clip(duration=5.0, clip_type="video")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)

        self.assertEqual(chain.clip_id, "clip-1")
        self.assertEqual(chain.input_index, 0)
        self.assertEqual(chain.video_label, "v0")
        self.assertEqual(chain.audio_label, "a0")
        self.assertEqual(chain.duration, 5.0)
        # 应有 7 个滤镜：scale, pad, format, fps, setpts, trim, setpts
        self.assertEqual(len(chain.filters), 7)

    def test_filter_order(self):
        """滤镜顺序：scale → pad → format → fps → setpts → trim → setpts。"""
        clip = _make_clip(duration=3.0, clip_type="video")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)

        self.assertTrue(chain.filters[0].startswith("scale="))
        self.assertTrue(chain.filters[1].startswith("pad="))
        self.assertEqual(chain.filters[2], "format=yuv420p")
        self.assertTrue(chain.filters[3].startswith("fps="))
        self.assertTrue(chain.filters[4].startswith("setpts="))
        self.assertTrue(chain.filters[5].startswith("trim="))
        self.assertEqual(chain.filters[6], "setpts=PTS-STARTPTS")

    def test_scale_force_original_aspect_ratio(self):
        """scale 使用 decrease 保持比例。"""
        clip = _make_clip(duration=5.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIn("force_original_aspect_ratio=decrease", chain.filters[0])

    def test_pad_centered_black(self):
        """pad 居中 + 黑边。"""
        clip = _make_clip(duration=5.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIn("(ow-iw)/2:(oh-ih)/2:black", chain.filters[1])

    def test_format_yuv420p(self):
        """像素格式统一为 yuv420p。"""
        clip = _make_clip(duration=5.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.filters[2], "format=yuv420p")

    def test_custom_resolution(self):
        """自定义输出分辨率。"""
        clip = _make_clip(duration=5.0)
        chain = build_clip_filter(clip, 0, 1920, 1080, 30)
        self.assertIn("scale=1920:1080", chain.filters[0])
        self.assertIn("pad=1920:1080", chain.filters[1])
        self.assertEqual(chain.filters[3], "fps=30")

    def test_zero_duration_uses_default(self):
        """duration <= 0 时使用默认时长。"""
        clip = _make_clip(duration=0.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.duration, DEFAULT_CLIP_DURATION)
        self.assertIn(f"trim=0:{DEFAULT_CLIP_DURATION}", chain.filters[5])

    def test_negative_duration_uses_default(self):
        """负时长也使用默认时长。"""
        clip = _make_clip(duration=-1.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.duration, DEFAULT_CLIP_DURATION)

    def test_start_time_offset(self):
        """start_time > 0 时 setpts 带偏移。"""
        clip = _make_clip(duration=3.0, start_time=2.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIn("PTS-STARTPTS+2.0/TB", chain.filters[4])

    def test_zero_start_time_no_offset(self):
        """start_time = 0 时 setpts 不带偏移。"""
        clip = _make_clip(duration=3.0, start_time=0.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.filters[4], "setpts=PTS-STARTPTS")

    def test_title_clip_no_audio(self):
        """title 类型片段没有音频。"""
        clip = _make_clip(clip_type="title")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIsNone(chain.audio_label)

    def test_subtitle_clip_no_audio(self):
        """subtitle 类型片段没有音频。"""
        clip = _make_clip(clip_type="subtitle")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIsNone(chain.audio_label)

    def test_video_clip_has_audio(self):
        """video 类型片段有音频。"""
        clip = _make_clip(clip_type="video")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.audio_label, "a0")

    def test_image_clip_has_audio(self):
        """image 类型片段有音频标签（可能有BGM）。"""
        clip = _make_clip(clip_type="image")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIsNotNone(chain.audio_label)

    def test_clip_type_case_insensitive(self):
        """clip_type 大小写不敏感。"""
        clip = _make_clip(clip_type="TITLE")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIsNone(chain.audio_label)

    def test_empty_clip_type_has_audio(self):
        """空 clip_type 默认有音频。"""
        clip = _make_clip(clip_type="")
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIsNotNone(chain.audio_label)

    def test_input_index_reflected_in_labels(self):
        """input_index 反映在 video_label 和 audio_label 中。"""
        clip = _make_clip()
        chain = build_clip_filter(clip, 3, 1280, 720, 25)
        self.assertEqual(chain.video_label, "v3")
        self.assertEqual(chain.audio_label, "a3")

    def test_zero_fps_skipped(self):
        """fps = 0 时跳过 fps 滤镜。"""
        clip = _make_clip(duration=5.0)
        chain = build_clip_filter(clip, 0, 1280, 720, 0)
        # 少了 fps 滤镜：scale, pad, format, setpts, trim, setpts = 6个
        self.assertEqual(len(chain.filters), 6)
        self.assertFalse(any(f.startswith("fps=") for f in chain.filters))

    def test_negative_fps_skipped(self):
        """fps < 0 时也跳过 fps 滤镜。"""
        clip = _make_clip(duration=5.0)
        chain = build_clip_filter(clip, 0, 1280, 720, -1)
        self.assertEqual(len(chain.filters), 6)

    def test_trim_uses_duration(self):
        """trim 时长等于 clip.duration。"""
        clip = _make_clip(duration=7.5)
        chain = build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIn("trim=0:7.5", chain.filters[5])


# ── build_concat_filter 测试 ────────────────────────────────────────────────


class TestBuildConcatFilter(unittest.TestCase):
    """build_concat_filter 拼接滤镜测试。"""

    def test_empty_clips(self):
        """空列表返回空字符串和 0 时长。"""
        filter_str, duration = build_concat_filter([])
        self.assertEqual(filter_str, "")
        self.assertEqual(duration, 0.0)

    def test_single_clip_no_audio(self):
        """单片段无音频：视频滤镜 + concat(n=1)。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, ["scale=1280:720"], 5.0),
        ]
        filter_str, duration = build_concat_filter(chains)

        self.assertIn("[0:v]scale=1280:720[v0]", filter_str)
        self.assertIn("[v0]concat=n=1:v=1:a=0[outv]", filter_str)
        self.assertEqual(duration, 5.0)
        # 没有音频相关
        self.assertNotIn("[outa]", filter_str)

    def test_single_clip_with_audio(self):
        """单片段有音频：视频 + 音频归一化 + concat。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", ["scale=1280:720"], 5.0),
        ]
        filter_str, duration = build_concat_filter(chains)

        self.assertIn("[0:v]scale=1280:720[v0]", filter_str)
        self.assertIn("[v0]concat=n=1:v=1:a=0[outv]", filter_str)
        # 音频归一化
        self.assertIn("[0:a]aformat=sample_rates=48000", filter_str)
        self.assertIn("stereo:sample_fmts=fltp", filter_str)
        self.assertIn("atrim=0:5.0", filter_str)
        self.assertIn("[a0]", filter_str)
        # 音频 concat（n=1）
        self.assertIn("[a0]concat=n=1:v=0:a=1[outa]", filter_str)
        self.assertEqual(duration, 5.0)

    def test_two_clips_no_audio(self):
        """两片段无音频：两个视频滤镜 + concat(n=2)。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, ["fps=25"], 5.0),
            ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0),
        ]
        filter_str, duration = build_concat_filter(chains)

        self.assertIn("[0:v]fps=25[v0]", filter_str)
        self.assertIn("[1:v]fps=25[v1]", filter_str)
        self.assertIn("[v0][v1]concat=n=2:v=1:a=0[outv]", filter_str)
        self.assertEqual(duration, 8.0)

    def test_two_clips_with_audio(self):
        """两片段都有音频：视频 concat + 音频归一化 + 音频 concat。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0),
            ClipFilterChain("c2", 1, "v1", "a1", ["fps=25"], 3.0),
        ]
        filter_str, duration = build_concat_filter(chains)

        # 视频
        self.assertIn("[v0][v1]concat=n=2:v=1:a=0[outv]", filter_str)
        # 音频归一化
        self.assertIn(
            "[0:a]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:5.0,asetpts=PTS-STARTPTS[a0]",
            filter_str,
        )
        self.assertIn(
            "[1:a]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:3.0,asetpts=PTS-STARTPTS[a1]",
            filter_str,
        )
        # 音频 concat
        self.assertIn("[a0][a1]concat=n=2:v=0:a=1[outa]", filter_str)
        self.assertEqual(duration, 8.0)

    def test_mixed_audio_some_none(self):
        """部分有音频部分没有：只有有音频的片段参与音频 concat。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0),
            ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0),
            ClipFilterChain("c3", 2, "v2", "a2", ["fps=25"], 4.0),
        ]
        filter_str, duration = build_concat_filter(chains)

        # 视频 concat 有 3 个输入
        self.assertIn("[v0][v1][v2]concat=n=3:v=1:a=0[outv]", filter_str)
        # 音频 concat 只有 2 个输入
        self.assertIn("[a0][a2]concat=n=2:v=0:a=1[outa]", filter_str)
        # 片段 1 没有音频归一化
        self.assertNotIn("[1:a]", filter_str)
        self.assertEqual(duration, 12.0)

    def test_three_clips_total_duration(self):
        """三片段总时长为各片段之和。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 2.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
            ClipFilterChain("c3", 2, "v2", None, [], 4.0),
        ]
        _, duration = build_concat_filter(chains)
        self.assertEqual(duration, 9.0)

    def test_audio_format_normalization(self):
        """音频归一化包含 aformat/atrim/asetpts。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
        ]
        filter_str, _ = build_concat_filter(chains)

        self.assertIn("aformat=sample_rates=48000", filter_str)
        self.assertIn("channel_layouts=stereo", filter_str)
        self.assertIn("sample_fmts=fltp", filter_str)
        self.assertIn("atrim=0:5.0", filter_str)
        self.assertIn("asetpts=PTS-STARTPTS", filter_str)

    def test_filter_parts_separated_by_semicolon(self):
        """各滤镜部分用分号分隔。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0),
            ClipFilterChain("c2", 1, "v1", "a1", ["fps=25"], 3.0),
        ]
        filter_str, _ = build_concat_filter(chains)
        parts = filter_str.split(";")
        # 2 视频 + 2 音频归一化 + 1 视频 concat + 1 音频 concat = 6
        self.assertEqual(len(parts), 6)


# ── build_xfade_filter 测试 ─────────────────────────────────────────────────


class TestBuildXfadeFilter(unittest.TestCase):
    """build_xfade_filter 转场滤镜测试。"""

    def test_empty_clips(self):
        """空列表返回空字符串和 0 时长。"""
        filter_str, duration = build_xfade_filter([], 0.5, [])
        self.assertEqual(filter_str, "")
        self.assertEqual(duration, 0.0)

    def test_single_clip_no_audio(self):
        """单片段无音频：视频滤镜 + copy。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, ["scale=1280:720"], 5.0),
        ]
        filter_str, duration = build_xfade_filter(chains, 0.5, ["fade"])

        self.assertIn("[0:v]scale=1280:720[v0]", filter_str)
        self.assertIn("[v0]copy[outv]", filter_str)
        self.assertEqual(duration, 5.0)
        # 单片段 xfade 没有音频输出
        self.assertNotIn("[outa]", filter_str)

    def test_single_clip_with_audio(self):
        """单片段有音频：xfade 路径下单片段不输出音频（与原实现一致）。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", ["scale=1280:720"], 5.0),
        ]
        filter_str, _ = build_xfade_filter(chains, 0.5, ["fade"])
        # 单片段 xfade 没有音频输出
        self.assertNotIn("[outa]", filter_str)
        self.assertNotIn("acopy", filter_str)

    def test_two_clips_fade_transition(self):
        """两片段 fade 转场：xfade 滤镜结构正确。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, ["fps=25"], 5.0),
            ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0),
        ]
        filter_str, duration = build_xfade_filter(chains, 0.5, ["cut", "fade"])

        # 两个视频滤镜链
        self.assertIn("[0:v]fps=25[v0]", filter_str)
        self.assertIn("[1:v]fps=25[v1]", filter_str)
        # xfade 转场
        self.assertIn("xfade=transition=fade", filter_str)
        self.assertIn(":duration=0.5", filter_str)
        self.assertIn("[outv]", filter_str)
        # 总时长 = 5 + 3 - 0.5 = 7.5
        self.assertAlmostEqual(duration, 7.5)

    def test_two_clips_offset_calculation(self):
        """转场 offset = 第一个片段时长 - 转场时长。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 5.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
        ]
        filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])

        # offset = 5.0 - 0.5 * 1 = 4.5
        self.assertIn(":offset=4.500", filter_str)

    def test_three_clips_chain(self):
        """三片段链式转场：两个 xfade，中间用 xf1 标签。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 4.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
            ClipFilterChain("c3", 2, "v2", None, [], 5.0),
        ]
        filter_str, duration = build_xfade_filter(chains, 0.5, ["cut", "fade", "dissolve"])

        # 第一个转场输出到 xf1
        self.assertIn("[xf1]", filter_str)
        # 第二个转场输出到 outv
        self.assertIn("[xf1][v2]xfade=transition=dissolve", filter_str)
        self.assertIn("[outv]", filter_str)
        # 总时长 = 4 + 3 + 5 - 0.5 * 2 = 11.0
        self.assertAlmostEqual(duration, 11.0)

    def test_three_clips_offsets(self):
        """三片段两个转场的 offset 计算正确。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 4.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
            ClipFilterChain("c3", 2, "v2", None, [], 5.0),
        ]
        filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade", "slideleft"])

        # 第一个 offset = 4.0 - 0.5*1 = 3.5
        # 第二个 offset = (4.0+3.0) - 0.5*2 = 7.0 - 1.0 = 6.0
        self.assertIn(":offset=3.500", filter_str)
        self.assertIn(":offset=6.000", filter_str)

    def test_all_transition_types(self):
        """所有转场类型都能正确映射。"""
        transitions = [
            (TransitionEffect.FADE, "fade"),
            (TransitionEffect.SLIDE_LEFT, "slideleft"),
            (TransitionEffect.SLIDE_RIGHT, "slideright"),
            (TransitionEffect.DISSOLVE, "dissolve"),
            (TransitionEffect.WIPE, "wipeleft"),
        ]
        for effect, expected_name in transitions:
            chains = [
                ClipFilterChain("c1", 0, "v0", None, [], 3.0),
                ClipFilterChain("c2", 1, "v1", None, [], 2.0),
            ]
            filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", effect])
            self.assertIn(
                f"xfade=transition={expected_name}",
                filter_str,
                f"Transition {effect} should map to {expected_name}",
            )

    def test_unknown_transition_defaults_to_fade(self):
        """未知转场类型默认使用 fade。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 3.0),
            ClipFilterChain("c2", 1, "v1", None, [], 2.0),
        ]
        filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "nonexistent"])
        self.assertIn("xfade=transition=fade", filter_str)

    def test_cut_still_uses_fade(self):
        """cut 类型在 xfade 路径下也映射为 fade（因为走了 xfade 分支）。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 3.0),
            ClipFilterChain("c2", 1, "v1", None, [], 2.0),
        ]
        # 只要有一个非 cut 就走 xfade，cut 的那个也用 fade 作为默认
        filter_str, _ = build_xfade_filter(chains, 0.5, ["fade", "cut"])
        # 第二个转场是 cut，默认用 fade
        self.assertIn("xfade=transition=fade", filter_str)

    def test_transitions_shorter_than_clips(self):
        """transitions 列表比 clip 短时，超出部分默认 cut→fade。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 2.0),
            ClipFilterChain("c2", 1, "v1", None, [], 2.0),
            ClipFilterChain("c3", 2, "v2", None, [], 2.0),
        ]
        # 只给 1 个 transition（索引0），索引1和2会越界
        filter_str, _ = build_xfade_filter(chains, 0.5, ["fade"])
        # 应该有两个 xfade，都用 fade（第二个是默认值）
        self.assertEqual(filter_str.count("xfade=transition=fade"), 2)

    def test_zero_transition_duration(self):
        """转场时长为 0 时不减时长。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 5.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
        ]
        _, duration = build_xfade_filter(chains, 0.0, ["cut", "fade"])
        self.assertAlmostEqual(duration, 8.0)

    def test_total_duration_not_negative(self):
        """总时长不会为负数。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 0.1),
            ClipFilterChain("c2", 1, "v1", None, [], 0.1),
        ]
        _, duration = build_xfade_filter(chains, 10.0, ["cut", "fade"])
        self.assertGreaterEqual(duration, 0.0)

    def test_two_clips_with_audio_normalize_and_concat(self):
        """两片段都有音频：归一化 + concat。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
            ClipFilterChain("c2", 1, "v1", "a1", [], 3.0),
        ]
        filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])

        # 音频归一化（注意：xfade 路径用 audio_label 作为输入，与原实现一致）
        self.assertIn(
            "[a0]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:5.0,asetpts=PTS-STARTPTS[anorm_v0]",
            filter_str,
        )
        self.assertIn(
            "[a1]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:3.0,asetpts=PTS-STARTPTS[anorm_v1]",
            filter_str,
        )
        # 音频 concat
        self.assertIn("[anorm_v0][anorm_v1]concat=n=2:v=0:a=1[outa]", filter_str)

    def test_single_audio_in_xfade_acopy(self):
        """xfade 路径下只有一个音频片段时直接 acopy。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
        ]
        filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])

        self.assertIn("[a0]acopy[outa]", filter_str)
        # 没有音频归一化
        self.assertNotIn("aformat", filter_str)
        self.assertNotIn("concat=n=", filter_str)

    def test_no_audio_in_xfade(self):
        """xfade 路径下都没有音频时没有 outa。"""
        chains = [
            ClipFilterChain("c1", 0, "v0", None, [], 5.0),
            ClipFilterChain("c2", 1, "v1", None, [], 3.0),
        ]
        filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
        self.assertNotIn("[outa]", filter_str)
        self.assertNotIn("acopy", filter_str)


# ── build_filter_complex 测试 ───────────────────────────────────────────────


class TestBuildFilterComplex(unittest.TestCase):
    """build_filter_complex 策略选择测试。"""

    def _chain(self, idx: int, has_audio: bool = True) -> ClipFilterChain:
        return ClipFilterChain(
            clip_id=f"c{idx}",
            input_index=idx,
            video_label=f"v{idx}",
            audio_label=f"a{idx}" if has_audio else None,
            filters=["fps=25"],
            duration=3.0,
        )

    def test_empty_clips(self):
        """空列表返回空字符串和 0 时长。"""
        filter_str, duration = build_filter_complex([], 1280, 720, 0.5, [])
        self.assertEqual(filter_str, "")
        self.assertEqual(duration, 0.0)

    def test_single_clip_direct_output(self):
        """单片段：直接输出单链滤镜。"""
        chains = [self._chain(0)]
        filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, [])

        self.assertIn("[0:v]fps=25[v0]", filter_str)
        self.assertNotIn("concat", filter_str)
        self.assertNotIn("xfade", filter_str)
        self.assertEqual(duration, 3.0)

    def test_single_clip_audio_passthrough(self):
        """单片段有音频：音频直通标签。"""
        chains = [self._chain(0, has_audio=True)]
        filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, [])
        # 单片段音频：[0:a]a0（直通标签）
        self.assertIn("[0:a]a0", filter_str)

    def test_single_clip_no_audio(self):
        """单片段无音频：没有音频部分。"""
        chains = [self._chain(0, has_audio=False)]
        filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, [])
        self.assertNotIn("[0:a]", filter_str)
        self.assertNotIn("[outa]", filter_str)

    def test_multiple_all_cut_uses_concat(self):
        """多片段 + 全 cut：使用 concat 滤镜。"""
        chains = [self._chain(0), self._chain(1), self._chain(2)]
        transitions = [TransitionEffect.CUT, TransitionEffect.CUT, TransitionEffect.CUT]
        filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, transitions)

        self.assertIn("concat=n=3:v=1:a=0[outv]", filter_str)
        self.assertNotIn("xfade", filter_str)
        self.assertEqual(duration, 9.0)

    def test_multiple_one_transition_uses_xfade(self):
        """多片段 + 有一个非 cut 转场：使用 xfade。"""
        chains = [self._chain(0), self._chain(1)]
        transitions = [TransitionEffect.CUT, TransitionEffect.FADE]
        filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, transitions)

        self.assertIn("xfade=transition=fade", filter_str)
        self.assertNotIn("concat=n=2:v=1:a=0", filter_str)
        self.assertAlmostEqual(duration, 5.5)  # 3 + 3 - 0.5

    def test_string_cut_value(self):
        """字符串 'cut' 也被识别为无转场。"""
        chains = [self._chain(0), self._chain(1)]
        transitions = ["cut", "cut"]
        filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions)
        self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str)
        self.assertNotIn("xfade", filter_str)

    def test_mixed_cut_and_transition(self):
        """混合 cut 和转场：走 xfade 路径。"""
        chains = [self._chain(0), self._chain(1), self._chain(2)]
        transitions = ["cut", TransitionEffect.FADE, "cut"]
        filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions)
        self.assertIn("xfade", filter_str)

    def test_all_dissolve_transition(self):
        """全部 dissolve 转场。"""
        chains = [self._chain(0), self._chain(1)]
        transitions = [TransitionEffect.CUT, TransitionEffect.DISSOLVE]
        filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions)
        self.assertIn("xfade=transition=dissolve", filter_str)


# ── 集成测试：build_clip_filter + build_filter_complex 端到端 ────────────────


class TestEndToEndFilterBuilding(unittest.TestCase):
    """端到端集成测试：从 EditPlanClip 到完整 filter_complex。"""

    def test_two_video_clips_concat(self):
        """两个视频片段走 concat 路径的完整流程。"""
        clip1 = _make_clip("c1", duration=5.0, clip_type="video")
        clip2 = _make_clip("c2", duration=3.0, clip_type="video")

        chain1 = build_clip_filter(clip1, 0, 1280, 720, 25)
        chain2 = build_clip_filter(clip2, 1, 1280, 720, 25)

        filter_str, duration = build_filter_complex(
            [chain1, chain2],
            1280,
            720,
            0.5,
            [TransitionEffect.CUT, TransitionEffect.CUT],
        )

        # 有两个视频滤镜链
        self.assertIn("[0:v]", filter_str)
        self.assertIn("[1:v]", filter_str)
        # concat 输出
        self.assertIn("[outv]", filter_str)
        self.assertIn("[outa]", filter_str)
        # 总时长
        self.assertAlmostEqual(duration, 8.0)
        # 结构：2视频 + 2音频 + 1视频concat + 1音频concat = 6 段
        self.assertEqual(len(filter_str.split(";")), 6)

    def test_two_clips_with_xfade(self):
        """两个片段走 xfade 转场的完整流程。"""
        clip1 = _make_clip("c1", duration=5.0, clip_type="video")
        clip2 = _make_clip("c2", duration=4.0, clip_type="video")

        chain1 = build_clip_filter(clip1, 0, 1920, 1080, 30)
        chain2 = build_clip_filter(clip2, 1, 1920, 1080, 30)

        filter_str, duration = build_filter_complex(
            [chain1, chain2],
            1920,
            1080,
            0.5,
            [TransitionEffect.CUT, TransitionEffect.FADE],
        )

        self.assertIn("xfade=transition=fade:duration=0.5", filter_str)
        self.assertIn("[outv]", filter_str)
        # 总时长减去转场
        self.assertAlmostEqual(duration, 8.5)  # 5 + 4 - 0.5

    def test_title_plus_video(self):
        """title 片段（无音频）+ video 片段（有音频）。"""
        clip1 = _make_clip("c1", duration=2.0, clip_type="title")
        clip2 = _make_clip("c2", duration=5.0, clip_type="video")

        chain1 = build_clip_filter(clip1, 0, 1280, 720, 25)
        chain2 = build_clip_filter(clip2, 1, 1280, 720, 25)

        # title 无音频，video 有音频
        self.assertIsNone(chain1.audio_label)
        self.assertIsNotNone(chain2.audio_label)

        # concat 路径
        filter_str, _ = build_filter_complex(
            [chain1, chain2],
            1280,
            720,
            0.5,
            [TransitionEffect.CUT, TransitionEffect.CUT],
        )
        # 音频 concat 只有 1 个输入（片段2）
        self.assertIn("[a1]concat=n=1:v=0:a=1[outa]", filter_str)
        self.assertNotIn("[a0]", filter_str)


if __name__ == "__main__":
    unittest.main()
