"""片段管理路由 clips.py 增量覆盖率测试.

覆盖 PR fix/clips-api-response-structure 新增代码:
- _clip_to_response: 枚举转换、日期格式化、asset_url 参数
- _build_asset_url_map: 批量素材 URL 解析（空列表/异常/正常路径）
- 路由层 asset_repo 注入与 URL 拼接逻辑
"""

from __future__ import annotations

import os
import sys
from enum import Enum
from pathlib import Path
from unittest.mock import MagicMock, patch

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

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

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

# ---------------------------------------------------------------------------
# 常量与工厂
# ---------------------------------------------------------------------------

TEST_TEMPLATE_ID = "tmpl-test-001"
TEST_PLAN_ID = "plan-draft-001"
TEST_USER_ID = "user-001"


def _auth_user():
    u = MagicMock()
    u.user.id = TEST_USER_ID
    u.user_id = TEST_USER_ID
    return u


def _clip(**overrides):
    """构造 mock clip，支持 Enum 类型字段"""
    c = MagicMock()
    c.id = overrides.get("id", "clip-001")
    c.plan_id = overrides.get("plan_id", TEST_PLAN_ID)
    c.clip_type = overrides.get("clip_type", "video")
    c.order = overrides.get("order", 0)
    c.duration = overrides.get("duration", 10.0)
    c.start_time = overrides.get("start_time", 0.0)
    c.text_content = overrides.get("text_content", "")
    c.transition_effect = overrides.get("transition_effect", "cut")
    c.transition_duration = overrides.get("transition_duration", 0.0)
    c.playback_speed = overrides.get("playback_speed", 1.0)
    c.asset_id = overrides.get("asset_id", "")
    c.status = overrides.get("status", "ready")
    c.template_clip_config_id = overrides.get("template_clip_config_id", "")
    c.config = overrides.get("config", {})
    c.created_at = overrides.get("created_at", None)
    c.updated_at = overrides.get("updated_at", None)
    return c


def _services(plan_svc_overrides=None):
    tpl = MagicMock()
    plan = MagicMock()
    if plan_svc_overrides:
        for k, v in plan_svc_overrides.items():
            setattr(plan, k, v)
    return tpl, plan


# ---------------------------------------------------------------------------
# 单元测试: _clip_to_response
# ---------------------------------------------------------------------------


class TestClipToResponse:
    """_clip_to_response 纯函数测试 — 覆盖行 53-80"""

    def test_basic_fields(self):
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip(id="c1", order=3, duration=5.5, text_content="hello")
        resp = _clip_to_response(c)
        assert resp.id == "c1"
        assert resp.order == 3
        assert resp.duration == 5.5
        assert resp.text_content == "hello"
        assert resp.asset_url is None

    def test_enum_clip_type(self):
        """Enum 值应被 .value 解包"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        class ClipType(str, Enum):
            VIDEO = "video"
            AUDIO = "audio"

        c = _clip(clip_type=ClipType.VIDEO)
        resp = _clip_to_response(c)
        assert resp.clip_type == "video"

    def test_plain_string_clip_type(self):
        """非 Enum 字符串直接用 str()"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip(clip_type="main")
        resp = _clip_to_response(c)
        assert resp.clip_type == "main"

    def test_enum_transition_effect(self):
        from app.api.routes.templates_editor.clips import _clip_to_response

        class Transition(str, Enum):
            FADE = "fade"

        c = _clip(transition_effect=Transition.FADE)
        resp = _clip_to_response(c)
        assert resp.transition_effect == "fade"

    def test_default_transition_when_none(self):
        """transition_effect 缺失时默认 cut"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip()
        del c.transition_effect  # 触发 getattr default
        resp = _clip_to_response(c)
        assert resp.transition_effect == "cut"

    def test_asset_url_passed(self):
        """asset_url 参数应透传到响应"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip(asset_id="a1")
        resp = _clip_to_response(c, asset_url="https://signed-url.example.com/video.mp4")
        assert resp.asset_url == "https://signed-url.example.com/video.mp4"

    def test_asset_url_none_by_default(self):
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip()
        resp = _clip_to_response(c)
        assert resp.asset_url is None

    def test_datetime_isoformat(self):
        """datetime 对象应被 isoformat()"""
        from datetime import datetime

        from app.api.routes.templates_editor.clips import _clip_to_response

        dt = datetime(2026, 8, 17, 12, 0, 0)
        c = _clip(created_at=dt, updated_at=dt)
        resp = _clip_to_response(c)
        assert "2026-08-17" in resp.created_at
        assert "2026-08-17" in resp.updated_at

    def test_none_datetime_empty_string(self):
        """None 日期应格式化为空字符串"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip(created_at=None, updated_at=None)
        resp = _clip_to_response(c)
        assert resp.created_at == ""
        assert resp.updated_at == ""

    def test_string_datetime_passthrough(self):
        """已经是字符串的日期直接 str()"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip(created_at="2026-08-17T00:00:00")
        resp = _clip_to_response(c)
        assert resp.created_at == "2026-08-17T00:00:00"

    def test_none_defaults_for_optional_fields(self):
        """None/缺失字段的默认值"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip(asset_id=None, status=None, template_clip_config_id=None)
        resp = _clip_to_response(c)
        assert resp.asset_id == ""
        assert resp.status == "pending"
        assert resp.template_clip_config_id == ""

    def test_zero_duration_fallback(self):
        """duration=0 → playback_speed 默认 1.0"""
        from app.api.routes.templates_editor.clips import _clip_to_response

        c = _clip(playback_speed=None)
        resp = _clip_to_response(c)
        assert resp.playback_speed == 1.0


# ---------------------------------------------------------------------------
# 单元测试: _build_asset_url_map
# ---------------------------------------------------------------------------


class TestBuildAssetUrlMap:
    """_build_asset_url_map 测试 — 覆盖行 93-118"""

    def test_empty_list(self):
        """空 asset_ids 直接返回空 dict"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        repo = MagicMock()
        result = _build_asset_url_map([], repo)
        assert result == {}

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_storage_service_failure(self, mock_get_storage):
        """存储服务获取失败时返回全 None"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        mock_get_storage.side_effect = RuntimeError("storage unavailable")
        repo = MagicMock()
        result = _build_asset_url_map(["a1", "a2"], repo)
        assert result == {"a1": None, "a2": None}

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_asset_not_found(self, mock_get_storage):
        """asset_id 找不到对应素材 → None"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        storage = MagicMock()
        mock_get_storage.return_value = storage
        repo = MagicMock()
        repo.find_by_ids.return_value = []

        result = _build_asset_url_map(["missing-id"], repo)
        assert result == {"missing-id": None}

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_no_storage_key(self, mock_get_storage):
        """素材没有 storage_key → None"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        storage = MagicMock()
        mock_get_storage.return_value = storage
        repo = MagicMock()
        asset = MagicMock()
        asset.id = "a1"
        asset.storage_key = ""
        repo.find_by_ids.return_value = [asset]

        result = _build_asset_url_map(["a1"], repo)
        assert result == {"a1": None}

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_successful_url_generation(self, mock_get_storage):
        """正常路径：返回签名 URL"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        storage = MagicMock()
        storage.get_download_url.return_value = "https://cdn.example.com/signed.mp4"
        mock_get_storage.return_value = storage
        repo = MagicMock()
        asset = MagicMock()
        asset.id = "a1"
        asset.storage_key = "videos/test.mp4"
        repo.find_by_ids.return_value = [asset]

        result = _build_asset_url_map(["a1"], repo)
        assert result == {"a1": "https://cdn.example.com/signed.mp4"}
        storage.get_download_url.assert_called_once_with("videos/test.mp4", expires_seconds=3600)

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_exception_during_url_generation(self, mock_get_storage):
        """单个 asset 生成 URL 异常 → None，不影响其他"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        storage = MagicMock()
        storage.get_download_url.side_effect = [Exception("boom"), "https://ok.com/v2"]
        mock_get_storage.return_value = storage

        repo = MagicMock()
        asset1 = MagicMock()
        asset1.id = "a1"
        asset1.storage_key = "v1.mp4"
        asset2 = MagicMock()
        asset2.id = "a2"
        asset2.storage_key = "v2.mp4"
        repo.find_by_ids.return_value = [asset1, asset2]

        result = _build_asset_url_map(["a1", "a2"], repo)
        assert result["a1"] is None
        assert result["a2"] == "https://ok.com/v2"

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_skip_empty_asset_id(self, mock_get_storage):
        """空字符串 asset_id 被跳过"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        storage = MagicMock()
        mock_get_storage.return_value = storage
        repo = MagicMock()

        result = _build_asset_url_map(["", "a1"], repo)
        # "" not in result because it's skipped by `if not aid: continue`
        assert "" not in result

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_multiple_assets_mixed(self, mock_get_storage):
        """混合场景：正常+异常+缺失"""
        from app.api.routes.templates_editor.clips import _build_asset_url_map

        storage = MagicMock()
        storage.get_download_url.return_value = "https://cdn.com/ok.mp4"
        mock_get_storage.return_value = storage

        repo = MagicMock()
        good_asset = MagicMock()
        good_asset.id = "a1"
        good_asset.storage_key = "good.mp4"
        # a1=good, a2=not found, a3=good
        good_asset2 = MagicMock()
        good_asset2.id = "a3"
        good_asset2.storage_key = "good.mp4"
        repo.find_by_ids.return_value = [good_asset, good_asset2]

        result = _build_asset_url_map(["a1", "a2", "a3"], repo)
        assert result["a1"] == "https://cdn.com/ok.mp4"
        assert result["a2"] is None
        assert result["a3"] == "https://cdn.com/ok.mp4"


# ---------------------------------------------------------------------------
# 集成测试: 路由层 asset_repo 注入
# ---------------------------------------------------------------------------


class TestClipRoutesAssetIntegration:
    """路由层测试 — 覆盖 asset_url 在 list/detail/split/merge 中的拼接逻辑"""

    def _create_app(self, plan_svc_config=None):
        from app.api.routes import templates_editor as editor_module
        from app.dependencies import get_asset_repository

        mock_clip_1 = _clip(id="c1", asset_id="asset-001")
        mock_clip_2 = _clip(id="c2", asset_id="")

        mock_tpl_svc = MagicMock()
        mock_plan_svc = MagicMock()
        mock_plan_svc.list_clips.return_value = [mock_clip_1, mock_clip_2]
        mock_plan_svc.count_clips.return_value = 2
        mock_plan_svc.get_clip.return_value = mock_clip_1
        mock_plan_svc.create_clip.return_value = _clip(id="c-new", asset_id="")
        mock_plan_svc.update_clip.return_value = _clip(id="c1", duration=15.0)
        mock_plan_svc.delete_clip.return_value = True
        mock_plan_svc.split_clip.return_value = {
            "left_clip": _clip(id="c-left", asset_id="asset-L"),
            "right_clip": _clip(id="c-right", asset_id="asset-R"),
        }
        mock_plan_svc.merge_clips.return_value = _clip(id="c-merged", asset_id="asset-M")

        if plan_svc_config:
            for k, v in plan_svc_config.items():
                setattr(mock_plan_svc, k, v)

        def _deps():
            return mock_tpl_svc, mock_plan_svc

        mock_asset_repo = MagicMock()

        app = FastAPI()
        app.include_router(
            editor_module.router,
            prefix="/api/v1/templates/{template_id}/editor",
        )
        app.dependency_overrides[editor_module.get_current_user] = _auth_user
        app.dependency_overrides[editor_module.get_draft_plan_id] = lambda: TEST_PLAN_ID
        app.dependency_overrides[editor_module.get_editor_services] = _deps
        app.dependency_overrides[get_asset_repository] = lambda: mock_asset_repo

        return TestClient(app), mock_plan_svc, mock_asset_repo

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_list_clips_includes_asset_urls(self, mock_get_storage):
        """GET /clips 应为有 asset_id 的片段返回签名 URL"""
        storage = MagicMock()
        storage.get_download_url.return_value = "https://cdn.com/c1.mp4"
        mock_get_storage.return_value = storage

        client, _, asset_repo = self._create_app()
        asset = MagicMock()
        asset.id = "asset-001"
        asset.storage_key = "videos/c1.mp4"
        asset_repo.find_by_ids.return_value = [asset]

        resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips")
        assert resp.status_code == 200
        data = resp.json()
        items = data["items"]
        assert len(items) == 2
        # c1 has asset_id → should have url
        assert items[0]["asset_url"] == "https://cdn.com/c1.mp4"
        # c2 has empty asset_id → None
        assert items[1]["asset_url"] is None

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_get_clip_detail_with_asset_url(self, mock_get_storage):
        """GET /clips/{clip_id} 应返回素材签名 URL"""
        storage = MagicMock()
        storage.get_download_url.return_value = "https://cdn.com/detail.mp4"
        mock_get_storage.return_value = storage

        client, _, asset_repo = self._create_app()
        asset = MagicMock()
        asset.id = "asset-001"
        asset.storage_key = "videos/detail.mp4"
        asset_repo.find_by_ids.return_value = [asset]

        resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
        assert resp.status_code == 200
        assert resp.json()["asset_url"] == "https://cdn.com/detail.mp4"

    def test_get_clip_detail_no_asset(self):
        """片段没有 asset_id 时不应调用 URL 解析"""
        client, plan_svc, asset_repo = self._create_app()
        # 返回没有 asset_id 的片段
        plan_svc.get_clip.return_value = _clip(id="c-no-asset", asset_id="")

        resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/c-no-asset")
        assert resp.status_code == 200
        assert resp.json()["asset_url"] is None

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_split_clip_returns_asset_urls(self, mock_get_storage):
        """POST /clips/{clip_id}/split 返回的左右片段应带签名 URL"""
        storage = MagicMock()
        storage.get_download_url.side_effect = ["https://cdn.com/L.mp4", "https://cdn.com/R.mp4"]
        mock_get_storage.return_value = storage

        client, _, asset_repo = self._create_app()
        asset_l = MagicMock()
        asset_l.storage_key = "videos/L.mp4"
        asset_r = MagicMock()
        asset_r.storage_key = "videos/R.mp4"
        asset_l.id = "asset-L"
        asset_r.id = "asset-R"
        asset_repo.find_by_ids.return_value = [asset_l, asset_r]

        resp = client.post(
            f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001/split",
            json={"split_time": 5.0},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["left_clip"]["asset_url"] == "https://cdn.com/L.mp4"
        assert data["right_clip"]["asset_url"] == "https://cdn.com/R.mp4"

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_merge_clips_returns_asset_url(self, mock_get_storage):
        """POST /clips/merge 返回的合并片段应带签名 URL"""
        storage = MagicMock()
        storage.get_download_url.return_value = "https://cdn.com/M.mp4"
        mock_get_storage.return_value = storage

        client, _, asset_repo = self._create_app()
        asset = MagicMock()
        asset.id = "asset-M"
        asset.storage_key = "videos/M.mp4"
        asset_repo.find_by_ids.return_value = [asset]

        resp = client.post(
            f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
            json={"clip_ids": ["c1", "c2"]},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["merged_clip"]["asset_url"] == "https://cdn.com/M.mp4"
        assert data["deleted_clip_ids"] == ["c1", "c2"]

    def test_merge_clips_not_found(self):
        """merge 时某片段不存在应返回 404"""
        client, plan_svc, _ = self._create_app()
        plan_svc.get_clip.return_value = None

        resp = client.post(
            f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
            json={"clip_ids": ["nonexistent-1", "nonexistent-2"]},
        )
        assert resp.status_code == 404

    def test_delete_clip_success(self):
        """DELETE /clips/{clip_id} 成功返回 204"""
        client, _, _ = self._create_app()
        resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
        assert resp.status_code == 204

    def test_delete_clip_not_found(self):
        """DELETE 片段不存在返回 404"""
        client, plan_svc, _ = self._create_app()
        plan_svc.delete_clip.return_value = False
        resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/bad-id")
        assert resp.status_code == 404
