"""shared.ai_service 单元测试.

主要测试纯逻辑部分：_parse_recommend_response / _fallback_recommend_clips / _call_ai_cover_service.
"""

from __future__ import annotations

import json
from unittest.mock import patch

import pytest
from shared.ai_service import (
    _call_ai_cover_service,
    _fallback_recommend_clips,
    _parse_recommend_response,
)

# ── _parse_recommend_response 测试 ────────────────────────────────────────


class TestParseRecommendResponseBasic:
    """基础解析测试."""

    def test_parse_valid_json(self):
        content = json.dumps(
            {
                "clips": [
                    {
                        "clip_type": "intro",
                        "order": 0,
                        "text_content": "开场",
                        "duration": 3.0,
                        "transition_effect": "fade",
                        "asset_id": "asset1",
                        "start_time": 0.0,
                        "config": {},
                    },
                    {
                        "clip_type": "outro",
                        "order": 1,
                        "text_content": "结尾",
                        "duration": 2.0,
                        "transition_effect": "fade",
                        "asset_id": "",
                        "start_time": 0.0,
                        "config": {},
                    },
                ],
                "title": "测试视频",
                "confidence": 0.85,
            }
        )
        result = _parse_recommend_response(content, ["asset1"], 30.0)
        assert result is not None
        assert len(result["clips"]) == 2
        assert result["confidence"] == 0.85
        assert result["total_duration"] == 5.0
        assert result["config"]["title"]["text"] == "测试视频"
        assert result["config"]["title"]["ai_auto"] is True

    def test_parse_none_returns_none(self):
        result = _parse_recommend_response(None, ["a1"], 30.0)  # type: ignore[arg-type]
        assert result is None

    def test_parse_empty_string_returns_none(self):
        result = _parse_recommend_response("", ["a1"], 30.0)
        assert result is None

    def test_parse_whitespace_only_returns_none(self):
        result = _parse_recommend_response("   ", ["a1"], 30.0)
        assert result is None

    def test_parse_invalid_json_returns_none(self):
        result = _parse_recommend_response("not json", ["a1"], 30.0)
        assert result is None

    def test_parse_non_dict_json_returns_none(self):
        result = _parse_recommend_response("[1, 2, 3]", ["a1"], 30.0)
        assert result is None


class TestParseRecommendResponseClips:
    """clips 解析测试."""

    def test_parse_no_clips_returns_none(self):
        content = json.dumps({"title": "test", "clips": []})
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is None

    def test_parse_clips_not_list_returns_none(self):
        content = json.dumps({"clips": "not a list"})
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is None

    def test_parse_clips_sorted_by_order(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
                    {"clip_type": "intro", "order": 0, "duration": 3, "asset_id": "a1"},
                    {"clip_type": "showcase", "order": 1, "duration": 5, "asset_id": "a1"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert len(result["clips"]) == 3
        assert result["clips"][0]["clip_type"] == "intro"
        assert result["clips"][1]["clip_type"] == "showcase"
        assert result["clips"][2]["clip_type"] == "outro"

    def test_parse_clips_renumbered_continuously(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 10, "duration": 2, "asset_id": "a1"},
                    {"clip_type": "outro", "order": 20, "duration": 2, "asset_id": "a1"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["clips"][0]["order"] == 0
        assert result["clips"][1]["order"] == 1

    def test_parse_skips_invalid_clip_dicts(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"},
                    "not a dict",
                    {"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert len(result["clips"]) == 2


class TestParseRecommendResponseFields:
    """各字段解析与边界测试."""

    def test_parse_duration_clamped_min(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 0, "duration": 0.5, "asset_id": "a1"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["clips"][0]["duration"] == 1.0

    def test_parse_duration_clamped_max(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 0, "duration": 100, "asset_id": "a1"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["clips"][0]["duration"] == 30.0

    def test_parse_start_time_clamped_min(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1", "start_time": -5.0},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["clips"][0]["start_time"] == 0.0

    def test_parse_asset_id_not_in_list_empty(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "unknown_asset"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
        assert result is not None
        assert result["clips"][0]["asset_id"] == ""

    def test_parse_asset_id_in_list_kept(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a2"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
        assert result is not None
        assert result["clips"][0]["asset_id"] == "a2"

    def test_parse_default_values(self):
        content = json.dumps(
            {
                "clips": [
                    {"order": 0},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        clip = result["clips"][0]
        assert clip["clip_type"] == "showcase"
        assert clip["text_content"] == ""
        assert clip["duration"] == 3.0
        assert clip["transition_effect"] == "cut"
        assert clip["asset_id"] == ""
        assert clip["start_time"] == 0.0
        assert clip["config"] == {}


class TestParseRecommendResponseMarkdown:
    """Markdown 代码块包裹的 JSON 测试."""

    def test_parse_markdown_json(self):
        content = (
            "```json\n"
            + json.dumps(
                {
                    "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
                    "title": "md test",
                }
            )
            + "\n```"
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert len(result["clips"]) == 1
        assert result["config"]["title"]["text"] == "md test"

    def test_parse_backticks_no_language(self):
        content = (
            "```\n"
            + json.dumps(
                {
                    "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
                }
            )
            + "\n```"
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert len(result["clips"]) == 1


class TestParseRecommendResponseConfidence:
    """confidence 解析测试."""

    def test_parse_confidence_normal(self):
        content = json.dumps(
            {
                "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
                "confidence": 0.85,
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["confidence"] == 0.85

    def test_parse_confidence_clamped_min(self):
        content = json.dumps(
            {
                "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
                "confidence": -0.5,
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["confidence"] == 0.0

    def test_parse_confidence_clamped_max(self):
        content = json.dumps(
            {
                "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
                "confidence": 1.5,
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["confidence"] == 1.0

    def test_parse_confidence_default(self):
        content = json.dumps(
            {
                "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["confidence"] == 0.7


class TestParseRecommendResponseConfig:
    """config 生成测试."""

    def test_parse_no_title_no_ai_auto(self):
        content = json.dumps(
            {
                "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        # 没有 title 时，config 的 title.text 保持默认（DEFAULT_EDIT_PLAN_CONFIG 中的值）
        assert "title" in result["config"]

    def test_parse_config_is_deep_copy(self):
        content = json.dumps(
            {
                "clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
                "title": "test",
            }
        )
        result1 = _parse_recommend_response(content, ["a1"], 30.0)
        result2 = _parse_recommend_response(content, ["a1"], 30.0)
        # 修改其中一个不影响另一个
        result1["config"]["title"]["text"] = "modified"
        assert result2["config"]["title"]["text"] != "modified"


class TestParseRecommendResponseTotalDuration:
    """total_duration 计算测试."""

    def test_parse_total_duration_sum(self):
        content = json.dumps(
            {
                "clips": [
                    {"clip_type": "intro", "order": 0, "duration": 3.5, "asset_id": "a1"},
                    {"clip_type": "showcase", "order": 1, "duration": 5.2, "asset_id": "a1"},
                    {"clip_type": "outro", "order": 2, "duration": 2.0, "asset_id": "a1"},
                ],
            }
        )
        result = _parse_recommend_response(content, ["a1"], 30.0)
        assert result is not None
        assert result["total_duration"] == pytest.approx(10.7, abs=0.01)


# ── _fallback_recommend_clips 测试 ────────────────────────────────────────


class TestFallbackRecommendClips:
    """本地降级推荐方案测试."""

    def test_fallback_returns_dict_with_clips(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
        assert "clips" in result
        assert "config" in result
        assert "total_duration" in result
        assert "confidence" in result

    def test_fallback_has_intro_and_outro(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
        clips = result["clips"]
        assert clips[0]["clip_type"] == "intro"
        assert clips[-1]["clip_type"] == "outro"

    def test_fallback_showcase_count_matches_assets(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
        showcase_clips = [c for c in result["clips"] if c["clip_type"] == "showcase"]
        assert len(showcase_clips) == 3

    def test_fallback_no_assets_still_works(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", [], "one_take", 30.0)
        assert len(result["clips"]) >= 2  # 至少有intro和outro

    def test_fallback_intro_uses_first_asset(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
        assert result["clips"][0]["asset_id"] == "a1"

    def test_fallback_outro_has_empty_asset(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
        assert result["clips"][-1]["asset_id"] == ""

    def test_fallback_confidence_in_range(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
        assert 0.75 <= result["confidence"] <= 0.95

    def test_fallback_title_contains_asset_count(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
        assert "3" in result["config"]["title"]["text"]
        assert result["config"]["title"]["ai_auto"] is True

    def test_fallback_total_duration_matches(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
        total = sum(c["duration"] for c in result["clips"])
        assert result["total_duration"] == round(total, 1)

    def test_fallback_orders_are_sequential(self):
        with patch("shared.ai_service.time.sleep"):
            result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
        orders = [c["order"] for c in result["clips"]]
        assert orders == list(range(len(result["clips"])))


# ── _call_ai_cover_service 测试 ───────────────────────────────────────────


class TestAiCoverService:
    """AI封面生成服务测试."""

    def test_cover_type_upload(self):
        with patch("shared.ai_service.time.sleep"):
            result = _call_ai_cover_service("plan1", ["a1"], "upload")
        assert result["type"] == "upload"
        assert result["image_url"] == ""

    def test_cover_type_manual_with_frame_time(self):
        with patch("shared.ai_service.time.sleep"):
            result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=5.5)
        assert result["type"] == "manual"
        assert result["frame_time"] == 5.5
        assert result["image_url"].startswith("data:image/svg+xml,")
        assert "手动选帧" in result["image_url"]

    def test_cover_type_ai_frame_raises_without_mediakit(self):
        """ai_frame mode raises RuntimeError when MediaKit is unavailable."""
        with pytest.raises(RuntimeError, match="封面数据不可用"):
            _call_ai_cover_service("plan1", ["a1"], "ai_frame")

    def test_cover_type_ai_regenerate_raises_without_mediakit(self):
        """ai_regenerate mode raises RuntimeError when MediaKit is unavailable."""
        with pytest.raises(RuntimeError, match="封面数据不可用"):
            _call_ai_cover_service("plan1", ["a1"], "ai_regenerate")

    def test_cover_type_manual_still_works(self):
        """manual mode does not require MediaKit and still returns stub."""
        with patch("shared.ai_service.time.sleep"):
            result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=5.5)
        assert result["type"] == "manual"
        assert result["frame_time"] == 5.5

    def test_manual_stub_returns_svg_data_uri(self):
        """manual 模式返回 SVG data URI 占位图."""
        with patch("shared.ai_service.time.sleep"):
            result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=3.0)
        assert result["image_url"].startswith("data:image/svg+xml,")
        assert "/api/v1/" not in result["image_url"]
        assert "手动选帧" in result["image_url"]
