"""
PlanGeneratorService 单元测试

覆盖（6 组测试）：
- ONE_TAKE 模式：素材顺序分配给 main clips
- PIP 模式：第1个素材→main，其余→overlay
- VOICE_OVER 模式：素材→main clips (B-roll)
- VOICE_PIP 模式：第1个→background, 第2个→corner_voice, 其余→b_roll
- 无 clip_configs 时自动生成默认结构
- 空素材列表时 clips 创建但无素材分配
"""

from __future__ import annotations

import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from unittest.mock import MagicMock, patch

os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")


sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))

from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.editing_mode import EditingMode
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect

# ---------------------------------------------------------------------------
# Stub Repositories
# ---------------------------------------------------------------------------


class StubEditPlanRepository:
    """内存中的 EditPlan 仓储 stub"""

    def __init__(self) -> None:
        self._plans: dict[str, EditPlan] = {}
        self._counter = 0

    def _next_id(self) -> str:
        self._counter += 1
        return f"plan-{self._counter:03d}"

    def get(self, plan_id: str) -> Optional[EditPlan]:
        return self._plans.get(plan_id)

    def create(self, plan: EditPlan) -> EditPlan:
        if not plan.id:
            plan = EditPlan(
                id=self._next_id(),
                template_id=plan.template_id,
                name=plan.name,
                status=plan.status,
                total_duration=plan.total_duration,
                source_edit_plan_id=plan.source_edit_plan_id,
                project_id=plan.project_id,
                created_by_user_id=plan.created_by_user_id,
                config=plan.config,
                created_at=plan.created_at,
                updated_at=plan.updated_at,
            )
        self._plans[plan.id] = plan
        return plan

    def update(self, plan: EditPlan) -> EditPlan:
        self._plans[plan.id] = plan
        return plan

    def list_all(self, **kwargs) -> List[EditPlan]:
        return list(self._plans.values())

    def count(self, **kwargs) -> int:
        return len(self._plans)

    def delete(self, plan_id: str) -> bool:
        return self._plans.pop(plan_id, None) is not None


class StubEditPlanClipRepository:
    """内存中的 EditPlanClip 仓储 stub"""

    def __init__(self) -> None:
        self._clips: dict[str, EditPlanClip] = {}
        self._counter = 0

    def _next_id(self) -> str:
        self._counter += 1
        return f"clip-{self._counter:03d}"

    def get(self, clip_id: str) -> Optional[EditPlanClip]:
        return self._clips.get(clip_id)

    def create(self, clip: EditPlanClip) -> EditPlanClip:
        if not clip.id:
            clip = EditPlanClip(
                id=self._next_id(),
                plan_id=clip.plan_id,
                template_clip_config_id=clip.template_clip_config_id,
                clip_type=clip.clip_type,
                order=clip.order,
                asset_id=clip.asset_id,
                text_content=clip.text_content,
                start_time=clip.start_time,
                duration=clip.duration,
                transition_effect=clip.transition_effect,
                status=clip.status,
                config=clip.config,
                created_at=clip.created_at,
                updated_at=clip.updated_at,
            )
        self._clips[clip.id] = clip
        return clip

    def update(self, clip: EditPlanClip) -> EditPlanClip:
        self._clips[clip.id] = clip
        return clip

    def list_by_plan(
        self,
        plan_id: str,
        *,
        status: Optional[EditPlanClipStatus] = None,
        skip: int = 0,
        limit: int = 100,
    ) -> List[EditPlanClip]:
        items = [c for c in self._clips.values() if c.plan_id == plan_id]
        items.sort(key=lambda c: c.order)
        if status:
            items = [c for c in items if c.status == status]
        return items[skip : skip + limit]

    def delete(self, clip_id: str) -> bool:
        return self._clips.pop(clip_id, None) is not None


# ---------------------------------------------------------------------------
# Helper: 构建 PlanGeneratorService（patch 仓储）
# ---------------------------------------------------------------------------


def _make_generator():
    """创建使用 stub 仓储的 PlanGeneratorService"""
    from apps.api.app.services.plan_generator_service import PlanGeneratorService

    plan_repo = StubEditPlanRepository()
    clip_repo = StubEditPlanClipRepository()

    with (
        patch(
            "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository",
            return_value=plan_repo,
        ),
        patch(
            "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository",
            return_value=clip_repo,
        ),
    ):
        db = MagicMock()
        svc = PlanGeneratorService(db)
        # 替换为 stub
        svc._plan_repo = plan_repo
        svc._clip_repo = clip_repo

    return svc, plan_repo, clip_repo


def _make_template(
    editing_mode: str = "one_take",
    config: Optional[dict] = None,
) -> EditTemplate:
    """创建测试用 EditTemplate"""
    return EditTemplate(
        id="tpl-001",
        name="测试模板",
        description="",
        template_type="default",
        editing_mode=editing_mode,
        config=config or {},
        preview_url="",
        sort_weight=0,
        status=EditTemplateStatus.ACTIVE,
        created_at=datetime.now(timezone.utc),
        updated_at=datetime.now(timezone.utc),
    )


def _make_clip_configs(
    template_id: str = "tpl-001",
    specs: Optional[List[dict]] = None,
) -> List[TemplateClipConfig]:
    """创建测试用 TemplateClipConfig 列表

    specs 示例: [{"clip_type": ClipType.INTRO, "order": 0}, ...]
    """
    if specs is None:
        specs = [
            {"clip_type": ClipType.INTRO, "order": 0, "min_duration": 2.0, "max_duration": 4.0},
            {"clip_type": ClipType.MAIN, "order": 1, "min_duration": 3.0, "max_duration": 7.0},
            {"clip_type": ClipType.OUTRO, "order": 2, "min_duration": 2.0, "max_duration": 4.0},
        ]
    configs = []
    for i, spec in enumerate(specs):
        cfg = TemplateClipConfig(
            id=f"cfg-{i:03d}",
            template_id=template_id,
            clip_type=spec.get("clip_type", ClipType.MAIN),
            order=spec.get("order", i),
            min_duration=spec.get("min_duration", 0.0),
            max_duration=spec.get("max_duration", 0.0),
            text_template=spec.get("text_template", ""),
            material_requirements=spec.get("material_requirements"),
            transition_effect=spec.get("transition_effect", TransitionEffect.CUT),
            config=spec.get("config"),
            created_at=datetime.now(timezone.utc),
            updated_at=datetime.now(timezone.utc),
        )
        configs.append(cfg)
    return configs


# ---------------------------------------------------------------------------
# 测试：ONE_TAKE 模式
# ---------------------------------------------------------------------------


class TestGenerateOneTakePlan:
    """ONE_TAKE 模式：素材顺序分配给 main clips"""

    def test_generate_one_take_plan(self):
        """3个clip_configs + 3个asset_ids → 按顺序分配"""
        svc, plan_repo, clip_repo = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
        clip_configs = _make_clip_configs()
        asset_ids = ["asset-1", "asset-2", "asset-3"]

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=asset_ids,
            project_id="proj-001",
            created_by_user_id="user-001",
        )

        plan = result["plan"]
        clips = result["clips"]

        assert plan.template_id == "tpl-001"
        assert plan.config["editing_mode"] == "one_take"
        assert plan.status == EditPlanStatus.EDITING
        assert len(clips) == 3

        # 按 order 排序后检查素材分配
        sorted_clips = sorted(clips, key=lambda c: c.order)
        # intro clip (order=0) 不是 main 类型，不分配素材
        # main clip (order=1) → asset-2（ONE_TAKE 只分配给 main clips）
        # outro clip (order=2) 不是 main 类型
        main_clips = [c for c in sorted_clips if c.clip_type == ClipType.MAIN.value]
        assert len(main_clips) == 1
        assert main_clips[0].asset_id == "asset-1"

    def test_one_take_plan_name_from_template(self):
        """name 为空时自动取模板名"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=["asset-1"],
        )

        assert "测试模板" in result["plan"].name

    def test_one_take_custom_name(self):
        """指定 name 时使用自定义名称"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=["asset-1"],
            name="我的剪辑",
        )

        assert result["plan"].name == "我的剪辑"


# ---------------------------------------------------------------------------
# 测试：PIP 模式
# ---------------------------------------------------------------------------


class TestGeneratePipPlan:
    """PIP 模式：第1个素材→main，其余→overlay"""

    def test_generate_pip_plan(self):
        """4个素材 → 第1个→main，其余→overlay"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.PIP.value)

        # 无 clip_configs，自动生成默认结构
        asset_ids = ["bg-asset", "overlay-1", "overlay-2", "overlay-3"]

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=asset_ids,
        )

        plan = result["plan"]
        clips = result["clips"]

        assert plan.config["editing_mode"] == "pip"
        # 自动生成: 1 main + 3 overlay
        assert len(clips) == 4

        main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
        overlay_clips = [c for c in clips if c.clip_type == "overlay"]

        assert len(main_clips) == 1
        assert len(overlay_clips) == 3

        # 第1个素材 → main
        assert main_clips[0].asset_id == "bg-asset"
        # 其余 → overlay
        assert overlay_clips[0].asset_id == "overlay-1"
        assert overlay_clips[1].asset_id == "overlay-2"
        assert overlay_clips[2].asset_id == "overlay-3"

    def test_pip_with_clip_configs(self):
        """PIP + 有 clip_configs（都是 MAIN 类型）→ 自动映射为 main + overlay，素材正确分配"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.PIP.value)

        # 4 个 MAIN 类型的 clip_config（模拟 PIP 模板配置）
        clip_configs = [
            TemplateClipConfig.create(
                template_id=template.id,
                clip_type=ClipType.MAIN,
                order=i,
                min_duration=3.0,
                max_duration=5.0,
            )
            for i in range(4)
        ]
        asset_ids = ["bg-asset", "overlay-1", "overlay-2", "overlay-3"]

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=asset_ids,
        )

        plan = result["plan"]
        clips = result["clips"]

        assert plan.config["editing_mode"] == "pip"
        assert len(clips) == 4

        # 第1个 MAIN → main（背景），其余 MAIN → overlay（画中画）
        main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
        overlay_clips = [c for c in clips if c.clip_type == "overlay"]

        assert len(main_clips) == 1
        assert len(overlay_clips) == 3

        # 素材分配正确
        assert main_clips[0].asset_id == "bg-asset"
        assert overlay_clips[0].asset_id == "overlay-1"
        assert overlay_clips[1].asset_id == "overlay-2"
        assert overlay_clips[2].asset_id == "overlay-3"

    def test_voice_pip_with_clip_configs(self):
        """VOICE_PIP + 有 clip_configs（都是 MAIN 类型）→ 自动映射为 background + corner_voice + b_roll"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.VOICE_PIP.value)

        # 4 个 MAIN 类型的 clip_config
        clip_configs = [
            TemplateClipConfig.create(
                template_id=template.id,
                clip_type=ClipType.MAIN,
                order=i,
                min_duration=3.0,
                max_duration=5.0,
            )
            for i in range(4)
        ]
        asset_ids = ["bg-asset", "voice-asset", "broll-1", "broll-2"]

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=asset_ids,
        )

        plan = result["plan"]
        clips = result["clips"]

        assert plan.config["editing_mode"] == "voice_pip"
        assert len(clips) == 4

        bg_clips = [c for c in clips if c.clip_type == "background"]
        corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
        broll_clips = [c for c in clips if c.clip_type == "b_roll"]

        assert len(bg_clips) == 1
        assert len(corner_clips) == 1
        assert len(broll_clips) == 2

        # 素材分配正确
        assert bg_clips[0].asset_id == "bg-asset"
        assert corner_clips[0].asset_id == "voice-asset"
        assert broll_clips[0].asset_id == "broll-1"
        assert broll_clips[1].asset_id == "broll-2"


# ---------------------------------------------------------------------------
# 测试：VOICE_OVER 模式
# ---------------------------------------------------------------------------


class TestGenerateVoiceOverPlan:
    """VOICE_OVER 模式：素材→main clips (B-roll)"""

    def test_generate_voice_over_plan(self):
        """3个素材 → 3个 main clips，每个标记为 b_roll"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.VOICE_OVER.value)
        asset_ids = ["video-1", "video-2", "video-3"]

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=asset_ids,
        )

        plan = result["plan"]
        clips = result["clips"]

        assert plan.config["editing_mode"] == "voice_over"
        assert len(clips) == 3

        # 所有 clips 都是 main 类型
        for clip in clips:
            assert clip.clip_type == ClipType.MAIN.value

        # 素材按顺序分配
        assert clips[0].asset_id == "video-1"
        assert clips[1].asset_id == "video-2"
        assert clips[2].asset_id == "video-3"

        # 每个 clip 的 config 标记为 b_roll
        for clip in clips:
            assert clip.config.get("role") == "b_roll"


# ---------------------------------------------------------------------------
# 测试：VOICE_PIP 模式
# ---------------------------------------------------------------------------


class TestGenerateVoicePipPlan:
    """VOICE_PIP 模式：第1个→background, 第2个→corner_voice, 其余→b_roll"""

    def test_generate_voice_pip_plan(self):
        """4个素材 → background + corner_voice + 2 b_roll"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.VOICE_PIP.value)
        asset_ids = ["bg-video", "corner-video", "broll-1", "broll-2"]

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=asset_ids,
        )

        plan = result["plan"]
        clips = result["clips"]

        assert plan.config["editing_mode"] == "voice_pip"
        assert len(clips) == 4

        bg_clips = [c for c in clips if c.clip_type == "background"]
        corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
        broll_clips = [c for c in clips if c.clip_type == "b_roll"]

        assert len(bg_clips) == 1
        assert len(corner_clips) == 1
        assert len(broll_clips) == 2

        # 素材分配
        assert bg_clips[0].asset_id == "bg-video"
        assert corner_clips[0].asset_id == "corner-video"
        assert broll_clips[0].asset_id == "broll-1"
        assert broll_clips[1].asset_id == "broll-2"


# ---------------------------------------------------------------------------
# 测试：无 clip_configs 时自动生成默认结构
# ---------------------------------------------------------------------------


class TestGenerateWithoutClipConfigs:
    """无 clip_configs 时根据 editing_mode 生成默认 clip 结构"""

    def test_one_take_default_clips(self):
        """ONE_TAKE + 3个素材 → 3个 main clips"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=["a1", "a2", "a3"],
        )

        clips = result["clips"]
        assert len(clips) == 3
        for clip in clips:
            assert clip.clip_type == ClipType.MAIN.value

    def test_pip_default_clips(self):
        """PIP + 3个素材 → 1 main + 2 overlay"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.PIP.value)

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=["a1", "a2", "a3"],
        )

        clips = result["clips"]
        assert len(clips) == 3
        main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
        overlay_clips = [c for c in clips if c.clip_type == "overlay"]
        assert len(main_clips) == 1
        assert len(overlay_clips) == 2

    def test_voice_pip_default_clips(self):
        """VOICE_PIP + 4个素材 → 1 background + 1 corner_voice + 2 b_roll"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.VOICE_PIP.value)

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=["a1", "a2", "a3", "a4"],
        )

        clips = result["clips"]
        assert len(clips) == 4


# ---------------------------------------------------------------------------
# 测试：空素材列表
# ---------------------------------------------------------------------------


class TestGenerateEmptyAssets:
    """空素材列表时 clips 创建但无素材分配"""

    def test_empty_assets(self):
        """空 asset_ids → clips 创建但 asset_id 为空"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
        clip_configs = _make_clip_configs()

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=[],
        )

        plan = result["plan"]
        clips = result["clips"]

        assert plan.total_duration > 0  # clips 有默认时长
        assert len(clips) == 3
        for clip in clips:
            assert clip.asset_id == ""

    def test_empty_assets_pip(self):
        """PIP 模式空素材 → 1个 main clip（至少1个）"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.PIP.value)

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=[],
        )

        clips = result["clips"]
        # 至少1个 main clip（n = max(asset_count, 1) = 1）
        assert len(clips) == 1
        assert clips[0].clip_type == ClipType.MAIN.value


# ---------------------------------------------------------------------------
# 测试：plan config 继承模板配置
# ---------------------------------------------------------------------------


class TestPlanConfigInheritance:
    """plan config 继承模板的 cover/title/subtitle/bgm"""

    def test_inherit_template_config(self):
        """模板有 cover/title/bgm 配置 → plan 继承"""
        svc, _, _ = _make_generator()
        template_config = {
            "editing_mode": "one_take",
            "cover": {"type": "ai_frame"},
            "title": {"text": "测试标题", "font": "思源黑体"},
            "bgm": {"url": "https://example.com/bgm.mp3"},
        }
        template = _make_template(
            editing_mode=EditingMode.ONE_TAKE.value,
            config=template_config,
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=["a1"],
        )

        plan_config = result["plan"].config
        assert plan_config["editing_mode"] == "one_take"
        assert plan_config["cover"]["type"] == "ai_frame"
        assert plan_config["title"]["text"] == "测试标题"
        assert plan_config["bgm"]["url"] == "https://example.com/bgm.mp3"


# ---------------------------------------------------------------------------
# 测试：total_duration 计算
# ---------------------------------------------------------------------------


class TestTotalDuration:
    """total_duration 正确计算"""

    def test_duration_from_clip_configs(self):
        """有 clip_configs 时，duration 取 min/max 中间值"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
        clip_configs = _make_clip_configs(
            specs=[
                {"clip_type": ClipType.INTRO, "order": 0, "min_duration": 2.0, "max_duration": 4.0},
                {"clip_type": ClipType.MAIN, "order": 1, "min_duration": 4.0, "max_duration": 6.0},
                {"clip_type": ClipType.OUTRO, "order": 2, "min_duration": 2.0, "max_duration": 4.0},
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1"],
        )

        # intro: (2+4)/2=3, main: (4+6)/2=5, outro: (2+4)/2=3 → total=11
        assert result["plan"].total_duration == 11.0

    def test_duration_from_default_clips(self):
        """无 clip_configs 时，每个 clip 默认 5 秒"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)

        result = svc.generate_from_template(
            template=template,
            clip_configs=[],
            asset_ids=["a1", "a2", "a3"],
        )

        # 3 个 clips × 5 秒 = 15 秒
        assert result["plan"].total_duration == 15.0


# ---------------------------------------------------------------------------
# 测试：模板配置传递（export / filter / 片段级 speed_ratio）
# ---------------------------------------------------------------------------


class TestTemplateConfigPropagation:
    """模板配置到剪辑计划的传递验证"""

    def test_export_config_propagated(self):
        """模板 export 配置（watermark/resolution）应传递到 plan.config.export"""
        svc, _, _ = _make_generator()
        template = _make_template(
            editing_mode=EditingMode.ONE_TAKE.value,
            config={
                "export": {
                    "resolution": "1080x1920",
                    "fps": 30,
                    "watermark_enabled": True,
                    "watermark_text": "小虾剪辑",
                },
            },
        )
        clip_configs = _make_clip_configs(
            specs=[
                {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0},
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1"],
        )

        export_cfg = result["plan"].config.get("export", {})
        assert export_cfg.get("watermark_enabled") is True
        assert export_cfg.get("watermark_text") == "小虾剪辑"
        assert export_cfg.get("resolution") == "1080x1920"
        assert export_cfg.get("fps") == 30

    def test_filter_config_propagated(self):
        """模板 filter 配置应传递到 plan.config.filter"""
        svc, _, _ = _make_generator()
        template = _make_template(
            editing_mode=EditingMode.ONE_TAKE.value,
            config={
                "filter": {
                    "enabled": True,
                    "preset_id": "filter_vivid",
                    "intensity": 80,
                },
            },
        )
        clip_configs = _make_clip_configs(
            specs=[
                {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0},
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1"],
        )

        filter_cfg = result["plan"].config.get("filter", {})
        assert filter_cfg.get("enabled") is True
        assert filter_cfg.get("preset_id") == "filter_vivid"
        assert filter_cfg.get("intensity") == 80

    def test_subtitle_asr_config_propagated(self):
        """模板 subtitle ASR 配置应传递到 plan.config.subtitle"""
        svc, _, _ = _make_generator()
        template = _make_template(
            editing_mode=EditingMode.ONE_TAKE.value,
            config={
                "subtitle": {
                    "enabled": True,
                    "auto_generated": True,
                    "position": "bottom",
                    "source": "asr",
                },
            },
        )
        clip_configs = _make_clip_configs(
            specs=[
                {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0},
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1"],
        )

        subtitle_cfg = result["plan"].config.get("subtitle", {})
        assert subtitle_cfg.get("enabled") is True
        assert subtitle_cfg.get("auto_generated") is True
        assert subtitle_cfg.get("position") == "bottom"

    def test_speed_ratio_mapped_to_playback_speed(self):
        """clip_config.config.speed_ratio 应映射到 clip.playback_speed"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
        clip_configs = _make_clip_configs(
            specs=[
                {
                    "clip_type": ClipType.MAIN,
                    "order": 0,
                    "min_duration": 3.0,
                    "max_duration": 5.0,
                    "config": {"speed_ratio": 1.2, "name": "开场"},
                },
                {
                    "clip_type": ClipType.MAIN,
                    "order": 1,
                    "min_duration": 3.0,
                    "max_duration": 5.0,
                    "config": {"speed_ratio": 0.8, "name": "结尾"},
                },
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1", "a2"],
        )

        clips = result["clips"]
        assert clips[0].playback_speed == 1.2
        assert clips[1].playback_speed == 0.8

    def test_playback_speed_takes_priority_over_speed_ratio(self):
        """clip_config.config.playback_speed 优先于 speed_ratio"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
        clip_configs = _make_clip_configs(
            specs=[
                {
                    "clip_type": ClipType.MAIN,
                    "order": 0,
                    "min_duration": 3.0,
                    "max_duration": 5.0,
                    "config": {"speed_ratio": 1.2, "playback_speed": 1.5},
                },
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1"],
        )

        assert result["clips"][0].playback_speed == 1.5

    def test_default_playback_speed_is_1_0(self):
        """无 speed_ratio / playback_speed 时，默认 1.0"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
        clip_configs = _make_clip_configs(
            specs=[
                {"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0},
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1"],
        )

        assert result["clips"][0].playback_speed == 1.0

    def test_clip_config_dict_propagated(self):
        """clip_config.config 整体应传递到 clip.config"""
        svc, _, _ = _make_generator()
        template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
        clip_configs = _make_clip_configs(
            specs=[
                {
                    "clip_type": ClipType.MAIN,
                    "order": 0,
                    "min_duration": 3.0,
                    "max_duration": 5.0,
                    "config": {"speed_ratio": 1.2, "name": "开场", "custom_field": "value"},
                },
            ]
        )

        result = svc.generate_from_template(
            template=template,
            clip_configs=clip_configs,
            asset_ids=["a1"],
        )

        clip_cfg = result["clips"][0].config
        assert clip_cfg.get("speed_ratio") == 1.2
        assert clip_cfg.get("name") == "开场"
        assert clip_cfg.get("custom_field") == "value"


class TestAssetDurationsAlwaysFetched:
    """验证 asset_durations 不再受 random_preview 条件限制。

    修复前：asset_durations 仅在 random_preview=True 时传入 distribute_assets
    修复后：只要 _asset_repo 存在，就始终获取 asset_durations
    """

    def _make_service_with_asset_repo(self):
        """创建带 mock asset_repo 的 PlanGeneratorService"""
        from apps.api.app.services.plan_generator_service import PlanGeneratorService

        plan_repo = StubEditPlanRepository()
        clip_repo = StubEditPlanClipRepository()

        # mock asset_repo: 返回带 duration 的素材
        asset_repo = MagicMock()

        def fake_get(asset_id):
            mock_asset = MagicMock()
            mock_asset.duration = 30.0  # 每个素材 30 秒
            # score_asset 所需的属性
            mock_asset.quality_score = None
            mock_asset.created_at = None
            mock_asset.metadata = {}
            return mock_asset

        asset_repo.get = MagicMock(side_effect=fake_get)

        with (
            patch(
                "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository",
                return_value=plan_repo,
            ),
            patch(
                "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository",
                return_value=clip_repo,
            ),
        ):
            db = MagicMock()
            svc = PlanGeneratorService(db, asset_repo=asset_repo)
            svc._plan_repo = plan_repo
            svc._clip_repo = clip_repo

        return svc, asset_repo

    def test_asset_durations_fetched_without_random_preview(self):
        """random_preview=False 时也应获取 asset_durations"""
        svc, asset_repo = self._make_service_with_asset_repo()

        template = _make_template("one_take")
        clip_configs = _make_clip_configs(
            template_id=template.id,
            specs=[{"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}],
        )

        # patch distribute_assets 以捕获传入的参数
        with patch("apps.api.app.services.plan_generator_service.distribute_assets") as mock_distribute:
            svc.generate_from_template(
                template=template,
                clip_configs=clip_configs,
                asset_ids=["a1", "a2"],
                random_preview=False,  # 关键：非随机预览模式
            )

            # 验证 asset_durations 被传入（不是 None）
            mock_distribute.assert_called_once()
            call_kwargs = mock_distribute.call_args
            asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations"))
            assert asset_durations is not None, "asset_durations should be fetched even when random_preview=False"
            assert "a1" in asset_durations
            assert "a2" in asset_durations
            assert asset_durations["a1"] == 30.0

        # 验证 asset_repo.get 被调用（说明 _fetch_asset_durations 执行了）
        assert asset_repo.get.call_count >= 2

    def test_asset_durations_fetched_with_random_preview(self):
        """random_preview=True 时仍正常获取 asset_durations（行为不变）"""
        svc, asset_repo = self._make_service_with_asset_repo()

        template = _make_template("one_take")
        clip_configs = _make_clip_configs(
            template_id=template.id,
            specs=[{"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}],
        )

        with patch("apps.api.app.services.plan_generator_service.distribute_assets") as mock_distribute:
            svc.generate_from_template(
                template=template,
                clip_configs=clip_configs,
                asset_ids=["a1"],
                random_preview=True,
            )

            call_kwargs = mock_distribute.call_args
            asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations"))
            assert asset_durations is not None
            assert "a1" in asset_durations

    def test_no_asset_repo_means_no_durations(self):
        """_asset_repo 为 None 时 asset_durations 应为 None"""
        svc, _, _ = _make_generator()  # 默认不带 asset_repo

        template = _make_template("one_take")
        clip_configs = _make_clip_configs(
            template_id=template.id,
            specs=[{"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}],
        )

        with patch("apps.api.app.services.plan_generator_service.distribute_assets") as mock_distribute:
            svc.generate_from_template(
                template=template,
                clip_configs=clip_configs,
                asset_ids=["a1"],
            )

            call_kwargs = mock_distribute.call_args
            asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations"))
            assert asset_durations is None


# ---------------------------------------------------------------------------
# 测试：正式生成片段随机重排（Issue #1663）
# ---------------------------------------------------------------------------


class TestFormalGenerationShuffle:
    """验证正式生成时片段顺序随机化。

    Issue #1663: 正式生成时 smart_match 排序后对 asset_ids 做 random.shuffle，
    使得同一批素材每次生成的视频片段顺序不同，有利于查重降重。
    """

    def _make_service_with_asset_repo(self):
        """创建带 mock asset_repo 的 PlanGeneratorService（复用 TestAssetDurationsAlwaysFetched 模式）"""
        from apps.api.app.services.plan_generator_service import PlanGeneratorService

        plan_repo = StubEditPlanRepository()
        clip_repo = StubEditPlanClipRepository()

        asset_repo = MagicMock()

        def fake_get(asset_id):
            mock_asset = MagicMock()
            mock_asset.duration = 30.0
            mock_asset.quality_score = None
            mock_asset.created_at = None
            mock_asset.metadata = {}
            return mock_asset

        asset_repo.get = MagicMock(side_effect=fake_get)

        with (
            patch(
                "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository",
                return_value=plan_repo,
            ),
            patch(
                "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository",
                return_value=clip_repo,
            ),
        ):
            db = MagicMock()
            svc = PlanGeneratorService(db, asset_repo=asset_repo)
            svc._plan_repo = plan_repo
            svc._clip_repo = clip_repo

        return svc, asset_repo

    def test_formal_generation_shuffles_asset_ids(self):
        """正式生成路径下 asset_ids 应被打乱，多次调用顺序应不同"""
        svc, _ = self._make_service_with_asset_repo()

        template = _make_template("one_take")
        # 6 个 clip 容纳 6 个素材
        clip_configs = _make_clip_configs(
            template_id=template.id,
            specs=[
                {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(6)
            ],
        )

        asset_ids = ["a1", "a2", "a3", "a4", "a5", "a6"]

        # 收集多次调用中 distribute_assets 收到的 asset_ids 顺序
        captured_orders = []
        with patch(
            "apps.api.app.services.plan_generator_service.distribute_assets",
            side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)),
        ):
            # mock _sort_assets_by_smart_score 返回固定顺序，验证 shuffle 会打乱
            with patch.object(
                svc,
                "_sort_assets_by_smart_score",
                side_effect=lambda ids: list(ids),  # 原样返回
            ):
                with patch.object(
                    svc,
                    "_fetch_asset_scene_points",
                    return_value={},
                ):
                    for _ in range(10):
                        svc.generate_from_template(
                            template=template,
                            clip_configs=clip_configs,
                            asset_ids=list(asset_ids),  # 每次传新列表
                            random_preview=False,  # 正式生成
                        )

        assert len(captured_orders) == 10
        # 每次 order 应该是 asset_ids 的一个排列
        expected_set = set(asset_ids)
        for order in captured_orders:
            assert set(order) == expected_set

        # 10 次调用中应至少出现 2 种不同顺序（概率 > 99.9%）
        unique_orders = set(tuple(o) for o in captured_orders)
        assert (
            len(unique_orders) >= 2
        ), f"Expected shuffled orders to vary, but got only {len(unique_orders)} unique order(s): {unique_orders}"

    def test_formal_generation_does_not_mutate_original_list(self):
        """shuffle 不应修改调用方的原始 asset_ids 列表"""
        svc, _ = self._make_service_with_asset_repo()

        template = _make_template("one_take")
        clip_configs = _make_clip_configs(
            template_id=template.id,
            specs=[
                {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4)
            ],
        )

        original = ["a1", "a2", "a3", "a4"]
        original_copy = list(original)

        with patch("apps.api.app.services.plan_generator_service.distribute_assets"):
            with patch.object(svc, "_sort_assets_by_smart_score", side_effect=lambda ids: list(ids)):
                with patch.object(svc, "_fetch_asset_scene_points", return_value={}):
                    svc.generate_from_template(
                        template=template,
                        clip_configs=clip_configs,
                        asset_ids=original,
                        random_preview=False,
                    )

        assert original == original_copy, "Original asset_ids list should not be mutated"

    def test_preview_random_mode_unaffected_by_shuffle(self):
        """预览随机模式不走 shuffle 路径，行为不变"""
        svc, _ = self._make_service_with_asset_repo()

        template = _make_template("one_take")
        clip_configs = _make_clip_configs(
            template_id=template.id,
            specs=[
                {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4)
            ],
        )

        asset_ids = ["a1", "a2", "a3", "a4"]

        captured_orders = []
        with patch(
            "apps.api.app.services.plan_generator_service.distribute_assets",
            side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)),
        ):
            for _ in range(5):
                svc.generate_from_template(
                    template=template,
                    clip_configs=clip_configs,
                    asset_ids=list(asset_ids),
                    random_preview=True,  # 预览随机模式
                )

        assert len(captured_orders) == 5
        # 预览模式下 random.shuffle 不应被调用（在 _distribute_assets 的 if not random_selection 块内）
        # 所以 asset_ids 应该保持调用方传入的顺序（可能已由上层 shuffle 过）
