"""VideoComposeService 单元测试.

使用 stub 仓储替代真实数据库，测试 FFmpeg 命令生成和校验逻辑。
"""

from __future__ import annotations

import sys
from pathlib import Path
from unittest import TestCase

# 修正 import 路径
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))

from app.services.video_compose_service import (
    ComposeCommand,
    VideoComposeService,
    _build_concat_filter,
    _build_xfade_filter,
    _chain_filters,
)

from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClipStatus

# ── Stub 实体 ─────────────────────────────────────────────────────────────────


class _StubClip:
    """EditPlanClip 的轻量替身。"""

    def __init__(
        self,
        clip_id: str = "clip-1",
        plan_id: str = "plan-1",
        clip_type: str = "main",
        order: int = 0,
        asset_id: str = "",
        duration: float = 5.0,
        start_time: float = 0.0,
        transition_effect: str = "cut",
        status: EditPlanClipStatus = EditPlanClipStatus.READY,
    ):
        self.id = clip_id
        self.plan_id = plan_id
        self.clip_type = clip_type
        self.order = order
        self.asset_id = asset_id
        self.duration = duration
        self.start_time = start_time
        self.transition_effect = transition_effect
        self.status = status
        self.template_clip_config_id = ""
        self.text_content = ""
        self.config = {}


class _StubPlan:
    """EditPlan 的轻量替身。"""

    def __init__(
        self,
        plan_id: str = "plan-1",
        status: EditPlanStatus = EditPlanStatus.EDITING,
    ):
        self.id = plan_id
        self.template_id = "tpl-1"
        self.name = "测试计划"
        self.status = status
        self.total_duration = 0.0
        self.config = {}


# ── Stub 仓储 ─────────────────────────────────────────────────────────────────


class _StubPlanRepo:
    def __init__(self, plans: dict[str, _StubPlan] | None = None):
        self._plans = plans or {}

    def get(self, plan_id: str):
        return self._plans.get(plan_id)

    def list_by_project(self, *args, **kwargs):
        return list(self._plans.values())


class _StubClipRepo:
    def __init__(self, clips: list[_StubClip] | None = None):
        self._clips = {c.id: c for c in (clips or [])}
        self._by_plan: dict[str, list[_StubClip]] = {}
        for c in clips or []:
            self._by_plan.setdefault(c.plan_id, []).append(c)

    def get(self, clip_id: str):
        return self._clips.get(clip_id)

    def list_by_plan(self, plan_id: str, skip: int = 0, limit: int = 100):
        clips = self._by_plan.get(plan_id, [])
        return clips[skip : skip + limit]


# ── 辅助工厂 ──────────────────────────────────────────────────────────────────


def _make_service(
    plan: _StubPlan | None = None,
    clips: list[_StubClip] | None = None,
) -> VideoComposeService:
    """创建注入 stub 仓储的 VideoComposeService。"""
    svc = VideoComposeService.__new__(VideoComposeService)
    svc._db = None  # type: ignore[assignment]
    svc._plan_repo = _StubPlanRepo({plan.id: plan} if plan else {})  # type: ignore[assignment]
    svc._clip_repo = _StubClipRepo(clips or [])  # type: ignore[assignment]
    return svc


def _make_ready_clip(
    clip_id: str = "clip-1",
    plan_id: str = "plan-1",
    order: int = 0,
    duration: float = 5.0,
    asset_id: str = "assets/video.mp4",
    transition: str = "cut",
) -> _StubClip:
    return _StubClip(
        clip_id=clip_id,
        plan_id=plan_id,
        order=order,
        asset_id=asset_id,
        duration=duration,
        transition_effect=transition,
        status=EditPlanClipStatus.READY,
    )


# ── 测试用例 ──────────────────────────────────────────────────────────────────


class TestValidateCompose(TestCase):
    """validate_compose 校验逻辑测试。"""

    def test_plan_not_found(self):
        """计划不存在 → 校验失败。"""
        svc = _make_service()
        result = svc.validate_compose("nonexistent")
        self.assertFalse(result.valid)
        self.assertIn("剪辑计划不存在", result.errors[0])

    def test_wrong_status(self):
        """计划状态不是 editing/rendering → 校验失败。"""
        plan = _StubPlan(status=EditPlanStatus.DRAFT)
        clips = [_make_ready_clip()]
        svc = _make_service(plan, clips)
        result = svc.validate_compose(plan.id)
        self.assertFalse(result.valid)
        self.assertTrue(any("状态不正确" in e for e in result.errors))

    def test_no_clips(self):
        """计划没有片段 → 校验失败。"""
        plan = _StubPlan(status=EditPlanStatus.EDITING)
        svc = _make_service(plan, [])
        result = svc.validate_compose(plan.id)
        self.assertFalse(result.valid)
        self.assertIn("计划没有任何片段", result.errors[0])

    def test_no_ready_clips(self):
        """没有 ready 状态的片段 → 校验失败。"""
        plan = _StubPlan(status=EditPlanStatus.EDITING)
        clips = [
            _StubClip(
                clip_id="c1",
                plan_id=plan.id,
                status=EditPlanClipStatus.PENDING,
                asset_id="a.mp4",
            )
        ]
        svc = _make_service(plan, clips)
        result = svc.validate_compose(plan.id)
        self.assertFalse(result.valid)
        self.assertTrue(any("没有就绪" in e for e in result.errors))

    def test_ready_clip_without_asset(self):
        """ready 片段没有 asset_id → 校验失败。"""
        plan = _StubPlan(status=EditPlanStatus.EDITING)
        clips = [
            _StubClip(
                clip_id="c1",
                plan_id=plan.id,
                status=EditPlanClipStatus.READY,
                asset_id="",
            )
        ]
        svc = _make_service(plan, clips)
        result = svc.validate_compose(plan.id)
        self.assertFalse(result.valid)
        self.assertTrue(any("没有分配素材" in e for e in result.errors))

    def test_valid_single_clip(self):
        """单个 ready 片段 → 校验通过。"""
        plan = _StubPlan(status=EditPlanStatus.EDITING)
        clips = [_make_ready_clip(plan_id=plan.id)]
        svc = _make_service(plan, clips)
        result = svc.validate_compose(plan.id)
        self.assertTrue(result.valid)
        self.assertEqual(result.ready_clip_count, 1)
        self.assertEqual(result.total_clip_count, 1)
        self.assertEqual(len(result.errors), 0)

    def test_valid_multiple_clips(self):
        """多个 ready 片段 → 校验通过。"""
        plan = _StubPlan(status=EditPlanStatus.EDITING)
        clips = [
            _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0),
            _make_ready_clip(clip_id="c2", plan_id=plan.id, order=1),
            _make_ready_clip(clip_id="c3", plan_id=plan.id, order=2),
        ]
        svc = _make_service(plan, clips)
        result = svc.validate_compose(plan.id)
        self.assertTrue(result.valid)
        self.assertEqual(result.ready_clip_count, 3)

    def test_rendering_status_also_valid(self):
        """rendering 状态也允许合成。"""
        plan = _StubPlan(status=EditPlanStatus.RENDERING)
        clips = [_make_ready_clip(plan_id=plan.id)]
        svc = _make_service(plan, clips)
        result = svc.validate_compose(plan.id)
        self.assertTrue(result.valid)

    def test_mixed_statuses_with_pending_warning(self):
        """混合状态：ready + pending → 通过但有警告。"""
        plan = _StubPlan(status=EditPlanStatus.EDITING)
        clips = [
            _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0),
            _StubClip(
                clip_id="c2",
                plan_id=plan.id,
                order=1,
                status=EditPlanClipStatus.PENDING,
                asset_id="b.mp4",
            ),
        ]
        svc = _make_service(plan, clips)
        result = svc.validate_compose(plan.id)
        self.assertTrue(result.valid)
        self.assertEqual(result.ready_clip_count, 1)
        self.assertEqual(result.total_clip_count, 2)
        self.assertTrue(any("pending" in w for w in result.warnings))


class TestBuildComposeCommand(TestCase):
    """build_compose_command 命令生成测试。"""

    def test_plan_not_found_raises(self):
        """计划不存在 → ValueError。"""
        svc = _make_service()
        with self.assertRaises(ValueError):
            svc.build_compose_command("nonexistent", "/tmp/out.mp4")

    def test_no_clips_raises(self):
        """没有片段 → ValueError。"""
        plan = _StubPlan()
        svc = _make_service(plan, [])
        with self.assertRaises(ValueError):
            svc.build_compose_command(plan.id, "/tmp/out.mp4")

    def test_no_ready_clips_raises(self):
        """没有 ready 片段 → ValueError。"""
        plan = _StubPlan()
        clips = [
            _StubClip(
                clip_id="c1",
                plan_id=plan.id,
                status=EditPlanClipStatus.PENDING,
                asset_id="a.mp4",
            )
        ]
        svc = _make_service(plan, clips)
        with self.assertRaises(ValueError):
            svc.build_compose_command(plan.id, "/tmp/out.mp4")

    def test_single_clip_command(self):
        """单片段命令生成。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(plan_id=plan.id, duration=10.0)]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")

        self.assertIsInstance(cmd, ComposeCommand)
        self.assertEqual(cmd.input_paths, ["assets/video.mp4"])
        self.assertEqual(cmd.output_path, "/tmp/out.mp4")
        self.assertEqual(cmd.estimated_duration, 10.0)
        self.assertEqual(len(cmd.clip_chains), 1)
        self.assertIn("ffmpeg", cmd.command[0])
        self.assertIn("-filter_complex", cmd.command)

    def test_multi_clip_concat_command(self):
        """多片段 concat 命令生成。"""
        plan = _StubPlan()
        clips = [
            _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0),
            _make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0),
        ]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")

        self.assertEqual(len(cmd.input_paths), 2)
        self.assertEqual(cmd.estimated_duration, 13.0)
        self.assertIn("concat", cmd.filter_complex)
        self.assertIn("[outv]", cmd.filter_complex)

    def test_multi_clip_xfade_command(self):
        """多片段 xfade 转场命令生成。"""
        plan = _StubPlan()
        clips = [
            _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0, transition="fade"),
            _make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0, transition="cut"),
        ]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")

        self.assertEqual(len(cmd.input_paths), 2)
        self.assertIn("xfade", cmd.filter_complex)
        self.assertIn("transition=fade", cmd.filter_complex)
        # 总时长应减去转场时长
        self.assertLess(cmd.estimated_duration, 13.0)

    def test_custom_output_params(self):
        """自定义输出参数。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(plan_id=plan.id)]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(
            plan.id,
            "/tmp/out.mp4",
            output_width=1920,
            output_height=1080,
            codec="libx265",
            crf=28,
        )

        self.assertIn("-crf", cmd.command)
        crf_idx = cmd.command.index("-crf")
        self.assertEqual(cmd.command[crf_idx + 1], "28")

    def test_filter_chain_contains_scale_and_pad(self):
        """滤镜链包含 scale 和 pad（保持比例留黑边，不裁剪）。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(plan_id=plan.id)]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")

        chain = cmd.clip_chains[0]
        filter_text = ",".join(chain.filters)
        self.assertIn("scale=", filter_text)
        self.assertIn("force_original_aspect_ratio=decrease", filter_text)
        self.assertIn("pad=", filter_text)
        self.assertIn("black", filter_text)
        self.assertIn("trim=", filter_text)

    def test_filter_chain_contains_format_yuv420p(self):
        """滤镜链包含 format=yuv420p，统一像素格式避免 concat 失败。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(plan_id=plan.id)]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")

        chain = cmd.clip_chains[0]
        filter_text = ",".join(chain.filters)
        # format=yuv420p 必须在 pad 之后、fps 之后（像素格式统一放在分辨率之后）
        pad_idx = filter_text.index("pad=")
        fmt_idx = filter_text.index("format=yuv420p")
        self.assertGreater(fmt_idx, pad_idx, "format 应该在 pad 之后")

    def test_filter_chain_contains_fps(self):
        """滤镜链包含 fps 滤镜，用于统一帧率避免 concat 失败。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(plan_id=plan.id)]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4", fps=25)

        chain = cmd.clip_chains[0]
        filter_text = ",".join(chain.filters)
        # fps 必须在 pad 之后、setpts 之前
        pad_idx = filter_text.index("pad=")
        fps_idx = filter_text.index("fps=25")
        setpts_idx = filter_text.index("setpts=")
        self.assertGreater(fps_idx, pad_idx, "fps 应该在 pad 之后")
        self.assertLess(fps_idx, setpts_idx, "fps 应该在 setpts 之前")

    def test_fps_zero_or_none_skips_fps_filter(self):
        """fps 为 0 或负值时不添加 fps 滤镜。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(plan_id=plan.id)]
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4", fps=0)

        chain = cmd.clip_chains[0]
        filter_text = ",".join(chain.filters)
        self.assertNotIn("fps=", filter_text)

    def test_start_time_offset(self):
        """片段 start_time > 0 时生成 setpts 偏移。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(plan_id=plan.id, duration=5.0)]
        clips[0].start_time = 2.5
        svc = _make_service(plan, clips)
        cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")

        chain = cmd.clip_chains[0]
        filter_text = ",".join(chain.filters)
        self.assertIn("2.5/TB", filter_text)


class TestBuildSingleClipCommand(TestCase):
    """build_single_clip_command 测试。"""

    def test_clip_not_found_raises(self):
        """片段不存在 → ValueError。"""
        svc = _make_service()
        with self.assertRaises(ValueError):
            svc.build_single_clip_command("nonexistent", "/tmp/out.mp4")

    def test_no_asset_raises(self):
        """片段没有素材 → ValueError。"""
        plan = _StubPlan()
        clips = [_StubClip(clip_id="c1", plan_id=plan.id, asset_id="")]
        svc = _make_service(plan, clips)
        with self.assertRaises(ValueError):
            svc.build_single_clip_command("c1", "/tmp/out.mp4")

    def test_single_clip_preview(self):
        """单片段预览命令。"""
        plan = _StubPlan()
        clips = [_make_ready_clip(clip_id="c1", plan_id=plan.id)]
        svc = _make_service(plan, clips)
        cmd = svc.build_single_clip_command("c1", "/tmp/preview.mp4")

        self.assertEqual(cmd.output_path, "/tmp/preview.mp4")
        self.assertEqual(len(cmd.clip_chains), 1)
        # filter_complex 字段是原始滤镜字符串（不含 [outv] 标签）
        self.assertIn("scale=", cmd.filter_complex)
        # 完整命令中包含 [outv]
        self.assertIn("[outv]", " ".join(cmd.command))


class TestGetComposeStatus(TestCase):
    """get_compose_status 测试。"""

    def test_plan_not_found_raises(self):
        """计划不存在 → ValueError。"""
        svc = _make_service()
        with self.assertRaises(ValueError):
            svc.get_compose_status("nonexistent")

    def test_status_summary(self):
        """状态摘要正确。"""
        plan = _StubPlan(status=EditPlanStatus.EDITING)
        clips = [
            _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0),
            _StubClip(
                clip_id="c2",
                plan_id=plan.id,
                order=1,
                status=EditPlanClipStatus.PENDING,
                asset_id="b.mp4",
            ),
            _StubClip(
                clip_id="c3",
                plan_id=plan.id,
                order=2,
                status=EditPlanClipStatus.RENDERED,
                asset_id="c.mp4",
                duration=3.0,
            ),
        ]
        svc = _make_service(plan, clips)
        status = svc.get_compose_status(plan.id)

        self.assertEqual(status["plan_id"], plan.id)
        self.assertEqual(status["plan_status"], "editing")
        self.assertEqual(status["total_clips"], 3)
        self.assertEqual(status["ready_clips"], 1)
        self.assertEqual(status["pending_clips"], 1)
        self.assertEqual(status["rendered_clips"], 1)
        self.assertEqual(status["total_duration"], 13.0)  # 5.0 + 5.0 + 3.0（所有有 duration 的片段）
        self.assertTrue(status["can_compose"])


class TestChainFilters(TestCase):
    """_chain_filters 辅助函数测试。"""

    def test_basic_chain(self):
        """基本滤镜链。"""
        result = _chain_filters(["scale=1280:720", "crop=1280:720"], "v0")
        self.assertEqual(result, "[0:v]scale=1280:720,crop=1280:720[v0]")

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


class TestBuildConcatFilter(TestCase):
    """_build_concat_filter 测试。"""

    def test_single_clip(self):
        """单片段 concat。"""
        from app.services.video_compose_service import ClipFilterChain

        chains = [
            ClipFilterChain(
                clip_id="c1",
                input_index=0,
                video_label="v0",
                audio_label="a0",
                filters=["scale=1280:720", "trim=0:5"],
                duration=5.0,
            )
        ]
        filter_str, duration = _build_concat_filter(chains)
        self.assertIn("concat=n=1", filter_str)
        self.assertEqual(duration, 5.0)

    def test_multi_clip(self):
        """多片段 concat。"""
        from app.services.video_compose_service import ClipFilterChain

        chains = [
            ClipFilterChain(
                clip_id="c1",
                input_index=0,
                video_label="v0",
                audio_label=None,
                filters=["scale=1280:720"],
                duration=5.0,
            ),
            ClipFilterChain(
                clip_id="c2",
                input_index=1,
                video_label="v1",
                audio_label=None,
                filters=["scale=1280:720"],
                duration=8.0,
            ),
        ]
        filter_str, duration = _build_concat_filter(chains)
        self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str)
        self.assertEqual(duration, 13.0)

    def test_multi_clip_with_audio_aformat(self):
        """多片段+音频时，每个音频流都经过 aformat 归一化再concat。"""
        from app.services.video_compose_service import ClipFilterChain

        chains = [
            ClipFilterChain(
                clip_id="c1",
                input_index=0,
                video_label="v0",
                audio_label="a0",
                filters=["scale=1280:720"],
                duration=5.0,
            ),
            ClipFilterChain(
                clip_id="c2",
                input_index=1,
                video_label="v1",
                audio_label="a1",
                filters=["scale=1280:720"],
                duration=8.0,
            ),
        ]
        filter_str, _ = _build_concat_filter(chains)
        # 两个音频流都必须有 aformat 归一化（48000Hz + stereo + fltp）
        self.assertIn("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", filter_str)
        # 音频 concat
        self.assertIn("concat=n=2:v=0:a=1[outa]", filter_str)
        # aformat 在 atrim 之前（先统一格式再裁剪）
        aformat_idx = filter_str.index("aformat=")
        atrim_idx = filter_str.index("atrim=")
        self.assertLess(aformat_idx, atrim_idx, "aformat 应该在 atrim 之前")


class TestBuildXfadeFilter(TestCase):
    """_build_xfade_filter 测试。"""

    def test_two_clips_with_fade(self):
        """两个片段 + fade 转场。"""
        from app.services.video_compose_service import ClipFilterChain

        chains = [
            ClipFilterChain(
                clip_id="c1",
                input_index=0,
                video_label="v0",
                audio_label=None,
                filters=["scale=1280:720"],
                duration=5.0,
            ),
            ClipFilterChain(
                clip_id="c2",
                input_index=1,
                video_label="v1",
                audio_label=None,
                filters=["scale=1280:720"],
                duration=8.0,
            ),
        ]
        filter_str, duration = _build_xfade_filter(chains, transition_duration=0.5, transitions=["cut", "fade"])
        self.assertIn("xfade=transition=fade", filter_str)
        self.assertIn("duration=0.5", filter_str)
        self.assertIn("[outv]", filter_str)
        # 总时长 = 5 + 8 - 0.5 = 12.5
        self.assertAlmostEqual(duration, 12.5, places=2)

    def test_three_clips_chained_xfade(self):
        """三个片段链式 xfade。"""
        from app.services.video_compose_service import ClipFilterChain

        chains = [
            ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label=None, filters=[], duration=5.0),
            ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=[], duration=5.0),
            ClipFilterChain(clip_id="c3", input_index=2, video_label="v2", audio_label=None, filters=[], duration=5.0),
        ]
        filter_str, duration = _build_xfade_filter(
            chains, transition_duration=0.5, transitions=["cut", "fade", "slide_left"]
        )
        self.assertIn("xfade=transition=fade", filter_str)
        self.assertIn("xfade=transition=slideleft", filter_str)
        # 总时长 = 15 - 0.5*2 = 14.0
        self.assertAlmostEqual(duration, 14.0, places=2)

    def test_xfade_with_audio_aformat_normalization(self):
        """xfade 路径下多片段音频 concat 前必须经过 aformat 归一化。"""
        from app.services.video_compose_service import ClipFilterChain

        chains = [
            ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label="a0", filters=[], duration=5.0),
            ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label="a1", filters=[], duration=8.0),
        ]
        filter_str, _ = _build_xfade_filter(chains, transition_duration=0.5, transitions=["fade"])
        # 两个音频流都必须经过 aformat 归一化（48000Hz + stereo + fltp）
        aformat_count = filter_str.count("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
        self.assertEqual(aformat_count, 2, "两个音频片段都应该有aformat归一化")
        # 最终音频 concat
        self.assertIn("concat=n=2:v=0:a=1[outa]", filter_str)

    def test_xfade_single_audio_passthrough(self):
        """xfade 路径下只有一个音频片段时直接 acopy。"""
        from app.services.video_compose_service import ClipFilterChain

        chains = [
            ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label="a0", filters=[], duration=5.0),
            ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=[], duration=8.0),
        ]
        filter_str, _ = _build_xfade_filter(chains, transition_duration=0.5, transitions=["fade"])
        self.assertIn("[a0]acopy[outa]", filter_str)


class TestHasAudioTitleSubtitleFix(TestCase):
    """P0 修复验证：title/subtitle 片段不应有音频流。"""

    def _make_clip(self, clip_id, clip_type, **kwargs):
        """创建测试用 stub clip。"""
        return _StubClip(clip_id=clip_id, clip_type=clip_type, **kwargs)

    def test_title_clip_has_no_audio_label(self):
        """title 类型片段的 audio_label 应为 None。"""
        clip = self._make_clip("c1", "title")
        chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIsNone(chain.audio_label, "title 片段不应有音频标签")

    def test_subtitle_clip_has_no_audio_label(self):
        """subtitle 类型片段的 audio_label 应为 None。"""
        clip = self._make_clip("c1", "subtitle")
        chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertIsNone(chain.audio_label, "subtitle 片段不应有音频标签")

    def test_main_clip_has_audio_label(self):
        """main 类型片段应有音频标签。"""
        clip = self._make_clip("c1", "main")
        chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.audio_label, "a0")

    def test_intro_clip_has_audio_label(self):
        """intro 类型片段应有音频标签。"""
        clip = self._make_clip("c1", "intro")
        chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.audio_label, "a0")

    def test_has_audio_false_when_only_title_subtitle(self):
        """当所有片段都是 title/subtitle 时，_has_audio 应返回 False。"""
        chains = [
            VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25),
            VideoComposeService._build_clip_filter(self._make_clip("c2", "subtitle"), 1, 1280, 720, 25),
        ]
        self.assertFalse(VideoComposeService._has_audio(chains))

    def test_has_audio_true_when_mixed_clips(self):
        """混合片段（含 main）时，_has_audio 应返回 True。"""
        chains = [
            VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25),
            VideoComposeService._build_clip_filter(self._make_clip("c2", "main"), 1, 1280, 720, 25),
        ]
        self.assertTrue(VideoComposeService._has_audio(chains))

    def test_empty_clip_type_has_audio(self):
        """clip_type 为空字符串时，应有音频标签（保守策略）。"""
        clip = self._make_clip("c1", "")
        chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
        self.assertEqual(chain.audio_label, "a0")


if __name__ == "__main__":
    import unittest

    unittest.main()
