"""Tests for generation cover route — schema validation and import checks."""

from __future__ import annotations

import pytest
from pydantic import ValidationError


def test_generation_cover_router_importable():
    """新路由模块可以正确导入"""
    from app.api.routes.generation_cover import router

    assert router is not None
    # tags 应该是 Generation
    assert "Generation" in router.tags


def test_generation_cover_route_path():
    """路由路径应为 /generate-cover"""
    from app.api.routes.generation_cover import router

    paths = [route.path for route in router.routes]
    assert "/generate-cover" in paths


def test_generation_cover_schemas_importable():
    """Schema 可以从新模块导入"""
    from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse

    # 验证请求 schema 默认值
    req = GenerateCoverRequest()
    assert req.asset_ids == []
    assert req.cover_type == "ai_frame"
    assert req.frame_time is None

    # 验证响应 schema
    resp = GenerateCoverResponse(plan_id="p1", cover={"image_url": "http://x"})
    assert resp.plan_id == "p1"
    assert resp.cover["image_url"] == "http://x"


def test_generation_cover_schemas_not_in_templates_editor():
    """旧的 templates_editor/schemas.py 不再包含封面 schema"""
    from app.api.routes.templates_editor import schemas as te_schemas

    assert not hasattr(te_schemas, "GenerateCoverRequest")
    assert not hasattr(te_schemas, "GenerateCoverResponse")


def test_templates_editor_no_cover_router():
    """templates_editor 不再包含 cover_router"""
    from app.api.routes.templates_editor import _sub_routers

    # cover_router 应该已被移除
    for sub in _sub_routers:
        for route in sub.routes:
            assert "generate-cover" not in getattr(route, "path", ""), "templates_editor 不应再有 generate-cover 路由"


def _collect_route_paths(route, acc):
    """递归收集路由路径（兼容新版 fastapi 的 _IncludedRouter 延迟包装）。"""
    path = getattr(route, "path", None)
    if isinstance(path, str):
        acc.append(path)
    # 新版 fastapi：_IncludedRouter.original_router；旧版：嵌套 .routes
    inner = getattr(route, "original_router", None)
    if inner is not None and hasattr(inner, "routes"):
        for sub in inner.routes:
            _collect_route_paths(sub, acc)
    sub_routes = getattr(route, "routes", None)
    if sub_routes:
        for sub in sub_routes:
            _collect_route_paths(sub, acc)


def test_api_router_has_generation_cover():
    """api_router 应该包含 /api/v1/generation/generate-cover 路径"""
    from app.api.router import api_router

    all_paths: list[str] = []
    for route in api_router.routes:
        _collect_route_paths(route, all_paths)

    # 应该能找到 generate-cover 路径
    cover_paths = [p for p in all_paths if "generate-cover" in p]
    assert len(cover_paths) > 0, f"未找到 generate-cover 路由, 所有路径: {all_paths[:20]}"


def test_generation_cover_request_validation():
    """验证请求 schema 的字段约束"""
    from app.api.routes.generation_cover import GenerateCoverRequest

    # frame_time 不允许负数
    with pytest.raises(ValidationError):
        GenerateCoverRequest(frame_time=-1.0)

    # 合法的 frame_time
    req = GenerateCoverRequest(frame_time=5.5)
    assert req.frame_time == 5.5

    # 自定义 cover_type
    req2 = GenerateCoverRequest(cover_type="upload", asset_ids=["a1", "a2"])
    assert req2.cover_type == "upload"
    assert req2.asset_ids == ["a1", "a2"]


class TestUnifiedCoverPipelineEndpoint:
    """测试统一封面管道在 generate_cover endpoint 中的逻辑 (lines 189-215)."""

    def test_cover_url_from_generation_task(self):
        """当 GenerationTask 有 cover_url 时，直接返回该 URL 作为封面。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse

        # Mock plan with rendered_storage_key (so we skip the 3-step lookup)
        mock_plan = MagicMock()
        mock_plan.config = {
            "generation_task_id": "task-123",
            "rendered_storage_key": "rendered/plan-1/video.mp4",
        }

        # Mock plan_svc
        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan

        # Mock template_svc
        mock_template_svc = MagicMock()

        # Mock generation task with cover_url
        mock_task = MagicMock()
        mock_task.cover_url = "https://oss.example.com/rendered/plan-1/cover.jpg"

        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = mock_task
            mock_repo_cls.return_value = mock_repo

            # normalize_plan_config should return the config with cover
            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/plan-1/cover.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-1",
                plan_id="plan-1",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            # 验证返回的封面数据来自 GenerationTask.cover_url
            assert result.plan_id == "plan-1"
            assert result.cover["image_url"] == "https://oss.example.com/rendered/plan-1/cover.jpg"
            assert result.cover["type"] == "ai_frame"

    def test_cover_url_all_fallbacks_fail_returns_400(self):
        """当所有步骤都找不到 cover_url 时，返回 400 而非 500。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest
        from fastapi import HTTPException

        mock_plan = MagicMock()
        mock_plan.config = {
            "generation_task_id": "task-456",
            "rendered_storage_key": "rendered/plan-2/video.mp4",
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        # Task has no cover_url
        mock_task = MagicMock()
        mock_task.cover_url = ""

        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
            patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = mock_task
            # No tasks found by source_edit_plan_id or user+template
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            mock_storage_svc = MagicMock()
            mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/plan-2/video.mp4"
            mock_storage_getter.return_value = mock_storage_svc

            # MediaKit 抽帧也返回 None，模拟最终失败
            mock_mk = MagicMock()
            mock_mk.is_available = True
            mock_mk.extract_frames.return_value = None
            mock_mk_getter.return_value = mock_mk

            # body 不传 asset_ids，步骤 E2 不会进入
            from app.api.routes.generation_cover import generate_cover

            with pytest.raises(HTTPException) as exc_info:
                generate_cover(
                    body=body,
                    template_id="template-2",
                    plan_id="plan-2",
                    services=(mock_template_svc, mock_plan_svc),
                    db=mock_db,
                    current_user=MagicMock(),
                )

            assert exc_info.value.status_code == 400

    def test_cover_url_found_via_source_edit_plan(self):
        """步骤B：通过 source_edit_plan_id 找到预览任务的 cover_url。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        # No generation_task_id, so step A is skipped
        mock_plan.config = {
            "rendered_storage_key": "rendered/plan-x/video.mp4",
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        # Preview task found by source_edit_plan_id with cover_url
        mock_preview_task = MagicMock()
        mock_preview_task.id = "preview-task-abc"
        mock_preview_task.status = "completed"
        mock_preview_task.cover_url = "https://oss.example.com/rendered/preview/cover.jpg"

        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task]
            mock_repo_cls.return_value = mock_repo

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/preview/cover.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-x",
                plan_id="plan-x",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            assert result.cover["image_url"] == "https://oss.example.com/rendered/preview/cover.jpg"
            mock_repo.list_by_source_edit_plan.assert_called_once_with("plan-x")

    def test_cover_url_found_via_user_template(self):
        """步骤C：通过 user+template 找到预览任务的 cover_url。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {
            "rendered_storage_key": "rendered/plan-y/video.mp4",
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        # Step B finds nothing, step C finds a task
        mock_preview_task = MagicMock()
        mock_preview_task.id = "preview-task-def"
        mock_preview_task.status = "completed"
        mock_preview_task.cover_url = "https://oss.example.com/rendered/user-template-cover.jpg"

        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-123"
        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = [mock_preview_task]
            mock_repo_cls.return_value = mock_repo

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/user-template-cover.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-y",
                plan_id="plan-y",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=mock_current_user,
            )

            assert result.cover["image_url"] == "https://oss.example.com/rendered/user-template-cover.jpg"
            mock_repo.list_latest_completed_preview.assert_called_once_with(
                user_id="user-123",
                template_id="template-y",
            )

    def test_cover_url_found_via_cover_candidates_image_url(self):
        """步骤D：plan.config.cover_candidates 有 image_url 时，直接使用第一个候选封面。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        # 步骤A/B/C 都找不到，进入步骤D
        mock_plan.config = {
            "rendered_storage_key": "rendered/plan-z/video.mp4",  # 必须有预览视频才能通过前置检查
            "cover_candidates": [
                {"image_url": "https://oss.example.com/candidates/cover-1.jpg", "score": 0.95},
                {"image_url": "https://oss.example.com/candidates/cover-2.jpg", "score": 0.80},
            ],
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/candidates/cover-1.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-z",
                plan_id="plan-z",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            # 步骤D从 cover_candidates 第一个元素的 image_url 提取封面
            assert result.cover["image_url"] == "https://oss.example.com/candidates/cover-1.jpg"
            # 验证 plan.config 被更新（至少调用一次：rendered_storage_key + cover）
            assert mock_plan_svc.update_plan_config.call_count >= 1

    def test_cover_url_found_via_cover_candidates_url_key(self):
        """步骤D：cover_candidates 用 url 键（非 image_url）时，也能正确提取。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {
            "rendered_storage_key": "rendered/plan-w/video.mp4",  # 必须有预览视频才能通过前置检查
            "cover_candidates": [
                {"url": "https://oss.example.com/candidates/alt-cover.jpg"},
            ],
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/candidates/alt-cover.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-w",
                plan_id="plan-w",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            # 步骤D fallback 到 url 键
            assert result.cover["image_url"] == "https://oss.example.com/candidates/alt-cover.jpg"

    def test_cover_candidates_skips_non_dict_first_element(self):
        """步骤D：cover_candidates 第一个元素不是 dict 时，安全跳过不崩溃。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest
        from fastapi import HTTPException

        mock_plan = MagicMock()
        mock_plan.config = {
            "rendered_storage_key": "rendered/plan-skip/video.mp4",  # 必须有预览视频才能通过前置检查
            "cover_candidates": ["not-a-dict", 42, None],
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            # storage fallback 也找不到封面
            mock_storage_svc = MagicMock()
            mock_storage_svc.get_url.return_value = ""
            mock_storage_getter.return_value = mock_storage_svc

            from app.api.routes.generation_cover import generate_cover

            # 所有步骤都失败，应返回 400
            with pytest.raises(HTTPException) as exc_info:
                generate_cover(
                    body=body,
                    template_id="template-skip",
                    plan_id="plan-skip",
                    services=(mock_template_svc, mock_plan_svc),
                    db=mock_db,
                    current_user=MagicMock(),
                )
            assert exc_info.value.status_code == 400


class TestSourceEditPlanFallback:
    """测试步骤 2.5：通过 source_edit_plan_id 查找预览视频兜底逻辑。"""

    def test_step25_finds_video_by_source_edit_plan_id(self):
        """当步骤1和步骤2都找不到时，步骤2.5通过source_edit_plan_id找到预览视频和封面。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        # plan.config 没有 rendered_storage_key 和 generation_task_id
        mock_plan = MagicMock()
        mock_plan.config = {}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        # Mock preview task found by source_edit_plan_id — with cover_url
        mock_preview_task = MagicMock()
        mock_preview_task.id = "preview-task-789"
        mock_preview_task.status = "completed"
        mock_preview_task.is_preview = True
        mock_preview_task.cover_url = "https://oss.example.com/rendered/cover.jpg"

        # Mock generated video
        mock_video = MagicMock()
        mock_video.file_url = "rendered/plan-x/video.mp4"

        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
            patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            # Both video lookup (step 2.5) and cover_url lookup (step B) use this
            mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task]
            mock_repo_cls.return_value = mock_repo

            mock_usecase = MagicMock()
            mock_usecase.execute.return_value = [mock_video]
            mock_usecase_cls.return_value = mock_usecase

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/cover.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-1",
                plan_id="plan-x",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            # Verify step 2.5 was called for video
            mock_repo.list_by_source_edit_plan.assert_called_with("plan-x")
            # Cover was found via unified pipeline step B
            assert result.cover["image_url"] == "https://oss.example.com/rendered/cover.jpg"

    def test_step25_skips_non_completed_or_non_preview_tasks(self):
        """步骤2.5跳过非completed或非is_preview的任务，继续到步骤3。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        # Task that is not completed
        mock_task_failed = MagicMock()
        mock_task_failed.id = "task-failed"
        mock_task_failed.status = "failed"
        mock_task_failed.is_preview = True
        mock_task_failed.cover_url = ""

        # Task that is not preview
        mock_task_full = MagicMock()
        mock_task_full.id = "task-full"
        mock_task_full.status = "completed"
        mock_task_full.is_preview = False
        mock_task_full.cover_url = ""

        mock_db = MagicMock()

        # Step 3 fallback finds a valid preview task WITH cover_url
        mock_step3_task = MagicMock()
        mock_step3_task.id = "step3-task"
        mock_step3_task.status = "completed"
        mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg"

        mock_video = MagicMock()
        mock_video.file_url = "rendered/step3/video.mp4"

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
            patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = [mock_task_failed, mock_task_full]
            # Video step 3 and cover step C both use list_latest_completed_preview
            mock_repo.list_latest_completed_preview.return_value = [mock_step3_task]
            mock_repo_cls.return_value = mock_repo

            mock_usecase = MagicMock()
            mock_usecase.execute.return_value = [mock_video]
            mock_usecase_cls.return_value = mock_usecase

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"}
            }

            mock_storage_svc = MagicMock()
            mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4"
            mock_storage_getter.return_value = mock_storage_svc

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-1",
                plan_id="plan-y",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            # Step 2.5 found tasks but none matched -> step 3 should be called
            mock_repo.list_by_source_edit_plan.assert_called()
            mock_repo.list_latest_completed_preview.assert_called()
            assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg"

    def test_step25_exception_does_not_block_step3(self):
        """步骤2.5异常时不影响步骤3兜底（视频和封面都通过步骤3找到）。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        mock_step3_task = MagicMock()
        mock_step3_task.id = "step3-task"
        mock_step3_task.status = "completed"
        mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg"

        mock_video = MagicMock()
        mock_video.file_url = "rendered/step3/video.mp4"

        mock_db = MagicMock()

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
            patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            # Both video step 2.5 and cover step B raise
            mock_repo.list_by_source_edit_plan.side_effect = RuntimeError("db error")
            # Step 3 / step C succeeds
            mock_repo.list_latest_completed_preview.return_value = [mock_step3_task]
            mock_repo_cls.return_value = mock_repo

            mock_usecase = MagicMock()
            mock_usecase.execute.return_value = [mock_video]
            mock_usecase_cls.return_value = mock_usecase

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"}
            }

            mock_storage_svc = MagicMock()
            mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4"
            mock_storage_getter.return_value = mock_storage_svc

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-1",
                plan_id="plan-z",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            # Step 3 was called after step 2.5 failed
            mock_repo.list_latest_completed_preview.assert_called()
            assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg"


class TestStrayLoggerRemoved:
    """验证多余的 logger.info(plan_id, generation_task_id) 已被删除。"""

    def test_no_stray_logger_call_in_source(self):
        """源码中不应存在 logger.info(plan_id, generation_task_id) 这样的调用。"""
        import inspect

        from app.api.routes import generation_cover

        source = inspect.getsource(generation_cover)
        # The stray call was logger.info(\n plan_id,\n generation_task_id,\n)
        # with no format string — should not exist
        assert (
            "logger.info(\n                            plan_id," not in source
        ), "Stray logger.info(plan_id, generation_task_id) should be removed"


class TestUploadCoverType:
    """测试 cover_type=upload 封面上传功能。"""

    def test_upload_cover_saves_url_directly(self):
        """cover_type=upload 时直接保存 cover_url，不需要预览视频。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-upload"

        body = GenerateCoverRequest(
            cover_type="upload",
            cover_url="https://oss.example.com/uploaded/cover.jpg",
        )

        with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize:
            mock_normalize.return_value = {
                "cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="template-upload",
                plan_id="plan-upload",
                services=(mock_template_svc, mock_plan_svc),
                db=MagicMock(),
                current_user=mock_current_user,
            )

            assert result.plan_id == "plan-upload"
            assert result.cover["type"] == "upload"
            assert result.cover["image_url"] == "https://oss.example.com/uploaded/cover.jpg"

            # 验证 plan config 被更新
            mock_plan_svc.update_plan_config.assert_called_once()
            call_args = mock_plan_svc.update_plan_config.call_args
            assert call_args[0][0] == "plan-upload"
            assert call_args[0][1]["cover"]["type"] == "upload"

    def test_upload_cover_without_url_returns_400(self):
        """cover_type=upload 但未提供 cover_url 时返回 400。"""
        from unittest.mock import MagicMock

        from app.api.routes.generation_cover import GenerateCoverRequest
        from fastapi import HTTPException

        mock_plan = MagicMock()
        mock_plan.config = {}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        body = GenerateCoverRequest(cover_type="upload")
        # cover_url is None by default

        import pytest
        from app.api.routes.generation_cover import generate_cover

        with pytest.raises(HTTPException) as exc_info:
            generate_cover(
                body=body,
                template_id="template-upload",
                plan_id="plan-upload",
                services=(mock_template_svc, mock_plan_svc),
                db=MagicMock(),
                current_user=MagicMock(),
            )

        assert exc_info.value.status_code == 400
        assert "cover_url" in exc_info.value.detail

    def test_upload_cover_schema_has_cover_url_field(self):
        """GenerateCoverRequest schema 包含 cover_url 字段。"""
        from app.api.routes.generation_cover import GenerateCoverRequest

        req = GenerateCoverRequest(cover_type="upload", cover_url="https://example.com/img.jpg")
        assert req.cover_url == "https://example.com/img.jpg"
        assert req.cover_type == "upload"

        # 默认值为 None
        req2 = GenerateCoverRequest()
        assert req2.cover_url is None

    def test_upload_cover_does_not_require_preview_video(self):
        """cover_type=upload 时不查找预览视频，即使 plan.config 为空也不报错。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}  # 没有 rendered_storage_key

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        body = GenerateCoverRequest(
            cover_type="upload",
            cover_url="https://oss.example.com/uploaded/my-cover.png",
        )

        with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize:
            mock_normalize.return_value = {
                "cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/my-cover.png"}
            }

            from app.api.routes.generation_cover import generate_cover

            # 不应该抛出 "请先生成预览视频" 的异常
            result = generate_cover(
                body=body,
                template_id="template-1",
                plan_id="plan-no-preview",
                services=(mock_template_svc, mock_plan_svc),
                db=MagicMock(),
                current_user=MagicMock(),
            )

            assert result.cover["image_url"] == "https://oss.example.com/uploaded/my-cover.png"
            # 验证没有调用任何预览视频查找逻辑
            # (normalize_plan_config 是唯一被调用的外部函数)

    def test_cover_extracted_from_source_asset_when_no_preview(self):
        """步骤E2：无后端渲染产物时，直接从用户选择的视频素材抽帧。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}  # 无 rendered_storage_key，无 generation_task_id

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        # 模拟视频素材
        mock_asset = MagicMock()
        mock_asset.file_type = "video"
        mock_asset.storage_key = "uploads/source-clip.mp4"

        mock_asset_repo = MagicMock()
        mock_asset_repo.get.return_value = mock_asset

        mock_mk = MagicMock()
        mock_mk.is_available = True
        mock_mk.extract_frames.return_value = [{"image_url": "https://mediakit.internal/frame-abc.jpg"}]

        mock_storage = MagicMock()
        mock_storage.get_url.return_value = "https://oss.example.com/uploads/source-clip.mp4"

        body = GenerateCoverRequest(
            cover_type="ai_frame",
            asset_ids=["asset-video-1"],
        )

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
                return_value=mock_asset_repo,
            ),
            patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
            patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage),
            patch(
                "app.api.routes.generation_cover._persist_cover_frame",
                return_value="https://oss.example.com/covers/final.jpg",
            ),
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo
            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="tpl-source",
                plan_id="plan-source",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            assert result.cover["image_url"] == "https://oss.example.com/covers/final.jpg"
            mock_mk.extract_frames.assert_called_once()
            # 确保用的是源素材 URL
            call_kwargs = mock_mk.extract_frames.call_args.kwargs
            assert "source-clip.mp4" in call_kwargs["video_url"]

    def test_e2_passes_plan_title_to_persist_for_overlay(self):
        """步骤E2：plan.config.title.text 存在时，作为 title_text 传给 _persist_cover_frame 叠加标题。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {"title": {"enabled": True, "text": "我的视频标题"}}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        mock_asset = MagicMock()
        mock_asset.file_type = "video"
        mock_asset.storage_key = "uploads/src.mp4"
        mock_asset_repo = MagicMock()
        mock_asset_repo.get.return_value = mock_asset

        mock_mk = MagicMock()
        mock_mk.is_available = True
        mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]

        mock_storage = MagicMock()
        mock_storage.get_url.return_value = "https://oss.example.com/uploads/src.mp4"

        body = GenerateCoverRequest(cover_type="ai_frame", asset_ids=["a1"])

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
                return_value=mock_asset_repo,
            ),
            patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
            patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage),
            patch(
                "app.api.routes.generation_cover._persist_cover_frame",
                return_value="https://oss.example.com/covers/final.jpg",
            ) as mock_persist,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo
            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="tpl",
                plan_id="plan-title",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            assert result.cover["image_url"] == "https://oss.example.com/covers/final.jpg"
            # 标题文字必须透传给持久化函数（用于源素材帧叠加标题）
            assert mock_persist.call_args.kwargs.get("title_text") == "我的视频标题"

    def test_e2_passes_full_title_style_to_persist(self):
        """步骤E2：plan.config.title 包含完整样式时，color/position/font_size 都传给 _persist_cover_frame。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {
            "title": {
                "enabled": True,
                "text": "样式标题",
                "color": "#00ff00",
                "position": "top",
                "font_size": 42,
            }
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        mock_asset = MagicMock()
        mock_asset.file_type = "video"
        mock_asset.storage_key = "uploads/src.mp4"
        mock_asset_repo = MagicMock()
        mock_asset_repo.get.return_value = mock_asset

        mock_mk = MagicMock()
        mock_mk.is_available = True
        mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]

        mock_storage = MagicMock()
        mock_storage.get_url.return_value = "https://oss.example.com/uploads/src.mp4"

        body = GenerateCoverRequest(cover_type="ai_frame", asset_ids=["a1"])

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
                return_value=mock_asset_repo,
            ),
            patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
            patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage),
            patch(
                "app.api.routes.generation_cover._persist_cover_frame",
                return_value="https://oss.example.com/covers/styled.jpg",
            ) as mock_persist,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo
            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/styled.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="tpl",
                plan_id="plan-style",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            assert result.cover["image_url"] == "https://oss.example.com/covers/styled.jpg"
            kwargs = mock_persist.call_args.kwargs
            assert kwargs["title_text"] == "样式标题"
            assert kwargs["title_color"] == "#00ff00"
            assert kwargs["title_position"] == "top"
            assert kwargs["title_font_size"] == 42

    def test_e2_title_style_fallback_font_color(self):
        """步骤E2：前端传 font_color 时能正确兼容读取。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {
            "title": {
                "enabled": True,
                "text": "兼容标题",
                "font_color": "#123456",
                "position": "center",
            }
        }

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        mock_asset = MagicMock()
        mock_asset.file_type = "video"
        mock_asset.storage_key = "uploads/src.mp4"
        mock_asset_repo = MagicMock()
        mock_asset_repo.get.return_value = mock_asset

        mock_mk = MagicMock()
        mock_mk.is_available = True
        mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]

        mock_storage = MagicMock()
        mock_storage.get_url.return_value = "https://oss.example.com/uploads/src.mp4"

        body = GenerateCoverRequest(cover_type="ai_frame", asset_ids=["a1"])

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
                return_value=mock_asset_repo,
            ),
            patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
            patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage),
            patch(
                "app.api.routes.generation_cover._persist_cover_frame",
                return_value="https://oss.example.com/covers/compat.jpg",
            ) as mock_persist,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo
            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/compat.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            generate_cover(
                body=body,
                template_id="tpl",
                plan_id="plan-compat",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=MagicMock(),
            )

            kwargs = mock_persist.call_args.kwargs
            assert kwargs["title_color"] == "#123456"
            assert kwargs["title_position"] == "center"
            assert kwargs["title_font_size"] is None

    def test_step_e_skips_non_video_assets(self):
        """步骤E2：asset_ids 里只有图片素材时，不调用 MediaKit 并返回 400。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest
        from fastapi import HTTPException

        mock_plan = MagicMock()
        mock_plan.config = {}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()

        mock_image_asset = MagicMock()
        mock_image_asset.file_type = "image"
        mock_image_asset.storage_key = "uploads/photo.png"

        mock_asset_repo = MagicMock()
        mock_asset_repo.get.return_value = mock_image_asset

        mock_mk = MagicMock()
        mock_mk.is_available = True

        body = GenerateCoverRequest(
            cover_type="ai_frame",
            asset_ids=["asset-img-1"],
        )

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
                return_value=mock_asset_repo,
            ),
            patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo
            mock_storage_getter.return_value = MagicMock()

            from app.api.routes.generation_cover import generate_cover

            with pytest.raises(HTTPException) as exc_info:
                generate_cover(
                    body=body,
                    template_id="tpl-img",
                    plan_id="plan-img",
                    services=(mock_template_svc, mock_plan_svc),
                    db=mock_db,
                    current_user=MagicMock(),
                )

            assert exc_info.value.status_code == 400
            mock_mk.extract_frames.assert_not_called()


class TestCoverFromFinalVideo:
    """测试封面从最终成片任务(is_preview=False)获取视频源。"""

    def test_generated_video_fields_in_schema(self):
        """请求体支持 generated_video_id 和 video_url 字段。"""
        from app.api.routes.generation_cover import GenerateCoverRequest

        req = GenerateCoverRequest(
            generated_video_id="gv-001",
            video_url="https://example.com/final.mp4",
        )
        assert req.generated_video_id == "gv-001"
        assert req.video_url == "https://example.com/final.mp4"

        # 默认 None
        req_default = GenerateCoverRequest()
        assert req_default.generated_video_id is None
        assert req_default.video_url is None

    def test_cover_uses_final_video_when_generated_video_id_provided(self):
        """传 generated_video_id 时，从该最终成片视频抽帧。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        # Generated video
        mock_gv = MagicMock()
        mock_gv.file_url = "rendered/final/video.mp4"
        mock_gv.generation_task_id = "task-final-001"
        mock_gv.user_id = "user-1"

        # 最终成片任务
        mock_final_task = MagicMock()
        mock_final_task.id = "task-final-001"
        mock_final_task.created_by_user_id = "user-1"
        mock_final_task.cover_url = ""

        mock_gv_repo = MagicMock()
        mock_gv_repo.get.return_value = mock_gv

        mock_db = MagicMock()
        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-1"

        body = GenerateCoverRequest(
            cover_type="ai_frame",
            generated_video_id="gv-final-001",
        )

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "app.api.routes.generation_cover.get_generated_video_repository",
                return_value=mock_gv_repo,
            ),
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
            patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
            patch(
                "app.api.routes.generation_cover._persist_cover_frame",
                return_value="https://oss.example.com/covers/final-cover.jpg",
            ) as mock_persist,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = mock_final_task
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            mock_storage_svc = MagicMock()
            mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/final/video.mp4"
            mock_storage_svc.public_url = "https://oss.example.com"
            mock_storage_svc.endpoint = "oss.example.com"
            mock_storage_getter.return_value = mock_storage_svc

            mock_mk = MagicMock()
            mock_mk.is_available = True
            mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]
            mock_mk_getter.return_value = mock_mk

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final-cover.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            result = generate_cover(
                body=body,
                template_id="tpl-1",
                plan_id="plan-final",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=mock_current_user,
            )

            assert result.cover["image_url"] == "https://oss.example.com/covers/final-cover.jpg"
            call_kwargs = mock_mk.extract_frames.call_args.kwargs
            assert "rendered/final/video.mp4" in call_kwargs["video_url"]
            assert call_kwargs["strategy"] == "SpecifiedFrames"
            assert call_kwargs["max_frames"] == 1
            assert call_kwargs["max_retries"] == 0
            mock_persist.assert_called_once()

    def test_cover_uses_video_url_directly(self):
        """传 video_url 时，直接从该 URL 抽帧。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}
        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()
        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-1"

        body = GenerateCoverRequest(
            cover_type="ai_frame",
            video_url="https://oss.example.com/rendered/final/video.mp4",
        )

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
            patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
            patch(
                "app.api.routes.generation_cover._persist_cover_frame",
                return_value="https://oss.example.com/covers/c.jpg",
            ),
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            mock_storage_svc = MagicMock()
            mock_storage_svc.public_url = "https://oss.example.com"
            mock_storage_svc.endpoint = "oss.example.com"
            mock_storage_getter.return_value = mock_storage_svc

            mock_mk = MagicMock()
            mock_mk.is_available = True
            mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
            mock_mk_getter.return_value = mock_mk

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            generate_cover(
                body=body,
                template_id="tpl-1",
                plan_id="plan-url",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=mock_current_user,
            )

            call_kwargs = mock_mk.extract_frames.call_args.kwargs
            assert "rendered/final/video.mp4" in call_kwargs["video_url"]

    def test_cover_prefers_final_task_over_preview_in_source_plan(self):
        """步骤3：source_edit_plan 关联任务中，优先使用 is_preview=False 的最终成片。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}  # 无 rendered_storage_key / generation_task_id

        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        # 一个预览任务 + 一个最终成片任务
        mock_preview = MagicMock()
        mock_preview.id = "task-preview"
        mock_preview.status = "completed"
        mock_preview.is_preview = True
        mock_preview.cover_url = ""

        mock_final = MagicMock()
        mock_final.id = "task-final"
        mock_final.status = "completed"
        mock_final.is_preview = False
        mock_final.cover_url = ""

        mock_video_preview = MagicMock()
        mock_video_preview.file_url = "rendered/preview/video.mp4"
        mock_video_final = MagicMock()
        mock_video_final.file_url = "rendered/final/video.mp4"

        mock_db = MagicMock()
        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-1"

        body = GenerateCoverRequest(cover_type="ai_frame")

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_list_videos,
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
            patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
            patch(
                "app.api.routes.generation_cover._persist_cover_frame",
                return_value="https://oss.example.com/covers/c.jpg",
            ),
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            # list_by_source_edit_plan 返回 [preview, final]，最终成片排在后面
            mock_repo.list_by_source_edit_plan.return_value = [mock_preview, mock_final]
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            # 根据 task id 返回不同 video
            def get_videos(task_id):
                if task_id == "task-final":
                    return [mock_video_final]
                return [mock_video_preview]

            mock_use_case = MagicMock()
            mock_use_case.execute.side_effect = get_videos
            mock_list_videos.return_value = mock_use_case

            mock_storage_svc = MagicMock()
            mock_storage_svc.get_url.side_effect = lambda key: f"https://oss.example.com/{key}"
            mock_storage_svc.public_url = "https://oss.example.com"
            mock_storage_svc.endpoint = "oss.example.com"
            mock_storage_getter.return_value = mock_storage_svc

            mock_mk = MagicMock()
            mock_mk.is_available = True
            mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
            mock_mk_getter.return_value = mock_mk

            mock_normalize.return_value = {
                "cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
            }

            from app.api.routes.generation_cover import generate_cover

            generate_cover(
                body=body,
                template_id="tpl-1",
                plan_id="plan-priority",
                services=(mock_template_svc, mock_plan_svc),
                db=mock_db,
                current_user=mock_current_user,
            )

            # 应该使用 final video URL 抽帧，而非 preview
            call_kwargs = mock_mk.extract_frames.call_args.kwargs
            assert "rendered/final/video.mp4" in call_kwargs["video_url"]
            assert "rendered/preview" not in call_kwargs["video_url"]

    def test_cover_generated_video_permission_denied(self):
        """generated_video_id 关联任务属于其他用户时，返回 403。"""
        from unittest.mock import MagicMock, patch

        import pytest
        from app.api.routes.generation_cover import GenerateCoverRequest
        from fastapi import HTTPException

        mock_plan = MagicMock()
        mock_plan.config = {}
        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        mock_gv = MagicMock()
        mock_gv.file_url = "rendered/other/video.mp4"
        mock_gv.generation_task_id = "task-other"
        mock_gv.user_id = ""  # 老数据无 user_id，走关联任务归属校验

        mock_other_task = MagicMock()
        mock_other_task.created_by_user_id = "other-user"

        mock_gv_repo = MagicMock()
        mock_gv_repo.get.return_value = mock_gv

        mock_db = MagicMock()
        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-1"

        body = GenerateCoverRequest(
            cover_type="ai_frame",
            generated_video_id="gv-other",
        )

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "app.api.routes.generation_cover.get_generated_video_repository",
                return_value=mock_gv_repo,
            ),
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = mock_other_task
            mock_repo_cls.return_value = mock_repo

            from app.api.routes.generation_cover import generate_cover

            with pytest.raises(HTTPException) as exc_info:
                generate_cover(
                    body=body,
                    template_id="tpl-1",
                    plan_id="plan-perm",
                    services=(mock_template_svc, mock_plan_svc),
                    db=mock_db,
                    current_user=mock_current_user,
                )
            assert exc_info.value.status_code == 403

    def test_cover_video_url_ssrf_blocked(self):
        """video_url 指向内网/非白名单域名时被忽略，不向其发起抽帧请求。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import GenerateCoverRequest

        mock_plan = MagicMock()
        mock_plan.config = {}
        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()
        mock_db = MagicMock()
        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-1"

        # SSRF 攻击载荷：内网元数据地址
        body = GenerateCoverRequest(
            cover_type="ai_frame",
            video_url="http://100.100.100.200/latest/meta-data/",
        )

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
            patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
            patch("app.api.routes.generation_cover._persist_cover_frame") as mock_persist,
            patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None
            mock_repo.list_by_source_edit_plan.return_value = []
            mock_repo.list_latest_completed_preview.return_value = []
            mock_repo_cls.return_value = mock_repo

            mock_storage_svc = MagicMock()
            mock_storage_svc.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
            mock_storage_svc.endpoint = "oss-cn-hangzhou.aliyuncs.com"
            mock_storage_svc.get_url.side_effect = lambda k: f"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/{k}"
            mock_storage_getter.return_value = mock_storage_svc

            mock_mk = MagicMock()
            mock_mk.is_available = True
            mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
            mock_mk_getter.return_value = mock_mk

            mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": "https://mk/f.jpg"}}

            from app.api.routes.generation_cover import generate_cover
            from fastapi import HTTPException

            # 内网 URL 被白名单拦截后，无任何可用视频源 → 400（而不是向内网发请求）
            with pytest.raises(HTTPException) as exc_info:
                generate_cover(
                    body=body,
                    template_id="tpl-1",
                    plan_id="plan-ssrf",
                    services=(mock_template_svc, mock_plan_svc),
                    db=mock_db,
                    current_user=mock_current_user,
                )
            assert exc_info.value.status_code == 400
            # MediaKit 从未被要求抽帧该内网地址
            if mock_mk.extract_frames.called:
                called_url = mock_mk.extract_frames.call_args.kwargs.get("video_url", "")
                assert "100.100.100.200" not in called_url
                assert "meta-data" not in called_url

    def test_cover_generated_video_ownership_unverifiable_denied(self):
        """video 无 user_id 且关联任务不存在时，归属无法确认 → 403（防权限绕过）。"""
        from unittest.mock import MagicMock, patch

        import pytest
        from app.api.routes.generation_cover import GenerateCoverRequest
        from fastapi import HTTPException

        mock_plan = MagicMock()
        mock_plan.config = {}
        mock_plan_svc = MagicMock()
        mock_plan_svc.get_plan_or_raise.return_value = mock_plan
        mock_template_svc = MagicMock()

        mock_gv = MagicMock()
        mock_gv.file_url = "rendered/mystery/video.mp4"
        mock_gv.generation_task_id = "task-gone"  # 关联任务已删除
        mock_gv.user_id = ""  # 老数据无 owner

        mock_gv_repo = MagicMock()
        mock_gv_repo.get.return_value = mock_gv

        mock_db = MagicMock()
        mock_current_user = MagicMock()
        mock_current_user.user.id = "user-1"

        body = GenerateCoverRequest(
            cover_type="ai_frame",
            generated_video_id="gv-mystery",
        )

        with (
            patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
            patch(
                "app.api.routes.generation_cover.get_generated_video_repository",
                return_value=mock_gv_repo,
            ),
        ):
            mock_repo = MagicMock()
            mock_repo.get.return_value = None  # 关联任务查不到
            mock_repo_cls.return_value = mock_repo

            from app.api.routes.generation_cover import generate_cover

            with pytest.raises(HTTPException) as exc_info:
                generate_cover(
                    body=body,
                    template_id="tpl-1",
                    plan_id="plan-orphan",
                    services=(mock_template_svc, mock_plan_svc),
                    db=mock_db,
                    current_user=mock_current_user,
                )
            assert exc_info.value.status_code == 403

    def test_is_trusted_media_url_blocks_internal_and_ipv6(self):
        """白名单函数：内网 IPv4/IPv6/元数据地址一律拒绝，自家 OSS 域名放行。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import _is_trusted_media_url

        mock_storage = MagicMock()
        mock_storage.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
        mock_storage.endpoint = "oss-cn-hangzhou.aliyuncs.com"

        with patch(
            "app.api.routes.generation_cover.get_shared_storage_service",
            return_value=mock_storage,
        ):
            # 内网 / 元数据 / IPv6 本地地址全部拒绝
            for bad in [
                "http://127.0.0.1/admin",
                "http://10.0.0.5/video.mp4",
                "http://192.168.1.1/video.mp4",
                "http://172.16.0.1/video.mp4",
                "http://169.254.169.254/latest/meta-data/",
                "http://[::1]:8080/video.mp4",
                "http://[fe80::1]/video.mp4",
                "http://[fc00::1]/video.mp4",
                "http://localhost/x",
                "ftp://oss-cn-hangzhou.aliyuncs.com/a.mp4",
                "",
            ]:
                assert _is_trusted_media_url(bad) is False, f"应拒绝: {bad}"

            # 自家 OSS 域名（含签名 URL 子路径、bucket 域名）放行
            for good in [
                "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/final/v.mp4",
                "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/v.mp4?Expires=123&Signature=abc",
            ]:
                assert _is_trusted_media_url(good) is True, f"应放行: {good}"

    def test_is_trusted_media_url_endpoint_with_scheme_parsed(self):
        """endpoint 配置带 http:// 前缀时也能正确提取主机名，不出现 .http 后缀绕过。"""
        from unittest.mock import MagicMock, patch

        from app.api.routes.generation_cover import _is_trusted_media_url

        mock_storage = MagicMock()
        mock_storage.public_url = "http://oss.internal.example.com:9000"
        mock_storage.endpoint = "http://oss.internal.example.com:9000"

        with patch(
            "app.api.routes.generation_cover.get_shared_storage_service",
            return_value=mock_storage,
        ):
            # 正确域名放行
            assert _is_trusted_media_url("http://oss.internal.example.com:9000/a/b.mp4") is True
            # 伪造后缀域名必须拒绝（修复前 split(':')[0] 会取到 'http' 导致绕过）
            assert _is_trusted_media_url("http://evil-http.com/x.mp4") is False
            assert _is_trusted_media_url("http://evil.http/x.mp4") is False
