"""asset_quality_scoring 纯逻辑单测 — 第89波.

测试评分纯函数，不依赖 FFmpeg/视频文件。
覆盖：分辨率评分、帧率评分、码率评分、清晰度评分、稳定性评分、
     质量总评分、9分类评分、分类结果计算。
"""

import numpy as np
import pytest

from apps.worker.worker_app.tasks.asset_quality_scoring import (
    AudioAnalysis,
    ClassificationResult,
    ColorAnalysis,
    MotionAnalysis,
    QualityScore,
    VideoInfo,
    calculate_category_scores,
    calculate_quality_score,
    classify_from_analysis,
    score_bitrate,
    score_clarity,
    score_framerate,
    score_resolution,
    score_stability,
)
from packages.domain.classification import AssetClassification

# ── 分辨率评分 ────────────────────────────────────────────────────────


class TestScoreResolution:
    """分辨率评分边界测试."""

    def test_4k_full_score(self):
        """4K 及以上满分 25."""
        assert score_resolution(3840, 2160) == 25.0
        assert score_resolution(4096, 2160) == 25.0
        assert score_resolution(7680, 4320) == 25.0  # 8K

    def test_2k_score(self):
        """2K 档 22 分."""
        assert score_resolution(2560, 1440) == 22.0
        assert score_resolution(3000, 1600) == 22.0
        # 刚好低于 4K
        assert score_resolution(3839, 2159) == 22.0

    def test_1080p_score(self):
        """1080p 档 20 分."""
        assert score_resolution(1920, 1080) == 20.0
        assert score_resolution(2000, 1080) == 20.0
        # 刚好低于 2K
        assert score_resolution(2559, 1439) == 20.0

    def test_720p_score(self):
        """720p 档 15 分."""
        assert score_resolution(1280, 720) == 15.0
        assert score_resolution(1280, 720) == 15.0
        # 刚好低于 1080p
        assert score_resolution(1919, 1079) == 15.0
        # 1080x720 像素数 < 1280x720，掉到 480p 档
        assert score_resolution(1080, 720) == 8.0

    def test_480p_score(self):
        """480p 档 8 分."""
        assert score_resolution(854, 480) == 8.0
        assert score_resolution(854, 480) == 8.0
        # 刚好低于 720p
        assert score_resolution(1279, 719) == 8.0
        # 720x480 像素数 < 854x480，掉到最低档
        assert score_resolution(720, 480) == 3.0

    def test_low_resolution_score(self):
        """低于 480p 给 3 分."""
        assert score_resolution(640, 360) == 3.0
        assert score_resolution(320, 240) == 3.0
        assert score_resolution(0, 0) == 3.0

    def test_non_standard_aspect_ratio(self):
        """非标准宽高比按像素总数计算."""
        # 竖屏 1080x1920 像素数 = 1080p
        assert score_resolution(1080, 1920) == 20.0
        # 超宽屏
        assert score_resolution(2560, 1080) == 20.0  # 像素≈2.7M < 2K(3.6M)
        # 1x1 极低分辨率
        assert score_resolution(1, 1) == 3.0

    def test_negative_values(self):
        """负尺寸：负负得正按像素数算，一正一负 = 负数像素 = 最低档."""
        # 一正一负 → 负像素总数 → < 480p → 3分
        assert score_resolution(1920, -1080) == 3.0
        assert score_resolution(-1920, 1080) == 3.0
        # 都是 0 → 3分
        assert score_resolution(0, 0) == 3.0


# ── 帧率评分 ──────────────────────────────────────────────────────────


class TestScoreFramerate:
    """帧率评分边界测试."""

    def test_60fps_full_score(self):
        """60fps 及以上满分 20."""
        assert score_framerate(60) == 20.0
        assert score_framerate(120) == 20.0
        assert score_framerate(240) == 20.0

    def test_30fps_score(self):
        """30-59fps 给 15 分."""
        assert score_framerate(30) == 15.0
        assert score_framerate(59.9) == 15.0
        assert score_framerate(59) == 15.0

    def test_24fps_score(self):
        """24-29fps 给 10 分."""
        assert score_framerate(24) == 10.0
        assert score_framerate(29.97) == 10.0
        assert score_framerate(25) == 10.0

    def test_15fps_score(self):
        """15-23fps 给 7 分."""
        assert score_framerate(15) == 7.0
        assert score_framerate(23.9) == 7.0
        assert score_framerate(20) == 7.0

    def test_low_fps_score(self):
        """低于 15fps 给 5 分."""
        assert score_framerate(10) == 5.0
        assert score_framerate(1) == 5.0
        assert score_framerate(0) == 5.0

    def test_negative_fps(self):
        """负帧率按最低档."""
        assert score_framerate(-30) == 5.0

    def test_float_fps(self):
        """浮点帧率正确判断边界."""
        # 29.97 (NTSC) < 30 → 24fps 档
        assert score_framerate(29.97) == 10.0
        assert score_framerate(23.976) == 7.0
        assert score_framerate(59.94) == 15.0  # 59.94 < 60 → 30fps 档
        assert score_framerate(30.0) == 15.0


# ── 码率评分 ──────────────────────────────────────────────────────────


class TestScoreBitrate:
    """码率评分边界测试."""

    def test_high_bitrate_full_score(self):
        """10Mbps 以上满分 20."""
        assert score_bitrate(10_000_001) == 20.0
        assert score_bitrate(50_000_000) == 20.0
        assert score_bitrate(100_000_000) == 20.0

    def test_5mbps_score(self):
        """5-10Mbps 给 15 分."""
        assert score_bitrate(5_000_000) == 15.0
        assert score_bitrate(8_000_000) == 15.0
        assert score_bitrate(10_000_000) == 15.0  # 刚好 10Mbps = 不 > 10

    def test_2mbps_score(self):
        """2-5Mbps 给 10 分."""
        assert score_bitrate(2_000_000) == 10.0
        assert score_bitrate(3_000_000) == 10.0
        assert score_bitrate(4_999_999) == 10.0

    def test_05mbps_score(self):
        """0.5-2Mbps 给 5 分."""
        assert score_bitrate(500_000) == 5.0
        assert score_bitrate(1_000_000) == 5.0
        assert score_bitrate(1_999_999) == 5.0

    def test_low_bitrate_score(self):
        """低于 0.5Mbps 给 3 分."""
        assert score_bitrate(499_999) == 3.0
        assert score_bitrate(100_000) == 3.0
        assert score_bitrate(0) == 3.0

    def test_negative_bitrate(self):
        """负码率按最低档."""
        assert score_bitrate(-5_000_000) == 3.0

    def test_zero_bitrate(self):
        """0 码率 = 最低档."""
        assert score_bitrate(0) == 3.0


# ── 清晰度评分 ────────────────────────────────────────────────────────


class TestScoreClarity:
    """清晰度评分测试."""

    def test_empty_frames_default_score(self):
        """空帧列表给默认 10 分."""
        assert score_clarity([]) == 10.0

    def test_constant_image_low_clarity(self):
        """纯色图像比高细节图像清晰度低很多."""
        # 纯灰色图像
        gray_frame = np.full((100, 100), 128, dtype=np.uint8)
        score_constant = score_clarity([gray_frame])

        # 高细节随机图像
        detail_frame = np.random.randint(0, 256, (100, 100), dtype=np.uint8)
        score_detail = score_clarity([detail_frame])

        # 纯色图应该显著低于高细节图
        assert score_constant < score_detail
        assert 0.0 <= score_constant <= 20.0

    def test_edge_rich_image_high_clarity(self):
        """高频边缘图像有较高清晰度得分."""
        # 棋盘格图案，边缘丰富
        frame = np.zeros((100, 100), dtype=np.uint8)
        for i in range(0, 100, 10):
            for j in range(0, 100, 10):
                if (i // 10 + j // 10) % 2 == 0:
                    frame[i : i + 10, j : j + 10] = 255
        score = score_clarity([frame])
        assert score > 1.0  # 应有一定清晰度
        assert 0.0 <= score <= 20.0

    def test_rgb_frame_converts_to_gray(self):
        """RGB 帧会被转灰度后计算."""
        rgb_frame = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8)
        score_rgb = score_clarity([rgb_frame])
        # 对应灰度图
        gray = np.dot(rgb_frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
        score_gray = score_clarity([gray])
        # 两者应近似相等
        assert abs(score_rgb - score_gray) < 0.01

    def test_only_first_five_frames_analyzed(self):
        """只分析前 5 帧."""
        # 10 帧：前 5 帧纯色，后 5 帧高对比度
        frames = []
        for _ in range(5):
            frames.append(np.full((50, 50), 128, dtype=np.uint8))
        for _ in range(5):
            high_freq = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
            frames.append(high_freq)
        score_10 = score_clarity(frames)
        score_5 = score_clarity(frames[:5])
        # 前 5 帧相同，得分应相同
        assert abs(score_10 - score_5) < 0.01

    def test_score_within_bounds(self):
        """得分始终在 0-20 范围内."""
        for _ in range(10):
            frame = np.random.randint(0, 256, (30, 30, 3), dtype=np.uint8)
            score = score_clarity([frame])
            assert 0.0 <= score <= 20.0

    def test_multiple_frames_averaged(self):
        """多帧取平均方差."""
        # 第 1 帧低细节，第 2 帧高细节
        low_detail = np.full((50, 50), 100, dtype=np.uint8)
        high_detail = np.random.randint(0, 256, (50, 50), dtype=np.uint8)

        score_low = score_clarity([low_detail])
        score_high = score_clarity([high_detail])
        score_both = score_clarity([low_detail, high_detail])

        # 混合得分应在两者之间
        assert score_low <= score_both <= score_high


# ── 稳定性评分 ────────────────────────────────────────────────────────


class TestScoreStability:
    """稳定性评分测试."""

    def test_single_frame_default_score(self):
        """不足 2 帧给默认 10 分."""
        assert score_stability([]) == 10.0
        assert score_stability([np.zeros((10, 10, 3), dtype=np.uint8)]) == 10.0

    def test_identical_frames_max_stability(self):
        """完全相同的帧 = 高稳定性."""
        frame = np.random.randint(50, 200, (100, 100, 3), dtype=np.uint8)
        score = score_stability([frame, frame.copy()])
        assert score > 10.0  # 应该接近满分 15

    def test_very_different_frames_low_stability(self):
        """位移方差大的多帧序列 = 低稳定性."""
        # 构造 4 帧：帧间位移差异大（有的帧相似、有的帧完全不同）
        # 位移方差大 → 不稳定 → 低分
        base = np.random.randint(100, 150, (100, 100, 3), dtype=np.uint8)
        frames = [
            base,  # 帧0
            base,  # 帧1 (完全相同 → 位移0)
            np.full_like(base, 255),  # 帧2 (纯白 → 位移大)
            base,  # 帧3 (回到基准 → 位移又大)
        ]
        score = score_stability(frames)
        # 位移差异大 → 方差大 → 稳定性低
        assert score < 10.0
        assert 0.0 <= score <= 15.0

    def test_score_within_bounds(self):
        """得分始终在 0-15 范围内."""
        for _ in range(10):
            f1 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
            f2 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
            score = score_stability([f1, f2])
            assert 0.0 <= score <= 15.0

    def test_gray_frames_also_work(self):
        """灰度帧也能计算."""
        f1 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
        f2 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
        score = score_stability([f1, f2])
        assert 0.0 <= score <= 15.0

    def test_multiple_frame_pairs(self):
        """多对帧取方差."""
        base = np.random.randint(100, 150, (60, 60, 3), dtype=np.uint8)
        # 5 帧相似的
        frames = []
        for _ in range(5):
            f = base.copy()
            # 轻微变化
            f = np.clip(f.astype(int) + np.random.randint(-5, 6, f.shape), 0, 255).astype(np.uint8)
            frames.append(f)
        score = score_stability(frames)
        assert score > 5.0  # 相似帧应该有一定稳定性


# ── 质量总评分 ────────────────────────────────────────────────────────


class TestCalculateQualityScore:
    """质量综合评分测试."""

    def test_perfect_video_near_100(self):
        """完美参数的视频接近 100 分."""
        info = VideoInfo(
            width=3840,
            height=2160,
            fps=60,
            bitrate=20_000_000,
        )
        # 用高细节帧提升清晰度分
        frame = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
        result = calculate_quality_score(info, [frame])
        assert isinstance(result, QualityScore)
        assert result.resolution_score == 25.0
        assert result.fps_score == 20.0
        assert result.bitrate_score == 20.0
        assert 50.0 <= result.total <= 100.0

    def test_low_quality_video(self):
        """低质量视频得分低."""
        info = VideoInfo(
            width=320,
            height=240,
            fps=10,
            bitrate=100_000,
        )
        result = calculate_quality_score(info, [])
        assert isinstance(result, QualityScore)
        assert result.resolution_score == 3.0
        assert result.fps_score == 5.0
        assert result.bitrate_score == 3.0
        # 无帧时清晰度和稳定性各给 10 分默认
        assert result.clarity_score == 10.0
        assert result.stability_score == 10.0
        assert result.total == 31.0  # 3+5+3+10+10

    def test_no_frames_uses_defaults(self):
        """不传 frames 时清晰度/稳定性给默认分."""
        info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
        result = calculate_quality_score(info)
        assert result.clarity_score == 10.0
        assert result.stability_score == 10.0
        assert result.resolution_score == 20.0
        assert result.fps_score == 15.0
        assert result.bitrate_score == 15.0
        assert result.total == 70.0

    def test_total_capped_at_100(self):
        """总分不超过 100."""
        info = VideoInfo(
            width=7680,
            height=4320,
            fps=240,
            bitrate=100_000_000,
        )
        # 即使所有维度都满，总分不超 100
        result = calculate_quality_score(info, [])
        assert result.total <= 100.0

    def test_total_minimum_zero(self):
        """总分不低于 0."""
        info = VideoInfo(width=0, height=0, fps=0, bitrate=0)
        result = calculate_quality_score(info, [])
        assert result.total >= 0.0

    def test_total_is_rounded(self):
        """总分保留 1 位小数."""
        info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
        result = calculate_quality_score(info, [])
        # 检查是 1 位小数
        assert round(result.total, 1) == result.total


# ── 分类评分 ──────────────────────────────────────────────────────────


class TestCalculateCategoryScores:
    """分类评分计算测试."""

    def test_scenic_high_green_and_motion(self):
        """绿色+适度运动+自然音 → 风景高分."""
        color = ColorAnalysis(
            green_ratio=0.5,
            avg_saturation=0.5,
            avg_brightness=0.6,
            warm_ratio=0.2,
            cool_ratio=0.3,
        )
        motion = MotionAnalysis(motion_score=0.3, scene_changes=1)
        audio = AudioAnalysis(has_audio=True, ambient_ratio=0.7)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.SCENIC.value] > 0.5
        assert scores[AssetClassification.SCENIC.value] <= 1.0

    def test_product_low_motion_cool_tone(self):
        """低运动+冷色调 → 产品高分."""
        color = ColorAnalysis(
            avg_brightness=0.5,
            avg_saturation=0.3,
            cool_ratio=0.5,
            green_ratio=0.1,
            warm_ratio=0.2,
        )
        motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
        audio = AudioAnalysis(has_audio=False)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.PRODUCT.value] > 0.5

    def test_person_with_speech(self):
        """有人声+适度运动 → 人物高分."""
        color = ColorAnalysis(avg_brightness=0.5)
        motion = MotionAnalysis(motion_score=0.25, scene_changes=1)
        audio = AudioAnalysis(has_audio=True, speech_ratio=0.6)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.PERSON.value] > 0.5

    def test_animal_high_motion(self):
        """高运动+多场景切换 → 动物高分."""
        color = ColorAnalysis()
        motion = MotionAnalysis(motion_score=0.6, scene_changes=5)
        audio = AudioAnalysis(has_audio=True, ambient_ratio=0.5)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.ANIMAL.value] > 0.5

    def test_food_warm_saturated(self):
        """暖色调+高饱和 → 美食高分."""
        color = ColorAnalysis(
            warm_ratio=0.6,
            avg_saturation=0.7,
            avg_brightness=0.6,
            green_ratio=0.1,
            cool_ratio=0.2,
        )
        motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
        audio = AudioAnalysis(has_audio=False)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.FOOD.value] > 0.5

    def test_tech_cool_low_saturation(self):
        """冷色调+低饱和+低运动 → 科技高分."""
        color = ColorAnalysis(
            cool_ratio=0.6,
            avg_saturation=0.3,
            avg_brightness=0.5,
            green_ratio=0.1,
            warm_ratio=0.2,
        )
        motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
        audio = AudioAnalysis(has_audio=False)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.TECH.value] > 0.5

    def test_sport_high_motion(self):
        """高运动+多场景 → 运动高分."""
        color = ColorAnalysis(avg_brightness=0.6)
        motion = MotionAnalysis(motion_score=0.7, scene_changes=5)
        audio = AudioAnalysis(has_audio=False)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.SPORT.value] > 0.5

    def test_music_high_music_ratio(self):
        """高音乐比例 → 音乐高分."""
        color = ColorAnalysis(avg_saturation=0.6, green_ratio=0.1)
        motion = MotionAnalysis(motion_score=0.2)
        audio = AudioAnalysis(has_audio=True, music_ratio=0.7)
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.MUSIC.value] > 0.5

    def test_other_has_base_score(self):
        """其他分类有基础分 0.1."""
        color = ColorAnalysis()
        motion = MotionAnalysis()
        audio = AudioAnalysis()
        scores = calculate_category_scores(color, motion, audio)
        assert scores[AssetClassification.OTHER.value] == 0.1

    def test_all_scores_within_bounds(self):
        """所有分类得分都在 0-1 范围内."""
        color = ColorAnalysis(
            green_ratio=0.9,
            warm_ratio=0.9,
            cool_ratio=0.9,
            avg_saturation=0.99,
            avg_brightness=0.99,
        )
        motion = MotionAnalysis(motion_score=0.9, scene_changes=100)
        audio = AudioAnalysis(
            has_audio=True,
            speech_ratio=0.99,
            music_ratio=0.99,
            ambient_ratio=0.99,
        )
        scores = calculate_category_scores(color, motion, audio)
        for cat, score in scores.items():
            assert 0.0 <= score <= 1.0, f"{cat} score {score} out of bounds"

    def test_all_nine_categories_present(self):
        """返回 9 个分类的得分."""
        color = ColorAnalysis()
        motion = MotionAnalysis()
        audio = AudioAnalysis()
        scores = calculate_category_scores(color, motion, audio)
        assert len(scores) == 9


# ── 分类结果计算 ──────────────────────────────────────────────────────


class TestClassifyFromAnalysis:
    """分类结果计算测试."""

    def test_returns_classification_result(self):
        """返回 ClassificationResult 对象."""
        color = ColorAnalysis()
        motion = MotionAnalysis()
        audio = AudioAnalysis()
        result = classify_from_analysis(color, motion, audio)
        assert isinstance(result, ClassificationResult)
        assert isinstance(result.category, AssetClassification)
        assert isinstance(result.confidence, float)
        assert isinstance(result.scores, dict)

    def test_highest_score_wins(self):
        """得分最高的分类胜出."""
        # 构造明显偏向风景的特征
        color = ColorAnalysis(
            green_ratio=0.8,
            avg_saturation=0.6,
            avg_brightness=0.7,
        )
        motion = MotionAnalysis(motion_score=0.3)
        audio = AudioAnalysis(has_audio=True, ambient_ratio=0.8)
        result = classify_from_analysis(color, motion, audio)
        assert result.category == AssetClassification.SCENIC

    def test_confidence_within_bounds(self):
        """置信度在 0.3-0.95 范围内."""
        color = ColorAnalysis()
        motion = MotionAnalysis()
        audio = AudioAnalysis()
        result = classify_from_analysis(color, motion, audio)
        assert 0.3 <= result.confidence <= 0.95

    def test_confidence_capped_at_095(self):
        """极高得分也被限制在 0.95."""
        color = ColorAnalysis(
            green_ratio=0.9,
            avg_saturation=0.9,
            avg_brightness=0.9,
            warm_ratio=0.9,
        )
        motion = MotionAnalysis(motion_score=0.9, scene_changes=10)
        audio = AudioAnalysis(
            has_audio=True,
            speech_ratio=0.9,
            music_ratio=0.9,
            ambient_ratio=0.9,
        )
        result = classify_from_analysis(color, motion, audio)
        assert result.confidence <= 0.95

    def test_confidence_floored_at_03(self):
        """极低得分也有 0.3 最低置信度."""
        color = ColorAnalysis()
        motion = MotionAnalysis()
        audio = AudioAnalysis()
        result = classify_from_analysis(color, motion, audio)
        assert result.confidence >= 0.3

    def test_scores_dict_included(self):
        """结果中包含完整分数字典."""
        color = ColorAnalysis()
        motion = MotionAnalysis()
        audio = AudioAnalysis()
        result = classify_from_analysis(color, motion, audio)
        assert len(result.scores) == 9
        assert AssetClassification.OTHER.value in result.scores

    def test_food_category_wins_on_warm_colors(self):
        """暖色调+高饱和 → 美食分类胜出."""
        color = ColorAnalysis(
            warm_ratio=0.7,
            avg_saturation=0.8,
            avg_brightness=0.6,
            green_ratio=0.05,
        )
        motion = MotionAnalysis(motion_score=0.05)
        audio = AudioAnalysis(has_audio=False)
        result = classify_from_analysis(color, motion, audio)
        assert result.category == AssetClassification.FOOD


# ── 数据类默认值 ──────────────────────────────────────────────────────


class TestDataclassDefaults:
    """数据类默认值测试."""

    def test_video_info_defaults(self):
        info = VideoInfo()
        assert info.width == 0
        assert info.height == 0
        assert info.fps == 0.0
        assert info.bitrate == 0
        assert info.has_audio is False

    def test_color_analysis_defaults(self):
        color = ColorAnalysis()
        assert color.green_ratio == 0.0
        assert color.avg_brightness == 0.0
        assert color.dominant_hue == 0.0

    def test_motion_analysis_defaults(self):
        motion = MotionAnalysis()
        assert motion.motion_score == 0.0
        assert motion.scene_changes == 0

    def test_audio_analysis_defaults(self):
        audio = AudioAnalysis()
        assert audio.has_audio is False
        assert audio.speech_ratio == 0.0
        assert audio.music_ratio == 0.0

    def test_quality_score_requires_total(self):
        with pytest.raises(TypeError):
            QualityScore()
        qs = QualityScore(total=50.0)
        assert qs.total == 50.0
        assert qs.resolution_score == 0.0
