"""Search 模块测试"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from index1.config import Config
from index1.db import Database
from index1.embed import OllamaEmbedder
from index1.search import (
    SearchResponse,
    _cjk_ratio,
    _dedup_by_file,
    _escape_fts5_query,
    _has_cjk,
    _max_for_file,
    _rerank,
    _rrf_fusion,
    _score_to_relevance,
    search,
)


class TestHasCJK:
    def test_chinese(self):
        assert _has_cjk("清算接口") is True

    def test_japanese(self):
        assert _has_cjk("こんにちは") is True

    def test_korean(self):
        assert _has_cjk("안녕하세요") is True

    def test_english(self):
        assert _has_cjk("hello world") is False

    def test_mixed(self):
        assert _has_cjk("hello 清算") is True

    def test_empty(self):
        assert _has_cjk("") is False


class TestRRFFusion:
    def test_basic_fusion(self):
        bm25 = [(1, 10.0), (2, 8.0), (3, 5.0)]
        vec = [(2, 0.1), (3, 0.2), (4, 0.3)]

        results = _rrf_fusion(bm25, vec, k=60)

        # chunk 2 出现在两路结果中，应该排名最高
        ids = [r[0] for r in results]
        assert ids[0] == 2

    def test_no_overlap(self):
        bm25 = [(1, 10.0)]
        vec = [(2, 0.1)]

        results = _rrf_fusion(bm25, vec, k=60)
        assert len(results) == 2

    def test_weighted_fusion(self):
        """CJK 查询应该向量权重更高"""
        bm25 = [(1, 10.0)]
        vec = [(2, 0.1)]

        # 向量权重高时，vec 的结果分数更高
        results = _rrf_fusion(bm25, vec, k=60, bm25_weight=0.2, vector_weight=0.8)
        scores = {r[0]: r[1] for r in results}
        assert scores[2] > scores[1]  # vec 结果分数更高

    def test_empty_inputs(self):
        results = _rrf_fusion([], [], k=60)
        assert results == []


class TestEscapeFTS5:
    def test_simple_query(self):
        result = _escape_fts5_query("hello world")
        assert '"hello"' in result
        assert '"world"' in result
        assert "OR" in result

    def test_special_chars(self):
        result = _escape_fts5_query('hello (world) "test"')
        assert "(" not in result.replace('"hello"', "").replace('"world"', "").replace('"test"', "")

    def test_empty_query(self):
        result = _escape_fts5_query("")
        assert result == '""'


class TestScoreToRelevance:
    def test_high(self):
        assert _score_to_relevance(0.9) == "high"
        assert _score_to_relevance(0.7) == "high"

    def test_medium(self):
        assert _score_to_relevance(0.5) == "medium"
        assert _score_to_relevance(0.4) == "medium"

    def test_low(self):
        assert _score_to_relevance(0.3) == "low"
        assert _score_to_relevance(0.0) == "low"


@pytest.fixture
def config(tmp_path):
    return Config(db_path=str(tmp_path / "test.db"))


@pytest.fixture
def db(config):
    d = Database(config.db_path_resolved, config.embedding_dim)
    d.connect()
    yield d
    d.close()


@pytest.fixture
def mock_embedder():
    embedder = AsyncMock(spec=OllamaEmbedder)
    embedder.model = "nomic-embed-text"
    embedder.dim = 768
    embedder.available = False
    embedder.check_availability = AsyncMock(return_value=False)
    embedder.close = AsyncMock()
    return embedder


def _seed_db(db: Database, collection: str = "test"):
    """在数据库中插入测试数据"""
    doc_id = db.upsert_document(
        path="docs/api.md", hash="abc123",
        collection=collection, doc_type="markdown",
    )
    chunks = [
        {
            "content": "Liquidation API handles position liquidation when health factor drops below 1.2",
            "section": "## Liquidation API",
            "parent_section": "# Risk Module",
            "chunk_index": 0,
            "tags": "liquidation, risk, api",
            "embedding": None,
            "token_count": 15,
            "summary": "Liquidation API handles position liquidation.",
        },
        {
            "content": "Margin Call triggers notification to users when collateral ratio approaches threshold",
            "section": "## Margin Call",
            "parent_section": "# Risk Module",
            "chunk_index": 1,
            "tags": "margin, call, notification",
            "embedding": None,
            "token_count": 14,
            "summary": "Margin Call triggers notification.",
        },
    ]
    db.insert_chunks(doc_id, chunks)
    return doc_id


class TestSearch:
    @pytest.mark.asyncio
    async def test_bm25_only(self, config, db, mock_embedder):
        """BM25 降级搜索"""
        _seed_db(db)
        resp = await search("liquidation", config, db, mock_embedder)

        assert isinstance(resp, SearchResponse)
        assert resp.mode == "bm25_only"
        assert len(resp.results) > 0
        assert resp.results[0].source == "docs/api.md"

    @pytest.mark.asyncio
    async def test_no_results(self, config, db, mock_embedder):
        """无结果搜索"""
        _seed_db(db)
        resp = await search("nonexistent_term_xyz", config, db, mock_embedder)

        assert resp.total == 0
        assert len(resp.results) == 0

    @pytest.mark.asyncio
    async def test_collection_filter(self, config, db, mock_embedder):
        """集合过滤"""
        _seed_db(db, collection="project-a")
        resp = await search("liquidation", config, db, mock_embedder, collection="project-b")

        assert resp.total == 0

    @pytest.mark.asyncio
    async def test_result_fields(self, config, db, mock_embedder):
        """验证结果字段完整性"""
        _seed_db(db)
        resp = await search("liquidation", config, db, mock_embedder)

        r = resp.results[0]
        assert r.chunk_id > 0
        assert r.score > 0
        assert r.source is not None
        assert r.section is not None
        assert r.summary is not None
        assert r.relevance in ("high", "medium", "low")
        assert r.content is None  # non-verbose

    @pytest.mark.asyncio
    async def test_verbose_mode(self, config, db, mock_embedder):
        """verbose 模式返回完整内容"""
        _seed_db(db)
        resp = await search("liquidation", config, db, mock_embedder, verbose=True)

        r = resp.results[0]
        assert r.content is not None
        assert "Liquidation" in r.content

    @pytest.mark.asyncio
    async def test_cjk_detection(self, config, db, mock_embedder):
        """CJK 查询应该正常工作（降级到 BM25）"""
        _seed_db(db)
        resp = await search("清算接口", config, db, mock_embedder)
        # 中文查询在 BM25 only 模式下可能没有结果（FTS5 分词问题）
        assert isinstance(resp, SearchResponse)
        assert resp.mode == "bm25_only"

    @pytest.mark.asyncio
    async def test_query_ms(self, config, db, mock_embedder):
        """查询时间应大于 0"""
        _seed_db(db)
        resp = await search("liquidation", config, db, mock_embedder)
        assert resp.query_ms >= 0

    @pytest.mark.asyncio
    async def test_top_k(self, config, db, mock_embedder):
        """top_k 限制结果数"""
        _seed_db(db)
        resp = await search("risk", config, db, mock_embedder, top_k=1)
        assert len(resp.results) <= 1


class TestCjkRatio:
    """CJK 占比计算"""

    def test_pure_chinese(self):
        assert _cjk_ratio("清算接口") > 0.99

    def test_pure_english(self):
        assert _cjk_ratio("hello world") == 0.0

    def test_mixed(self):
        # "Event Buffer 审查修复" — 4 CJK / 16 non-space chars ≈ 0.25
        ratio = _cjk_ratio("Event Buffer 审查修复")
        assert 0.2 < ratio < 0.5

    def test_empty(self):
        assert _cjk_ratio("") == 0.0

    def test_spaces_only(self):
        assert _cjk_ratio("   ") == 0.0

    def test_high_cjk(self):
        # "数据库连接池配置" — pure CJK
        ratio = _cjk_ratio("数据库连接池配置")
        assert ratio > 0.7


class TestCrossEncoderRerank:
    """Cross-encoder rerank 测试"""

    @pytest.mark.asyncio
    async def test_skip_high_cjk(self, config, db, mock_embedder):
        """CJK > 70% → 跳过 rerank，返回原始 fused"""
        fused = [(1, 0.8), (2, 0.5), (3, 0.3)]
        result = await _rerank("纯中文查询测试句子", fused, db, mock_embedder, config)
        assert result is fused  # identity check — 同一个对象

    @pytest.mark.asyncio
    async def test_skip_few_candidates(self, config, db, mock_embedder):
        """候选 < 2 → 跳过 rerank"""
        _seed_db(db)
        # 只有 1 个候选（且 chunk_id 存在于 db 中）
        fused = [(1, 0.8)]
        with patch("index1.search._get_reranker") as mock_get:
            mock_get.return_value = MagicMock()
            result = await _rerank("liquidation", fused, db, mock_embedder, config)
        assert result is fused

    @pytest.mark.asyncio
    async def test_degradation_on_error(self, config, db, mock_embedder):
        """_get_reranker 异常 → 返回原始 fused"""
        _seed_db(db)
        fused = [(1, 0.8), (2, 0.5)]
        with patch("index1.search._get_reranker", side_effect=RuntimeError("模型加载失败")):
            result = await _rerank("liquidation", fused, db, mock_embedder, config)
        assert result is fused

    @pytest.mark.asyncio
    async def test_degradation_on_timeout(self, config, db, mock_embedder):
        """推理超时 → 返回原始 fused"""
        import asyncio

        _seed_db(db)
        fused = [(1, 0.8), (2, 0.5)]

        mock_reranker = MagicMock()
        # 模拟超时：rerank 调用阻塞超过 500ms
        def slow_rerank(q, docs):
            import time
            time.sleep(0.6)
            return [0.5, 0.3]

        mock_reranker.rerank = slow_rerank

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            result = await _rerank("liquidation", fused, db, mock_embedder, config)
        assert result is fused

    @pytest.mark.asyncio
    async def test_success_reorder(self, config, db, mock_embedder):
        """正常 rerank → 按 cross-encoder 分数重排 + 归一化"""
        _seed_db(db)
        # fused: chunk 1 排第一，chunk 2 排第二
        fused = [(1, 0.8), (2, 0.5)]

        mock_reranker = MagicMock()
        # cross-encoder 认为 chunk 2 比 chunk 1 更相关
        mock_reranker.rerank = MagicMock(return_value=[-5.0, 10.0])

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            result = await _rerank("liquidation", fused, db, mock_embedder, config)

        # 新排序: chunk 2 排第一（score=10.0 > -5.0）
        assert result is not fused
        assert result[0][0] == 2
        assert result[1][0] == 1
        # 归一化: max=10.0 → 1.0, min=-5.0 → 0.0
        assert result[0][1] == pytest.approx(1.0)
        assert result[1][1] == pytest.approx(0.0)

    @pytest.mark.asyncio
    async def test_normalization_equal_scores(self, config, db, mock_embedder):
        """所有分数相同 → 全部归一化为 1.0"""
        _seed_db(db)
        fused = [(1, 0.8), (2, 0.5)]

        mock_reranker = MagicMock()
        mock_reranker.rerank = MagicMock(return_value=[3.0, 3.0])

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            result = await _rerank("liquidation", fused, db, mock_embedder, config)

        # 所有分数相同 → 全部 1.0
        assert result[0][1] == pytest.approx(1.0)
        assert result[1][1] == pytest.approx(1.0)


def _seed_multi_file_db(db: Database, collection: str = "test"):
    """插入多文件测试数据，模拟 CHANGELOG 洪泛场景

    文件布局：
    - CHANGELOG.md: 5 chunks（每个版本都提到 HMR）
    - docs/guide/hmr.md: 1 chunk（真正的 HMR 文档）
    - docs/guide/ssr.md: 1 chunk（SSR 文档）
    - docs/api.md: 2 chunks（API 文档）
    """
    # CHANGELOG — 5 chunks，模拟巨型文件
    changelog_id = db.upsert_document(
        path="CHANGELOG.md", hash="ch1",
        collection=collection, doc_type="markdown",
    )
    db.insert_chunks(changelog_id, [
        {
            "content": f"v{5-i}.0.0 - Added HMR hot module replacement support for CSS",
            "section": f"## v{5-i}.0.0", "parent_section": "# Changelog",
            "chunk_index": i, "tags": "hmr, changelog",
            "embedding": None, "token_count": 12,
            "summary": f"v{5-i}.0.0 release notes",
        }
        for i in range(5)
    ])

    # docs/guide/hmr.md — 真正相关的文档
    hmr_id = db.upsert_document(
        path="docs/guide/hmr.md", hash="hmr1",
        collection=collection, doc_type="markdown",
    )
    db.insert_chunks(hmr_id, [{
        "content": "HMR hot module replacement API allows updating modules at runtime without full reload",
        "section": "# HMR API", "parent_section": None,
        "chunk_index": 0, "tags": "hmr, api",
        "embedding": None, "token_count": 15,
        "summary": "HMR API documentation",
    }])

    # docs/guide/ssr.md
    ssr_id = db.upsert_document(
        path="docs/guide/ssr.md", hash="ssr1",
        collection=collection, doc_type="markdown",
    )
    db.insert_chunks(ssr_id, [{
        "content": "SSR server side rendering setup and configuration guide",
        "section": "# SSR Guide", "parent_section": None,
        "chunk_index": 0, "tags": "ssr, guide",
        "embedding": None, "token_count": 10,
        "summary": "SSR setup guide",
    }])

    # docs/api.md — 2 chunks
    api_id = db.upsert_document(
        path="docs/api.md", hash="api1",
        collection=collection, doc_type="markdown",
    )
    db.insert_chunks(api_id, [
        {
            "content": "The HMR API provides programmatic control over hot module replacement",
            "section": "## HMR", "parent_section": "# API Reference",
            "chunk_index": 0, "tags": "hmr, api",
            "embedding": None, "token_count": 12,
            "summary": "HMR API reference",
        },
        {
            "content": "The Plugin API allows extending build functionality with custom transforms",
            "section": "## Plugin API", "parent_section": "# API Reference",
            "chunk_index": 1, "tags": "plugin, api",
            "embedding": None, "token_count": 11,
            "summary": "Plugin API reference",
        },
    ])


class TestMaxForFile:
    """文件类型感知的 max_per_file"""

    def test_normal_file(self):
        assert _max_for_file("src/index.ts", 2) == 2

    def test_changelog(self):
        assert _max_for_file("CHANGELOG.md", 2) == 1

    def test_changelog_nested(self):
        assert _max_for_file("packages/vite/CHANGELOG.md", 2) == 1

    def test_license(self):
        assert _max_for_file("LICENSE.md", 2) == 1

    def test_license_no_ext(self):
        assert _max_for_file("LICENSE", 2) == 1

    def test_history(self):
        assert _max_for_file("HISTORY.md", 2) == 1

    def test_changes(self):
        assert _max_for_file("CHANGES.rst", 2) == 1

    def test_copying(self):
        assert _max_for_file("COPYING", 2) == 1

    def test_lock_file(self):
        assert _max_for_file("package-lock.json", 2) == 1

    def test_minified_js(self):
        assert _max_for_file("vendor/jquery.min.js", 2) == 1

    def test_minified_css(self):
        assert _max_for_file("dist/style.min.css", 2) == 1

    def test_case_insensitive(self):
        """CHANGELOG 大小写不敏感"""
        assert _max_for_file("changelog.md", 2) == 1
        assert _max_for_file("Changelog.md", 2) == 1


class TestDedupByFile:
    """文档级去重"""

    def test_basic_dedup(self, config, db):
        """同文件超过 max_per_file 的 chunk 被去重"""
        _seed_multi_file_db(db)

        # 模拟 RRF 融合后的结果：CHANGELOG 5 chunks 排最前面
        # 需要查询实际的 chunk_id
        rows = db.conn.execute(
            "SELECT c.id, d.path FROM chunks c JOIN documents d ON c.doc_id = d.id "
            "ORDER BY d.path, c.chunk_index"
        ).fetchall()
        id_by_path: dict[str, list[int]] = {}
        for cid, path in rows:
            id_by_path.setdefault(path, []).append(cid)

        changelog_ids = id_by_path["CHANGELOG.md"]
        hmr_ids = id_by_path["docs/guide/hmr.md"]
        api_ids = id_by_path["docs/api.md"]

        # fused: CHANGELOG 5 chunks 排前面，然后是 hmr.md 和 api.md
        fused = (
            [(cid, 0.9 - i * 0.01) for i, cid in enumerate(changelog_ids)]
            + [(hmr_ids[0], 0.84)]
            + [(api_ids[0], 0.83), (api_ids[1], 0.82)]
        )

        result, skipped = _dedup_by_file(fused, db, top_k=5)

        # CHANGELOG max=1（噪音文件），所以只保留 1 个
        sources = []
        for cid, _ in result:
            row = db.conn.execute(
                "SELECT d.path FROM chunks c JOIN documents d ON c.doc_id = d.id WHERE c.id = ?",
                (cid,),
            ).fetchone()
            sources.append(row[0])

        assert skipped is True
        assert sources.count("CHANGELOG.md") == 1  # CHANGELOG 限 1
        assert "docs/guide/hmr.md" in sources  # 真正相关的文档出现了
        assert "docs/api.md" in sources

    def test_normal_file_max_2(self, config, db):
        """普通文件保留最多 2 个 chunk"""
        _seed_multi_file_db(db)

        rows = db.conn.execute(
            "SELECT c.id, d.path FROM chunks c JOIN documents d ON c.doc_id = d.id "
            "WHERE d.path = 'docs/api.md' ORDER BY c.chunk_index"
        ).fetchall()
        api_ids = [r[0] for r in rows]

        hmr_row = db.conn.execute(
            "SELECT c.id FROM chunks c JOIN documents d ON c.doc_id = d.id "
            "WHERE d.path = 'docs/guide/hmr.md'"
        ).fetchone()

        # 3 个 api.md chunk（只有 2 个在 DB 中，再加一个假的也没关系）
        fused = [(api_ids[0], 0.9), (api_ids[1], 0.8), (hmr_row[0], 0.7)]

        result, skipped = _dedup_by_file(fused, db, top_k=5)

        # api.md 2 个都保留（max_per_file=2），hmr.md 1 个
        assert len(result) == 3
        assert skipped is False

    def test_empty_input(self, config, db):
        """空输入返回空列表"""
        result, skipped = _dedup_by_file([], db, top_k=5)
        assert result == []
        assert skipped is False

    def test_chunk_not_in_db(self, config, db):
        """chunk 不存在时直接通过（不崩溃）"""
        fused = [(99999, 0.9), (99998, 0.8)]
        result, skipped = _dedup_by_file(fused, db, top_k=5)
        assert len(result) == 2
        assert skipped is False

    def test_top_k_respected(self, config, db):
        """结果数不超过 top_k"""
        _seed_multi_file_db(db)

        rows = db.conn.execute(
            "SELECT c.id FROM chunks c JOIN documents d ON c.doc_id = d.id "
            "ORDER BY c.id"
        ).fetchall()
        all_ids = [r[0] for r in rows]

        fused = [(cid, 0.9 - i * 0.01) for i, cid in enumerate(all_ids)]
        result, _ = _dedup_by_file(fused, db, top_k=3)
        assert len(result) <= 3

    @pytest.mark.asyncio
    async def test_e2e_search_with_dedup(self, config, db, mock_embedder):
        """端到端测试：搜索 HMR，CHANGELOG 不再霸屏"""
        _seed_multi_file_db(db)

        resp = await search("HMR hot module replacement", config, db, mock_embedder, top_k=5)

        sources = [r.source for r in resp.results]
        # CHANGELOG 最多 1 个（噪音文件限制）
        assert sources.count("CHANGELOG.md") <= 1
        # 真正的 HMR 文档应该出现
        assert "docs/guide/hmr.md" in sources or "docs/api.md" in sources

    def test_dedup_returns_skipped_flag(self, config, db):
        """去重时 skipped=True，搜索管线据此添加 +dedup mode 标记"""
        _seed_multi_file_db(db)

        rows = db.conn.execute(
            "SELECT c.id, d.path FROM chunks c JOIN documents d ON c.doc_id = d.id "
            "WHERE d.path = 'CHANGELOG.md' ORDER BY c.chunk_index"
        ).fetchall()
        changelog_ids = [r[0] for r in rows]
        assert len(changelog_ids) >= 3

        # 构造 fused：CHANGELOG 3 chunks 排前面 → max=1 → 必触发去重
        fused = [(cid, 0.9 - i * 0.01) for i, cid in enumerate(changelog_ids[:3])]
        result, skipped = _dedup_by_file(fused, db, top_k=5)

        assert skipped is True
        assert len(result) == 1  # CHANGELOG 只保留 1 个


class TestFTS5ColumnWeights:
    """FTS5 列权重 — 标题匹配加权"""

    def test_heading_match_scores_higher(self, config, db):
        """section 匹配的 chunk 应比仅 content 匹配的 chunk 得分更高"""
        doc_id = db.upsert_document(
            path="test.md", hash="h1", collection="test", doc_type="markdown",
        )
        db.insert_chunks(doc_id, [
            {
                "content": "This module handles user login flows.",
                "section": "## Authentication",
                "parent_section": "# Security",
                "chunk_index": 0,
                "tags": "authentication",
                "embedding": None,
                "token_count": 10,
            },
            {
                "content": "The authentication system verifies credentials against the database.",
                "section": "## Database Layer",
                "parent_section": "# Architecture",
                "chunk_index": 1,
                "tags": "database, layer",
                "embedding": None,
                "token_count": 10,
            },
        ])

        from index1.db import tokenize_for_fts5

        query = tokenize_for_fts5("authentication")
        weighted = db.fts5_search(query, "test", 10, column_weights=(1.0, 3.0, 1.5, 2.0))
        unweighted = db.fts5_search(query, "test", 10)

        assert len(weighted) >= 2
        assert len(unweighted) >= 2

        # weighted: 标题匹配的 chunk（chunk_index=0）应排第一
        weighted_ids = [cid for cid, _ in weighted]
        chunk_idx = db.conn.execute(
            "SELECT chunk_index FROM chunks WHERE id = ?", (weighted_ids[0],)
        ).fetchone()
        assert chunk_idx[0] == 0

    def test_column_weights_none_is_default(self, config, db):
        """column_weights=None 等价于全 1.0"""
        _seed_db(db)
        from index1.db import tokenize_for_fts5

        query = tokenize_for_fts5("liquidation")
        r1 = db.fts5_search(query, "test", 10)
        r2 = db.fts5_search(query, "test", 10, column_weights=None)
        assert r1 == r2

    def test_wrong_column_count_raises(self, config, db):
        """列权重数量不匹配 FTS5 列数时应 ValueError"""
        _seed_db(db)
        from index1.db import tokenize_for_fts5

        query = tokenize_for_fts5("liquidation")
        with pytest.raises(ValueError, match="需要 4 个值"):
            db.fts5_search(query, "test", 10, column_weights=(1.0, 2.0))

    def test_non_finite_weight_raises(self, config, db):
        """NaN/Inf 权重应 ValueError"""
        _seed_db(db)
        from index1.db import tokenize_for_fts5

        query = tokenize_for_fts5("liquidation")
        with pytest.raises(ValueError, match="非有限值"):
            db.fts5_search(query, "test", 10, column_weights=(1.0, float("nan"), 1.0, 1.0))
        with pytest.raises(ValueError, match="非有限值"):
            db.fts5_search(query, "test", 10, column_weights=(1.0, float("inf"), 1.0, 1.0))

    def test_config_default_weights(self, config):
        """config 默认权重"""
        assert config.fts5_column_weights == (1.0, 3.0, 1.5, 2.0)
