"""#1743 smart-match 排序随机噪声测试。

smart_select_assets 排序注入 0~SCORE_RANDOM_NOISE_MAX 随机噪声后：
- 同分/近分素材每次调用选出的组合与顺序不同（修复"每次只选同样几个素材"）
- 分差 > 噪声上限的高质量素材保持稳定优先级
- r.score 始终为无噪声原始分；噪声只影响排序
- 同一次调用内排序与多样性分桶使用一致噪声（结果稳定可复现）
"""

from __future__ import annotations

import random
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
for sub in ("packages",):
    p = str(REPO_ROOT / sub)
    if p not in sys.path:
        sys.path.insert(0, p)

from packages.domain.smart_match import (  # noqa: E402
    SCORE_RANDOM_NOISE_MAX,
    score_asset,
    smart_select_assets,
)

NOW = datetime(2026, 9, 6, 12, 0, 0, tzinfo=timezone.utc)


@dataclass
class FakeAsset:
    id: str
    duration: float = 15.0
    quality_score: float | None = None  # None → 按 50 计，模拟 staging 真实情况
    status: str = "ready"
    metadata: dict = field(default_factory=dict)
    created_at: datetime = NOW

    @property
    def file_type(self) -> str:
        return "video"


def _make_tied_assets(n: int) -> list[FakeAsset]:
    """构造 n 个综合得分完全相同的素材（quality NULL 按 50 + 时长 15s 满分 + 同创建时间）。"""
    return [FakeAsset(id=f"a{i}", duration=15.0, quality_score=None) for i in range(n)]


class TestScoreNoiseInjection:
    def test_tied_assets_same_raw_score(self):
        """前置校验：同分素材原始得分确实一致。"""
        assets = _make_tied_assets(6)
        scores = {score_asset(a, now=NOW)[0] for a in assets}
        assert len(scores) == 1, f"测试前提不成立：同分素材得分不一致 {scores}"

    def test_tied_assets_order_varies_across_calls(self):
        """同分素材：不同随机种子选出的顺序/组合不同（核心修复点）。"""
        orderings = set()
        for seed in range(8):
            results = smart_select_assets(_make_tied_assets(8), rng=random.Random(seed))
            orderings.add(tuple(r.asset.id for r in results))
        # 8 个不同种子应产生多种不同排序（若零随机噪声则只有 1 种）
        assert len(orderings) >= 4, f"同分素材排序几乎不变: {len(orderings)} 种"

    def test_tied_assets_top_n_varies_with_limit(self):
        """同分素材 + limit 截断：不同种子选出的 Top-N 组合不同。"""
        top_sets = set()
        for seed in range(10):
            results = smart_select_assets(_make_tied_assets(10), limit=3, rng=random.Random(seed))
            top_sets.add(frozenset(r.asset.id for r in results))
        assert len(top_sets) >= 4, f"Top-N 组合几乎不变: {len(top_sets)} 种"

    def test_large_score_gap_keeps_priority(self):
        """分差 > 噪声上限（20）时：低质素材即使噪声拉满也排不到高质素材前面。"""
        # 高质：quality=100 → quality_component=40；低质：quality=0 → 0，仅质量项就差 40 分
        high = [FakeAsset(id="high", quality_score=100, duration=15.0)]
        low = [FakeAsset(id=f"low{i}", quality_score=0, duration=15.0) for i in range(6)]
        for seed in range(20):
            results = smart_select_assets(high + low, limit=3, rng=random.Random(seed))
            assert results[0].asset.id == "high", f"seed={seed} 低质素材靠噪声排到首位"

    def test_score_field_is_raw_without_noise(self):
        """r.score 始终是无噪声原始分（噪声只影响排序，不污染返回分值）。"""
        from datetime import datetime as _dt
        from unittest.mock import patch

        assets = _make_tied_assets(5)
        raw_scores = {score_asset(a, now=NOW)[0] for a in assets}
        # 冻结 datetime.now，与 score_asset 用的 NOW 一致（recency 时变分数需稳定）
        with patch("packages.domain.smart_match.datetime") as mock_dt:
            mock_dt.now.return_value = NOW
            # 保留 datetime 构造函数行为（如 datetime(...) 调用）
            mock_dt.side_effect = _dt
            results = smart_select_assets(assets, rng=random.Random(42))
        for r in results:
            assert r.score in raw_scores, f"score={r.score} 不在 raw_scores={raw_scores}"

    def test_rng_deterministic_same_seed(self):
        """同一种子多次调用结果完全一致（可复现，测试可依赖）。"""
        run = lambda: tuple(  # noqa: E731
            r.asset.id for r in smart_select_assets(_make_tied_assets(8), rng=random.Random(123))
        )
        assert run() == run()

    def test_diversity_bucket_respects_noise(self):
        """多样性分桶路径（候选数 > limit）：同分素材跨种子入选组合不同。"""
        # 构造短/中/长三档同分素材各 4 个，limit=6 触发分桶轮询
        assets = []
        for i in range(4):
            assets.append(FakeAsset(id=f"short{i}", duration=6.0))
        for i in range(4):
            assets.append(FakeAsset(id=f"med{i}", duration=15.0))
        for i in range(4):
            assets.append(FakeAsset(id=f"long{i}", duration=45.0))
        # 同档内时长接近 → 得分接近
        combos = set()
        for seed in range(10):
            results = smart_select_assets(assets, limit=6, rng=random.Random(seed))
            combos.add(frozenset(r.asset.id for r in results))
        assert len(combos) >= 3, f"分桶选取组合几乎不变: {len(combos)} 种"

    def test_noise_constant_matches_from_assets(self):
        """噪声上限与 from-assets 片段分配的 SCORE_RANDOM_NOISE_MAX 同源（20 分）。"""
        assert SCORE_RANDOM_NOISE_MAX == 20.0

    def test_returns_all_when_no_limit(self):
        """无 limit 时返回全部候选（噪声只改顺序，不丢素材）。"""
        assets = _make_tied_assets(7)
        results = smart_select_assets(assets, rng=random.Random(1))
        assert len(results) == 7
        assert {r.asset.id for r in results} == {a.id for a in assets}

    def test_non_ready_assets_excluded_before_noise(self):
        """非 ready 素材不参与排序（噪声不影响状态过滤）。"""
        assets = _make_tied_assets(4)
        assets[0].status = "processing"
        results = smart_select_assets(assets, rng=random.Random(1))
        assert all(r.asset.status == "ready" for r in results)
        assert len(results) == 3
