"""测试编辑器 from-assets 端点：按模板segment创建片段 + 事务性替换 + 随机起始.

覆盖：
- 片段数量 = segment 数量（required_clips_count 被忽略）
- 素材不足时同一素材轮询切多个片段
- 随机 start_time + used_segments 去重
- 素材时长不足时 clip duration 缩短
- 素材时长全部为 0/缺失时抛 400「素材可切区间不足」；混合池中零时长素材被跳过
- 使用 replace_all_clips_transactional 原子性替换
- order 从 0 开始
- start_time=None 时抛出 400
- mark_clips_ready 在事务方法内部完成
"""

from __future__ import annotations

import os
import sys
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 HTTPException

TEST_PLAN_ID = "plan-draft-001"
TEST_USER_ID = "user-001"

# 默认测试用 segments：4 个片段，每个 3~5 秒
DEFAULT_SEGMENTS = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]


def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
    return [(i, dur_min, dur_max) for i in range(count)]


def _patch_zero_noise():
    """消除 clips.py 排序随机噪声，用于确定性断言（如均衡分配）。

    排序噪声（random.uniform(0, SCORE_RANDOM_NOISE_MAX)）返回 0；
    其他 uniform 调用（片段时长随机）委托给独立 Random 实例，行为不变。
    """
    import random as _stdlib_random

    from app.api.routes.templates_editor import clips as clips_module

    from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX

    _fallback = _stdlib_random.Random()

    def _fake_uniform(a, b):
        if b == SCORE_RANDOM_NOISE_MAX:
            return 0.0
        return _fallback.uniform(a, b)

    return patch.object(clips_module.random, "uniform", _fake_uniform)


def _patch_segments(segments=None):
    return patch(
        "app.api.routes.templates_editor.clips._get_template_segments",
        return_value=segments if segments is not None else DEFAULT_SEGMENTS,
    )


def _make_auth_user():
    auth = MagicMock()
    auth.user.id = TEST_USER_ID
    auth.user.email = "test@example.com"
    auth.user.display_name = "测试用户"
    auth.user_id = TEST_USER_ID
    return auth


def _make_mock_asset(asset_id, duration):
    asset = MagicMock()
    asset.id = asset_id
    asset.duration = duration
    # score_asset 所需的属性（避免 MagicMock 导致类型比较错误）
    asset.quality_score = None
    asset.created_at = None
    asset.metadata = {}
    return asset


def _make_plan_svc(replace_return_count=None):
    svc = MagicMock()
    # replace_all_clips_transactional 返回创建的片段数量
    if replace_return_count is not None:
        svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
    else:
        svc.replace_all_clips_transactional = MagicMock(return_value=0)
    return svc


def _get_clips_data_from_call(mock_plan_svc):
    """从 replace_all_clips_transactional 的调用中获取 clips_data。"""
    assert mock_plan_svc.replace_all_clips_transactional.called, "replace_all_clips_transactional 未被调用"
    call_args = mock_plan_svc.replace_all_clips_transactional.call_args
    # call_args = ((plan_id, clips_data), kwargs)
    if len(call_args.args) >= 2:
        return call_args.args[1]
    return call_args.kwargs.get("clips_data", [])


@pytest.fixture(autouse=True)
def _mock_segment_tracker():
    """from-assets 现在会读/写素材 metadata 的片段区间记录，测试中 mock 掉避免依赖真实 DB。

    get_used_segments 返回空 dict（等价历史行为：无历史区间）；
    record/remove/reset 回调均无副作用。
    """
    with (
        patch(
            "app.api.routes.templates_editor.clips.get_used_segments",
            return_value={},
        ),
        patch(
            "app.api.routes.templates_editor.clips.record_used_segments",
            return_value=None,
        ),
        patch(
            "app.api.routes.templates_editor.clips.make_reuse_callback",
            return_value=lambda asset_id, clip_duration: None,
        ),
        patch(
            "app.api.routes.templates_editor.clips.remove_used_segment",
            return_value=False,
        ),
    ):
        yield


class TestEditorClipsBySegments:
    """测试按 segment 数量创建片段 + 素材轮询。"""

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_creates_clips_matching_segment_count(self, mock_storage):
        """4 个 segment 即使只有2个素材也创建4个片段，required_clips_count 被忽略。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=4)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]))

        body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)

        # 均衡分配由 use_count 贪心保证，消除排序噪声后确定性断言
        with _patch_zero_noise(), _patch_segments(DEFAULT_SEGMENTS):
            result = create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        assert result.created_count == 4
        clips_data = _get_clips_data_from_call(mock_plan_svc)
        assert len(clips_data) == 4

        # 验证均衡分配（贪心策略保证）：2个素材分4个片段，每个素材恰好使用2次
        from collections import Counter

        asset_ids = [c["asset_id"] for c in clips_data]
        counts = Counter(asset_ids)
        assert counts["a1"] == 2 and counts["a2"] == 2

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_orders_start_at_zero(self, mock_storage):
        """片段 order 从 0 开始递增。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=3)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)

        with _patch_segments(_segments(3)):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        clips_data = _get_clips_data_from_call(mock_plan_svc)
        assert clips_data[0]["order"] == 0
        assert clips_data[1]["order"] == 1
        assert clips_data[2]["order"] == 2

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_uses_transactional_replace(self, mock_storage):
        """使用 replace_all_clips_transactional 而不是分别 delete + create。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=2)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1"])

        with _patch_segments(_segments(2)):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        # 必须调用事务方法
        mock_plan_svc.replace_all_clips_transactional.assert_called_once()
        # 不应调用单独的 delete 或 create
        assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_no_segments_raises_400(self, mock_storage):
        """模板没有 segment 配置时返回 400。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc()
        mock_asset_repo = MagicMock()

        body = ClipsFromAssetsRequest(asset_ids=["a1"])

        with _patch_segments([]):
            with pytest.raises(HTTPException) as exc_info:
                create_clips_from_assets_editor(
                    template_id="tpl-001",
                    body=body,
                    background_tasks=MagicMock(),
                    plan_id=TEST_PLAN_ID,
                    services=(MagicMock(), mock_plan_svc),
                    asset_repo=mock_asset_repo,
                    db=MagicMock(),
                    current_user=_make_auth_user(),
                )

        assert exc_info.value.status_code == 400
        assert "片段配置" in exc_info.value.detail
        # 不应调用替换方法
        mock_plan_svc.replace_all_clips_transactional.assert_not_called()


class TestEditorClipsDurationAndStartTime:
    """测试素材时长获取、clip duration 缩短、start_time 传入。"""

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_clip_duration_shortened_for_short_assets(self, mock_storage):
        """素材只有 3s 时 clip duration 缩短到不超过 3.0。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=1)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))

        body = ClipsFromAssetsRequest(asset_ids=["short"])

        with _patch_segments(_segments(1, dur_min=5.0, dur_max=10.0)):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        clips_data = _get_clips_data_from_call(mock_plan_svc)
        assert clips_data[0]["duration"] <= 3.0

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_zero_duration_asset_raises_400(self, mock_storage):
        """所有素材时长均为 0 时轮询无可用素材，抛出 400「素材可切区间不足」。

        新轮询逻辑下零时长素材被跳过（而非立即报错）；全部素材都被跳过时
        返回 400，不创建无效片段。
        """
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc()
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("bad", 0.0))

        body = ClipsFromAssetsRequest(asset_ids=["bad"])

        with _patch_segments(_segments(1)):
            with pytest.raises(HTTPException) as exc_info:
                create_clips_from_assets_editor(
                    template_id="tpl-001",
                    body=body,
                    background_tasks=MagicMock(),
                    plan_id=TEST_PLAN_ID,
                    services=(MagicMock(), mock_plan_svc),
                    asset_repo=mock_asset_repo,
                    db=MagicMock(),
                    current_user=_make_auth_user(),
                )

        assert exc_info.value.status_code == 400
        assert "素材可切区间不足" in exc_info.value.detail

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage):
        """素材池混合零时长与正常素材时，零时长素材被跳过、正常素材承担片段。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=2)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(
            side_effect=lambda aid: {
                "zero": _make_mock_asset("zero", 0.0),
                "good": _make_mock_asset("good", 30.0),
            }[aid]
        )

        body = ClipsFromAssetsRequest(asset_ids=["zero", "good"], required_clips_count=2)

        with (
            _patch_segments(_segments(2)),
            patch(
                "app.api.routes.templates_editor.clips._calc_random_start_time",
                side_effect=[5.0, 12.0],
            ),
        ):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        clips_data = _get_clips_data_from_call(mock_plan_svc)
        assert len(clips_data) == 2
        # 所有片段都分配给正常素材，零时长素材被跳过
        assert all(c["asset_id"] == "good" for c in clips_data)

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_missing_duration_asset_raises_400(self, mock_storage):
        """素材时长缺失（asset_repo.get 返回 None）时抛出 400。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc()
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=None)

        body = ClipsFromAssetsRequest(asset_ids=["missing"])

        with _patch_segments(_segments(1)):
            with pytest.raises(HTTPException) as exc_info:
                create_clips_from_assets_editor(
                    template_id="tpl-001",
                    body=body,
                    background_tasks=MagicMock(),
                    plan_id=TEST_PLAN_ID,
                    services=(MagicMock(), mock_plan_svc),
                    asset_repo=mock_asset_repo,
                    db=MagicMock(),
                    current_user=_make_auth_user(),
                )

        assert exc_info.value.status_code == 400

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_start_time_passed_to_create(self, mock_storage):
        """_calc_random_start_time 返回值被传入 clips_data。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=2)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)

        with (
            _patch_segments(_segments(2)),
            patch(
                "app.api.routes.templates_editor.clips._calc_random_start_time",
                side_effect=[12.5, 18.0],
            ) as mock_calc,
        ):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        assert mock_calc.call_count == 2
        clips_data = _get_clips_data_from_call(mock_plan_svc)
        # clips_data 按 order 排序，但分配顺序因 shuffle 而随机，
        # 因此只验证两个 start_time 值都存在
        start_times = {c["start_time"] for c in clips_data}
        assert start_times == {12.5, 18.0}
        # 验证 order 仍然有序
        orders = [c["order"] for c in clips_data]
        assert orders == sorted(orders)

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_asset_durations_deduped(self, mock_storage):
        """asset_ids 有重复时只查询一次素材时长。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=3)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])

        with _patch_segments(_segments(3)):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        # 去重后只调用 1 次获取素材时长
        assert mock_asset_repo.get.call_count == 1

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_used_segments_maintained_across_clips(self, mock_storage):
        """同一素材切多个片段时，used_segments 应被维护。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=3)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)

        captured_used_segments = []

        def fake_calc(asset_id, clip_duration, asset_durations, used_segments, on_exhausted=None):
            captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()})
            return (len(captured_used_segments) - 1) * 5.0

        with (
            _patch_segments(_segments(3)),
            patch(
                "app.api.routes.templates_editor.clips._calc_random_start_time",
                side_effect=fake_calc,
            ),
        ):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        # 第一次没有已使用时间段
        assert captured_used_segments[0] == {}
        # 第二次有第一次的记录
        assert len(captured_used_segments[1]["a1"]) == 1
        # 第三次有前两次的记录
        assert len(captured_used_segments[2]["a1"]) == 2


class TestEditorClipsErrorHandling:
    """测试异常处理。"""

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_none_start_time_raises_400(self, mock_storage):
        """所有素材 calc 均返回 None（区间耗尽且复用被拒）→ 轮询失败抛 400。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc()
        mock_asset_repo = MagicMock()
        # 素材有 duration 但 calc 返回 None（模拟可用区间耗尽、复用被闸门拒绝）
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1"])

        with (
            _patch_segments(_segments(1)),
            patch(
                "app.api.routes.templates_editor.clips._calc_random_start_time",
                return_value=None,
            ),
        ):
            with pytest.raises(HTTPException) as exc_info:
                create_clips_from_assets_editor(
                    template_id="tpl-001",
                    body=body,
                    background_tasks=MagicMock(),
                    plan_id=TEST_PLAN_ID,
                    services=(MagicMock(), mock_plan_svc),
                    asset_repo=mock_asset_repo,
                    db=MagicMock(),
                    current_user=_make_auth_user(),
                )

        assert exc_info.value.status_code == 400
        assert "素材可切区间不足" in exc_info.value.detail
        # 复用被拒导致无起点时，不应创建任何片段
        assert not mock_plan_svc.replace_all_clips_transactional.called

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_transactional_replace_exception_propagates(self, mock_storage):
        """replace_all_clips_transactional 抛异常时应向上传播（事务已回滚）。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc()
        mock_plan_svc.replace_all_clips_transactional = MagicMock(side_effect=RuntimeError("DB connection lost"))
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1"])

        with _patch_segments(_segments(1)):
            with pytest.raises(RuntimeError, match="DB connection lost"):
                create_clips_from_assets_editor(
                    template_id="tpl-001",
                    body=body,
                    background_tasks=MagicMock(),
                    plan_id=TEST_PLAN_ID,
                    services=(MagicMock(), mock_plan_svc),
                    asset_repo=mock_asset_repo,
                    db=MagicMock(),
                    current_user=_make_auth_user(),
                )


class TestReuseRatioGate:
    """素材区间耗尽后的受控复用与 15% 占比闸门（路由级）。"""

    @staticmethod
    def _make_calc_with_reuse(normal_starts, reused_durations):
        """构造模拟「区间耗尽后受控复用」的 _calc_random_start_time。

        normal_starts: list[float | None]，前 N 次调用返回的空闲起点；
            返回 None 表示随机找不到空闲 → 触发 on_exhausted 复用回调。
        回调被调用时返回复用区间（固定 0.0 起点），复用片段时长由路由累加到
        reused_durations；回调内部占比预判超 15% 时返回 None（calc 随之 None）。
        """
        calls = {"i": 0}

        def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
            i = calls["i"]
            calls["i"] += 1
            if i < len(normal_starts) and normal_starts[i] is not None:
                return normal_starts[i]
            # 空闲耗尽 → 走受控复用回调（回调返回 (start, end) 元组，calc 取起点）
            if on_exhausted is not None:
                result = on_exhausted(asset_id, clip_duration)
                return result[0] if result else None
            return None

        return fake_calc, calls

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_reused_clip_ratio_within_threshold(self, mock_storage):
        """素材 60s、片段 5s：前 12 个用空闲区间，第 13 个复用，
        复用占比 5/(12*5+5)=7.7% ≤ 15%，正常创建 13 个片段。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=13)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
        body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=13)

        reused: dict = {}

        def reuse_cb(aid, dur):
            # 模拟真实回调：返回复用区间前记录复用时长
            reused[aid] = reused.get(aid, 0.0) + dur
            return (0.0, dur)

        # 前 12 次分配空闲起点；第 13 次 calc 直接走回调（normal_starts 越界 → None → 回调）
        normal_starts = [float(i * 5) for i in range(12)]
        fake_calc, _ = self._make_calc_with_reuse(normal_starts, reused)
        with (
            _patch_segments(_segments(13, dur_min=5.0, dur_max=5.0)),
            patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
            patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=reuse_cb),
        ):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )
        clips_data = _get_clips_data_from_call(mock_plan_svc)
        assert len(clips_data) == 13
        # 1 个复用片段，占比 1/13 ≈ 7.7% ≤ 15%
        # 转场补偿: raw_duration = 5.0 + (13-1)*0.5/13 ≈ 5.462 → round(5.462,1) = 5.5
        assert abs(reused.get("a1", 0.0) - 5.5) < 0.1

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_reuse_ratio_exceeded_returns_400(self, mock_storage):
        """复用占比将超 15% 时回调拒绝复用 → 无可用素材 → 400「素材可切区间不足」。

        60s 素材、5s 片段：前 12 个空闲、随后复用占比累计；当 (reused+d)/(assigned+d)
        超过 15% 时回调返回 None，calc 返回 None，轮询无素材 → 400。
        """
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=0)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
        body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=20)

        # 模拟真实回调：累计复用时长，预判超 15% 拒绝
        reused: dict = {}
        assigned: dict = {}

        def fake_reuse_cb(aid, clip_duration):
            a = assigned.get(aid, 0.0)
            r = reused.get(aid, 0.0)
            if a > 0 and (r + clip_duration) / (a + clip_duration) > 0.15:
                return None  # 占比闸门拒绝
            reused[aid] = r + clip_duration
            return (0.0, clip_duration)

        def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
            a = assigned.get(asset_id, 0.0)
            # 前 12 个片段（60s/5s）有空闲区间
            if a < 60.0:
                start = a
                assigned[asset_id] = a + clip_duration
                return start
            # 之后空闲耗尽 → 复用
            if on_exhausted is not None:
                result = on_exhausted(asset_id, clip_duration)
                if result is not None:
                    assigned[asset_id] = assigned.get(asset_id, 0.0) + clip_duration
                return result[0] if result else None
            return None

        with (
            _patch_segments(_segments(20, dur_min=5.0, dur_max=5.0)),
            patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
            patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=fake_reuse_cb),
        ):
            with pytest.raises(HTTPException) as exc_info:
                create_clips_from_assets_editor(
                    template_id="tpl-001",
                    body=body,
                    background_tasks=MagicMock(),
                    plan_id=TEST_PLAN_ID,
                    services=(MagicMock(), mock_plan_svc),
                    asset_repo=mock_asset_repo,
                    db=MagicMock(),
                    current_user=_make_auth_user(),
                )

        assert exc_info.value.status_code == 400
        assert "素材可切区间不足" in exc_info.value.detail
        # 闸门在复用占比达上限时拒绝：60s 空闲 + 至多 ~15% 复用
        assert reused.get("a1", 0.0) <= 12.0  # 10.0 或 15.0 以内，不会无限复用
        # 未创建任何片段（整批失败）
        assert not mock_plan_svc.replace_all_clips_transactional.called

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_calc_none_falls_through_to_next_asset(self, mock_storage):
        """一个素材区间耗尽且复用被拒（calc 返回 None）时，轮询到下一个可用素材。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=2)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(
            side_effect=lambda aid: {
                "exhausted": _make_mock_asset("exhausted", 60.0),
                "fresh": _make_mock_asset("fresh", 60.0),
            }[aid]
        )
        body = ClipsFromAssetsRequest(asset_ids=["exhausted", "fresh"], required_clips_count=2)

        def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
            if asset_id == "exhausted":
                # 空闲耗尽 + 回调拒绝 → None
                return on_exhausted(asset_id, clip_duration) if on_exhausted else None
            return 8.0  # 新鲜素材正常返回

        with (
            _patch_segments(_segments(2, dur_min=5.0, dur_max=5.0)),
            patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
            patch(
                "app.api.routes.templates_editor.clips.make_reuse_callback",
                return_value=lambda aid, d: None,  # 复用始终被拒
            ),
        ):
            create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )
        clips_data = _get_clips_data_from_call(mock_plan_svc)
        assert len(clips_data) == 2
        # 耗尽素材被跳过，两个片段都分配给新鲜素材
        assert all(c["asset_id"] == "fresh" for c in clips_data)


# ── P0 回归：from-assets 对 undefined/null/空串 asset_ids 容错 ──────────────
# 线上事故：前端 smart-match 拿到 {asset: {...}} 包装层后 items.map(a=>a.id)
# 全为 undefined，POST /clips/from-assets 携带 null → 422，自动模式预览断裂。


class TestClipsFromAssetsInvalidIds:
    def test_schema_filters_null_and_empty_ids(self):
        """请求 schema 在 pre 阶段剔除 null/空串/空白 id，不触发 422。"""
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "  ", "a2"])  # type: ignore[list-item]
        assert body.asset_ids == ["a1", "a2"]

    def test_schema_all_invalid_raises(self):
        """全部为非法 id 时 min_length=1 兜底报校验错误（前端得到 422 而非脏数据）。"""
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
        from pydantic import ValidationError

        with pytest.raises(ValidationError):
            ClipsFromAssetsRequest(asset_ids=[None, "", "  "])  # type: ignore[list-item]

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_route_filters_invalid_ids_and_uses_valid(self, mock_storage):
        """路由层二次兜底：混有 null/空串时只用合法 id 正常创建片段。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )
        from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest

        mock_plan_svc = _make_plan_svc(replace_return_count=2)
        mock_asset_repo = MagicMock()
        mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))

        body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "a2"])  # type: ignore[list-item]

        # 消除排序噪声，确定性断言两条合法素材各被使用
        with _patch_zero_noise(), _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
            result = create_clips_from_assets_editor(
                template_id="tpl-001",
                body=body,
                background_tasks=MagicMock(),
                plan_id=TEST_PLAN_ID,
                services=(MagicMock(), mock_plan_svc),
                asset_repo=mock_asset_repo,
                db=MagicMock(),
                current_user=_make_auth_user(),
            )

        assert result.created_count == 2
        clips_data = _get_clips_data_from_call(mock_plan_svc)
        assert {c["asset_id"] for c in clips_data} == {"a1", "a2"}

    @patch("app.api.routes.templates_editor.clips.get_storage_service")
    def test_route_all_empty_ids_raises_400(self, mock_storage):
        """schema 被绕过直接调路由、且 id 全非法时，路由 400 而非 500/422。"""
        from app.api.routes.templates_editor.clips import (
            create_clips_from_assets_editor,
        )

        mock_plan_svc = _make_plan_svc()
        mock_asset_repo = MagicMock()

        body = MagicMock()
        body.asset_ids = [None, "", "  "]

        with _patch_segments(DEFAULT_SEGMENTS):
            with pytest.raises(HTTPException) as exc_info:
                create_clips_from_assets_editor(
                    template_id="tpl-001",
                    body=body,
                    background_tasks=MagicMock(),
                    plan_id=TEST_PLAN_ID,
                    services=(MagicMock(), mock_plan_svc),
                    asset_repo=mock_asset_repo,
                    db=MagicMock(),
                    current_user=_make_auth_user(),
                )
        assert exc_info.value.status_code == 400
        mock_plan_svc.replace_all_clips_transactional.assert_not_called()
