# -*- coding: utf-8 -*-
"""
test_search_engine.py — search_engine 模块单元测试

覆盖：
  模块函数 tokenize / compute_tf / compute_idf / compute_tfidf / cosine_similarity
  类方法 SearchEngine.__init__ / search / get_projects / get_categories

被测模块：skill/code/web/search_engine.py
索引依赖：conftest.py autouse fixture 把 SEARCH_INDEX_PATHS 指向 deploy/搜索索引.json（357 条）
"""

import math

import search_engine
from search_engine import (
    tokenize,
    compute_tf,
    compute_idf,
    compute_tfidf,
    cosine_similarity,
    SearchEngine,
)


# ============================================================
# tokenize
# ============================================================

class TestTokenize:
    def test_chinese_words(self):
        """中文 2-4 字词组提取"""
        tokens = tokenize("门口屏连接失败")
        assert isinstance(tokens, list)
        assert len(tokens) > 0

    def test_english_words(self):
        """英文单词提取（小写化）"""
        tokens = tokenize("MQTT Connection Failed")
        assert "mqtt" in tokens
        assert "connection" in tokens
        assert "failed" in tokens

    def test_numbers_extracted(self):
        """数字提取"""
        tokens = tokenize("端口 8088 超时")
        assert "8088" in tokens

    def test_stopwords_filtered(self):
        """停用词被过滤"""
        tokens = tokenize("the docker is running")
        assert "the" not in tokens
        assert "is" not in tokens
        assert "docker" in tokens
        assert "running" in tokens

    def test_empty_string(self):
        """空字符串返回空列表"""
        assert tokenize("") == []


# ============================================================
# compute_tf
# ============================================================

class TestComputeTf:
    def test_normal_tokens(self):
        """正常 token 词频和为 1"""
        tf = compute_tf(["a", "a", "b"])
        assert tf["a"] == 2 / 3
        assert tf["b"] == 1 / 3

    def test_empty_tokens(self):
        """空 token 列表不报错（total 用 1 兜底）"""
        tf = compute_tf([])
        assert tf == {}

    def test_single_token(self):
        """单 token 词频为 1.0"""
        assert compute_tf(["solo"]) == {"solo": 1.0}


# ============================================================
# compute_idf
# ============================================================

class TestComputeIdf:
    def test_idf_dict_keys(self):
        """IDF 包含所有出现过的 token"""
        docs = [["a", "b"], ["b", "c"]]
        idf = compute_idf(docs)
        assert set(idf.keys()) == {"a", "b", "c"}

    def test_idf_smoothing_positive(self):
        """平滑后 IDF 为正（log((N+1)/(df+1))+1）"""
        docs = [["a"], ["a"], ["a"]]
        idf = compute_idf(docs)
        # df(a)=3, N=3 → log(4/4)+1 = 1
        assert idf["a"] == 1.0

    def test_rarer_token_higher_idf(self):
        """越稀有 IDF 越大"""
        docs = [["common", "rare"], ["common"], ["common"]]
        idf = compute_idf(docs)
        assert idf["rare"] > idf["common"]

    def test_empty_docs(self):
        """空文档列表返回空 dict"""
        assert compute_idf([]) == {}


# ============================================================
# compute_tfidf
# ============================================================

class TestComputeTfidf:
    def test_basic_product(self):
        """TF * IDF"""
        tf = {"a": 0.5}
        idf = {"a": 2.0}
        vec = compute_tfidf(tf, idf)
        assert vec["a"] == 1.0

    def test_unknown_token_defaults_one(self):
        """IDF 中不存在的 token 用 1.0 兜底"""
        tf = {"unknown": 0.5}
        vec = compute_tfidf(tf, {})
        assert vec["unknown"] == 0.5

    def test_empty_tf(self):
        """空 TF 返回空向量"""
        assert compute_tfidf({}, {"a": 1.0}) == {}


# ============================================================
# cosine_similarity
# ============================================================

class TestCosineSimilarity:
    def test_identical_vectors(self):
        """相同向量相似度约为 1.0（浮点误差容忍）"""
        v = {"a": 1.0, "b": 2.0}
        assert math.isclose(cosine_similarity(v, v), 1.0, rel_tol=1e-9)

    def test_orthogonal_vectors(self):
        """无共同词的向量相似度为 0.0"""
        v1 = {"a": 1.0}
        v2 = {"b": 1.0}
        assert cosine_similarity(v1, v2) == 0.0

    def test_zero_norm_returns_zero(self):
        """零模长向量返回 0.0"""
        assert cosine_similarity({"a": 0.0}, {"a": 1.0}) == 0.0
        assert cosine_similarity({}, {"a": 1.0}) == 0.0

    def test_partial_overlap_in_range(self):
        """部分重叠相似度在 (0, 1)"""
        v1 = {"a": 1.0, "b": 1.0}
        v2 = {"a": 1.0, "c": 1.0}
        sim = cosine_similarity(v1, v2)
        assert 0.0 < sim < 1.0


# ============================================================
# SearchEngine 实例化（依赖真实索引，conftest 已注入路径）
# ============================================================

class TestSearchEngineInit:
    def test_init_loads_records(self):
        """初始化加载 357 条记录"""
        engine = SearchEngine()
        assert len(engine.records) == 357

    def test_init_builds_vectors(self):
        """初始化预计算所有记录的 TF-IDF 向量"""
        engine = SearchEngine()
        assert len(engine.doc_vectors) == 357
        assert len(engine.doc_tokens) == 357
        assert len(engine.idf) > 0

    def test_record_fields(self):
        """记录字段完整"""
        engine = SearchEngine()
        r = engine.records[0]
        for field in ("title", "project", "category", "keywords", "full_text"):
            assert field in r


# ============================================================
# SearchEngine.search
# ============================================================

class TestSearchEngineSearch:
    def test_real_query_returns_results(self):
        """真实查询返回排序结果"""
        engine = SearchEngine()
        results = engine.search("mqtt 连接失败", top_k=5)
        assert isinstance(results, list)
        assert 1 <= len(results) <= 5
        for r in results:
            assert set(r.keys()) == {"rank", "score", "record"}
            assert 0 <= r["score"] <= 1.0

    def test_results_sorted_desc(self):
        """结果按得分降序"""
        engine = SearchEngine()
        results = engine.search("docker 容器", top_k=5)
        scores = [r["score"] for r in results]
        assert scores == sorted(scores, reverse=True)

    def test_rank_starts_at_one(self):
        """rank 从 1 开始递增"""
        engine = SearchEngine()
        results = engine.search("redis", top_k=3)
        if results:
            ranks = [r["rank"] for r in results]
            assert ranks == list(range(1, len(results) + 1))

    def test_top_k_limit(self):
        """top_k 限制返回数量"""
        engine = SearchEngine()
        for k in (1, 2, 10):
            results = engine.search("mqtt", top_k=k)
            assert len(results) <= k

    def test_empty_query_returns_empty_or_limited(self):
        """空 query：tokenize 返回空 → 查询向量为空 → 相似度 0 → 结果可能为空"""
        engine = SearchEngine()
        results = engine.search("", top_k=5)
        # 空 query 无 token，与所有记录无共同词，无 keyword 命中 → scores 为空
        assert results == []

    def test_no_match_query(self):
        """完全无关的 query（无共同词、无 keyword 命中）返回空"""
        engine = SearchEngine()
        # 用一串不可能出现在知识库的随机哈希字符串
        results = engine.search("zzqxwkjrandomstringzzz", top_k=5)
        # final_score 全为 0，不入 scores
        assert results == []

    def test_project_filter(self):
        """项目过滤：用首条结果的真实 project 值过滤，应只返回该项目记录"""
        engine = SearchEngine()
        results = engine.search("连接失败", top_k=10)
        if not results:
            return
        target_project = results[0]["record"].get("project")
        filtered = engine.search("连接失败", top_k=20, project_filter=target_project)
        # 过滤后结果都应是该项目（可能为空，但若有则必匹配）
        for r in filtered:
            assert r["record"].get("project", "").lower() == target_project.lower()

    def test_project_filter_no_match(self):
        """项目过滤不匹配返回空"""
        engine = SearchEngine()
        results = engine.search("mqtt", top_k=5, project_filter="不存在的项目XYZ")
        assert results == []

    def test_category_filter(self):
        """分类过滤：用首条结果的 category 值过滤"""
        engine = SearchEngine()
        results = engine.search("连接失败", top_k=10)
        if not results:
            return
        cats = results[0]["record"].get("category", [])
        if not cats:
            return
        target_cat = cats[0]
        filtered = engine.search("连接失败", top_k=20, category_filter=target_cat)
        for r in filtered:
            assert target_cat in r["record"].get("category", [])

    def test_score_capped_at_one(self):
        """final_score 被 min(1.0, ...) 封顶"""
        engine = SearchEngine()
        results = engine.search("mqtt 连接", top_k=5)
        for r in results:
            assert r["score"] <= 1.0


# ============================================================
# SearchEngine.get_projects / get_categories
# ============================================================

class TestSearchEngineMetadata:
    def test_get_projects(self):
        """返回 120 个项目"""
        engine = SearchEngine()
        projects = engine.get_projects()
        assert isinstance(projects, list)
        assert len(projects) == 120

    def test_get_categories(self):
        """返回 20 个分类"""
        engine = SearchEngine()
        cats = engine.get_categories()
        assert isinstance(cats, list)
        assert len(cats) == 20
