"""plan_generator_utils 纯逻辑单测 — 第90波.

测试素材分配、clip_type映射、默认clip生成、配置转clip等纯函数。
不依赖 DB，使用领域对象直接构造。
"""

import pytest

from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import (
    DEFAULT_CLIP_DURATION,
    _calc_random_start_time,
    create_clips_from_configs,
    distribute_assets,
    generate_default_clips,
    map_clip_types_for_mode,
)
from packages.domain.template_clip_config import ClipType, TemplateClipConfig

# ── 辅助函数 ──────────────────────────────────────────────────────────


def _make_main_clip(plan_id: str = "plan1", order: int = 0) -> EditPlanClip:
    """创建一个 MAIN 类型的 clip."""
    return EditPlanClip.create(
        plan_id=plan_id,
        clip_type=ClipType.MAIN.value,
        order=order,
        duration=5.0,
    )


def _make_clips(n: int, clip_type: str = "main") -> list[EditPlanClip]:
    """创建 n 个指定类型的 clip."""
    return [
        EditPlanClip.create(
            plan_id="plan1",
            clip_type=clip_type,
            order=i,
            duration=5.0,
        )
        for i in range(n)
    ]


def _collect_asset_ids(clips: list[EditPlanClip]) -> list[str]:
    """按顺序收集 clips 的 asset_id（空的跳过）."""
    return [c.asset_id for c in clips if c.asset_id]


# ── distribute_assets: ONE_TAKE ─────────────────────────────────────


class TestDistributeOneTake:
    """ONE_TAKE 模式素材分配."""

    def test_equal_count(self):
        """素材数 == clip 数：一一对应."""
        clips = _make_clips(3)
        distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value)
        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == "a2"
        assert clips[2].asset_id == "a3"

    def test_more_assets_than_clips(self):
        """素材多于 clip：多余的不用."""
        clips = _make_clips(2)
        distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value)
        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == "a2"

    def test_fewer_assets_than_clips(self):
        """素材少于 clip：后面的 clip 没素材."""
        clips = _make_clips(5)
        distribute_assets(clips, ["a1", "a2"], EditingMode.ONE_TAKE.value)
        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == "a2"
        assert clips[2].asset_id == ""
        assert clips[3].asset_id == ""
        assert clips[4].asset_id == ""

    def test_empty_assets(self):
        """空素材列表：无分配."""
        clips = _make_clips(3)
        distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
        for c in clips:
            assert c.asset_id == ""

    def test_empty_clips(self):
        """空 clip 列表：不报错."""
        distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value)

    def test_only_main_clips_get_assigned(self):
        """只分配给 MAIN 类型 clip，其他类型不受影响."""
        clips = _make_clips(2) + _make_clips(2, "intro") + _make_clips(2, "outro")
        distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.ONE_TAKE.value)
        mains = [c for c in clips if c.clip_type == "main"]
        others = [c for c in clips if c.clip_type != "main"]
        assert mains[0].asset_id == "a1"
        assert mains[1].asset_id == "a2"
        for c in others:
            assert c.asset_id == ""


# ── distribute_assets: PIP ──────────────────────────────────────────


class TestDistributePip:
    """PIP 模式素材分配."""

    def test_basic_pip_distribution(self):
        """第1个素材给 main，其余给 overlay."""
        clips = _make_clips(1) + _make_clips(3, "overlay")
        distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.PIP.value)
        mains = [c for c in clips if c.clip_type == "main"]
        overlays = [c for c in clips if c.clip_type == "overlay"]
        assert mains[0].asset_id == "a1"
        assert overlays[0].asset_id == "a2"
        assert overlays[1].asset_id == "a3"
        assert overlays[2].asset_id == "a4"

    def test_single_asset_only_main(self):
        """只有1个素材：只分配给 main，overlay 没素材."""
        clips = _make_clips(1) + _make_clips(2, "overlay")
        distribute_assets(clips, ["a1"], EditingMode.PIP.value)
        mains = [c for c in clips if c.clip_type == "main"]
        overlays = [c for c in clips if c.clip_type == "overlay"]
        assert mains[0].asset_id == "a1"
        assert overlays[0].asset_id == ""
        assert overlays[1].asset_id == ""

    def test_more_overlays_than_assets(self):
        """overlay 多于剩余素材：后面的 overlay 没素材."""
        clips = _make_clips(1) + _make_clips(5, "overlay")
        distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value)
        overlays = [c for c in clips if c.clip_type == "overlay"]
        assert overlays[0].asset_id == "a2"
        assert overlays[1].asset_id == "a3"
        assert overlays[2].asset_id == ""
        assert overlays[3].asset_id == ""
        assert overlays[4].asset_id == ""

    def test_no_main_clip(self):
        """没有 main clip：第1个素材没人拿，overlay 从第2个素材开始."""
        clips = _make_clips(3, "overlay")
        distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value)
        overlays = [c for c in clips if c.clip_type == "overlay"]
        # PIP 逻辑：先给 main 分配第1个素材（没有 main 则跳过），
        # 剩余从第2个开始分配给 overlay
        assert overlays[0].asset_id == "a2"
        assert overlays[1].asset_id == "a3"
        assert overlays[2].asset_id == ""


# ── distribute_assets: VOICE_OVER ───────────────────────────────────


class TestDistributeVoiceOver:
    """VOICE_OVER 模式素材分配."""

    def test_voice_over_same_as_one_take(self):
        """VOICE_OVER 和 ONE_TAKE 分配策略相同：按顺序给 main."""
        clips = _make_clips(3)
        assets = ["a1", "a2", "a3"]
        distribute_assets(clips, assets, EditingMode.VOICE_OVER.value)
        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == "a2"
        assert clips[2].asset_id == "a3"

    def test_voice_over_fewer_assets(self):
        """素材不足时，后面的 main clip 没素材."""
        clips = _make_clips(5)
        distribute_assets(clips, ["a1"], EditingMode.VOICE_OVER.value)
        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == ""


# ── distribute_assets: VOICE_PIP ────────────────────────────────────


class TestDistributeVoicePip:
    """VOICE_PIP 模式素材分配."""

    def test_three_assets_full_distribution(self):
        """3个素材：background + corner_voice + b_roll 各一个."""
        clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(1, "b_roll")
        distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value)
        bgs = [c for c in clips if c.clip_type == "background"]
        voices = [c for c in clips if c.clip_type == "corner_voice"]
        brolls = [c for c in clips if c.clip_type == "b_roll"]
        assert bgs[0].asset_id == "a1"
        assert voices[0].asset_id == "a2"
        assert brolls[0].asset_id == "a3"

    def test_single_asset_only_background(self):
        """1个素材：只分配给 background."""
        clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll")
        distribute_assets(clips, ["a1"], EditingMode.VOICE_PIP.value)
        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == ""
        assert clips[2].asset_id == ""
        assert clips[3].asset_id == ""

    def test_two_assets_bg_and_voice(self):
        """2个素材：background + corner_voice."""
        clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll")
        distribute_assets(clips, ["a1", "a2"], EditingMode.VOICE_PIP.value)
        bgs = [c for c in clips if c.clip_type == "background"]
        voices = [c for c in clips if c.clip_type == "corner_voice"]
        brolls = [c for c in clips if c.clip_type == "b_roll"]
        assert bgs[0].asset_id == "a1"
        assert voices[0].asset_id == "a2"
        assert brolls[0].asset_id == ""

    def test_many_broll_clips(self):
        """多个 b_roll clip：按顺序分配剩余素材."""
        clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(5, "b_roll")
        distribute_assets(
            clips,
            ["a1", "a2", "a3", "a4", "a5"],
            EditingMode.VOICE_PIP.value,
        )
        brolls = [c for c in clips if c.clip_type == "b_roll"]
        assert brolls[0].asset_id == "a3"
        assert brolls[1].asset_id == "a4"
        assert brolls[2].asset_id == "a5"
        assert brolls[3].asset_id == ""
        assert brolls[4].asset_id == ""

    def test_missing_some_layer_clips(self):
        """缺少某些层的 clip 不影响其他层."""
        # 没有 corner_voice，素材应该按顺序：bg 拿 a1，b_roll 从 a2 开始
        clips = _make_clips(1, "background") + _make_clips(3, "b_roll")
        distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value)
        bgs = [c for c in clips if c.clip_type == "background"]
        brolls = [c for c in clips if c.clip_type == "b_roll"]
        assert bgs[0].asset_id == "a1"
        # 没有 corner_voice，b_roll 从第2个素材开始
        assert brolls[0].asset_id == "a2"
        assert brolls[1].asset_id == "a3"


# ── distribute_assets: 边缘情况 ─────────────────────────────────────


class TestDistributeEdgeCases:
    """素材分配边缘情况."""

    def test_unknown_mode_falls_back_to_one_take(self):
        """未知模式退化为 ONE_TAKE."""
        clips = _make_clips(3)
        distribute_assets(clips, ["a1", "a2", "a3"], "unknown_mode")
        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == "a2"
        assert clips[2].asset_id == "a3"

    def test_both_empty(self):
        """两边都空：不报错."""
        distribute_assets([], [], EditingMode.ONE_TAKE.value)


# ── map_clip_types_for_mode ─────────────────────────────────────────


class TestMapClipTypesForMode:
    """clip_type 按模式映射."""

    def test_one_take_unchanged(self):
        """ONE_TAKE 模式：main 保持 main."""
        clips = _make_clips(5)
        map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value)
        for c in clips:
            assert c.clip_type == "main"

    def test_voice_over_unchanged(self):
        """VOICE_OVER 模式：main 保持 main."""
        clips = _make_clips(5)
        map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value)
        for c in clips:
            assert c.clip_type == "main"

    def test_pip_first_main_stays_rest_become_overlay(self):
        """PIP 模式：第1个 main 保持，其余变 overlay."""
        clips = _make_clips(5)
        map_clip_types_for_mode(clips, EditingMode.PIP.value)
        assert clips[0].clip_type == "main"
        assert clips[1].clip_type == "overlay"
        assert clips[2].clip_type == "overlay"
        assert clips[3].clip_type == "overlay"
        assert clips[4].clip_type == "overlay"

    def test_pip_single_main_unchanged(self):
        """PIP 模式只有1个 main：保持 main."""
        clips = _make_clips(1)
        map_clip_types_for_mode(clips, EditingMode.PIP.value)
        assert clips[0].clip_type == "main"

    def test_voice_pip_three_types(self):
        """VOICE_PIP 模式：background + corner_voice + b_roll."""
        clips = _make_clips(5)
        map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
        assert clips[0].clip_type == "background"
        assert clips[1].clip_type == "corner_voice"
        assert clips[2].clip_type == "b_roll"
        assert clips[3].clip_type == "b_roll"
        assert clips[4].clip_type == "b_roll"

    def test_voice_pip_one_main(self):
        """VOICE_PIP 只有1个 main：变成 background."""
        clips = _make_clips(1)
        map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
        assert clips[0].clip_type == "background"

    def test_voice_pip_two_mains(self):
        """VOICE_PIP 2个 main：background + corner_voice."""
        clips = _make_clips(2)
        map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
        assert clips[0].clip_type == "background"
        assert clips[1].clip_type == "corner_voice"

    def test_non_main_clips_unchanged(self):
        """非 MAIN 类型 clip 不受影响."""
        clips = _make_clips(1, "intro") + _make_clips(3) + _make_clips(1, "outro")  # main
        map_clip_types_for_mode(clips, EditingMode.PIP.value)
        assert clips[0].clip_type == "intro"
        assert clips[1].clip_type == "main"  # 第1个 main
        assert clips[2].clip_type == "overlay"  # 第2个 main → overlay
        assert clips[3].clip_type == "overlay"  # 第3个 main → overlay
        assert clips[4].clip_type == "outro"

    def test_no_main_clips_noop(self):
        """没有 main clip：什么都不做."""
        clips = _make_clips(3, "intro")
        original_types = [c.clip_type for c in clips]
        map_clip_types_for_mode(clips, EditingMode.PIP.value)
        assert [c.clip_type for c in clips] == original_types

    def test_empty_clips_noop(self):
        """空列表：不报错."""
        map_clip_types_for_mode([], EditingMode.PIP.value)


# ── generate_default_clips ──────────────────────────────────────────


class TestGenerateDefaultClips:
    """默认 clip 生成."""

    def test_one_take_normal(self):
        """ONE_TAKE：N 个 main clip."""
        clips = generate_default_clips("plan1", EditingMode.ONE_TAKE.value, 5)
        assert len(clips) == 5
        for c in clips:
            assert c.clip_type == "main"
            assert c.plan_id == "plan1"
            assert c.duration == DEFAULT_CLIP_DURATION
        # order 递增
        for i in range(5):
            assert clips[i].order == i

    def test_pip_structure(self):
        """PIP：1个 main + (N-1)个 overlay."""
        clips = generate_default_clips("plan1", EditingMode.PIP.value, 4)
        assert len(clips) == 4
        assert clips[0].clip_type == "main"
        assert clips[1].clip_type == "overlay"
        assert clips[2].clip_type == "overlay"
        assert clips[3].clip_type == "overlay"
        assert clips[0].order == 0
        assert clips[3].order == 3

    def test_pip_single_asset(self):
        """PIP 只有1个素材：1个 main，没有 overlay."""
        clips = generate_default_clips("plan1", EditingMode.PIP.value, 1)
        assert len(clips) == 1
        assert clips[0].clip_type == "main"

    def test_voice_over_structure(self):
        """VOICE_OVER：N 个 main clip，带 b_roll 标记."""
        clips = generate_default_clips("plan1", EditingMode.VOICE_OVER.value, 3)
        assert len(clips) == 3
        for c in clips:
            assert c.clip_type == "main"
            assert c.config.get("role") == "b_roll"

    def test_voice_pip_three_layers(self):
        """VOICE_PIP：background + corner_voice + b_roll."""
        clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 5)
        assert len(clips) == 5
        assert clips[0].clip_type == "background"
        assert clips[1].clip_type == "corner_voice"
        assert clips[2].clip_type == "b_roll"
        assert clips[3].clip_type == "b_roll"
        assert clips[4].clip_type == "b_roll"

    def test_voice_pip_single_asset(self):
        """VOICE_PIP 1个素材：只有 background."""
        clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 1)
        assert len(clips) == 1
        assert clips[0].clip_type == "background"

    def test_voice_pip_two_assets(self):
        """VOICE_PIP 2个素材：background + corner_voice."""
        clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 2)
        assert len(clips) == 2
        assert clips[0].clip_type == "background"
        assert clips[1].clip_type == "corner_voice"

    def test_zero_assets_at_least_one(self):
        """0 个素材：至少生成 1 个 clip."""
        for mode in [
            EditingMode.ONE_TAKE.value,
            EditingMode.PIP.value,
            EditingMode.VOICE_OVER.value,
            EditingMode.VOICE_PIP.value,
        ]:
            clips = generate_default_clips("plan1", mode, 0)
            assert len(clips) >= 1

    def test_unknown_mode_falls_back(self):
        """未知模式退化为 ONE_TAKE 风格."""
        clips = generate_default_clips("plan1", "unknown", 3)
        assert len(clips) == 3
        for c in clips:
            assert c.clip_type == "main"

    def test_order_is_sequential(self):
        """所有模式下 order 都是从 0 开始连续递增."""
        for mode in [
            EditingMode.ONE_TAKE.value,
            EditingMode.PIP.value,
            EditingMode.VOICE_OVER.value,
            EditingMode.VOICE_PIP.value,
        ]:
            clips = generate_default_clips("plan1", mode, 5)
            for i, c in enumerate(clips):
                assert c.order == i


# ── create_clips_from_configs ───────────────────────────────────────


class TestCreateClipsFromConfigs:
    """从模板配置创建 clips."""

    def test_basic_creation(self):
        """基本创建：按 order 排序，属性正确传递."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=1,
                min_duration=3.0,
                max_duration=7.0,
                transition_effect="fade",
            ),
            TemplateClipConfig(
                id="cfg2",
                template_id="tpl1",
                clip_type=ClipType.INTRO,
                order=0,
                min_duration=2.0,
                max_duration=4.0,
                transition_effect="cut",
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert len(clips) == 2
        # 按 order 排序：intro(order=0) 在前，main(order=1) 在后
        assert clips[0].clip_type == "intro"
        assert clips[1].clip_type == "main"
        assert clips[0].order == 0
        assert clips[1].order == 1
        assert clips[0].template_clip_config_id == "cfg2"
        assert clips[1].template_clip_config_id == "cfg1"

    def test_duration_average_of_min_max(self):
        """min_duration 和 max_duration 都有时，取平均值."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=4.0,
                max_duration=6.0,
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].duration == 5.0  # (4+6)/2

    def test_duration_only_min(self):
        """只有 min_duration 时，用 min_duration."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=3.5,
                max_duration=0,
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].duration == 3.5

    def test_duration_only_max(self):
        """只有 max_duration 时，用 max_duration."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=0,
                max_duration=8.0,
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].duration == 8.0

    def test_duration_default_when_both_zero(self):
        """都为 0 时用默认时长."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=0,
                max_duration=0,
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].duration == DEFAULT_CLIP_DURATION

    def test_empty_configs_returns_empty(self):
        """空配置列表返回空列表."""
        clips = create_clips_from_configs("plan1", [])
        assert clips == []

    def test_plan_id_passed_through(self):
        """plan_id 正确传递给所有 clip."""
        configs = [
            TemplateClipConfig(
                id=f"cfg{i}",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=i,
                min_duration=3,
                max_duration=5,
            )
            for i in range(3)
        ]
        clips = create_clips_from_configs("my_plan", configs)
        for c in clips:
            assert c.plan_id == "my_plan"

    def test_playback_speed_from_config(self):
        """playback_speed 从 config.playback_speed 读取."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=3,
                max_duration=5,
                config={"playback_speed": 1.5},
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].playback_speed == 1.5

    def test_playback_speed_fallback_to_speed_ratio(self):
        """playback_speed 不存在时回退到 speed_ratio."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=3,
                max_duration=5,
                config={"speed_ratio": 0.8},
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].playback_speed == 0.8

    def test_playback_speed_default_1(self):
        """没有 speed 配置时默认为 1.0."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=3,
                max_duration=5,
                config={},
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].playback_speed == 1.0

    def test_playback_speed_none_falls_back(self):
        """playback_speed 为 None 时回退到 1.0."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=3,
                max_duration=5,
                config={"playback_speed": None},
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].playback_speed == 1.0

    def test_transition_default_cut(self):
        """transition_effect 为空时默认为 cut."""
        configs = [
            TemplateClipConfig(
                id="cfg1",
                template_id="tpl1",
                clip_type=ClipType.MAIN,
                order=0,
                min_duration=3,
                max_duration=5,
                transition_effect=None,
            ),
        ]
        clips = create_clips_from_configs("plan1", configs)
        assert clips[0].transition_effect == "cut"


# ── 常量导出 ────────────────────────────────────────────────────────


class TestConstants:
    """常量导出验证."""

    def test_default_duration_value(self):
        """默认片段时长应为 5 秒."""
        assert DEFAULT_CLIP_DURATION == 5.0


# ── 随机 start_time 与去重测试 ─────────────────────────────────────────


class TestCalcRandomStartTime:
    """_calc_random_start_time 函数测试."""

    def test_no_durations_returns_none(self):
        """asset_durations 为 None 时返回 None."""
        result = _calc_random_start_time("a1", 5.0, None)
        assert result is None

    def test_asset_not_in_durations_returns_none(self):
        """素材不在 durations 映射中时返回 None."""
        result = _calc_random_start_time("a_missing", 5.0, {"a1": 60.0})
        assert result is None

    def test_zero_duration_returns_none(self):
        """素材时长为 0 时返回 None."""
        result = _calc_random_start_time("a1", 5.0, {"a1": 0.0})
        assert result is None

    def test_clip_longer_than_asset_returns_zero(self):
        """片段时长 >= 素材时长时返回 0.0."""
        result = _calc_random_start_time("a1", 60.0, {"a1": 30.0})
        assert result == 0.0

    def test_returns_value_in_range(self):
        """返回值在 [0, max_start] 范围内."""
        for _ in range(50):
            result = _calc_random_start_time("a1", 5.0, {"a1": 30.0})
            assert result is not None
            assert 0.0 <= result <= 25.0

    def test_avoids_used_segments(self):
        """生成的 start_time 不与已使用段重叠."""
        used = {"a1": [(10.0, 15.0), (20.0, 25.0)]}
        for _ in range(100):
            result = _calc_random_start_time("a1", 3.0, {"a1": 30.0}, used)
            assert result is not None
            end = result + 3.0
            # 不应与 [10,15) 重叠
            assert not (result < 15.0 and 10.0 < end)
            # 不应与 [20,25) 重叠
            assert not (result < 25.0 and 20.0 < end)

    def test_fallback_when_no_space(self):
        """所有空间都被占用时，回退到最后已使用段之后或 0."""
        used = {"a1": [(0.0, 28.0)]}
        result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used)
        assert result is not None
        # 应该返回 last_used_end (28.0) 但受限于 max_start (25.0)，取 min
        assert result == 25.0

    def test_no_used_segments_for_asset(self):
        """used_segments 中没有当前 asset_id 时正常随机."""
        used = {"a2": [(10.0, 15.0)]}
        for _ in range(20):
            result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used)
            assert result is not None
            assert 0.0 <= result <= 25.0


class TestDistributeAssetsOverlapAvoidance:
    """测试 distribute_assets 中同一素材被多次使用时的去重逻辑."""

    def test_same_asset_reused_no_overlap(self):
        """同一素材分配给多个 clip 时，start_time 不重叠."""
        # 2 个 clip 共享同一素材，素材时长足够
        clips = [
            EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
            EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0),
        ]
        asset_ids = ["a1", "a1"]  # 同一素材用两次
        durations = {"a1": 60.0}

        distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations)

        # 两个 clip 都应有 start_time
        assert clips[0].start_time is not None
        assert clips[1].start_time is not None
        # 两段不应重叠
        s1, e1 = clips[0].start_time, clips[0].start_time + 5.0
        s2, e2 = clips[1].start_time, clips[1].start_time + 5.0
        assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})"

    def test_different_assets_independent(self):
        """不同素材各自独立随机，互不影响."""
        clips = [
            EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
            EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0),
        ]
        asset_ids = ["a1", "a2"]
        durations = {"a1": 60.0, "a2": 60.0}

        distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations)

        assert clips[0].asset_id == "a1"
        assert clips[1].asset_id == "a2"
        assert clips[0].start_time is not None
        assert clips[1].start_time is not None

    def test_no_durations_still_works(self):
        """不传 asset_durations 时仍正常分配（start_time 为 None）."""
        clips = [
            EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
        ]
        distribute_assets(clips, ["a1"], EditingMode.ONE_TAKE.value)
        assert clips[0].asset_id == "a1"
        # start_time 默认为 0.0（模型默认值）
        assert clips[0].start_time == 0.0

    def test_voice_pip_overlap_avoidance(self):
        """VOICE_PIP 模式下同一素材多次使用时避免重叠."""
        clips = [
            EditPlanClip.create(plan_id="p1", clip_type="background", order=0, duration=5.0),
            EditPlanClip.create(plan_id="p1", clip_type="b_roll", order=1, duration=5.0),
        ]
        asset_ids = ["a1", "a1"]  # 同一素材用两次
        durations = {"a1": 60.0}

        distribute_assets(clips, asset_ids, EditingMode.VOICE_PIP.value, asset_durations=durations)

        assert clips[0].start_time is not None
        assert clips[1].start_time is not None
        s1, e1 = clips[0].start_time, clips[0].start_time + 5.0
        s2, e2 = clips[1].start_time, clips[1].start_time + 5.0
        assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})"
