# -*- 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 pytest

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


# ============================================================
# P2-1：向量搜索测试
# ============================================================

@pytest.fixture(autouse=True, scope="module")
def _enable_embedding_for_vector_tests():
    """P2-1 向量测试组：强制 embedding_enabled=True（config.json 默认 false 会走 TF-IDF）。

    通过 monkeypatch container._config 注入临时配置，测试结束恢复。
    注意：必须在 SearchEngine() 实例化前注入，因 _load_vectors 在 __init__ 读 config。
    """
    import container
    original_config = container._config
    # 构造启用 embedding 的配置（保留原 config 其余字段）
    base = dict(original_config) if original_config else {}
    base["embedding_enabled"] = True
    container._config = base
    container.get_config.cache_clear() if hasattr(container.get_config, "cache_clear") else None
    yield
    container._config = original_config


class TestVectorLoad:
    def test_vector_load_success(self, vector_index_file, monkeypatch):
        """向量文件存在且格式正确时 _vectors 非空，embedding_enabled=True"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        assert engine._vectors is not None
        assert engine._embedding_enabled is True
        mode = engine.get_search_mode()
        assert mode["mode"] == "vector"
        assert mode["vector_loaded"] is True

    def test_vector_load_missing_file(self):
        """向量文件不存在时降级 TF-IDF，不报错"""
        engine = SearchEngine()
        assert engine._vectors is None
        assert engine._embedding_enabled is False
        mode = engine.get_search_mode()
        assert mode["mode"] == "tfidf"

    def test_vector_load_dimension_mismatch(self, tmp_path, monkeypatch):
        """维度不匹配时降级 TF-IDF"""
        # dimensions 声明 8，但实际向量是 4 维
        vectors = {
            "model": "text-embedding-3-small",
            "dimensions": 8,
            "record_count": 3,
            "vectors": {
                "rec_000": [0.1, 0.2, 0.3, 0.4],  # 4 维，与声明不符
            },
        }
        path = tmp_path / "搜索向量.json"
        path.write_text(__import__("json").dumps(vectors), encoding='utf-8')
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [path])
        engine = SearchEngine()
        assert engine._embedding_enabled is False


class TestSearchVectorMode:
    def test_search_vector_mode(self, vector_index_file, mock_embedding_api, monkeypatch):
        """向量模式下 search 走向量路径，返回结构一致"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        results = engine.search("mqtt 连接失败", top_k=3)
        assert isinstance(results, list)
        for r in results:
            assert set(r.keys()) == {"rank", "score", "record"}
            assert 0 <= r["score"] <= 1.0

    def test_search_vector_api_failure(self, vector_index_file, mock_embedding_api_failure, monkeypatch):
        """API 失败时降级 TF-IDF，仍返回结果"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        results = engine.search("mqtt", top_k=3)
        assert isinstance(results, list)

    def test_search_vector_disabled(self, monkeypatch):
        """embedding_enabled=False 时走 TF-IDF，不调 API"""
        import container
        original_get_config = container.get_config

        class _FakeContainer:
            @staticmethod
            def get_config():
                cfg = original_get_config() if original_get_config else {}
                cfg = dict(cfg)
                cfg["embedding_enabled"] = False
                return cfg

        monkeypatch.setattr("container.get_config", _FakeContainer.get_config)
        engine = SearchEngine()
        assert engine._embedding_enabled is False
        results = engine.search("mqtt", top_k=3)
        assert isinstance(results, list)


class TestQueryVectorCache:
    def test_query_vector_cache_hit(self, vector_index_file, mock_embedding_api, monkeypatch):
        """相同 query 二次命中缓存，不重复调 API"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        # 第一次：调 API
        v1 = engine._get_query_vector("测试查询")
        calls_after_first = mock_embedding_api.call_count if hasattr(mock_embedding_api, "call_count") else None
        # 第二次：应命中缓存
        v2 = engine._get_query_vector("测试查询")
        assert v1 == v2

    def test_query_vector_cache_lru(self, vector_index_file, mock_embedding_api, monkeypatch):
        """缓存超限淘汰最旧"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        # 缓存上限 100，填入 105 条
        for i in range(105):
            engine._get_query_vector(f"查询_{i}")
        assert len(engine._query_vector_cache) == 100


class TestSearchVectorFilters:
    def test_search_vector_with_project_filter(self, vector_index_file, mock_embedding_api, monkeypatch):
        """向量模式下 project_filter 生效"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        # 用不存在的项目过滤，应返回空
        results = engine.search("连接失败", top_k=10, project_filter="不存在项目XYZ")
        assert results == []

    def test_search_vector_with_category_filter(self, vector_index_file, mock_embedding_api, monkeypatch):
        """向量模式下 category_filter 生效"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        results = engine.search("连接失败", top_k=10, category_filter="不存在分类XYZ")
        assert results == []

    def test_search_vector_top_k(self, vector_index_file, mock_embedding_api, monkeypatch):
        """top_k 参数正确截断"""
        monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
        engine = SearchEngine()
        for k in (1, 2, 5):
            results = engine.search("连接失败", top_k=k)
            assert len(results) <= k


class TestVectorBuilder:
    def test_vector_builder_cli(self, monkeypatch):
        """vector_builder.build_vectors 可执行（mock API）"""
        import importlib
        vector_builder = importlib.import_module("utils.vector_builder")

        # mock API 调用
        def _fake_call(texts, *args, **kwargs):
            return [[0.1] * 8 for _ in texts]
        monkeypatch.setattr(vector_builder, "_call_embedding_api", _fake_call)

        # 用 conftest 中定义的索引路径
        from pathlib import Path
        repo_root = Path(__file__).resolve().parents[2]  # troubleshoot-ai-assistant
        index_file = repo_root / "deploy" / "搜索索引.json"
        if not index_file.exists():
            return  # 索引文件不存在则跳过
        total, success, failed = vector_builder.build_vectors(index_path=str(index_file))
        assert total > 0
        assert success > 0
        assert failed == 0
