"""
EditPlanService 单元测试

覆盖（35+ 测试用例）：
- 计划 CRUD：创建、查询、更新、删除
- 状态机流转：合法流转、非法流转、幂等流转
- 片段管理：创建、更新、删除、分配素材
- 渲染生成流程：can_generate、mark_clips_ready、get_generation_status
- 异常处理：不存在、参数校验
"""

from __future__ import annotations

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

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

import pytest

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

# ---------------------------------------------------------------------------
# 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 list_all(
        self,
        *,
        status: Optional[EditPlanStatus] = None,
        skip: int = 0,
        limit: int = 50,
    ) -> List[EditPlan]:
        items = list(self._plans.values())
        if status:
            items = [p for p in items if p.status == status]
        return items[skip : skip + limit]

    def list_by_template(
        self,
        template_id: str,
        *,
        status: Optional[EditPlanStatus] = None,
        skip: int = 0,
        limit: int = 50,
    ) -> List[EditPlan]:
        items = [p for p in self._plans.values() if p.template_id == template_id]
        if status:
            items = [p for p in items if p.status == status]
        return items[skip : skip + limit]

    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,
                config=plan.config,
                created_at=datetime.now(timezone.utc),
                updated_at=datetime.now(timezone.utc),
            )
        self._plans[plan.id] = plan
        return plan

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

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

    def count(
        self,
        *,
        status: Optional[EditPlanStatus] = None,
    ) -> int:
        items = list(self._plans.values())
        if status:
            items = [p for p in items if p.status == status]
        return len(items)


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 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]
        if status:
            items = [c for c in items if c.status == status]
        items.sort(key=lambda c: c.order)
        return items[skip : skip + limit]

    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,
                clip_type=clip.clip_type,
                order=clip.order,
                template_clip_config_id=clip.template_clip_config_id,
                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=datetime.now(timezone.utc),
                updated_at=datetime.now(timezone.utc),
            )
        self._clips[clip.id] = clip
        return clip

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

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

    def delete_by_plan(self, plan_id: str) -> int:
        ids = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
        for cid in ids:
            del self._clips[cid]
        return len(ids)

    def count(
        self,
        *,
        plan_id: Optional[str] = None,
        status: Optional[EditPlanClipStatus] = None,
    ) -> int:
        items = list(self._clips.values())
        if plan_id:
            items = [c for c in items if c.plan_id == plan_id]
        if status:
            items = [c for c in items if c.status == status]
        return len(items)


class StubGenerationTaskRepository:
    """内存中的 GenerationTask 仓储 stub"""

    def __init__(self) -> None:
        self._tasks: dict[str, Any] = {}

    def get(self, task_id: str) -> Optional[Any]:
        return self._tasks.get(task_id)

    def create(self, task: Any) -> Any:
        self._tasks[task.id] = task
        return task

    def count_pending_by_user(self, user_id: str) -> int:
        return 0

    def count_pending_total(self) -> int:
        return 0

    def list_by_user_filtered(
        self,
        user_id: str,
        *,
        status: str | None = None,
        limit: int | None = None,
        offset: int = 0,
    ) -> list:
        """按用户+状态筛选任务列表（stub实现）。"""
        items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
        if status:
            items = [t for t in items if str(t.status) == status]
        # 按创建时间倒序
        items.sort(key=lambda t: t.created_at or "", reverse=True)
        if offset:
            items = items[offset:]
        if limit is not None:
            items = items[:limit]
        return items

    def count_by_user_filtered(
        self,
        user_id: str,
        *,
        status: str | None = None,
    ) -> int:
        """按用户+状态筛选计数（stub实现）。"""
        items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
        if status:
            items = [t for t in items if str(t.status) == status]
        return len(items)

    def list_by_project_filtered(
        self,
        project_id: str,
        *,
        status: str | None = None,
        limit: int | None = None,
        offset: int = 0,
    ) -> list:
        """按项目+状态筛选任务列表（stub实现）。"""
        items = [t for t in self._tasks.values() if t.project_id == project_id]
        if status:
            items = [t for t in items if str(t.status) == status]
        # 按创建时间倒序
        items.sort(key=lambda t: t.created_at or "", reverse=True)
        if offset:
            items = items[offset:]
        if limit is not None:
            items = items[:limit]
        return items

    def count_by_project_filtered(
        self,
        project_id: str,
        *,
        status: str | None = None,
    ) -> int:
        """按项目+状态筛选计数（stub实现）。"""
        items = [t for t in self._tasks.values() if t.project_id == project_id]
        if status:
            items = [t for t in items if str(t.status) == status]
        return len(items)


# ---------------------------------------------------------------------------
# Service factory
# ---------------------------------------------------------------------------


def _make_service():
    """创建使用 stub 仓储的 EditPlanService"""
    from app.services.edit_plan_service import EditPlanService

    db = MagicMock()
    svc = EditPlanService(db)
    svc._plan_repo = StubEditPlanRepository()
    svc._clip_repo = StubEditPlanClipRepository()
    svc._generation_task_repo = StubGenerationTaskRepository()
    return svc


# ===========================================================================
# 计划 CRUD 测试
# ===========================================================================


class TestEditPlanServiceCRUD:
    """计划 CRUD 测试"""

    def test_create_plan_success(self):
        svc = _make_service()
        plan = svc.create_plan(template_id="tpl-001", name="测试计划")
        assert plan.name == "测试计划"
        assert plan.template_id == "tpl-001"
        assert plan.status == EditPlanStatus.DRAFT
        assert plan.id

    def test_get_plan(self):
        svc = _make_service()
        created = svc.create_plan("tpl-001", "查询测试")
        fetched = svc.get_plan(created.id)
        assert fetched is not None
        assert fetched.id == created.id

    def test_get_plan_returns_none(self):
        svc = _make_service()
        assert svc.get_plan("nonexistent") is None

    def test_get_plan_or_raise(self):
        svc = _make_service()
        created = svc.create_plan("tpl-001", "查询测试")
        fetched = svc.get_plan_or_raise(created.id)
        assert fetched.id == created.id

    def test_get_plan_or_raise_not_found(self):
        svc = _make_service()
        with pytest.raises(ValueError, match="剪辑计划不存在"):
            svc.get_plan_or_raise("nonexistent")

    def test_update_plan_not_found_raises(self):
        svc = _make_service()
        with pytest.raises(ValueError, match="剪辑计划不存在"):
            svc.update_plan("nonexistent", name="新名")
        p = svc.create_plan("tpl-001", "测试")
        result = svc.transition_status(p.id, EditPlanStatus.EDITING)
        assert result.status == EditPlanStatus.EDITING

    def test_transition_editing_to_rendering(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        result = svc.transition_status(p.id, EditPlanStatus.RENDERING)
        assert result.status == EditPlanStatus.RENDERING

    def test_transition_rendering_to_completed(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        result = svc.transition_status(p.id, EditPlanStatus.COMPLETED)
        assert result.status == EditPlanStatus.COMPLETED

    def test_transition_rendering_to_failed(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        result = svc.transition_status(p.id, EditPlanStatus.FAILED)
        assert result.status == EditPlanStatus.FAILED

    def test_transition_failed_to_draft(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        svc.transition_status(p.id, EditPlanStatus.FAILED)
        result = svc.transition_status(p.id, EditPlanStatus.DRAFT)
        assert result.status == EditPlanStatus.DRAFT

    def test_transition_idempotent(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        result = svc.transition_status(p.id, EditPlanStatus.DRAFT)
        assert result.status == EditPlanStatus.DRAFT

    def test_transition_illegal_raises(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        # draft → completed 是非法的
        with pytest.raises(ValueError):
            svc.transition_status(p.id, EditPlanStatus.COMPLETED)

    def test_transition_not_found_raises(self):
        svc = _make_service()
        with pytest.raises(ValueError, match="剪辑计划不存在"):
            svc.transition_status("nonexistent", EditPlanStatus.EDITING)


# ===========================================================================
# 片段管理测试
# ===========================================================================


class TestClipManagement:
    """片段管理测试"""

    def test_create_clip(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "intro", 0)
        assert clip.plan_id == p.id
        assert clip.clip_type == "intro"
        assert clip.order == 0
        assert clip.status == EditPlanClipStatus.PENDING

    def test_create_clip_plan_not_found_raises(self):
        svc = _make_service()
        with pytest.raises(ValueError, match="剪辑计划不存在"):
            svc.create_clip("nonexistent", "intro", 0)

    def test_list_clips(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.create_clip(p.id, "intro", 0)
        svc.create_clip(p.id, "main", 1)
        svc.create_clip(p.id, "outro", 2)
        result = svc.list_clips(p.id)
        assert len(result) == 3

    def test_list_clips_by_status(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        c1 = svc.create_clip(p.id, "intro", 0)
        svc.create_clip(p.id, "main", 1)
        # assign_asset 只设置 asset_id，需要额外 mark_ready 才变 ready
        svc.assign_asset(c1.id, "asset-001")
        # 手动 mark_ready
        clip_obj = svc._clip_repo.get(c1.id)
        clip_obj.mark_ready()
        svc._clip_repo.update(clip_obj)
        result = svc.list_clips(p.id, status=EditPlanClipStatus.READY)
        assert len(result) == 1

    def test_count_clips(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.create_clip(p.id, "intro", 0)
        svc.create_clip(p.id, "main", 1)
        assert svc.count_clips(p.id) == 2

    def test_get_clip(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "intro", 0)
        fetched = svc.get_clip(clip.id)
        assert fetched is not None
        assert fetched.id == clip.id

    def test_get_clip_or_raise_not_found(self):
        svc = _make_service()
        with pytest.raises(ValueError, match="片段不存在"):
            svc.get_clip_or_raise("nonexistent")

    def test_update_clip(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "intro", 0, duration=3.0)
        updated = svc.update_clip(clip.id, duration=5.0)
        assert updated.duration == 5.0

    def test_update_clip_not_found_raises(self):
        svc = _make_service()
        with pytest.raises(ValueError, match="片段不存在"):
            svc.update_clip("nonexistent", duration=5.0)

    def test_assign_asset(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "intro", 0)
        result = svc.assign_asset(clip.id, "asset-001")
        assert result.asset_id == "asset-001"
        # assign_asset 只设置 asset_id，不改变状态（状态需 mark_ready 流转）
        assert result.status == EditPlanClipStatus.PENDING

    def test_assign_asset_empty_raises(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "intro", 0)
        with pytest.raises(ValueError):
            svc.assign_asset(clip.id, "")

    def test_delete_clip(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "intro", 0)
        assert svc.delete_clip(clip.id) is True
        assert svc.get_clip(clip.id) is None

    def test_delete_all_clips(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.create_clip(p.id, "intro", 0)
        svc.create_clip(p.id, "main", 1)
        count = svc.delete_all_clips(p.id)
        assert count == 2
        assert svc.count_clips(p.id) == 0


# ===========================================================================
# 渲染生成流程测试
# ===========================================================================


class TestGenerationWorkflow:
    """渲染生成流程测试"""

    def test_can_generate_editing_with_clips(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip = svc.create_clip(p.id, "intro", 0)
        svc.assign_asset(clip.id, "asset-001")
        can, reason = svc.can_generate(p.id)
        assert can is True
        assert reason == ""

    def test_can_generate_no_assets_fails(self):
        """片段存在但没有分配素材时，can_generate 应返回 False"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.create_clip(p.id, "intro", 0)
        can, reason = svc.can_generate(p.id)
        assert can is False
        assert "没有可渲染" in reason or "素材" in reason

    def test_can_generate_draft_fails(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.create_clip(p.id, "intro", 0)
        can, reason = svc.can_generate(p.id)
        assert can is False
        assert "编辑" in reason or "模板" in reason

    def test_can_generate_no_clips_fails(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        can, reason = svc.can_generate(p.id)
        assert can is False
        assert "请先添加片段后再生成视频" in reason

    def test_mark_clips_ready(self):
        """只有分配了素材的 pending 片段才会被标记为 ready"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip1 = svc.create_clip(p.id, "intro", 0)
        clip2 = svc.create_clip(p.id, "main", 1)
        # 只给 clip1 分配素材
        svc.assign_asset(clip1.id, "asset-001")
        count = svc.mark_clips_ready(p.id)
        assert count == 1  # 只有 clip1 被标记
        # 验证 clip1 是 ready，clip2 仍是 pending
        clips = svc.list_clips(p.id)
        clips_by_order = {c.order: c for c in clips}
        assert clips_by_order[0].status == EditPlanClipStatus.READY
        assert clips_by_order[1].status == EditPlanClipStatus.PENDING

    def test_update_plan_config(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"})
        updated = svc.update_plan_config(p.id, {"key2": "val2"})
        assert updated.config["key1"] == "val1"
        assert updated.config["key2"] == "val2"

    def test_update_plan_config_overwrites(self):
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"})
        updated = svc.update_plan_config(p.id, {"key1": "new_val"})
        assert updated.config["key1"] == "new_val"


# ── 重新编辑 & 再生成 ────────────────────────────────────────────────────────


class TestResumeEditingAndRegenerate:
    """完成/失败后重新编辑 → 再生成的状态流转测试"""

    def test_update_plan_from_completed_returns_to_editing(self):
        """更新计划配置：completed → 自动切回 editing"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        svc.transition_status(p.id, EditPlanStatus.COMPLETED)

        updated = svc.update_plan(p.id, name="新名字")
        assert updated.status == EditPlanStatus.EDITING
        assert updated.name == "新名字"

    def test_update_plan_config_from_completed_returns_to_editing(self):
        """update_plan_config: completed → 自动切回 editing"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        svc.transition_status(p.id, EditPlanStatus.COMPLETED)

        updated = svc.update_plan_config(p.id, {"foo": "bar"})
        assert updated.status == EditPlanStatus.EDITING

    def test_create_clip_from_completed_returns_to_editing(self):
        """创建片段：completed → 自动切回 editing"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        svc.transition_status(p.id, EditPlanStatus.COMPLETED)

        svc.create_clip(p.id, "main", 0)
        plan_after = svc.get_plan(p.id)
        assert plan_after.status == EditPlanStatus.EDITING

    def test_assign_asset_from_failed_returns_to_editing(self):
        """分配素材：failed → 自动切回 editing"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "main", 0)
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        svc.transition_status(p.id, EditPlanStatus.FAILED)

        svc.assign_asset(clip.id, "asset-001")
        plan_after = svc.get_plan(p.id)
        assert plan_after.status == EditPlanStatus.EDITING

    def test_completed_can_regenerate_after_edit(self):
        """完成后编辑 → can_generate 返回 True，可再生成"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        clip = svc.create_clip(p.id, "main", 0)
        svc.assign_asset(clip.id, "asset-001")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        svc.transition_status(p.id, EditPlanStatus.COMPLETED)

        # 完成后不能直接生成
        can, reason = svc.can_generate(p.id)
        assert not can
        assert "编辑" in reason

        # 编辑后自动切回 editing，可以生成
        svc.update_plan_config(p.id, {"edited": True})
        can, reason = svc.can_generate(p.id)
        assert can, f"期望可生成，实际: {reason}"

    def test_transition_completed_to_editing_via_service(self):
        """通过 transition_status 从 completed 切到 editing"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        svc.transition_status(p.id, EditPlanStatus.RENDERING)
        svc.transition_status(p.id, EditPlanStatus.COMPLETED)

        result = svc.transition_status(p.id, EditPlanStatus.EDITING)
        assert result.status == EditPlanStatus.EDITING

    def test_resume_editing_from_draft_raises(self):
        """从 draft 直接 resume_editing 应该报错"""
        p = EditPlan.create("tpl-001", "测试")
        with pytest.raises(ValueError):
            p.resume_editing()


# ===========================================================================
# 片段分割与合并测试
# ===========================================================================


class TestClipSplit:
    """片段分割测试"""

    def test_split_basic(self):
        """基础分割：10秒片段在第3秒处分割"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip = svc.create_clip(p.id, "main", 0, duration=10.0, text_content="测试文案")

        result = svc.split_clip(clip.id, 3.0)

        assert result["left_clip"].duration == 3.0
        assert result["left_clip"].order == 0
        assert result["right_clip"].duration == 7.0
        assert result["right_clip"].order == 1
        assert result["right_clip"].clip_type == "main"
        assert result["right_clip"].text_content == "测试文案"
        # 总片段数 = 2
        assert svc.count_clips(p.id) == 2

    def test_split_preserves_clip_properties(self):
        """分割后属性继承正确"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip = svc.create_clip(
            p.id,
            "intro",
            0,
            duration=10.0,
            transition_effect="fade",
            playback_speed=1.5,
            config={"filter": "vivid"},
        )

        result = svc.split_clip(clip.id, 5.0)

        right = result["right_clip"]
        assert right.clip_type == "intro"
        assert right.transition_effect == "fade"
        assert right.playback_speed == 1.5
        assert right.config.get("filter") == "vivid"

    def test_split_shifts_following_clips(self):
        """分割后，后面的片段 order 自动 +1"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
        clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
        clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)

        svc.split_clip(clip1.id, 2.0)

        # clip0: order 0
        # clip1(left): order 1
        # new right: order 2
        # clip2: order 3
        clips = svc.list_clips(p.id)
        order_map = {c.id: c.order for c in clips}
        assert order_map[clip0.id] == 0
        assert order_map[clip1.id] == 1
        assert order_map[clip2.id] == 3
        assert len(clips) == 4

    def test_split_at_boundary_raises(self):
        """分割点为0或等于时长时，报错"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip = svc.create_clip(p.id, "main", 0, duration=10.0)

        with pytest.raises(ValueError, match="分割时间"):
            svc.split_clip(clip.id, 0.0)

        with pytest.raises(ValueError, match="分割时间"):
            svc.split_clip(clip.id, 10.0)

    def test_split_negative_time_raises(self):
        """负数分割点报错"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip = svc.create_clip(p.id, "main", 0, duration=10.0)

        with pytest.raises(ValueError, match="分割时间"):
            svc.split_clip(clip.id, -1.0)

    def test_split_nonexistent_clip_raises(self):
        """不存在的片段报错"""
        svc = _make_service()

        with pytest.raises(ValueError, match="片段不存在"):
            svc.split_clip("nonexistent", 5.0)

    def test_split_with_asset_adds_trim_info(self):
        """有素材的片段分割后，添加trim_start/trim_end"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip = svc.create_clip(p.id, "main", 0, duration=10.0, asset_id="asset-001")

        result = svc.split_clip(clip.id, 3.0)

        left = result["left_clip"]
        right = result["right_clip"]
        # 左半部分有 trim_end
        assert left.config.get("trim_end") == 7.0
        # 右半部分有 trim_start
        assert right.config.get("trim_start") == 3.0
        # 右半部分也关联同一个素材
        assert right.asset_id == "asset-001"


class TestClipMerge:
    """片段合并测试"""

    def test_merge_two_clips(self):
        """基础合并：两个5秒片段合并为10秒"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, text_content="第一段")
        clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, text_content="第二段")

        merged = svc.merge_clips([clip1.id, clip2.id])

        assert merged.duration == 10.0
        assert merged.order == 0
        assert merged.clip_type == "main"
        assert "第一段" in merged.text_content
        assert "第二段" in merged.text_content
        # 总片段数 = 1
        assert svc.count_clips(p.id) == 1

    def test_merge_shifts_following_clips(self):
        """合并后，后面的片段 order 前移"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
        clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
        clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
        clip3 = svc.create_clip(p.id, "main", 3, duration=5.0)

        svc.merge_clips([clip1.id, clip2.id])

        clips = svc.list_clips(p.id)
        order_map = {c.id: c.order for c in clips}
        assert order_map[clip0.id] == 0
        assert order_map[clip3.id] == 2  # 原来order=3，前移1位=2
        assert len(clips) == 3

    def test_merge_three_clips(self):
        """合并3个片段"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clips = []
        for i in range(3):
            c = svc.create_clip(p.id, "main", i, duration=3.0)
            clips.append(c)

        merged = svc.merge_clips([c.id for c in clips])

        assert merged.duration == 9.0
        assert svc.count_clips(p.id) == 1

    def test_merge_different_types_raises(self):
        """不同类型片段不能合并"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip1 = svc.create_clip(p.id, "intro", 0, duration=5.0)
        clip2 = svc.create_clip(p.id, "main", 1, duration=5.0)

        with pytest.raises(ValueError, match="相同类型"):
            svc.merge_clips([clip1.id, clip2.id])

    def test_merge_non_contiguous_raises(self):
        """不连续的片段不能合并"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
        svc.create_clip(p.id, "main", 1, duration=5.0)
        clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)

        with pytest.raises(ValueError, match="不连续"):
            svc.merge_clips([clip0.id, clip2.id])

    def test_merge_single_clip_raises(self):
        """单个片段不能合并"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip = svc.create_clip(p.id, "main", 0, duration=5.0)

        with pytest.raises(ValueError, match="至少需要 2 个"):
            svc.merge_clips([clip.id])

    def test_merge_different_plans_raises(self):
        """不同计划的片段不能合并"""
        svc = _make_service()
        p1 = svc.create_plan("tpl-001", "计划1")
        p2 = svc.create_plan("tpl-001", "计划2")
        svc.transition_status(p1.id, EditPlanStatus.EDITING)
        svc.transition_status(p2.id, EditPlanStatus.EDITING)
        clip1 = svc.create_clip(p1.id, "main", 0, duration=5.0)
        clip2 = svc.create_clip(p2.id, "main", 0, duration=5.0)

        with pytest.raises(ValueError, match="同一计划"):
            svc.merge_clips([clip1.id, clip2.id])

    def test_merge_clears_trim_fields(self):
        """合并后清理trim字段"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, config={"trim_end": 2.0, "filter": "vivid"})
        clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, config={"trim_start": 1.0})

        merged = svc.merge_clips([clip1.id, clip2.id])

        assert "trim_start" not in merged.config
        assert "trim_end" not in merged.config
        # 非 trim 字段保留（后面的覆盖前面的）
        assert merged.config.get("filter") == "vivid"

    def test_split_then_merge_recovers(self):
        """分割后再合并，时长基本恢复（浮点精度内）"""
        svc = _make_service()
        p = svc.create_plan("tpl-001", "测试计划")
        svc.transition_status(p.id, EditPlanStatus.EDITING)
        original = svc.create_clip(p.id, "main", 0, duration=10.0)

        result = svc.split_clip(original.id, 3.5)
        left = result["left_clip"]
        right = result["right_clip"]

        merged = svc.merge_clips([left.id, right.id])

        assert abs(merged.duration - 10.0) < 0.001
        assert svc.count_clips(p.id) == 1
