"""模板片段转换器单测.

纯函数模块，覆盖：枚举安全解析、config过滤、
clip→template转换、snapshot双向转换、名称校验。
"""

from __future__ import annotations

from dataclasses import dataclass

from packages.domain.template_clip_config import (
    ClipType,
    TemplateClipConfig,
    TransitionEffect,
)
from packages.domain.template_clip_converter import (
    clip_config_to_snapshot,
    clip_configs_to_snapshots,
    clip_to_template_clip_config,
    clips_to_template_clip_configs,
    filter_clip_config,
    filter_plan_config_to_template,
    safe_parse_clip_type,
    safe_parse_transition_effect,
    snapshot_to_template_clip_config,
    snapshots_to_template_clip_configs,
    validate_template_name,
)


class TestSafeParseTransitionEffect:
    def test_enum_passthrough(self):
        result = safe_parse_transition_effect(TransitionEffect.FADE)
        assert result == TransitionEffect.FADE
        assert isinstance(result, TransitionEffect)

    def test_valid_string(self):
        result = safe_parse_transition_effect("fade")
        assert result == TransitionEffect.FADE

    def test_cut_string(self):
        result = safe_parse_transition_effect("cut")
        assert result == TransitionEffect.CUT

    def test_invalid_string_returns_default(self):
        result = safe_parse_transition_effect("invalid_effect")
        assert result == TransitionEffect.CUT  # 默认

    def test_invalid_string_custom_default(self):
        result = safe_parse_transition_effect("bad", default=TransitionEffect.DISSOLVE)
        assert result == TransitionEffect.DISSOLVE

    def test_none_returns_default(self):
        result = safe_parse_transition_effect(None)
        assert result == TransitionEffect.CUT

    def test_int_value_returns_default(self):
        result = safe_parse_transition_effect(123)
        assert result == TransitionEffect.CUT

    def test_empty_string_returns_default(self):
        result = safe_parse_transition_effect("")
        assert result == TransitionEffect.CUT


class TestSafeParseClipType:
    def test_enum_passthrough(self):
        result = safe_parse_clip_type(ClipType.SUBTITLE)
        assert result == ClipType.SUBTITLE
        assert isinstance(result, ClipType)

    def test_valid_string_main(self):
        result = safe_parse_clip_type("main")
        assert result == ClipType.MAIN

    def test_valid_string_text(self):
        result = safe_parse_clip_type("subtitle")
        assert result == ClipType.SUBTITLE

    def test_invalid_string_returns_default(self):
        result = safe_parse_clip_type("unknown_type")
        assert result == ClipType.MAIN

    def test_invalid_string_custom_default(self):
        result = safe_parse_clip_type("bad", default=ClipType.TITLE)
        assert result == ClipType.TITLE

    def test_none_returns_default(self):
        result = safe_parse_clip_type(None)
        assert result == ClipType.MAIN

    def test_dict_returns_default(self):
        result = safe_parse_clip_type({"key": "val"})
        assert result == ClipType.MAIN


class TestFilterClipConfig:
    def test_none_config(self):
        result = filter_clip_config(None)
        assert result == {}

    def test_empty_dict(self):
        result = filter_clip_config({})
        assert result == {}

    def test_basic_config_passthrough(self):
        cfg = {"font_size": 24, "color": "red"}
        result = filter_clip_config(cfg)
        assert result == {"font_size": 24, "color": "red"}

    def test_filters_asset_info(self):
        cfg = {"font_size": 24, "asset_info": {"id": "123"}}
        result = filter_clip_config(cfg)
        assert "asset_info" not in result
        assert result["font_size"] == 24

    def test_filters_source_asset_id(self):
        cfg = {"source_asset_id": "asset_1", "text_key": "hi"}
        result = filter_clip_config(cfg)
        assert "source_asset_id" not in result
        assert result["text_key"] == "hi"

    def test_playback_speed_added_when_not_one(self):
        result = filter_clip_config({}, playback_speed=1.5)
        assert result["playback_speed"] == 1.5

    def test_playback_speed_one_not_added(self):
        result = filter_clip_config({}, playback_speed=1.0)
        assert "playback_speed" not in result

    def test_playback_speed_none_not_added(self):
        result = filter_clip_config({}, playback_speed=None)
        assert "playback_speed" not in result

    def test_playback_speed_config_takes_priority(self):
        """clip_config中的playback_speed会覆盖参数传入的（因为update在后面）."""
        cfg = {"playback_speed": 0.5, "other": "val"}
        result = filter_clip_config(cfg, playback_speed=2.0)
        assert result["playback_speed"] == 0.5  # config里的覆盖参数的
        assert result["other"] == "val"

    def test_custom_skip_keys(self):
        cfg = {"keep_me": 1, "drop_me": 2, "also_drop": 3}
        skip = frozenset({"drop_me", "also_drop"})
        result = filter_clip_config(cfg, skip_keys=skip)
        assert result == {"keep_me": 1}

    def test_does_not_mutate_input(self):
        cfg = {"a": 1, "asset_info": "x"}
        original = dict(cfg)
        filter_clip_config(cfg)
        assert cfg == original  # 原dict不变


class TestFilterPlanConfigToTemplate:
    def test_none_config(self):
        result = filter_plan_config_to_template(None)
        assert result == {}

    def test_empty_dict(self):
        result = filter_plan_config_to_template({})
        assert result == {}

    def test_keeps_template_fields(self):
        cfg = {"title": "My Template", "aspect_ratio": "9:16"}
        result = filter_plan_config_to_template(cfg)
        assert result == cfg

    def test_filters_runtime_fields(self):
        cfg = {
            "title": "T",
            "is_template_draft": True,
            "asset_ids": ["a1"],
            "source_edit_plan_id": "ep1",
            "generation_task_id": "gt1",
        }
        result = filter_plan_config_to_template(cfg)
        assert "is_template_draft" not in result
        assert "asset_ids" not in result
        assert "source_edit_plan_id" not in result
        assert "generation_task_id" not in result
        assert result["title"] == "T"

    def test_custom_skip_keys(self):
        cfg = {"keep": 1, "skip_a": 2, "skip_b": 3}
        skip = frozenset({"skip_a", "skip_b"})
        result = filter_plan_config_to_template(cfg, skip_keys=skip)
        assert result == {"keep": 1}


class TestClipToTemplateClipConfig:
    @dataclass
    class FakeClip:
        clip_type: str = "main"
        order: int = 0
        duration: float = 5.0
        text_content: str = ""
        transition_effect: str = "cut"
        playback_speed: float | None = None
        config: dict | None = None

    def test_basic_conversion(self):
        clip = self.FakeClip(
            clip_type="subtitle",
            order=2,
            duration=3.5,
            text_content="Hello",
            transition_effect="fade",
        )
        result = clip_to_template_clip_config("tpl_1", clip)
        assert isinstance(result, TemplateClipConfig)
        assert result.template_id == "tpl_1"
        assert result.clip_type == ClipType.SUBTITLE
        assert result.order == 2
        assert result.min_duration == 3.5
        assert result.max_duration == 3.5
        assert result.text_template == "Hello"
        assert result.transition_effect == TransitionEffect.FADE

    def test_duration_fixed_min_max_equal(self):
        """转换后 min_duration == max_duration == clip.duration."""
        clip = self.FakeClip(duration=7.2)
        result = clip_to_template_clip_config("t1", clip)
        assert result.min_duration == 7.2
        assert result.max_duration == 7.2

    def test_zero_duration(self):
        clip = self.FakeClip(duration=0.0)
        result = clip_to_template_clip_config("t1", clip)
        assert result.min_duration == 0.0
        assert result.max_duration == 0.0

    def test_none_duration_defaults_to_zero(self):
        clip = self.FakeClip()
        clip.duration = None  # type: ignore
        result = clip_to_template_clip_config("t1", clip)
        assert result.min_duration == 0.0
        assert result.max_duration == 0.0

    def test_empty_text_content_becomes_empty_string(self):
        clip = self.FakeClip(text_content="")
        result = clip_to_template_clip_config("t1", clip)
        assert result.text_template == ""

    def test_none_text_content_becomes_empty_string(self):
        clip = self.FakeClip()
        clip.text_content = None  # type: ignore
        result = clip_to_template_clip_config("t1", clip)
        assert result.text_template == ""

    def test_playback_speed_in_config(self):
        clip = self.FakeClip(playback_speed=1.5, config={"font": "bold"})
        result = clip_to_template_clip_config("t1", clip)
        assert result.config["playback_speed"] == 1.5
        assert result.config["font"] == "bold"

    def test_playback_speed_one_not_in_config(self):
        clip = self.FakeClip(playback_speed=1.0)
        result = clip_to_template_clip_config("t1", clip)
        assert "playback_speed" not in result.config

    def test_config_asset_info_filtered(self):
        clip = self.FakeClip(config={"text_key": "hi", "asset_info": {"id": "a"}})
        result = clip_to_template_clip_config("t1", clip)
        assert "asset_info" not in result.config
        assert result.config["text_key"] == "hi"

    def test_invalid_clip_type_falls_back(self):
        clip = self.FakeClip(clip_type="invalid_type")
        result = clip_to_template_clip_config("t1", clip)
        assert result.clip_type == ClipType.MAIN

    def test_missing_attributes(self):
        """对象没有某些属性时使用默认值."""

        class MinimalClip:
            pass

        result = clip_to_template_clip_config("t1", MinimalClip())
        assert result.clip_type == ClipType.MAIN
        assert result.order == 0
        assert result.min_duration == 0.0
        assert result.text_template == ""
        assert result.transition_effect == TransitionEffect.CUT


class TestClipsToTemplateClipConfigs:
    def test_empty_list(self):
        result = clips_to_template_clip_configs("t1", [])
        assert result == []

    def test_multiple_clips(self):
        clip_a = TestClipToTemplateClipConfig.FakeClip(clip_type="subtitle", order=0, duration=3.0, text_content="A")
        clip_b = TestClipToTemplateClipConfig.FakeClip(clip_type="title", order=1, duration=5.0, text_content="")
        result = clips_to_template_clip_configs("t1", [clip_a, clip_b])
        assert len(result) == 2
        assert result[0].clip_type == ClipType.SUBTITLE
        assert result[0].order == 0
        assert result[1].clip_type == ClipType.TITLE
        assert result[1].order == 1
        assert all(isinstance(r, TemplateClipConfig) for r in result)


class TestClipConfigToSnapshot:
    def test_basic_snapshot(self):
        cfg = TemplateClipConfig.create(
            template_id="t1",
            clip_type=ClipType.SUBTITLE,
            order=2,
            min_duration=3.0,
            max_duration=5.0,
            text_template="Hello",
            transition_effect=TransitionEffect.FADE,
            config={"font_size": 20},
        )
        snap = clip_config_to_snapshot(cfg)
        assert snap["clip_type"] == "subtitle"
        assert snap["order"] == 2
        assert snap["min_duration"] == 3.0
        assert snap["max_duration"] == 5.0
        assert snap["text_template"] == "Hello"
        assert snap["transition_effect"] == "fade"
        assert snap["config"] == {"font_size": 20}

    def test_enum_values_are_strings(self):
        cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
        snap = clip_config_to_snapshot(cfg)
        assert snap["clip_type"] == "main"
        assert isinstance(snap["clip_type"], str)
        assert snap["transition_effect"] == "cut"
        assert isinstance(snap["transition_effect"], str)

    def test_config_is_copy_not_reference(self):
        config = {"key": "val"}
        cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config=config)
        snap = clip_config_to_snapshot(cfg)
        snap["config"]["key"] = "changed"
        assert config["key"] == "val"  # 原config不变

    def test_empty_config(self):
        cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config={})
        snap = clip_config_to_snapshot(cfg)
        assert snap["config"] == {}

    def test_none_text_becomes_empty(self):
        cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
        cfg.text_template = None  # type: ignore
        snap = clip_config_to_snapshot(cfg)
        assert snap["text_template"] == ""


class TestClipConfigsToSnapshots:
    def test_empty_list(self):
        assert clip_configs_to_snapshots([]) == []

    def test_multiple_configs(self):
        cfg1 = TemplateClipConfig.create(
            template_id="t1",
            clip_type=ClipType.SUBTITLE,
            order=0,
            min_duration=2.0,
            max_duration=2.0,
        )
        cfg2 = TemplateClipConfig.create(
            template_id="t1",
            clip_type=ClipType.TITLE,
            order=1,
            min_duration=3.0,
            max_duration=3.0,
        )
        snaps = clip_configs_to_snapshots([cfg1, cfg2])
        assert len(snaps) == 2
        assert snaps[0]["clip_type"] == "subtitle"
        assert snaps[1]["clip_type"] == "title"


class TestSnapshotToTemplateClipConfig:
    def test_basic_conversion(self):
        snap = {
            "clip_type": "subtitle",
            "order": 3,
            "min_duration": 2.5,
            "max_duration": 4.5,
            "text_template": "World",
            "transition_effect": "dissolve",
            "config": {"color": "blue"},
        }
        result = snapshot_to_template_clip_config("tpl_2", snap)
        assert isinstance(result, TemplateClipConfig)
        assert result.template_id == "tpl_2"
        assert result.clip_type == ClipType.SUBTITLE
        assert result.order == 3
        assert result.min_duration == 2.5
        assert result.max_duration == 4.5
        assert result.text_template == "World"
        assert result.transition_effect == TransitionEffect.DISSOLVE
        assert result.config == {"color": "blue"}

    def test_empty_snapshot_uses_defaults(self):
        result = snapshot_to_template_clip_config("t1", {})
        assert result.clip_type == ClipType.MAIN
        assert result.order == 0
        assert result.min_duration == 0.0
        assert result.max_duration == 0.0
        assert result.text_template == ""
        assert result.transition_effect == TransitionEffect.CUT
        assert result.config == {}

    def test_invalid_clip_type_defaults(self):
        snap = {"clip_type": "unknown"}
        result = snapshot_to_template_clip_config("t1", snap)
        assert result.clip_type == ClipType.MAIN

    def test_invalid_transition_defaults(self):
        snap = {"transition_effect": "bad_effect"}
        result = snapshot_to_template_clip_config("t1", snap)
        assert result.transition_effect == TransitionEffect.CUT

    def test_none_config_becomes_empty(self):
        snap = {"config": None}
        result = snapshot_to_template_clip_config("t1", snap)
        assert result.config == {}


class TestSnapshotsToTemplateClipConfigs:
    def test_empty_list(self):
        result = snapshots_to_template_clip_configs("t1", [])
        assert result == []

    def test_multiple_snapshots(self):
        snaps = [
            {"clip_type": "subtitle", "order": 0, "text_template": "A"},
            {"clip_type": "title", "order": 1},
        ]
        result = snapshots_to_template_clip_configs("t1", snaps)
        assert len(result) == 2
        assert result[0].clip_type == ClipType.SUBTITLE
        assert result[0].text_template == "A"
        assert result[1].clip_type == ClipType.TITLE


class TestRoundTrip:
    """clip → config → snapshot → config 双向转换一致性."""

    def test_snapshot_config_round_trip(self):
        original = TemplateClipConfig.create(
            template_id="t1",
            clip_type=ClipType.SUBTITLE,
            order=5,
            min_duration=3.0,
            max_duration=6.0,
            text_template="Round trip",
            transition_effect=TransitionEffect.FADE,
            config={"key": "value"},
        )
        snap = clip_config_to_snapshot(original)
        restored = snapshot_to_template_clip_config("t1", snap)
        assert restored.clip_type == original.clip_type
        assert restored.order == original.order
        assert restored.min_duration == original.min_duration
        assert restored.max_duration == original.max_duration
        assert restored.text_template == original.text_template
        assert restored.transition_effect == original.transition_effect
        assert restored.config == original.config


class TestValidateTemplateName:
    def test_valid_name(self):
        assert validate_template_name("我的模板") == "我的模板"

    def test_strips_whitespace(self):
        assert validate_template_name("  Hello  ") == "Hello"

    def test_empty_string_raises(self):
        try:
            validate_template_name("")
        except ValueError as e:
            assert "不能为空" in str(e)
        else:
            raise AssertionError("expected ValueError")

    def test_whitespace_only_raises(self):
        try:
            validate_template_name("   ")
        except ValueError as e:
            assert "不能为空" in str(e)
        else:
            raise AssertionError("expected ValueError")

    def test_none_raises(self):
        try:
            validate_template_name(None)
        except ValueError as e:
            assert "不能为空" in str(e)
        else:
            raise AssertionError("expected ValueError")
