"""unified_search.py 统一搜索测试"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from index1.unified_search import (
    CodeItem,
    FactItem,
    TacticItem,
    UnifiedResult,
    unified_recall,
    _build_hint,
    _get_chunk_symbols,
    _find_model,
    _search_cognition,
    _wants_cross_collection,
    _apply_collection_boost,
    _merge_cognition_fallback,
    _rerank_facts,
    _cosine_similarity,
    _centroid_affinity,
    _combined_decay,
    _time_decay_factor,
    _access_rate_factor,
    _return_noise_factor,
    _intent_category_match,
    _apply_engagement_boost,
    _adaptive_top_k,
    _expand_causal_chain,
    _apply_session_continuity_boost,
    _apply_returned_demotion,
    _expand_cross_type,
    _extract_file_ref_l0,
)
from index1.query import EnrichedQuery
from index1.search import SearchResponse, SearchResult
from index1.cognitive.search_cog import RecallResult


# ── Fixtures ──


@pytest.fixture()
def mock_config():
    # 显式设置属性防止 MagicMock 干扰 `getattr(...) is True` 模式（#190）
    cfg = MagicMock()
    cfg.proximity_enabled = False
    cfg.proximity_max_distance = 5
    cfg.engagement_boost_enabled = False
    cfg.min_score_ratio = 0.15
    cfg.expansion_enabled = False
    cfg.rerank_enabled = False
    cfg.adaptive_top_k_enabled = False
    cfg.causal_expansion_enabled = False
    return cfg


@pytest.fixture()
def mock_db():
    db = MagicMock()
    db.conn = MagicMock()
    db.get_all_symbol_names.return_value = []  # 避免 MagicMock truthy 误触发 symbol_lookup
    return db


@pytest.fixture()
def mock_embedder():
    embedder = MagicMock()
    embedder.available = True
    return embedder


@pytest.fixture()
def mock_db_cog():
    db_cog = MagicMock()
    db_cog.search_tactics.return_value = []
    db_cog.get_mental_model.return_value = None
    return db_cog


def _make_search_response(n: int = 3) -> SearchResponse:
    """构造 corpus SearchResponse"""
    results = []
    for i in range(n):
        results.append(SearchResult(
            chunk_id=100 + i,
            score=0.9 - i * 0.1,
            source=f"src/index1/module{i}.py",
            section=f"section_{i}",
            summary=f"Summary for result {i}",
            relevance="high" if i == 0 else "medium",
            content=f"Code content for result {i}" * 20,
        ))
    return SearchResponse(results=results, total=n, mode="hybrid")


def _make_recall_result(n: int = 2) -> RecallResult:
    """构造 cognition RecallResult

    facts 携带 _rrf_score（MaxScore 归一化后），与 recall() 实际返回一致。
    """
    facts = []
    for i in range(n):
        facts.append({
            "id": 200 + i,
            "assertion": f"Fact assertion {i}",
            "category": "code",
            "confidence": 0.8 + i * 0.05,
            "_rrf_score": round(1.0 - i * 0.2, 4),  # 第一个=1.0，递减
        })
    return RecallResult(facts=facts, total=n, mode="bm25_only")


# ── 并行搜索测试 ──


class TestUnifiedRecall:
    def test_corpus_only(self, mock_config, mock_db, mock_embedder):
        """db_cog=None 时只搜 corpus"""
        resp = _make_search_response(2)
        with patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search:
            mock_search.return_value = resp
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "test query", mock_config, mock_db, mock_embedder,
                db_cog=None, top_k=5,
            ))

        assert isinstance(result, UnifiedResult)
        assert len(result.code) == 2
        assert len(result.facts) == 0
        assert len(result.tactics) == 0
        assert result.model is None
        assert result.query_ms >= 0

    def test_corpus_plus_cognition(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """corpus + cognition 都有结果"""
        corpus_resp = _make_search_response(3)
        cog_resp = _make_recall_result(2)

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
        ):
            mock_search.return_value = corpus_resp
            mock_cog.return_value = [
                FactItem(id=200, score=0.8, assertion="Fact 0", category="code", confidence=0.8),
                FactItem(id=201, score=0.85, assertion="Fact 1", category="code", confidence=0.85),
            ]
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "test query", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=5,
            ))

        assert len(result.code) == 3
        assert len(result.facts) == 2
        assert result.mode in ("hybrid", "bm25")

    def test_with_tactics(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """tactics 有结果"""
        corpus_resp = _make_search_response(1)
        mock_db_cog.search_tactics.return_value = [
            {"id": 300, "trigger_pattern": "error handling", "steps": "step 1", "success_rate": 0.9},
        ]

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
        ):
            mock_search.return_value = corpus_resp
            mock_cog.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "error handling", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=5,
            ))

        assert len(result.tactics) == 1
        assert result.tactics[0].trigger_pattern == "error handling"
        assert result.tactics[0].success_rate == 0.9

    def test_bm25_degradation(self, mock_config, mock_db, mock_embedder):
        """embedder 不可用时降级 bm25"""
        mock_embedder.available = False
        resp = _make_search_response(1)
        resp.mode = "bm25_only"  # search.py 实际降级时返回此值

        with patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search:
            mock_search.return_value = resp
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "test", mock_config, mock_db, mock_embedder,
                db_cog=None, top_k=5,
            ))

        assert result.mode == "bm25_only"

    def test_empty_results(self, mock_config, mock_db, mock_embedder):
        """空结果"""
        resp = SearchResponse()

        with patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search:
            mock_search.return_value = resp
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "nonexistent", mock_config, mock_db, mock_embedder,
                db_cog=None, top_k=5,
            ))

        assert len(result.code) == 0
        assert result.hint == "未找到相关结果"

    def test_corpus_exception_handled(self, mock_config, mock_db, mock_embedder):
        """corpus 搜索异常不影响整体"""
        with patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search:
            mock_search.side_effect = RuntimeError("DB error")

            result = asyncio.run(unified_recall(
                "test", mock_config, mock_db, mock_embedder,
                db_cog=None, top_k=5,
            ))

        assert len(result.code) == 0

    def test_cognition_exception_handled(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """cognition 搜索异常不影响 corpus"""
        corpus_resp = _make_search_response(2)

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
        ):
            mock_search.return_value = corpus_resp
            mock_cog.side_effect = RuntimeError("Cognition error")
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "test", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=5,
            ))

        assert len(result.code) == 2
        assert len(result.facts) == 0

    def test_collection_passed_through(self, mock_config, mock_db, mock_embedder):
        """collection 参数传递给底层搜索"""
        resp = _make_search_response(1)

        with patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search:
            mock_search.return_value = resp
            mock_db.conn.execute.return_value.fetchall.return_value = []

            asyncio.run(unified_recall(
                "test", mock_config, mock_db, mock_embedder,
                db_cog=None, collection="my-proj", top_k=5,
            ))

        call_kwargs = mock_search.call_args
        assert call_kwargs.kwargs.get("collection") == "my-proj"

    def test_collection_fallback_to_config(self, mock_config, mock_db, mock_embedder):
        """collection=None 时，使用 config.collection 作为 fallback（#129 修复）"""
        mock_config.collection = "index1"
        resp = _make_search_response(1)

        with patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search:
            mock_search.return_value = resp
            mock_db.conn.execute.return_value.fetchall.return_value = []

            asyncio.run(unified_recall(
                "test query", mock_config, mock_db, mock_embedder,
                db_cog=None, collection=None, top_k=5,
            ))

        call_kwargs = mock_search.call_args
        assert call_kwargs.kwargs.get("collection") == "index1"

    def test_explicit_collection_overrides_config(self, mock_config, mock_db, mock_embedder):
        """显式传 collection 时，不用 config.collection"""
        mock_config.collection = "default-proj"
        resp = _make_search_response(1)

        with patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search:
            mock_search.return_value = resp
            mock_db.conn.execute.return_value.fetchall.return_value = []

            asyncio.run(unified_recall(
                "test", mock_config, mock_db, mock_embedder,
                db_cog=None, collection="specific-proj", top_k=5,
            ))

        call_kwargs = mock_search.call_args
        assert call_kwargs.kwargs.get("collection") == "specific-proj"


# ── Symbols 附带测试 ──


class TestGetChunkSymbols:
    def test_returns_symbols(self, mock_db):
        mock_db.conn.execute.return_value.fetchall.return_value = [
            ("func_a",), ("ClassB",), ("method_c",),
        ]
        symbols = _get_chunk_symbols(mock_db, chunk_id=100)
        assert symbols == ["func_a", "ClassB", "method_c"]

    def test_empty_symbols(self, mock_db):
        mock_db.conn.execute.return_value.fetchall.return_value = []
        symbols = _get_chunk_symbols(mock_db, chunk_id=999)
        assert symbols == []

    def test_exception_returns_empty(self, mock_db):
        mock_db.conn.execute.side_effect = Exception("DB error")
        symbols = _get_chunk_symbols(mock_db, chunk_id=100)
        assert symbols == []


# ── Mental Model 匹配测试 ──


class TestFindModel:
    def test_finds_model(self, mock_db_cog):
        mock_db_cog.get_mental_model.return_value = {
            "scope": "src/index1/",
            "subject": "Core module",
            "understanding_level": 0.75,
        }
        items = [
            CodeItem(id=1, score=0.9, source="src/index1/search.py",
                     section=None, content="test", symbols=[]),
        ]
        model = _find_model(items, mock_db_cog, collection=None)
        assert model is not None
        assert model["scope"] == "src/index1/"
        assert model["understanding_level"] == 0.75

    def test_no_model(self, mock_db_cog):
        mock_db_cog.get_mental_model.return_value = None
        items = [
            CodeItem(id=1, score=0.9, source="src/index1/search.py",
                     section=None, content="test", symbols=[]),
        ]
        model = _find_model(items, mock_db_cog, collection=None)
        assert model is None

    def test_no_source(self, mock_db_cog):
        items = [
            CodeItem(id=1, score=0.9, source=None,
                     section=None, content="test", symbols=[]),
        ]
        model = _find_model(items, mock_db_cog, collection=None)
        assert model is None

    def test_exception_handled(self, mock_db_cog):
        mock_db_cog.get_mental_model.side_effect = Exception("DB error")
        items = [
            CodeItem(id=1, score=0.9, source="src/index1/search.py",
                     section=None, content="test", symbols=[]),
        ]
        model = _find_model(items, mock_db_cog, collection=None)
        assert model is None


# ── Hint 生成测试 ──


class TestBuildHint:
    def test_facts_only(self):
        result = UnifiedResult(
            facts=[FactItem(id=1, score=0.8, assertion="x", category="code", confidence=0.8)],
        )
        hint = _build_hint(result)
        assert "1 条相关经验" in hint

    def test_tactics_only(self):
        result = UnifiedResult(
            tactics=[
                TacticItem(id=1, trigger_pattern="p", steps="s", success_rate=0.9),
                TacticItem(id=2, trigger_pattern="p2", steps="s2", success_rate=0.8),
            ],
        )
        hint = _build_hint(result)
        assert "2 个可用策略" in hint

    def test_model_hint(self):
        result = UnifiedResult(
            code=[CodeItem(id=1, score=0.9, source="x", section=None, content="c", symbols=[])],
            model={"scope": "src/index1/", "understanding_level": 0.75},
        )
        hint = _build_hint(result)
        assert "75%" in hint

    def test_code_only_hint(self):
        result = UnifiedResult(
            code=[CodeItem(id=1, score=0.9, source="x", section=None, content="c", symbols=[])],
        )
        hint = _build_hint(result)
        assert "1 个代码片段" in hint

    def test_empty_hint(self):
        result = UnifiedResult()
        hint = _build_hint(result)
        assert hint == "未找到相关结果"

    def test_combined_hint(self):
        result = UnifiedResult(
            code=[CodeItem(id=1, score=0.9, source="x", section=None, content="c", symbols=[])],
            facts=[FactItem(id=1, score=0.8, assertion="x", category="code", confidence=0.8)],
            tactics=[TacticItem(id=1, trigger_pattern="p", steps="s", success_rate=0.9)],
        )
        hint = _build_hint(result)
        assert "经验" in hint
        assert "策略" in hint
        # code 不应出现在 hint 中（facts/tactics 已有内容时）
        assert "代码片段" not in hint


# ── CodeItem 构造测试 ──


class TestSearchCognitionScoreMapping:
    """#137: _search_cognition 应从 _rrf_score 读取 score，而非 confidence"""

    def test_score_from_rrf_not_confidence(self, mock_db_cog, mock_embedder):
        """FactItem.score 应来自 _rrf_score，不是 confidence"""
        recall_result = RecallResult(
            facts=[
                {"id": 1, "assertion": "A", "category": "code",
                 "confidence": 0.9, "_rrf_score": 1.0},
                {"id": 2, "assertion": "B", "category": "code",
                 "confidence": 0.3, "_rrf_score": 0.75},
            ],
            total=2,
            mode="bm25_only",
        )

        with patch("index1.cognitive.search_cog.recall", new_callable=AsyncMock) as mock_recall:
            mock_recall.return_value = recall_result
            items = asyncio.run(_search_cognition(
                "test", mock_db_cog, mock_embedder,
                collection=None, top_k=10,
            ))

        assert len(items) == 2
        # score 应来自 _rrf_score，不是 confidence
        assert items[0].score == 1.0
        assert items[1].score == 0.75
        # confidence 保持独立
        assert items[0].confidence == 0.9
        assert items[1].confidence == 0.3

    def test_score_fallback_without_rrf_score(self, mock_db_cog, mock_embedder):
        """缺少 _rrf_score 时 fallback 到 0.5"""
        recall_result = RecallResult(
            facts=[{"id": 1, "assertion": "A", "category": "code", "confidence": 0.9}],
            total=1,
            mode="bm25_only",
        )

        with patch("index1.cognitive.search_cog.recall", new_callable=AsyncMock) as mock_recall:
            mock_recall.return_value = recall_result
            items = asyncio.run(_search_cognition(
                "test", mock_db_cog, mock_embedder,
                collection=None, top_k=10,
            ))

        assert len(items) == 1
        assert items[0].score == 0.5  # fallback 默认值
        assert items[0].confidence == 0.9


# ── CodeItem 构造测试 ──


class TestCodeItemConstruction:
    def test_content_truncated(self):
        """content 应截断到 500 字符"""
        long_content = "x" * 1000
        item = CodeItem(id=1, score=0.9, source="a.py", section=None,
                        content=long_content[:500], symbols=[])
        assert len(item.content) == 500

    def test_symbols_default(self):
        item = CodeItem(id=1, score=0.9, source="a.py", section=None, content="c")
        assert item.symbols == []


# ── A2 策略：Cross-Collection Search 测试 ──


class TestWantsCrossCollection:
    """A2 策略 — query-type routing 决策"""

    def test_error_query_wants_cross(self):
        """error 关键词 → 跨集合"""
        enriched = EnrichedQuery(original="error handling", normalized="error handling")
        assert _wants_cross_collection(enriched, "error handling") is True

    def test_bug_query_wants_cross(self):
        """bug 关键词 → 跨集合"""
        enriched = EnrichedQuery(original="how to fix this bug", normalized="how to fix this bug")
        assert _wants_cross_collection(enriched, "how to fix this bug") is True

    def test_exception_query_wants_cross(self):
        """exception 关键词 → 跨集合"""
        enriched = EnrichedQuery(original="TypeError exception", normalized="TypeError exception")
        assert _wants_cross_collection(enriched, "TypeError exception") is True

    def test_chinese_error_wants_cross(self):
        """中文报错 → 跨集合"""
        enriched = EnrichedQuery(original="报错信息", normalized="报错信息")
        assert _wants_cross_collection(enriched, "报错信息") is True

    def test_crash_wants_cross(self):
        """crash → 跨集合"""
        enriched = EnrichedQuery(original="crash on startup", normalized="crash on startup")
        assert _wants_cross_collection(enriched, "crash on startup") is True

    def test_normal_query_no_cross(self):
        """普通查询 → 不跨集合"""
        enriched = EnrichedQuery(original="search pipeline", normalized="search pipeline")
        assert _wants_cross_collection(enriched, "search pipeline") is False

    def test_symbol_lookup_never_cross(self):
        """symbol_lookup 路由 → 永远不跨集合（即使含 error）"""
        enriched = EnrichedQuery(
            original="error_handler", normalized="error_handler",
            route="symbol_lookup",
        )
        assert _wants_cross_collection(enriched, "error_handler") is False

    def test_graph_query_never_cross(self):
        """graph_query 路由 → 永远不跨集合"""
        enriched = EnrichedQuery(
            original="谁调用了 error_handler", normalized="谁调用了 error_handler",
            route="graph_query",
        )
        assert _wants_cross_collection(enriched, "谁调用了 error_handler") is False

    def test_traceback_wants_cross(self):
        """traceback → 跨集合"""
        enriched = EnrichedQuery(original="traceback info", normalized="traceback info")
        assert _wants_cross_collection(enriched, "traceback info") is True

    def test_chinese_crash_wants_cross(self):
        """崩溃 → 跨集合"""
        enriched = EnrichedQuery(original="程序崩溃", normalized="程序崩溃")
        assert _wants_cross_collection(enriched, "程序崩溃") is True


class TestApplyCollectionBoost:
    """A2 策略 — collection affinity boost"""

    def test_current_collection_no_decay(self):
        """当前 collection 的结果不衰减"""
        facts = [
            FactItem(id=1, score=1.0, assertion="A", category="code",
                     confidence=0.9, collection="index1"),
        ]
        result = _apply_collection_boost(facts, "index1")
        assert result[0].score == 1.0

    def test_other_collection_decays(self):
        """其他 collection 的结果衰减 0.5"""
        facts = [
            FactItem(id=1, score=1.0, assertion="A", category="code",
                     confidence=0.9, collection="blockchain_app"),
        ]
        result = _apply_collection_boost(facts, "index1")
        assert result[0].score == 0.5

    def test_none_collection_decays(self):
        """collection=None 的结果衰减"""
        facts = [
            FactItem(id=1, score=1.0, assertion="A", category="code",
                     confidence=0.9, collection=None),
        ]
        result = _apply_collection_boost(facts, "index1")
        assert result[0].score == 0.5

    def test_mixed_collections_sorted(self):
        """混合 collection → 按衰减后分数排序"""
        facts = [
            FactItem(id=1, score=0.8, assertion="other", category="code",
                     confidence=0.9, collection="blockchain_app"),
            FactItem(id=2, score=0.7, assertion="current", category="code",
                     confidence=0.8, collection="index1"),
        ]
        result = _apply_collection_boost(facts, "index1")
        # index1: 0.7 * 1.0 = 0.7; blockchain_app: 0.8 * 0.5 = 0.4
        assert result[0].id == 2  # index1 排第一
        assert result[0].score == 0.7
        assert result[1].id == 1
        assert result[1].score == 0.4

    def test_empty_facts(self):
        """空列表不报错"""
        result = _apply_collection_boost([], "index1")
        assert result == []


class TestMergeCognitionFallback:
    """A2 策略 — 跨集合 fallback 合并"""

    def test_dedup_by_id(self):
        """相同 id 的 fact 不重复"""
        primary = [
            FactItem(id=1, score=1.0, assertion="A", category="code",
                     confidence=0.9, collection="index1"),
        ]
        fallback = [
            FactItem(id=1, score=0.8, assertion="A", category="code",
                     confidence=0.9, collection="index1"),
            FactItem(id=2, score=0.7, assertion="B", category="code",
                     confidence=0.8, collection="blockchain_app"),
        ]
        result = _merge_cognition_fallback(primary, fallback, "index1", top_k=10)
        assert len(result) == 2
        ids = [f.id for f in result]
        assert 1 in ids
        assert 2 in ids

    def test_other_collection_decays(self):
        """fallback 中其他 collection 的结果衰减"""
        primary = []
        fallback = [
            FactItem(id=1, score=1.0, assertion="A", category="code",
                     confidence=0.9, collection="blockchain_app"),
        ]
        result = _merge_cognition_fallback(primary, fallback, "index1", top_k=10)
        assert result[0].score == 0.5  # 1.0 * 0.5

    def test_same_collection_no_decay(self):
        """fallback 中当前 collection 的结果不衰减"""
        primary = []
        fallback = [
            FactItem(id=1, score=1.0, assertion="A", category="code",
                     confidence=0.9, collection="index1"),
        ]
        result = _merge_cognition_fallback(primary, fallback, "index1", top_k=10)
        assert result[0].score == 1.0

    def test_truncate_to_top_k(self):
        """结果截断到 top_k"""
        primary = [
            FactItem(id=i, score=1.0 - i * 0.1, assertion=f"F{i}",
                     category="code", confidence=0.9, collection="index1")
            for i in range(5)
        ]
        fallback = [
            FactItem(id=100 + i, score=0.5 - i * 0.1, assertion=f"X{i}",
                     category="code", confidence=0.8, collection="other")
            for i in range(5)
        ]
        result = _merge_cognition_fallback(primary, fallback, "index1", top_k=5)
        assert len(result) == 5

    def test_sorted_by_score(self):
        """合并后按分数排序"""
        primary = [
            FactItem(id=1, score=0.3, assertion="A", category="code",
                     confidence=0.9, collection="index1"),
        ]
        fallback = [
            FactItem(id=2, score=1.0, assertion="B", category="code",
                     confidence=0.9, collection="index1"),
        ]
        result = _merge_cognition_fallback(primary, fallback, "index1", top_k=10)
        assert result[0].id == 2  # 分数更高排前面
        assert result[0].score == 1.0


class TestA2CognitionScopeRouting:
    """A2 策略 — cognition 搜索 scope 路由集成测试"""

    def test_default_query_uses_collection(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """普通查询 → cognition 搜索传 effective_coll（非 None）"""
        mock_config.collection = "index1"
        mock_config.expansion_enabled = False
        corpus_resp = _make_search_response(1)

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
        ):
            mock_search.return_value = corpus_resp
            mock_cog.return_value = [
                FactItem(id=i, score=0.8 - i * 0.01, assertion=f"Fact about topic {i}",
                         category="code", confidence=0.8, collection="index1")
                for i in range(5)
            ]  # 5 条不同的 facts，不触发 fallback
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            asyncio.run(unified_recall(
                "search pipeline", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=10,
            ))

        # _search_cognition 应该用 effective_coll（"index1"），而非 None
        cog_call = mock_cog.call_args
        assert cog_call.args[3] == "index1"  # collection 参数

    def test_error_query_uses_none(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """error 查询 → cognition 搜索传 None（跨集合）"""
        mock_config.collection = "index1"
        mock_config.expansion_enabled = False
        corpus_resp = _make_search_response(1)

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
        ):
            mock_search.return_value = corpus_resp
            mock_cog.return_value = [
                FactItem(id=1, score=0.8, assertion="F", category="code",
                         confidence=0.8, collection="index1"),
            ]
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            asyncio.run(unified_recall(
                "TypeError exception in handler", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=10,
            ))

        # _search_cognition 应该用 None（跨集合搜索）
        cog_call = mock_cog.call_args
        assert cog_call.args[3] is None

    def test_fallback_triggers_when_insufficient(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """结果不足时触发跨集合 fallback"""
        mock_config.collection = "index1"
        mock_config.expansion_enabled = False
        corpus_resp = _make_search_response(1)

        call_count = 0

        async def mock_cognition(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                # 初始搜索：只返回 1 条（< top_k // 2 = 5）
                return [
                    FactItem(id=1, score=1.0, assertion="F1", category="code",
                             confidence=0.9, collection="index1"),
                ]
            else:
                # fallback 搜索：返回更多
                return [
                    FactItem(id=1, score=1.0, assertion="F1", category="code",
                             confidence=0.9, collection="index1"),
                    FactItem(id=2, score=0.8, assertion="F2", category="code",
                             confidence=0.8, collection="blockchain_app"),
                    FactItem(id=3, score=0.6, assertion="F3", category="code",
                             confidence=0.7, collection="other"),
                ]

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", side_effect=mock_cognition) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
        ):
            mock_search.return_value = corpus_resp
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "search pipeline", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=10,
            ))

        # 应该调用了两次 _search_cognition（初始 + fallback）
        assert call_count == 2
        # 有 3 条结果（去重后），其中非当前 collection 的有衰减
        assert len(result.facts) == 3
        # debug 应记录 fallback 触发
        assert result.debug["cognition_scope"]["fallback_triggered"] is True

    def test_no_fallback_when_sufficient(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """结果充足时不触发 fallback"""
        mock_config.collection = "index1"
        mock_config.expansion_enabled = False
        corpus_resp = _make_search_response(1)

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
        ):
            mock_search.return_value = corpus_resp
            mock_cog.return_value = [
                FactItem(id=i, score=1.0 - i * 0.1, assertion=f"F{i}",
                         category="code", confidence=0.9, collection="index1")
                for i in range(6)  # 6 条 >= top_k // 2 = 5
            ]
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "search pipeline", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=10,
            ))

        # 只调了一次 _search_cognition
        assert mock_cog.call_count == 1
        assert result.debug["cognition_scope"]["fallback_triggered"] is False

    def test_debug_includes_cognition_scope(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        """debug 信息包含 cognition_scope"""
        mock_config.collection = "index1"
        mock_config.expansion_enabled = False
        corpus_resp = _make_search_response(1)

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
        ):
            mock_search.return_value = corpus_resp
            mock_cog.return_value = []
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "search pipeline", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, top_k=10,
            ))

        assert "cognition_scope" in result.debug
        assert "cross_collection" in result.debug["cognition_scope"]
        assert "fallback_triggered" in result.debug["cognition_scope"]

    def test_fact_item_has_collection(self, mock_db_cog, mock_embedder):
        """FactItem 应包含 collection 字段"""
        recall_result = RecallResult(
            facts=[
                {"id": 1, "assertion": "A", "category": "code",
                 "confidence": 0.9, "_rrf_score": 1.0, "collection": "index1"},
            ],
            total=1,
            mode="bm25_only",
        )

        with patch("index1.cognitive.search_cog.recall", new_callable=AsyncMock) as mock_recall:
            mock_recall.return_value = recall_result
            items = asyncio.run(_search_cognition(
                "test", mock_db_cog, mock_embedder,
                collection="index1", top_k=10,
            ))

        assert len(items) == 1
        assert items[0].collection == "index1"


# ── Cross-encoder Rerank for Facts 测试 ──


def _make_facts(n: int = 5) -> list[FactItem]:
    """构造 FactItem 列表用于 rerank 测试"""
    return [
        FactItem(
            id=i,
            score=round(1.0 - i * 0.15, 4),
            assertion=f"This is fact number {i} about testing software",
            category="code",
            confidence=0.85,
            collection="index1",
        )
        for i in range(n)
    ]


class TestRerankFacts:
    """_rerank_facts() — cross-encoder rerank for cognition facts"""

    def test_disabled_config(self):
        """rerank_enabled=False → 跳过，原始顺序不变"""
        config = MagicMock()
        config.rerank_enabled = False
        facts = _make_facts(3)
        original_scores = [f.score for f in facts]

        debug = asyncio.run(_rerank_facts("test query", facts, config))

        assert debug["applied"] is False
        assert debug["reason"] == "disabled"
        assert [f.score for f in facts] == original_scores

    def test_too_few_facts(self):
        """少于 2 条 → 跳过"""
        config = MagicMock()
        config.rerank_enabled = True
        facts = _make_facts(1)

        debug = asyncio.run(_rerank_facts("test", facts, config))

        assert debug["applied"] is False
        assert debug["reason"] == "too_few"

    def test_cjk_skip(self):
        """纯中文查询 → 跳过（MiniLM 是英文模型）"""
        config = MagicMock()
        config.rerank_enabled = True
        facts = _make_facts(3)
        original_scores = [f.score for f in facts]

        debug = asyncio.run(_rerank_facts("搜索管线实现原理", facts, config))

        assert debug["applied"] is False
        assert debug["reason"] == "cjk_skip"
        assert [f.score for f in facts] == original_scores

    def test_cjk_mixed_not_skipped(self):
        """中英混合查询（CJK < 70%）→ 不跳过"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"
        facts = _make_facts(3)

        mock_reranker = MagicMock()
        mock_reranker.rerank.return_value = [0.9, 0.1, 0.5]

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            debug = asyncio.run(_rerank_facts("hooks.py 超时处理", facts, config))

        assert debug["applied"] is True

    def test_rerank_reorders_by_cross_encoder_score(self):
        """cross-encoder 分数重排：原顺序被 cross-encoder 推翻"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"
        facts = _make_facts(4)
        # 原始顺序: id=0(1.0), id=1(0.85), id=2(0.7), id=3(0.55)

        mock_reranker = MagicMock()
        # cross-encoder 认为 id=2 最相关，id=0 最不相关
        mock_reranker.rerank.return_value = [0.1, 0.5, 0.9, 0.3]

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            debug = asyncio.run(_rerank_facts("test query", facts, config))

        assert debug["applied"] is True
        assert debug["candidates"] == 4
        # 重排后: id=2(最高) → id=1 → id=3 → id=0(最低)
        assert facts[0].id == 2
        assert facts[-1].id == 0
        # 分数归一化到 [0, 1]
        assert facts[0].score == 1.0  # max → 1.0
        assert facts[-1].score == 0.0  # min → 0.0

    def test_timeout_fallback(self):
        """超时 → 保持原始 RRF 顺序"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"
        facts = _make_facts(3)
        original_ids = [f.id for f in facts]

        mock_reranker = MagicMock()
        mock_reranker.rerank.side_effect = lambda q, d: (_ for _ in ()).throw(TimeoutError())

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            debug = asyncio.run(_rerank_facts("test", facts, config))

        assert debug["applied"] is False
        assert debug["reason"] == "timeout"
        assert [f.id for f in facts] == original_ids

    def test_import_error_fallback(self):
        """fastembed 未安装 → 降级到 RRF"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"
        facts = _make_facts(3)
        original_ids = [f.id for f in facts]

        with patch("index1.search._get_reranker", side_effect=ImportError("no fastembed")):
            debug = asyncio.run(_rerank_facts("test", facts, config))

        assert debug["applied"] is False
        assert "no fastembed" in debug["reason"]
        assert [f.id for f in facts] == original_ids

    def test_minmax_normalization_equal_scores(self):
        """所有 cross-encoder 分数相同 → 全部归一化为 1.0"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"
        facts = _make_facts(3)

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

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            debug = asyncio.run(_rerank_facts("test", facts, config))

        assert debug["applied"] is True
        assert all(f.score == 1.0 for f in facts)

    def test_assertion_truncated_to_500_chars(self):
        """长 assertion 截断到 500 字符"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"

        facts = [
            FactItem(id=0, score=0.9, assertion="x" * 1000,
                     category="code", confidence=0.8),
            FactItem(id=1, score=0.8, assertion="y" * 1000,
                     category="code", confidence=0.8),
        ]

        captured_docs = []
        mock_reranker = MagicMock()
        def capture_rerank(q, docs):
            captured_docs.extend(docs)
            return [0.8, 0.6]
        mock_reranker.rerank.side_effect = capture_rerank

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            asyncio.run(_rerank_facts("test", facts, config))

        assert all(len(d) == 500 for d in captured_docs)

    def test_negative_cross_encoder_logits(self):
        """cross-encoder 返回负数 logits → min-max 正确归一化"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"
        facts = _make_facts(3)

        mock_reranker = MagicMock()
        mock_reranker.rerank.return_value = [-2.5, -0.5, -1.5]

        with patch("index1.search._get_reranker", return_value=mock_reranker):
            debug = asyncio.run(_rerank_facts("test", facts, config))

        assert debug["applied"] is True
        # -0.5 → 1.0 (max), -2.5 → 0.0 (min), -1.5 → 0.5 (middle)
        assert facts[0].id == 1  # -0.5 → highest
        assert facts[0].score == 1.0
        assert facts[-1].id == 0  # -2.5 → lowest
        assert facts[-1].score == 0.0

    def test_integration_unified_recall_rerank_debug(self, mock_db, mock_embedder, mock_db_cog):
        """集成测试: unified_recall 中 rerank 触发时 debug 字段存在"""
        config = MagicMock()
        config.rerank_enabled = True
        config.rerank_model = "test-model"
        config.collection = "index1"
        config.expansion_enabled = False
        config.min_score_ratio = 0.15

        corpus_resp = _make_search_response(1)
        mock_reranker = MagicMock()
        mock_reranker.rerank.return_value = [0.3, 0.9]  # id=201 应排到前面

        with (
            patch("index1.unified_search.corpus_search", new_callable=AsyncMock) as mock_search,
            patch("index1.unified_search._search_cognition", new_callable=AsyncMock) as mock_cog,
            patch("index1.unified_search._search_tactics", new_callable=AsyncMock) as mock_tac,
            patch("index1.search._get_reranker", return_value=mock_reranker),
        ):
            mock_search.return_value = corpus_resp
            mock_cog.return_value = [
                FactItem(id=200, score=0.9, assertion="Older fact about search",
                         category="code", confidence=0.8, collection="index1"),
                FactItem(id=201, score=0.7, assertion="Newer more relevant fact",
                         category="code", confidence=0.85, collection="index1"),
            ]
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "search pipeline", config, mock_db, mock_embedder,
                db_cog=mock_db_cog, collection="index1", top_k=5,
            ))

        # rerank 应触发并出现在 debug 中
        assert result.debug is not None
        assert "rerank_facts" in result.debug
        assert result.debug["rerank_facts"]["applied"] is True
        # id=201 应因 cross-encoder 分数更高排到前面
        assert result.facts[0].id == 201


# ── Centroid Similarity Tests (#153 Phase 3 Task 7) ──


class TestCosineSimilarity:
    """_cosine_similarity() — 纯函数余弦相似度"""

    def test_identical_vectors(self):
        v = [1.0, 0.0, 0.0]
        assert _cosine_similarity(v, v) == pytest.approx(1.0)

    def test_orthogonal_vectors(self):
        a = [1.0, 0.0, 0.0]
        b = [0.0, 1.0, 0.0]
        assert _cosine_similarity(a, b) == pytest.approx(0.0)

    def test_opposite_vectors(self):
        a = [1.0, 0.0]
        b = [-1.0, 0.0]
        assert _cosine_similarity(a, b) == pytest.approx(-1.0)

    def test_zero_vector(self):
        a = [0.0, 0.0, 0.0]
        b = [1.0, 2.0, 3.0]
        assert _cosine_similarity(a, b) == 0.0

    def test_high_dimensional(self):
        """384 维向量计算正确"""
        import math
        a = [1.0 / math.sqrt(384)] * 384
        b = [1.0 / math.sqrt(384)] * 384
        assert _cosine_similarity(a, b) == pytest.approx(1.0, abs=1e-6)


class TestCentroidAffinity:
    """_centroid_affinity() — 语义亲和度 [0.3, 0.9]"""

    def test_no_centroid_returns_negative(self):
        centroids: dict = {}
        assert _centroid_affinity([1.0, 0.0], centroids, "missing") == -1.0

    def test_identical_gives_max(self):
        vec = [1.0, 0.0, 0.0]
        centroids = {"coll": (vec, 10)}
        result = _centroid_affinity(vec, centroids, "coll")
        assert result == pytest.approx(0.9, abs=0.01)

    def test_orthogonal_gives_floor(self):
        query = [1.0, 0.0, 0.0]
        centroids = {"coll": ([0.0, 1.0, 0.0], 10)}
        result = _centroid_affinity(query, centroids, "coll")
        assert result == pytest.approx(0.3, abs=0.01)

    def test_medium_similarity(self):
        """cosine ~0.5 → affinity ~0.6"""
        import math
        query = [1.0, 0.0]
        centroid = [1.0 / math.sqrt(2), 1.0 / math.sqrt(2)]
        centroids = {"coll": (centroid, 5)}
        result = _centroid_affinity(query, centroids, "coll")
        # cosine(query, centroid) ≈ 0.707
        assert 0.5 < result < 0.85

    def test_negative_cosine_clamped_to_floor(self):
        query = [1.0, 0.0]
        centroids = {"coll": ([-1.0, 0.0], 10)}
        result = _centroid_affinity(query, centroids, "coll")
        assert result == pytest.approx(0.3, abs=0.01)


class TestCombinedDecay:
    """_combined_decay() — max(session, centroid) 组合"""

    def test_same_collection_always_1(self):
        f = FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="index1")
        decay = _combined_decay(f, "index1", None, None, None)
        assert decay == 1.0

    def test_no_signals_fallback_05(self):
        f = FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="other")
        decay = _combined_decay(f, "index1", None, None, None)
        assert decay == 0.5

    def test_centroid_dominates_over_session(self):
        """高质心相似度 > 低 session 亲和度"""
        f = FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="blockchain_app")
        # Session: never visited → 0.3
        session_ctx = MagicMock()
        session_ctx.collection_counts = {"index1": 10}
        session_ctx.collection_affinity.return_value = 0.3
        # Centroid: very similar → ~0.9
        query_vec = [1.0, 0.0, 0.0]
        centroids = {"blockchain_app": ([1.0, 0.0, 0.0], 10)}
        decay = _combined_decay(f, "index1", session_ctx, query_vec, centroids)
        assert decay > 0.85  # centroid ~0.9 dominates

    def test_session_dominates_over_centroid(self):
        """高 session 亲和度 > 低质心相似度"""
        f = FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="blockchain_app")
        # Session: heavily visited → 0.8
        session_ctx = MagicMock()
        session_ctx.collection_counts = {"blockchain_app": 50}
        session_ctx.collection_affinity.return_value = 0.8
        # Centroid: unrelated → ~0.3
        query_vec = [1.0, 0.0, 0.0]
        centroids = {"blockchain_app": ([0.0, 1.0, 0.0], 10)}
        decay = _combined_decay(f, "index1", session_ctx, query_vec, centroids)
        assert decay == 0.8  # session dominates

    def test_no_centroid_fallback_to_session(self):
        f = FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="other")
        session_ctx = MagicMock()
        session_ctx.collection_counts = {"other": 5}
        session_ctx.collection_affinity.return_value = 0.65
        decay = _combined_decay(f, "index1", session_ctx, [1.0], {})
        assert decay == 0.65


class TestBoostWithCentroids:
    """_apply_collection_boost with centroid parameters"""

    def test_centroid_boosts_cross_collection(self):
        facts = [
            FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="blockchain_app"),
            FactItem(id=2, score=0.8, assertion="", category="code",
                     confidence=0.7, collection="index1"),
        ]
        query_vec = [1.0, 0.0, 0.0]
        centroids = {"blockchain_app": ([0.95, 0.31, 0.0], 10)}
        result = _apply_collection_boost(
            facts, "index1", query_vec=query_vec, centroids=centroids,
        )
        # blockchain_app has high centroid affinity (~0.87) → mild decay
        bc_fact = [f for f in result if f.collection == "blockchain_app"][0]
        assert bc_fact.score > 0.8  # high affinity → score > 0.8 (was 1.0)
        assert bc_fact.score < 1.0  # still decayed (not primary)
        # primary collection not decayed
        idx_fact = [f for f in result if f.collection == "index1"][0]
        assert idx_fact.score == 0.8  # unchanged

    def test_no_centroid_falls_back_to_fixed_05(self):
        facts = [
            FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="other"),
        ]
        result = _apply_collection_boost(facts, "index1")
        assert result[0].score == 0.5  # default 0.5 decay

    def test_current_collection_never_decayed(self):
        facts = [
            FactItem(id=1, score=1.0, assertion="", category="code",
                     confidence=0.8, collection="index1"),
        ]
        query_vec = [1.0, 0.0, 0.0]
        centroids = {"index1": ([0.0, 1.0, 0.0], 10)}  # even if centroid is unrelated
        result = _apply_collection_boost(
            facts, "index1", query_vec=query_vec, centroids=centroids,
        )
        assert result[0].score == 1.0  # never decayed


class TestMergeFallbackWithCentroids:
    """_merge_cognition_fallback with centroid parameters"""

    def test_merge_with_centroid_affinity(self):
        primary = [
            FactItem(id=1, score=1.0, assertion="Primary", category="code",
                     confidence=0.8, collection="index1"),
        ]
        fallback = [
            FactItem(id=2, score=0.9, assertion="Fallback", category="code",
                     confidence=0.7, collection="blockchain_app"),
        ]
        query_vec = [1.0, 0.0, 0.0]
        centroids = {"blockchain_app": ([0.9, 0.4, 0.0], 10)}
        result = _merge_cognition_fallback(
            primary, fallback, "index1", top_k=10,
            query_vec=query_vec, centroids=centroids,
        )
        assert len(result) == 2
        # fallback fact should have centroid-informed decay (not just 0.5)
        fallback_fact = [f for f in result if f.id == 2][0]
        assert fallback_fact.score > 0.45  # centroid provides better-than-0.5 decay

    def test_dedup_preserved_with_centroids(self):
        f = FactItem(id=1, score=1.0, assertion="Same", category="code",
                     confidence=0.8, collection="index1")
        primary = [f]
        fallback = [FactItem(id=1, score=0.9, assertion="Same", category="code",
                             confidence=0.8, collection="index1")]
        result = _merge_cognition_fallback(
            primary, fallback, "index1", top_k=10,
            query_vec=[1.0], centroids={},
        )
        assert len(result) == 1  # dedup works


class TestCentroidIntegration:
    """Integration: unified_recall with centroid debug fields"""

    def test_debug_centroid_fields_present(self, mock_config, mock_db, mock_embedder, mock_db_cog):
        mock_config.collection = "index1"
        mock_config.expansion_enabled = False
        mock_config.effective_embedding_dim = 3
        mock_db_cog.get_all_centroids.return_value = {"index1": ([1.0, 0.0, 0.0], 10)}

        with patch("index1.unified_search._search_corpus") as mock_corp, \
             patch("index1.unified_search._search_cognition") as mock_cog, \
             patch("index1.unified_search._search_tactics") as mock_tac:
            mock_corp.return_value = ([], "bm25_only")
            mock_cog.return_value = [
                FactItem(id=1, score=0.8, assertion="Test", category="code",
                         confidence=0.8, collection="index1"),
            ]
            mock_tac.return_value = []
            mock_db.conn.execute.return_value.fetchall.return_value = []

            result = asyncio.run(unified_recall(
                "test query", mock_config, mock_db, mock_embedder,
                db_cog=mock_db_cog, collection="index1", top_k=5,
            ))

        assert result.debug is not None
        scope = result.debug["cognition_scope"]
        assert "centroid_available" in scope
        assert "centroid_collections" in scope
        assert scope["centroid_available"] is True
        assert "index1" in scope["centroid_collections"]


# ── Engagement Boost 信号函数测试 (#190) ──


class TestTimeDecayFactor:
    def test_none_created_at(self):
        assert _time_decay_factor(None) == 0.5

    def test_empty_created_at(self):
        assert _time_decay_factor("") == 0.5

    def test_invalid_date(self):
        assert _time_decay_factor("not-a-date") == 0.5

    def test_recent_fact_high_score(self):
        """刚创建的 fact 应接近 1.0"""
        from datetime import datetime, timezone
        now = datetime.now(timezone.utc).isoformat()
        score = _time_decay_factor(now, halflife=180)
        assert score > 0.99

    def test_halflife_gives_half(self):
        """半衰期后应接近 0.5"""
        from datetime import datetime, timezone, timedelta
        dt = datetime.now(timezone.utc) - timedelta(days=180)
        score = _time_decay_factor(dt.isoformat(), halflife=180)
        assert 0.45 < score < 0.55

    def test_very_old_fact_low_score(self):
        """很久前的 fact 应接近 0"""
        score = _time_decay_factor("2020-01-01")
        assert score < 0.01

    def test_future_date_clamps_to_one(self):
        from datetime import datetime, timezone, timedelta
        future = (datetime.now(timezone.utc) + timedelta(days=10)).isoformat()
        assert _time_decay_factor(future) == 1.0

    def test_zero_halflife(self):
        assert _time_decay_factor("2024-01-01", halflife=0) == 0.5


class TestAccessRateFactor:
    def test_zero_access(self):
        assert _access_rate_factor(0, "2024-01-01") == 0.0

    def test_negative_access(self):
        assert _access_rate_factor(-1, "2024-01-01") == 0.0

    def test_none_created_at(self):
        assert _access_rate_factor(10, None) == 0.5

    def test_high_access_recent(self):
        """高频访问应有高 score"""
        from datetime import datetime, timezone, timedelta
        dt = datetime.now(timezone.utc) - timedelta(days=7)
        score = _access_rate_factor(100, dt.isoformat())
        assert score > 0.8

    def test_low_access_old(self):
        """低频访问应有低 score"""
        score = _access_rate_factor(1, "2020-01-01")
        assert score < 0.3

    def test_range_zero_to_one(self):
        """结果总在 [0, 1]"""
        from datetime import datetime, timezone
        now = datetime.now(timezone.utc).isoformat()
        for ac in [0, 1, 10, 100, 10000]:
            score = _access_rate_factor(ac, now)
            assert 0.0 <= score <= 1.0


class TestReturnNoiseFactor:
    def test_equal_access_return(self):
        """access == return → ratio=1.0, clamp to 0"""
        assert _return_noise_factor(10, 10) == 0.0

    def test_high_adoption(self):
        """access > threshold*return → ratio>threshold, clamp to 0"""
        assert _return_noise_factor(10, 10, threshold=0.5) == 0.0

    def test_low_adoption(self):
        """access << return → 负值（降权）"""
        score = _return_noise_factor(1, 10, threshold=0.5)
        assert score < 0.0

    def test_zero_return(self):
        assert _return_noise_factor(5, 0) == 0.0

    def test_zero_access(self):
        assert _return_noise_factor(0, 10) <= 0.0

    def test_range_negative_to_zero(self):
        """结果总在 [-1, 0]"""
        for ac, rc in [(0, 10), (1, 100), (5, 10), (10, 10), (100, 10)]:
            score = _return_noise_factor(ac, rc)
            assert -1.0 <= score <= 0.0


class TestIntentCategoryMatch:
    def test_no_enriched(self):
        assert _intent_category_match("root_cause", None) == 0.0

    def test_no_category(self):
        eq = EnrichedQuery(original="error", normalized="error")
        assert _intent_category_match("", eq) == 0.0

    def test_route_match_strong_signal(self):
        """query 包含 intent key 且 category 在映射中 → 1.0"""
        eq = EnrichedQuery(original="error in login", normalized="error in login")
        assert _intent_category_match("root_cause", eq) == 1.0

    def test_no_match(self):
        """无匹配 → 0.0"""
        eq = EnrichedQuery(original="hello world", normalized="hello world")
        assert _intent_category_match("code", eq) == 0.0

    def test_entity_match_weak_signal(self):
        """entities 包含 intent key → 0.5"""
        eq = EnrichedQuery(original="fix", normalized="fix")
        eq.entities = ["error_handler"]
        assert _intent_category_match("root_cause", eq) == 0.5

    def test_category_not_in_map(self):
        """category 不在映射中 → 0.0"""
        eq = EnrichedQuery(original="error test", normalized="error test")
        assert _intent_category_match("unknown_category", eq) == 0.0


class TestApplyEngagementBoost:
    def _make_config(self, enabled=True, **kwargs):
        from index1.config import Config
        cfg = Config()
        cfg.engagement_boost_enabled = enabled
        for k, v in kwargs.items():
            setattr(cfg, k, v)
        return cfg

    def test_disabled_returns_unchanged(self):
        cfg = self._make_config(enabled=False)
        facts = [FactItem(id=1, score=0.8, assertion="test", category="code", confidence=0.9)]
        result, debug = _apply_engagement_boost(facts, None, cfg)
        assert debug["applied"] is False
        assert result[0].score == 0.8

    def test_empty_items(self):
        cfg = self._make_config(enabled=True)
        result, debug = _apply_engagement_boost([], None, cfg)
        assert debug["reason"] == "no_items"

    def test_time_decay_signal(self):
        """时间衰减信号应影响 score"""
        from datetime import datetime, timezone
        cfg = self._make_config(
            enabled=True,
            time_decay_weight=0.10,
            access_rate_weight=0.0,
            return_noise_weight=0.0,
            intent_match_weight=0.0,
        )
        now = datetime.now(timezone.utc).isoformat()
        facts = [
            FactItem(id=1, score=1.0, assertion="recent", category="code",
                     confidence=0.9, created_at=now),
            FactItem(id=2, score=1.0, assertion="old", category="code",
                     confidence=0.9, created_at="2020-01-01"),
        ]
        result, debug = _apply_engagement_boost(facts, None, cfg)
        assert debug["applied"] is True
        assert "time_decay" in debug["signals"]
        # 最近创建的 fact boost 更高
        recent = next(f for f in result if f.id == 1)
        old = next(f for f in result if f.id == 2)
        assert recent.score > old.score

    def test_access_rate_signal(self):
        """高频访问的 fact 应获得更高 boost"""
        from datetime import datetime, timezone, timedelta
        cfg = self._make_config(
            enabled=True,
            time_decay_weight=0.0,
            access_rate_weight=0.10,
            return_noise_weight=0.0,
            intent_match_weight=0.0,
        )
        dt = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
        facts = [
            FactItem(id=1, score=1.0, assertion="popular", category="code",
                     confidence=0.9, created_at=dt, access_count=100),
            FactItem(id=2, score=1.0, assertion="unused", category="code",
                     confidence=0.9, created_at=dt, access_count=0),
        ]
        result, debug = _apply_engagement_boost(facts, None, cfg)
        popular = next(f for f in result if f.id == 1)
        unused = next(f for f in result if f.id == 2)
        assert popular.score > unused.score

    def test_return_noise_signal(self):
        """低 adoption 的 fact 应被降权"""
        cfg = self._make_config(
            enabled=True,
            time_decay_weight=0.0,
            access_rate_weight=0.0,
            return_noise_weight=0.10,
            intent_match_weight=0.0,
            return_noise_threshold=0.5,
        )
        facts = [
            FactItem(id=1, score=1.0, assertion="adopted", category="code",
                     confidence=0.9, access_count=10, return_count=10),
            FactItem(id=2, score=1.0, assertion="noisy", category="code",
                     confidence=0.9, access_count=1, return_count=20),
        ]
        result, debug = _apply_engagement_boost(facts, None, cfg)
        noisy = next(f for f in result if f.id == 2)
        adopted = next(f for f in result if f.id == 1)
        assert noisy.score < adopted.score

    def test_intent_match_signal(self):
        """intent 匹配的 fact 应获得 boost"""
        cfg = self._make_config(
            enabled=True,
            time_decay_weight=0.0,
            access_rate_weight=0.0,
            return_noise_weight=0.0,
            intent_match_weight=0.10,
        )
        eq = EnrichedQuery(original="error in db", normalized="error in db")
        facts = [
            FactItem(id=1, score=1.0, assertion="error fact", category="root_cause",
                     confidence=0.9),
            FactItem(id=2, score=1.0, assertion="other fact", category="architecture",
                     confidence=0.9),
        ]
        result, debug = _apply_engagement_boost(facts, eq, cfg)
        error_fact = next(f for f in result if f.id == 1)
        other_fact = next(f for f in result if f.id == 2)
        assert error_fact.score > other_fact.score

    def test_combined_signals(self):
        """所有信号组合不会颠覆排序"""
        from datetime import datetime, timezone
        cfg = self._make_config(
            enabled=True,
            time_decay_weight=0.10,
            access_rate_weight=0.08,
            return_noise_weight=0.05,
            intent_match_weight=0.05,
        )
        now = datetime.now(timezone.utc).isoformat()
        facts = [
            FactItem(id=1, score=0.9, assertion="high relevance", category="code",
                     confidence=0.9, created_at=now, access_count=50, return_count=50),
            FactItem(id=2, score=0.3, assertion="low relevance", category="code",
                     confidence=0.9, created_at=now, access_count=100, return_count=100),
        ]
        result, debug = _apply_engagement_boost(facts, None, cfg)
        # 排序不应被颠覆：0.9 仍应 > 0.3（boost 最大 +23%）
        assert result[0].id == 1

    def test_boost_range(self):
        """最大 boost 不超过 +23%（0.10+0.08+0.05=0.23）"""
        from datetime import datetime, timezone
        cfg = self._make_config(
            enabled=True,
            time_decay_weight=0.10,
            access_rate_weight=0.08,
            return_noise_weight=0.05,
            intent_match_weight=0.05,
        )
        now = datetime.now(timezone.utc).isoformat()
        eq = EnrichedQuery(original="error test", normalized="error test")
        facts = [
            FactItem(id=1, score=1.0, assertion="best", category="root_cause",
                     confidence=0.9, created_at=now, access_count=1000,
                     return_count=1000),
        ]
        result, debug = _apply_engagement_boost(facts, eq, cfg)
        # score 不应超过 1.0 * 1.23 = 1.23
        assert result[0].score <= 1.3

    def test_performance_100_items(self):
        """100 items 应在 5ms 内完成"""
        import time
        from datetime import datetime, timezone
        cfg = self._make_config(enabled=True)
        now = datetime.now(timezone.utc).isoformat()
        facts = [
            FactItem(id=i, score=1.0 - i * 0.01, assertion=f"fact {i}",
                     category="code", confidence=0.9, created_at=now,
                     access_count=i, return_count=i)
            for i in range(100)
        ]
        t0 = time.monotonic()
        _apply_engagement_boost(facts, None, cfg)
        elapsed_ms = (time.monotonic() - t0) * 1000
        assert elapsed_ms < 50  # 宽裕阈值

    def test_debug_info(self):
        from datetime import datetime, timezone
        cfg = self._make_config(enabled=True)
        now = datetime.now(timezone.utc).isoformat()
        facts = [
            FactItem(id=1, score=0.8, assertion="test", category="code",
                     confidence=0.9, created_at=now, access_count=5,
                     return_count=5),
        ]
        _, debug = _apply_engagement_boost(facts, None, cfg)
        assert debug["applied"] is True
        assert "signals" in debug
        assert "top_boost" in debug
        assert isinstance(debug["signals"], list)


# ── P3-2 Dynamic top_k 测试（#191）──


class TestAdaptiveTopK:
    """_adaptive_top_k 按查询类型自适应调整 top_k"""

    def _make_config(self, **kwargs):
        from index1.config import Config
        cfg = Config()
        for k, v in kwargs.items():
            setattr(cfg, k, v)
        return cfg

    def test_symbol_lookup_reduces(self):
        eq = EnrichedQuery(original="MyClass", normalized="myclass", route="symbol_lookup")
        cfg = self._make_config(adaptive_top_k_min=3, adaptive_top_k_max=20)
        assert _adaptive_top_k(eq, 10, cfg) == 3

    def test_graph_query_reduces(self):
        eq = EnrichedQuery(original="calls to foo", normalized="calls to foo", route="graph_query")
        cfg = self._make_config(adaptive_top_k_min=3, adaptive_top_k_max=20)
        assert _adaptive_top_k(eq, 10, cfg) == 5  # min + 2

    def test_compressed_expands(self):
        eq = EnrichedQuery(original="long query", normalized="long query", compressed=True)
        cfg = self._make_config(adaptive_top_k_min=3, adaptive_top_k_max=20)
        assert _adaptive_top_k(eq, 10, cfg) == 20

    def test_text_search_unchanged(self):
        eq = EnrichedQuery(original="search term", normalized="search term",
                           route="text_search")
        cfg = self._make_config(adaptive_top_k_min=3, adaptive_top_k_max=20)
        assert _adaptive_top_k(eq, 10, cfg) == 10

    def test_short_query_unchanged(self):
        eq = EnrichedQuery(original="foo", normalized="foo", short_query=True)
        cfg = self._make_config(adaptive_top_k_min=3, adaptive_top_k_max=20)
        assert _adaptive_top_k(eq, 10, cfg) == 10

    def test_user_top_k_smaller_than_min_respected(self):
        """用户请求 top_k=2 < min=3 时，尊重用户值（min 只是上限）"""
        eq = EnrichedQuery(original="MyClass", normalized="myclass", route="symbol_lookup")
        cfg = self._make_config(adaptive_top_k_min=3)
        assert _adaptive_top_k(eq, 2, cfg) == 2

    def test_compressed_user_top_k_already_larger(self):
        """用户 top_k=25 > max=20 时，保留用户值"""
        eq = EnrichedQuery(original="long query", normalized="long query", compressed=True)
        cfg = self._make_config(adaptive_top_k_max=20)
        assert _adaptive_top_k(eq, 25, cfg) == 25

    def test_relationship_query_unchanged(self):
        eq = EnrichedQuery(original="depends on X", normalized="depends on x",
                           route="relationship_query")
        cfg = self._make_config(adaptive_top_k_min=3, adaptive_top_k_max=20)
        assert _adaptive_top_k(eq, 10, cfg) == 10


# ── P3-1 Causal Chain Expansion 测试（#191）──


class TestExpandCausalChain:
    """_expand_causal_chain 因果链 1-hop 扩展"""

    def _make_config(self, **kwargs):
        from index1.config import Config
        cfg = Config()
        cfg.causal_expansion_enabled = True
        cfg.causal_expansion_max = 3
        cfg.causal_expansion_min_confidence = 0.6
        for k, v in kwargs.items():
            setattr(cfg, k, v)
        return cfg

    def _make_facts(self, n=2):
        return [
            FactItem(id=i + 1, score=round(1.0 - i * 0.2, 2),
                     assertion=f"Fact {i + 1}", category="code", confidence=0.8)
            for i in range(n)
        ]

    def _make_db_cog(self, edges_map=None):
        db_cog = MagicMock()
        if edges_map is None:
            edges_map = {}
        db_cog.get_fact_edges.side_effect = lambda fid: edges_map.get(fid, [])
        return db_cog

    def test_empty_facts_returns_empty(self):
        cfg = self._make_config()
        db_cog = self._make_db_cog()
        result = _expand_causal_chain([], db_cog, cfg)
        assert result == []
        db_cog.get_fact_edges.assert_not_called()

    def test_no_edges_returns_original(self):
        cfg = self._make_config()
        facts = self._make_facts(2)
        db_cog = self._make_db_cog()
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert len(result) == 2

    def test_expansion_appends_connected_facts(self):
        cfg = self._make_config()
        facts = self._make_facts(1)  # fact id=1
        edges = [{
            "from_fact": 1, "to_fact": 100,
            "relation": "causes", "confidence": 0.9,
            "from_assertion": "Fact 1", "to_assertion": "Effect of Fact 1",
        }]
        db_cog = self._make_db_cog({1: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert len(result) == 2
        expanded = result[1]
        assert expanded.id == 100
        assert expanded.assertion == "Effect of Fact 1"
        assert expanded.category == "expanded"

    def test_deduplicates_existing_facts(self):
        """edge 指向已在结果中的 fact → 跳过"""
        cfg = self._make_config()
        facts = self._make_facts(2)  # id=1, id=2
        edges = [{
            "from_fact": 1, "to_fact": 2,  # fact 2 已在结果中
            "relation": "causes", "confidence": 0.9,
            "from_assertion": "Fact 1", "to_assertion": "Fact 2",
        }]
        db_cog = self._make_db_cog({1: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert len(result) == 2  # 无新增

    def test_filters_low_confidence_edges(self):
        cfg = self._make_config(causal_expansion_min_confidence=0.7)
        facts = self._make_facts(1)
        edges = [{
            "from_fact": 1, "to_fact": 100,
            "relation": "causes", "confidence": 0.5,  # 低于阈值
            "from_assertion": "Fact 1", "to_assertion": "Low conf",
        }]
        db_cog = self._make_db_cog({1: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert len(result) == 1  # 无扩展

    def test_max_cap(self):
        cfg = self._make_config(causal_expansion_max=2)
        facts = self._make_facts(1)
        edges = [
            {"from_fact": 1, "to_fact": 100 + i, "relation": "causes",
             "confidence": 0.9, "from_assertion": "F1",
             "to_assertion": f"E{i}"}
            for i in range(5)
        ]
        db_cog = self._make_db_cog({1: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert len(result) == 3  # 1 original + 2 expanded (max=2)

    def test_score_decay(self):
        """expanded.score = parent.score * edge.confidence * 0.5"""
        cfg = self._make_config()
        facts = [FactItem(id=1, score=0.8, assertion="F1",
                          category="code", confidence=0.9)]
        edges = [{
            "from_fact": 1, "to_fact": 100,
            "relation": "causes", "confidence": 0.9,
            "from_assertion": "F1", "to_assertion": "E1",
        }]
        db_cog = self._make_db_cog({1: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        expanded = result[1]
        assert expanded.score == round(0.8 * 0.9 * 0.5, 4)

    def test_upstream_direction(self):
        """to_fact == fact.id → 上游方向 → 使用 from_assertion"""
        cfg = self._make_config()
        facts = [FactItem(id=5, score=0.7, assertion="F5",
                          category="code", confidence=0.8)]
        edges = [{
            "from_fact": 99, "to_fact": 5,  # 上游
            "relation": "explains", "confidence": 0.8,
            "from_assertion": "Upstream cause", "to_assertion": "F5",
        }]
        db_cog = self._make_db_cog({5: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        expanded = result[1]
        assert expanded.id == 99
        assert expanded.assertion == "Upstream cause"

    def test_only_expands_top5(self):
        """只对前 5 个 fact 做扩展"""
        cfg = self._make_config(causal_expansion_max=10)
        facts = self._make_facts(8)
        # 只给 fact 6,7,8 配 edges（它们在 top-5 之外）
        edges = [{
            "from_fact": 6, "to_fact": 200,
            "relation": "causes", "confidence": 0.9,
            "from_assertion": "F6", "to_assertion": "E6",
        }]
        db_cog = self._make_db_cog({6: edges, 7: edges, 8: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert len(result) == 8  # 无扩展（fact 6/7/8 不在 top-5）

    def test_db_error_graceful(self):
        """db_cog.get_fact_edges 抛异常 → 跳过该 fact，继续处理"""
        cfg = self._make_config()
        facts = self._make_facts(2)
        db_cog = MagicMock()
        db_cog.get_fact_edges.side_effect = [
            Exception("DB error"),
            [{"from_fact": 2, "to_fact": 100, "relation": "causes",
              "confidence": 0.9, "from_assertion": "F2",
              "to_assertion": "E1"}],
        ]
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert len(result) == 3  # 2 original + 1 expanded from fact 2

    def test_none_assertion_handled(self):
        """edge assertion 为 None 时使用空字符串"""
        cfg = self._make_config()
        facts = self._make_facts(1)
        edges = [{
            "from_fact": 1, "to_fact": 100,
            "relation": "causes", "confidence": 0.9,
            "from_assertion": "F1", "to_assertion": None,
        }]
        db_cog = self._make_db_cog({1: edges})
        result = _expand_causal_chain(facts, db_cog, cfg)
        assert result[1].assertion == ""


# ── P3-3 Cross-Type Association ──


class TestExtractFileRefL0:
    """_extract_file_ref_l0 L0 正则文件提取"""

    def test_full_path(self):
        text = "Fixed bug in /home/user/project/src/index1/hooks.py line 42"
        assert _extract_file_ref_l0(text) == "src/index1/hooks.py"

    def test_changed_in_pattern(self):
        text = "Changed function 'observe' in /home/user/project/src/cognitive/hooks.py"
        assert _extract_file_ref_l0(text) == "src/cognitive/hooks.py"

    def test_modified_pattern(self):
        text = "Modified /home/user/project/config.py"
        assert _extract_file_ref_l0(text) == "config.py"

    def test_short_module(self):
        text = "Bug in hooks.py causes crash"
        assert _extract_file_ref_l0(text) == "hooks.py"

    def test_no_match(self):
        text = "General observation about architecture"
        assert _extract_file_ref_l0(text) is None

    def test_long_text_no_short_match(self):
        """长文本不触发短模块正则（避免误匹配）"""
        text = "A" * 300 + " hooks.py something"
        assert _extract_file_ref_l0(text) is None


class TestExpandCrossType:
    """_expand_cross_type fact→file→corpus chunk 关联"""

    def _make_config(self, **kwargs):
        from index1.config import Config
        cfg = Config()
        cfg.cross_association_enabled = kwargs.get("enabled", True)
        cfg.cross_association_max = kwargs.get("max_add", 2)
        return cfg

    def _make_fact(self, id_: int, assertion: str, score: float = 0.8):
        return FactItem(
            id=id_, score=score, assertion=assertion,
            category="insight", confidence=0.8,
        )

    def _make_code(self, id_: int, source: str, score: float = 0.9):
        return CodeItem(
            id=id_, score=score, source=source,
            section="main", content="code content",
        )

    def test_disabled(self):
        """功能关闭时直接返回"""
        cfg = self._make_config(enabled=False)
        facts = [self._make_fact(1, "Bug in src/hooks.py")]
        code = [self._make_code(10, "other.py")]
        result, debug = _expand_cross_type(code, facts, MagicMock(), None, "col", cfg)
        assert not debug["applied"]
        assert len(result) == 1  # 没有新增

    def test_no_facts(self):
        """无 facts 时跳过"""
        cfg = self._make_config()
        code, debug = _expand_cross_type([], [], MagicMock(), None, "col", cfg)
        assert debug["reason"] == "no_facts"

    def test_regex_extraction_adds_chunk(self):
        """L0 正则提取文件 → 查 corpus 添加关联 chunk"""
        cfg = self._make_config()
        facts = [self._make_fact(1, "Fixed bug in /home/user/src/index1/hooks.py")]

        # Mock db: resolve_document 返回 doc, 查 chunk 返回结果
        db = MagicMock()
        db.resolve_document.return_value = {"id": 100, "path": "src/index1/hooks.py"}
        db.conn.execute.return_value.fetchone.return_value = (
            999, "def observe():\n    pass", "observe"
        )

        code = [self._make_code(10, "other.py")]
        result, debug = _expand_cross_type(code, facts, db, None, "col", cfg)
        assert debug["applied"]
        assert debug["added"] == 1
        assert len(result) == 2
        # 新增的 code 应该 score 衰减
        added_item = [c for c in result if c.id == 999][0]
        assert added_item.score < 0.8  # 衰减后低于原 fact score

    def test_db_cog_context_json_preferred(self):
        """db_cog context_json.file 优先于 regex"""
        cfg = self._make_config()
        facts = [self._make_fact(1, "Some vague description", score=0.9)]

        # db_cog 返回 context_json.file
        db_cog = MagicMock()
        db_cog.conn.execute.return_value.fetchall.return_value = [
            (1, "src/config.py")
        ]

        # db (corpus)
        db = MagicMock()
        db.resolve_document.return_value = {"id": 50, "path": "src/config.py"}
        db.conn.execute.return_value.fetchone.return_value = (
            500, "class Config:", "Config"
        )

        result, debug = _expand_cross_type([], facts, db, db_cog, "col", cfg)
        assert debug["applied"]
        assert debug["added"] == 1

    def test_existing_source_dedup(self):
        """已在结果中的文件不重复添加"""
        cfg = self._make_config()
        facts = [self._make_fact(1, "Bug in /home/user/src/index1/hooks.py")]

        db = MagicMock()
        # hooks.py 已在 code 结果中
        code = [self._make_code(10, "src/index1/hooks.py")]
        result, debug = _expand_cross_type(code, facts, db, None, "col", cfg)
        assert not debug["applied"]
        assert debug["reason"] == "no_file_refs"

    def test_max_add_limit(self):
        """不超过 cross_association_max"""
        cfg = self._make_config(max_add=1)
        facts = [
            self._make_fact(1, "Bug in /home/user/src/index1/hooks.py"),
            self._make_fact(2, "Issue in /home/user/src/index1/config.py"),
        ]

        db = MagicMock()
        db.resolve_document.side_effect = [
            {"id": 100, "path": "src/index1/hooks.py"},
            {"id": 101, "path": "src/index1/config.py"},
        ]
        db.conn.execute.return_value.fetchone.return_value = (
            999, "content", "section"
        )

        result, debug = _expand_cross_type([], facts, db, None, "col", cfg)
        assert debug["added"] == 1  # 限制为 1

    def test_resolve_document_miss(self):
        """corpus 中找不到文件时不崩溃"""
        cfg = self._make_config()
        facts = [self._make_fact(1, "Bug in /home/user/src/index1/nonexistent.py")]

        db = MagicMock()
        db.resolve_document.return_value = None

        result, debug = _expand_cross_type([], facts, db, None, "col", cfg)
        assert not debug["applied"]
        assert debug["reason"] == "no_chunks_found"

    def test_chunk_id_dedup(self):
        """chunk_id 已在 code 结果中时不重复添加"""
        cfg = self._make_config()
        facts = [self._make_fact(1, "Bug in /home/user/src/index1/hooks.py")]

        db = MagicMock()
        db.resolve_document.return_value = {"id": 100, "path": "src/index1/hooks.py"}
        db.conn.execute.return_value.fetchone.return_value = (
            10, "content", "section"  # chunk_id=10 已存在
        )

        code = [self._make_code(10, "other.py")]  # id=10
        result, debug = _expand_cross_type(code, facts, db, None, "col", cfg)
        assert not debug["applied"]


# ── P3-9 Session Continuity Boost ──


class TestSessionContinuityBoost:
    """_apply_session_continuity_boost 多轮搜索连续性"""

    def _make_config(self, **kwargs):
        from index1.config import Config
        cfg = Config()
        cfg.session_continuity_enabled = True
        cfg.session_continuity_weight = 0.10
        for k, v in kwargs.items():
            setattr(cfg, k, v)
        return cfg

    def _make_session_ctx(self, files=None, entities=None):
        """创建 mock SessionContext"""
        ctx = MagicMock()
        ctx.recent_files = files or []
        ctx.recent_entities = entities or []
        ctx.is_warm = bool(files or entities)
        return ctx

    def _make_code(self, sources):
        return [
            CodeItem(id=i + 1, score=round(1.0 - i * 0.1, 2),
                     source=src, section=None, content=f"content {i}",
                     symbols=[])
            for i, src in enumerate(sources)
        ]

    def _make_facts(self, assertions):
        return [
            FactItem(id=i + 1, score=round(1.0 - i * 0.1, 2),
                     assertion=a, category="code", confidence=0.8)
            for i, a in enumerate(assertions)
        ]

    def test_disabled_returns_unchanged(self):
        cfg = self._make_config(session_continuity_enabled=False)
        code = self._make_code(["a.py"])
        facts = self._make_facts(["fact about a.py"])
        ctx = self._make_session_ctx(files=["a.py"])
        c, f, debug = _apply_session_continuity_boost(code, facts, ctx, cfg)
        assert not debug["applied"]
        assert c[0].score == 1.0

    def test_cold_session_returns_unchanged(self):
        cfg = self._make_config()
        code = self._make_code(["a.py"])
        facts = self._make_facts(["fact"])
        ctx = self._make_session_ctx()  # is_warm = False
        c, f, debug = _apply_session_continuity_boost(code, facts, ctx, cfg)
        assert debug["reason"] == "cold_session"

    def test_none_session_returns_unchanged(self):
        cfg = self._make_config()
        code = self._make_code(["a.py"])
        facts = self._make_facts(["fact"])
        c, f, debug = _apply_session_continuity_boost(code, facts, None, cfg)
        assert debug["reason"] == "cold_session"

    def test_code_source_in_recent_files_boosted(self):
        cfg = self._make_config()
        code = self._make_code(["src/index1/search.py", "src/other.py"])
        ctx = self._make_session_ctx(files=["src/index1/search.py"])
        c, f, debug = _apply_session_continuity_boost(code, [], ctx, cfg)
        assert debug["applied"]
        assert debug["boosted_code"] == 1
        # search.py 被 boost：1.0 * 1.10 = 1.10
        boosted = [x for x in c if x.source == "src/index1/search.py"][0]
        assert boosted.score == 1.1

    def test_code_stem_match_boosted(self):
        """文件名 stem 匹配（不用完整路径）"""
        cfg = self._make_config()
        code = self._make_code(["src/index1/search.py"])
        ctx = self._make_session_ctx(files=["other/path/search.py"])
        c, f, debug = _apply_session_continuity_boost(code, [], ctx, cfg)
        assert debug["boosted_code"] == 1

    def test_code_symbol_match_half_weight(self):
        """symbols 匹配用半权重"""
        cfg = self._make_config(session_continuity_weight=0.20)
        code = [CodeItem(id=1, score=1.0, source="x.py", section=None,
                         content="", symbols=["MyClass"])]
        ctx = self._make_session_ctx(entities=["MyClass"])
        c, f, debug = _apply_session_continuity_boost(code, [], ctx, cfg)
        assert debug["boosted_code"] == 1
        # 1.0 * (1.0 + 0.20 * 0.5) = 1.10
        assert c[0].score == 1.1

    def test_fact_assertion_stem_match_boosted(self):
        """fact assertion 含 recent_file stem → boost"""
        cfg = self._make_config()
        facts = self._make_facts([
            "Fixed bug in unified_search module",
            "Unrelated fact about deployment",
        ])
        ctx = self._make_session_ctx(files=["src/index1/unified_search.py"])
        _, f, debug = _apply_session_continuity_boost([], facts, ctx, cfg)
        assert debug["boosted_facts"] == 1
        # unified_search 匹配
        boosted = [x for x in f if "unified_search" in x.assertion][0]
        assert boosted.score > 1.0

    def test_short_stem_skipped(self):
        """stem <= 2 字符不匹配（避免 'a.py' 的 'a' 误匹配）"""
        cfg = self._make_config()
        facts = self._make_facts(["a big problem"])
        ctx = self._make_session_ctx(files=["a.py"])
        _, f, debug = _apply_session_continuity_boost([], facts, ctx, cfg)
        assert debug.get("boosted_facts", 0) == 0

    def test_zero_weight_returns_unchanged(self):
        cfg = self._make_config(session_continuity_weight=0)
        code = self._make_code(["a.py"])
        ctx = self._make_session_ctx(files=["a.py"])
        c, _, debug = _apply_session_continuity_boost(code, [], ctx, cfg)
        assert debug["reason"] == "zero_weight"

    def test_sort_order_updated_after_boost(self):
        """boost 后 code 按 score 重新排序"""
        cfg = self._make_config(session_continuity_weight=0.50)
        # code[0]=other.py:1.0, code[1]=search.py:0.9
        code = self._make_code(["other.py", "src/index1/search.py"])
        ctx = self._make_session_ctx(files=["src/index1/search.py"])
        c, _, _ = _apply_session_continuity_boost(code, [], ctx, cfg)
        # search.py: 0.9 * 1.5 = 1.35 > other.py: 1.0
        assert c[0].source == "src/index1/search.py"


# ── P3-5 Returned Results Demotion ──


class TestReturnedDemotion:
    """_apply_returned_demotion 查询历史复用"""

    def _make_config(self, **kwargs):
        from index1.config import Config
        cfg = Config()
        cfg.returned_demotion_enabled = True
        cfg.returned_demotion_decay = 0.5
        for k, v in kwargs.items():
            setattr(cfg, k, v)
        return cfg

    def _make_session_ctx(self, fact_ids=None, code_ids=None):
        ctx = MagicMock()
        ctx.returned_fact_ids = set(fact_ids or [])
        ctx.returned_code_ids = set(code_ids or [])
        return ctx

    def _make_code(self, ids_scores):
        return [
            CodeItem(id=cid, score=sc, source=f"f{cid}.py",
                     section=None, content="", symbols=[])
            for cid, sc in ids_scores
        ]

    def _make_facts(self, ids_scores):
        return [
            FactItem(id=fid, score=sc, assertion=f"fact {fid}",
                     category="code", confidence=0.8)
            for fid, sc in ids_scores
        ]

    def test_disabled_returns_unchanged(self):
        cfg = self._make_config(returned_demotion_enabled=False)
        code = self._make_code([(1, 1.0)])
        ctx = self._make_session_ctx(code_ids=[1])
        c, f, debug = _apply_returned_demotion(code, [], ctx, cfg)
        assert not debug["applied"]
        assert c[0].score == 1.0

    def test_no_session_returns_unchanged(self):
        cfg = self._make_config()
        code = self._make_code([(1, 1.0)])
        c, f, debug = _apply_returned_demotion(code, [], None, cfg)
        assert debug["reason"] == "no_session"

    def test_no_history_returns_unchanged(self):
        cfg = self._make_config()
        code = self._make_code([(1, 1.0)])
        ctx = self._make_session_ctx()  # empty sets
        c, f, debug = _apply_returned_demotion(code, [], ctx, cfg)
        assert debug["reason"] == "no_history"

    def test_returned_code_demoted(self):
        cfg = self._make_config()
        code = self._make_code([(1, 1.0), (2, 0.8)])
        ctx = self._make_session_ctx(code_ids=[1])
        c, _, debug = _apply_returned_demotion(code, [], ctx, cfg)
        assert debug["demoted_code"] == 1
        # id=1 降权: 1.0 * 0.5 = 0.5
        demoted = [x for x in c if x.id == 1][0]
        assert demoted.score == 0.5

    def test_returned_fact_demoted(self):
        cfg = self._make_config()
        facts = self._make_facts([(10, 0.9), (20, 0.7)])
        ctx = self._make_session_ctx(fact_ids=[10])
        _, f, debug = _apply_returned_demotion([], facts, ctx, cfg)
        assert debug["demoted_facts"] == 1
        demoted = [x for x in f if x.id == 10][0]
        assert demoted.score == 0.45

    def test_new_result_not_demoted(self):
        cfg = self._make_config()
        code = self._make_code([(99, 1.0)])
        ctx = self._make_session_ctx(code_ids=[1, 2, 3])
        c, _, debug = _apply_returned_demotion(code, [], ctx, cfg)
        assert debug.get("demoted_code", 0) == 0
        assert c[0].score == 1.0

    def test_sort_order_updated(self):
        """降权后 code 按 score 重新排序"""
        cfg = self._make_config()
        # id=1:1.0（返回过）, id=2:0.6（新）
        code = self._make_code([(1, 1.0), (2, 0.6)])
        ctx = self._make_session_ctx(code_ids=[1])
        c, _, _ = _apply_returned_demotion(code, [], ctx, cfg)
        # id=1 降到 0.5, id=2 保持 0.6
        assert c[0].id == 2

    def test_custom_decay(self):
        cfg = self._make_config(returned_demotion_decay=0.3)
        facts = self._make_facts([(1, 1.0)])
        ctx = self._make_session_ctx(fact_ids=[1])
        _, f, _ = _apply_returned_demotion([], facts, ctx, cfg)
        assert f[0].score == 0.3

    def test_first_call_no_demotion(self):
        """首次 recall 无历史 → 无降权"""
        cfg = self._make_config()
        code = self._make_code([(1, 1.0)])
        facts = self._make_facts([(10, 0.9)])
        ctx = self._make_session_ctx()  # 空历史
        c, f, debug = _apply_returned_demotion(code, facts, ctx, cfg)
        assert debug["reason"] == "no_history"
        assert c[0].score == 1.0
        assert f[0].score == 0.9


# ── P3-4: Result Grouping 测试 ──


class TestResultGrouping:
    """P3-4: 结果层级分组 — _group_code_by_file"""

    def test_same_file_grouped(self):
        """同文件多 chunk → 合并为一组"""
        from index1.mcp_server import _group_code_by_file

        items = [
            {"source": "src/main.py", "score": 0.9, "content": "chunk1"},
            {"source": "src/main.py", "score": 0.7, "content": "chunk2"},
            {"source": "src/utils.py", "score": 0.8, "content": "chunk3"},
        ]
        groups = _group_code_by_file(items)
        assert len(groups) == 2
        # top_score 排序
        assert groups[0]["source"] == "src/main.py"
        assert groups[0]["top_score"] == 0.9
        assert groups[0]["total_items"] == 2
        assert len(groups[0]["items"]) == 2

    def test_different_files_separate(self):
        """不同文件 → 各自一组"""
        from index1.mcp_server import _group_code_by_file

        items = [
            {"source": "a.py", "score": 0.9, "content": "x"},
            {"source": "b.py", "score": 0.8, "content": "y"},
            {"source": "c.py", "score": 0.7, "content": "z"},
        ]
        groups = _group_code_by_file(items)
        assert len(groups) == 3
        assert groups[0]["source"] == "a.py"
        assert groups[0]["total_items"] == 1

    def test_empty_code(self):
        """空 code → 返回空列表"""
        from index1.mcp_server import _group_code_by_file

        assert _group_code_by_file([]) == []

    def test_top_score_tracks_max(self):
        """top_score 取最大值"""
        from index1.mcp_server import _group_code_by_file

        items = [
            {"source": "x.py", "score": 0.5, "content": "a"},
            {"source": "x.py", "score": 0.9, "content": "b"},
            {"source": "x.py", "score": 0.7, "content": "c"},
        ]
        groups = _group_code_by_file(items)
        assert len(groups) == 1
        assert groups[0]["top_score"] == 0.9
        assert groups[0]["total_items"] == 3

    def test_sorted_by_top_score(self):
        """分组按 top_score 降序排列"""
        from index1.mcp_server import _group_code_by_file

        items = [
            {"source": "low.py", "score": 0.3, "content": "a"},
            {"source": "high.py", "score": 0.95, "content": "b"},
            {"source": "mid.py", "score": 0.6, "content": "c"},
        ]
        groups = _group_code_by_file(items)
        assert [g["source"] for g in groups] == ["high.py", "mid.py", "low.py"]
