"""Issue #1658: pHash 阈值校准 + 颜色直方图融合 — 单元测试.

在 #1659（动态抽帧+滑动窗口）与 #1660（查重率）已合入 develop 的基础上，
本测试覆盖 #1658 的最小增量改动：

1. PHASH_THRESHOLD 由 10 收紧到 8（核心校准）
2. 融合权重常量 MATCH_RATIO_THRESHOLD / PHASH_WEIGHT / HISTOGRAM_WEIGHT 实际生效
   （不再是硬编码魔法数字）
3. VideoDeduplicator._compute_fusion_score 统一融合得分方法：
   - 无直方图数据时回退中性值 0.5
   - DB NULL（None）显式回退空列表，不崩溃
   - 全零直方图（全黑视频）为有效数据，参与 Bhattacharyya 计算
   - 返回 0~1 原始得分，判重由调用方与 DUPLICATE_THRESHOLD 比较
4. Bhattacharyya 系数对上游异常负值有 sqrt domain 防御
"""

from __future__ import annotations

import sys
from unittest.mock import MagicMock

import pytest


def _mock_module(**attrs):
    """Create a mock module with __spec__ to avoid AttributeError."""
    m = MagicMock()
    m.__spec__ = None
    for k, v in attrs.items():
        setattr(m, k, v)
    return m


# ── Module-level setup: mock deps, import dedup, then restore sys.modules ──
_SAVED_MODULES_KEYS = set(sys.modules.keys())
_SAVED_MODULES_VALUES = {
    k: sys.modules.get(k)
    for k in [
        "cv2",
        "celery",
        "sqlalchemy",
        "sqlalchemy.orm",
        "sqlalchemy.engine",
        "sqlalchemy.ext",
        "sqlalchemy.ext.declarative",
        "worker_app.db",
        "worker_app.celery_app",
        "worker_app.core.config",
        "packages.adapters.sqlalchemy_impl.session",
        "packages.adapters.sqlalchemy_impl.generated_video_repository",
        "packages.adapters.sqlalchemy_impl.models",
        "packages.shared.config",
        "packages.shared.storage",
    ]
}

sys.modules["cv2"] = _mock_module()

_mock_celery = MagicMock()
_mock_celery.Task = MagicMock
_mock_celery.Celery = MagicMock
_mock_celery.__spec__ = None
sys.modules["celery"] = _mock_celery

_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__spec__ = None
sys.modules["sqlalchemy"] = _mock_sqla

_mock_sqla_orm = MagicMock()
_mock_sqla_orm.__path__ = []
_mock_sqla_orm.__spec__ = None
_mock_sqla_orm.Session = MagicMock
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_module()
sys.modules["sqlalchemy.ext"] = _mock_module()
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()

sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))

sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
    Base=MagicMock(),
    build_engine=MagicMock(),
    build_session_factory=MagicMock(),
    ensure_database_exists=MagicMock(),
    initialize_database=MagicMock(),
)
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module(
    SQLAlchemyGeneratedVideoRepository=MagicMock
)
sys.modules["packages.adapters.sqlalchemy_impl.models"] = _mock_module(
    VideoFingerprintChunkModel=MagicMock,
    GeneratedVideoModel=MagicMock,
)
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
sys.modules["packages.shared.storage"] = _mock_module()

import video_processing.dedup as _dedup_mod  # noqa: E402
from video_processing.dedup import (  # noqa: E402
    DUPLICATE_THRESHOLD,
    HISTOGRAM_WEIGHT,
    MATCH_RATIO_THRESHOLD,
    PHASH_THRESHOLD,
    PHASH_WEIGHT,
    VideoDeduplicator,
)

# ── Restore sys.modules immediately after import ──
for _key in list(sys.modules.keys()):
    if _key not in _SAVED_MODULES_KEYS:
        del sys.modules[_key]
for _key, _value in _SAVED_MODULES_VALUES.items():
    if _value is not None:
        sys.modules[_key] = _value
    elif _key in sys.modules:
        del sys.modules[_key]
del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value


# ── 测试夹具 ─────────────────────────────────────────────────────

_UNIFORM_HIST = [1.0 / 96] * 96  # 归一化均匀直方图，sum=1.0，自相似度≈1.0
_ZERO_HIST = [0.0] * 96  # 全黑视频的全零直方图（有效数据）


# ── TestThresholdCalibration：#1658 核心校准 ────────────────────


class TestThresholdCalibration:
    """pHash 阈值校准（#1658 收紧到 8，#1702 两轮真实数据重校准 12→16）。

    #1702 第一轮 staging 离线实验：同帧两次 2-5% 随机裁剪距离 4~10；同源成片
    （密集 1s 采样）<=12 命中 4/11、异源成片最小距离 24 → 初定 12。
    #1702 第二轮（证据视频 B->A 仍漏检）扩样本到该用户 15 个真实成片实测：
    同源降重对中位数距离 14、<=16 命中 8/11=0.73；异源 13 个候选 <=16 命中
    全 0、每帧全局最近邻最小距离 18 → 校准为 16（与异源仍有 >=2bit 裕度）。
    """

    def test_phash_threshold_is_calibrated(self):
        assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD == 16

    def test_match_ratio_threshold_constant(self):
        assert MATCH_RATIO_THRESHOLD == 0.7

    def test_duplicate_threshold_constant(self):
        assert DUPLICATE_THRESHOLD == 0.70

    def test_fusion_weights(self):
        assert PHASH_WEIGHT == 0.7
        assert HISTOGRAM_WEIGHT == 0.3

    def test_threshold_matching_semantics(self):
        """阈值比较统一为 <=（帧匹配与片段匹配同一口径）。

        场景：5 个关键帧距离为 [10, 14, 16, 18, 26]。
        - <=16（#1702 二次校准阈值）：3 帧匹配 → 0.6 < 0.7，被帧比例门槛
          拦截（异源安全边界：真实数据异源最近邻最小距离 18，<=16 命中 0）
        - 距离正好 16 的同源降重帧应算匹配（< 与 <= 口径统一）
        """
        distances = [10, 14, 16, 18, 26]
        matched = sum(1 for d in distances if d <= VideoDeduplicator.PHASH_THRESHOLD)
        assert matched == 3
        assert matched / len(distances) == 0.6
        assert matched / len(distances) < MATCH_RATIO_THRESHOLD

        # 异源安全边界（实测最小距离 18）及以上绝不匹配
        assert not any(d <= VideoDeduplicator.PHASH_THRESHOLD for d in (18, 24, 26, 30))


# ── TestComputeFusionScore：统一融合得分方法 ────────────────────


class TestComputeFusionScore:
    """_compute_fusion_score(median_distance, histograms_a, histograms_b)。"""

    def test_no_histogram_falls_back_to_neutral_05(self):
        """双方均无直方图 → hist_similarity 回退 0.5。

        d=0: 0.7*1.0 + 0.3*0.5 = 0.85
        """
        score = VideoDeduplicator._compute_fusion_score(0, [], [])
        assert score == pytest.approx(0.85, abs=1e-6)

    def test_none_histograms_treated_as_empty(self):
        """DB NULL（None）必须显式回退空列表，不得 len(None) 崩溃。"""
        score_none = VideoDeduplicator._compute_fusion_score(0, [], None)
        score_empty = VideoDeduplicator._compute_fusion_score(0, [], [])
        assert score_none == pytest.approx(score_empty, abs=1e-9)
        assert score_none == pytest.approx(0.85, abs=1e-6)

    def test_none_histograms_on_query_side_no_crash(self):
        """查询侧直方图为 None 时同样不崩溃。"""
        score = VideoDeduplicator._compute_fusion_score(0, None, [_UNIFORM_HIST])
        # 查询侧无直方图 → 平均相似度为 0（无 ha 可匹配）→ 0.7*1.0 + 0.3*0 = 0.7
        assert score == pytest.approx(0.7, abs=1e-6)

    def test_identical_uniform_histograms_score_near_1(self):
        """完全相同的归一化直方图：Bhattacharyya≈1.0 → 融合分≈1.0。"""
        score = VideoDeduplicator._compute_fusion_score(0, [_UNIFORM_HIST], [_UNIFORM_HIST])
        assert score == pytest.approx(1.0, abs=1e-6)

    def test_all_zero_histogram_is_valid_data(self):
        """全零直方图（全黑视频）是有效数据，Bhattacharyya=0，不得走 0.5 回退。

        若错误地用 `if histograms_b` 之外的 `or []` 把全零列表清空，
        会错误回退到 0.5，把全黑视频的相似度抬高 0.15。
        d=0 时：正确行为 hist_sim=0 → 0.7*1.0 + 0.3*0 = 0.7；
        若全零直方图被错误清空回退 0.5 → 0.85。
        """
        score = VideoDeduplicator._compute_fusion_score(0, [_ZERO_HIST], [_ZERO_HIST])
        assert score == pytest.approx(0.7, abs=1e-6)
        # 与错误回退值 0.85 明确区分开
        assert abs(score - 0.85) > 0.1
        # 注：d=0 时 phash 满分 0.7 恰达 DUPLICATE_THRESHOLD，全黑+完全相同 phash 仍判重，符合预期
        assert score >= DUPLICATE_THRESHOLD - 1e-9

    def test_score_range_within_0_1(self):
        for d in (0, 8, 16, 32, 64):
            score = VideoDeduplicator._compute_fusion_score(d, [_UNIFORM_HIST], [_UNIFORM_HIST])
            assert 0.0 <= score <= 1.0

    def test_formula_matches_weights(self):
        """得分 = PHASH_WEIGHT * (1 - d/64) + HISTOGRAM_WEIGHT * hist_sim。"""
        d = 6  # phash_sim = 1 - 6/64 = 0.90625
        score = VideoDeduplicator._compute_fusion_score(d, [], [])  # hist 回退 0.5
        expected = PHASH_WEIGHT * (1 - d / 64) + HISTOGRAM_WEIGHT * 0.5
        assert score == pytest.approx(expected, abs=1e-9)
        # 0.7*0.90625 + 0.15 = 0.634375 + 0.15 = 0.784375
        assert score == pytest.approx(0.784375, abs=1e-6)


# ── TestBhattacharyyaDefense：负值/异常输入防御 ─────────────────


class TestBhattacharyyaDefense:
    """Bhattacharyya 系数对异常输入的防御。"""

    def test_negative_values_do_not_raise(self):
        """上游异常负值不得触发 sqrt domain error（max(0.0, ai*bi) 保护）。"""
        bad_hist = [-0.01] * 96  # 异常负值
        coeff = VideoDeduplicator._bhattacharyya_coefficient(bad_hist, _UNIFORM_HIST)
        # 负值乘积被钳为 0，系数为 0 而不是抛 ValueError
        assert coeff == pytest.approx(0.0, abs=1e-9)

    def test_normal_histograms_coefficient_near_1(self):
        coeff = VideoDeduplicator._bhattacharyya_coefficient(_UNIFORM_HIST, _UNIFORM_HIST)
        assert coeff == pytest.approx(1.0, abs=1e-6)

    def test_disjoint_histograms_coefficient_0(self):
        """完全不重叠的直方图（前半 vs 后半非零）系数为 0。"""
        hist_a = [0.0] * 96
        hist_b = [0.0] * 96
        for i in range(48):
            hist_a[i] = 1.0 / 48
        for i in range(48, 96):
            hist_b[i] = 1.0 / 48
        coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b)
        assert coeff == pytest.approx(0.0, abs=1e-9)


# ── TestHistogramSimilarityEdgeCases ────────────────────────────


class TestHistogramSimilarityEdgeCases:
    """_compute_histogram_similarity 的边界行为。"""

    def test_empty_either_side_returns_0(self):
        assert VideoDeduplicator._compute_histogram_similarity([], [_UNIFORM_HIST]) == 0.0
        assert VideoDeduplicator._compute_histogram_similarity([_UNIFORM_HIST], []) == 0.0

    def test_best_match_per_histogram(self):
        """每个查询直方图取与目标集合的最佳匹配，再取平均。"""
        h1 = _UNIFORM_HIST
        h2 = [0.0] * 96
        h2[0] = 1.0  # 与均匀直方图完全不重叠
        # 查询侧两张直方图：h1 最佳匹配≈1.0，h2 最佳匹配≈sqrt(1/96)≈0.102
        sim = VideoDeduplicator._compute_histogram_similarity([h1, h2], [h1])
        assert sim == pytest.approx((1.0 + (1.0 / 96) ** 0.5) / 2, abs=1e-3)
