"""统一搜索 — 并行 corpus + cognition，按类型分组返回

AI-first 设计：一次调用给齐所有维度（代码/经验/策略/理解），
各维度独立搜索独立排序，不做跨类型 RRF。
"""

from __future__ import annotations

import asyncio
import logging
import math
import re
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone

from .config import Config
from .db import Database, tokenize_for_fts5
from .embed import Embedder
from .query import EnrichedQuery, preprocess_query
from .query_expand import ExpandResult, expand_query, needs_expansion
from .search import search as corpus_search

logger = logging.getLogger(__name__)

# ── 返回结构 ──


@dataclass
class CodeItem:
    """corpus 命中（代码片段）"""
    id: int
    score: float
    source: str | None
    section: str | None
    content: str           # 前 500 字符
    symbols: list[str] = field(default_factory=list)


@dataclass
class FactItem:
    """认知事实"""
    id: int
    score: float
    assertion: str
    category: str
    confidence: float
    created_at: str | None = None
    session_id: str | None = None
    collection: str | None = None
    access_count: int = 0
    return_count: int = 0


@dataclass
class TacticItem:
    """策略"""
    id: int
    trigger_pattern: str
    steps: str
    success_rate: float


@dataclass
class UnifiedResult:
    """统一搜索结果 — 按类型分组"""
    code: list[CodeItem] = field(default_factory=list)
    facts: list[FactItem] = field(default_factory=list)
    tactics: list[TacticItem] = field(default_factory=list)
    model: dict | None = None
    mode: str = "corpus_only"
    hint: str = ""
    query_ms: int = 0
    debug: dict | None = None


# ── 核心函数 ──


async def unified_recall(
    query: str,
    config: Config,
    db: Database,
    embedder: Embedder,
    db_cog: object | None = None,
    *,
    collection: str | None = None,
    top_k: int = 10,
    session_id: str | None = None,
) -> UnifiedResult:
    """统一记忆检索 — 并行 corpus + cognition + tactics + model

    Args:
        query: 搜索查询
        config: 配置
        db: corpus 数据库
        embedder: embedding 客户端
        db_cog: 认知数据库（可选，None 时只搜 corpus）
        collection: 限定集合
        top_k: 每组返回数量

    Returns:
        UnifiedResult（按类型分组）
    """
    start = time.monotonic()
    result = UnifiedResult()

    # 统一计算 effective_collection — corpus/cognition/tactics 共用同一个值
    effective_coll = collection or config.collection

    # Query Understanding Layer — 预处理查询
    try:
        known_symbols = db.get_all_symbol_names() if db else None
    except Exception as e:
        logger.warning("符号表查询失败（symbol_lookup 降级为 text_search）: %s", e)
        known_symbols = None
    prox_enabled = getattr(config, "proximity_enabled", False) is True
    prox_dist = getattr(config, "proximity_max_distance", 5) if prox_enabled else 0
    enriched = preprocess_query(query, known_symbols=known_symbols, proximity_max_distance=prox_dist)

    # P3-2: Dynamic top_k — 按查询类型自适应调整
    effective_top_k = top_k
    if getattr(config, "adaptive_top_k_enabled", False) is True:
        effective_top_k = _adaptive_top_k(enriched, top_k, config)

    # ── Session Context 增强（Phase 3a）──
    original_query = query  # 共指消解前记录（Phase 3c 可观测性）
    session_ctx = None
    if db_cog is not None and session_id:
        try:
            from .session_context import SessionContext

            session_ctx = SessionContext.from_db(db_cog, session_id, effective_coll)
            if session_ctx.is_warm:
                resolved = session_ctx.resolve_coreferences(query)
                if resolved != query:
                    logger.debug("共指消解: %r → %r", query, resolved)
                    query = resolved
                    enriched = preprocess_query(query, known_symbols=known_symbols, proximity_max_distance=prox_dist)
        except Exception as e:
            logger.debug("session context 增强失败，使用原始查询: %s", e)

    # 符号路由：直接从符号表返回，跳过 BM25/Vec
    if enriched.route == "symbol_lookup" and enriched.symbol_hits:
        result = _symbol_lookup_result(enriched.symbol_hits, db, effective_coll)
        result.query_ms = int((time.monotonic() - start) * 1000)
        result.hint = _build_hint(result)
        return result

    # 构建并行任务
    tasks: dict[str, asyncio.Task] = {}

    # corpus 搜索（传入 enriched 对象）
    tasks["corpus"] = asyncio.ensure_future(
        _search_corpus(query, config, db, embedder, effective_coll, effective_top_k, enriched=enriched)
    )

    # cognition 搜索（db_cog 可用时）
    # P1-3 意图路由：symbol_lookup/graph_query 跳过 cognition（减少跨域噪音）
    # A2 策略：默认按 collection 隔离 + universal，error/bug 查询允许跨集合
    cog_cross_collection = False
    skip_cognition = enriched.route in ("symbol_lookup", "graph_query")
    if db_cog is not None and not skip_cognition:
        cog_collection: str | None = effective_coll  # A2 默认：当前 collection + universal
        if _wants_cross_collection(enriched, query):
            cog_collection = None  # error/bug: 跨集合搜索（经验共享价值高）
            cog_cross_collection = True

        tasks["cognition"] = asyncio.ensure_future(
            _search_cognition(query, db_cog, embedder, cog_collection, effective_top_k)
        )
        tasks["tactics"] = asyncio.ensure_future(
            _search_tactics(query, db_cog, effective_coll)
        )

    # LLM Query Expansion（Phase 3b — 并行，不阻塞主搜索）
    # 总超时 5s：expansion 是搜索增强，不值得让用户多等
    if config.expansion_enabled and needs_expansion(enriched):
        async def _expand_with_timeout() -> ExpandResult:
            try:
                return await asyncio.wait_for(
                    expand_query(
                        query, enriched, config, db_cog,
                        db=db, collection=effective_coll, known_symbols=known_symbols,
                    ),
                    timeout=5.0,
                )
            except asyncio.TimeoutError:
                logger.warning("expansion 总超时 (5s)")
                return ExpandResult(failure_reason="timeout_5s")

        tasks["expansion"] = asyncio.ensure_future(_expand_with_timeout())

    # Session expansion terms（L0，不经过 LLM）
    session_expansion_terms: list[str] = []
    if session_ctx is not None and session_ctx.is_warm:
        session_expansion_terms = session_ctx.get_expansion_terms()

    # 并行等待
    done = await asyncio.gather(*tasks.values(), return_exceptions=True)
    task_results = dict(zip(tasks.keys(), done))

    # 处理 corpus 结果（返回 tuple: (items, mode)）
    corpus_result = task_results.get("corpus")
    if isinstance(corpus_result, tuple):
        items, search_mode = corpus_result
        result.code = items
        result.mode = search_mode
    elif isinstance(corpus_result, Exception):
        logger.warning("corpus 搜索异常: %s", corpus_result)

    # 处理 cognition 结果
    cog_result = task_results.get("cognition")
    if isinstance(cog_result, list):
        # P1-4 版本折叠：相似 facts 只保留 score 最高的
        result.facts = _fold_fact_versions(cog_result)
    elif isinstance(cog_result, Exception):
        logger.warning("cognition 搜索异常: %s", cog_result)

    # 处理 tactics 结果
    tactic_result = task_results.get("tactics")
    if isinstance(tactic_result, list):
        result.tactics = tactic_result
    elif isinstance(tactic_result, Exception):
        logger.debug("tactics 搜索异常: %s", tactic_result)

    # ── Centroid affinity — 懒加载（仅跨集合场景需要时才加载）──
    centroids: dict[str, tuple[list[float], int]] | None = None
    query_vec: list[float] | None = None

    # ── A2 策略：跨集合 collection affinity boost ──
    cog_fallback_triggered = False
    need_centroid = (
        db_cog is not None and not skip_cognition
        and (cog_cross_collection or _should_fallback_cognition(result.facts, effective_top_k))
    )
    if need_centroid:
        try:
            centroids = db_cog.get_all_centroids(config.effective_embedding_dim)
            if not centroids:
                centroids = None
        except Exception as e:
            logger.debug("加载质心失败: %s", e)
    if centroids and embedder and embedder.available:
        try:
            query_vec = await embedder.embed_one(query)
        except Exception as e:
            logger.debug("质心查询 embedding 失败: %s", e)

    if cog_cross_collection and result.facts:
        # 跨集合搜索结果 → 按 collection 亲和度调权
        result.facts = _apply_collection_boost(
            result.facts, effective_coll, session_ctx, query_vec, centroids,
        )

    # ── A2 策略：结果不足时跨集合 fallback + ReDDE-lite 门控 ──
    redde_density: list[tuple[str, int]] = []
    if (db_cog is not None
            and not cog_cross_collection
            and not skip_cognition
            and _should_fallback_cognition(result.facts, effective_top_k)):
        try:
            # ReDDE-lite: 先检查其他集合是否有匹配内容（~1ms）
            redde_density = db_cog.estimate_collection_density(
                _build_fts5_for_redde(query), exclude=effective_coll,
            )
            if redde_density:  # 其他集合有命中 → 执行 fallback
                fallback_facts = await _search_cognition(
                    query, db_cog, embedder, None, effective_top_k,
                )
                if fallback_facts:
                    result.facts = _merge_cognition_fallback(
                        result.facts, fallback_facts, effective_coll, effective_top_k,
                        session_ctx, query_vec, centroids,
                    )
                    cog_fallback_triggered = True
        except Exception as e:
            logger.debug("cross-collection fallback 异常: %s", e)

    # ── 扩展词补充搜索（Phase 3b）──
    expansion_terms: list[str] = []
    expand_info: ExpandResult | None = None
    exp_result = task_results.get("expansion")
    if isinstance(exp_result, ExpandResult):
        expansion_terms = exp_result.terms
        expand_info = exp_result
    elif isinstance(exp_result, Exception):
        logger.debug("LLM expansion 异常: %s", exp_result)

    all_expansion = list(dict.fromkeys(session_expansion_terms + expansion_terms))
    score_decay = config.expansion_score_decay
    supplementary_count = 0
    if all_expansion and len(result.code) < effective_top_k:
        try:
            supplementary = await _supplementary_search(
                all_expansion, db, effective_coll,
                top_k=effective_top_k - len(result.code),
                exclude_ids={item.id for item in result.code},
                score_decay=score_decay,
            )
            if supplementary:
                supplementary_count = len(supplementary)
                result.code.extend(supplementary)
                result.mode = result.mode + "+expansion"
        except Exception as e:
            logger.debug("补充搜索异常: %s", e)

    # mental model 匹配（从 code 结果的 source 推断 scope）
    if db_cog is not None and result.code:
        result.model = _find_model(result.code, db_cog, effective_coll)

    # ── P3-1: Causal chain expansion（AFTER fold_versions, BEFORE engagement boost）──
    causal_debug: dict = {"applied": False}
    if (db_cog is not None and result.facts
            and getattr(config, "causal_expansion_enabled", False) is True):
        try:
            pre_count = len(result.facts)
            result.facts = _expand_causal_chain(result.facts, db_cog, config)
            expanded_count = len(result.facts) - pre_count
            if expanded_count > 0:
                result.facts.sort(key=lambda x: x.score, reverse=True)
                causal_debug = {"applied": True, "expanded": expanded_count}
        except Exception as e:
            logger.debug("causal expansion 失败（降级跳过）: %s", e)

    # ── P3-3: Cross-Type Association（fact→file→corpus chunk）──
    cross_assoc_debug: dict = {"applied": False}
    if (getattr(config, "cross_association_enabled", False)
            and db is not None and result.facts):
        try:
            result.code, cross_assoc_debug = _expand_cross_type(
                result.code, result.facts, db, db_cog, effective_coll, config,
            )
        except Exception as e:
            logger.debug("cross-type association 失败（降级跳过）: %s", e)

    # ── Engagement Boost（#190 — AFTER fold_versions, BEFORE rerank/filter）──
    engagement_debug: dict = {"applied": False, "reason": "disabled"}
    if result.facts:
        try:
            result.facts, engagement_debug = _apply_engagement_boost(
                result.facts, enriched, config,
            )
        except Exception as e:
            logger.debug("engagement boost 失败（降级跳过）: %s", e)
            engagement_debug = {"applied": False, "reason": f"error:{e}"}

    # ── P3-9: Session Continuity Boost（AFTER engagement, BEFORE rerank）──
    continuity_debug: dict = {"applied": False}
    try:
        result.code, result.facts, continuity_debug = _apply_session_continuity_boost(
            result.code, result.facts, session_ctx, config,
        )
    except Exception as e:
        logger.debug("session continuity boost 失败（降级跳过）: %s", e)

    # ── P3-5: Returned Results Demotion（AFTER continuity, BEFORE rerank）──
    demotion_debug: dict = {"applied": False}
    try:
        result.code, result.facts, demotion_debug = _apply_returned_demotion(
            result.code, result.facts, session_ctx, config,
        )
    except Exception as e:
        logger.debug("returned demotion 失败（降级跳过）: %s", e)

    # ── Phase 3 L1: Cross-encoder rerank for cognition facts ──
    rerank_facts_debug: dict = {"applied": False, "reason": "no_facts"}
    if result.facts:
        rerank_facts_debug = await _rerank_facts(query, result.facts, config)

    # P1-1 相对质量门控：过滤低分噪音
    min_ratio = getattr(config, "min_score_ratio", 0.15)
    if isinstance(min_ratio, (int, float)) and min_ratio > 0:
        result.code = _filter_by_relative_score(result.code, min_ratio)
        result.facts = _filter_by_relative_score(result.facts, min_ratio)

    # L0 hint 生成
    result.hint = _build_hint(result)

    # ── Phase 3c 可观测性 ──
    debug: dict = {
        "query": {
            "route": enriched.route,
            "short_query": enriched.short_query,
            "compressed": enriched.compressed,
            "phrases": enriched.phrases,
            "token_count": len([
                t for t in enriched.token_query.replace('"', "").split(" OR ")
                if t.strip()
            ]),
        },
        "cognition_scope": {
            "cross_collection": cog_cross_collection,
            "fallback_triggered": cog_fallback_triggered,
            "redde_density": {c: n for c, n in redde_density} if redde_density else {},
            "session_affinity": bool(session_ctx and session_ctx.collection_counts),
            "centroid_available": centroids is not None,
            "centroid_collections": list(centroids.keys()) if centroids else [],
            "centroid_query_vec": query_vec is not None,
        },
    }
    if session_id:
        debug["session"] = {
            "is_warm": session_ctx.is_warm if session_ctx else False,
            "coreference_resolved": original_query != query,
            "original_query": original_query,
            "resolved_query": query if original_query != query else None,
            "expansion_terms": session_expansion_terms,
        }
    if expand_info is not None:
        debug["expansion"] = {
            "triggered": expand_info.triggered,
            "cache_hit": expand_info.cache_hit,
            "provider_name": expand_info.provider_name,
            "latency_ms": expand_info.latency_ms,
            "terms": expand_info.terms,
            "parsed_method": expand_info.parsed_method,
            "raw_count": expand_info.raw_count,
            "failure_reason": expand_info.failure_reason,
            "grounded_count": expand_info.grounded_count,
            "pre_grounding_terms": expand_info.pre_grounding_terms,
        }
    if supplementary_count > 0:
        debug["supplementary"] = {
            "count": supplementary_count,
            "score_decay": score_decay,
        }
    _RERANK_SILENT = {"no_facts", "disabled", "too_few"}
    if rerank_facts_debug.get("applied") or rerank_facts_debug.get("reason") not in _RERANK_SILENT:
        debug["rerank_facts"] = rerank_facts_debug
    if causal_debug.get("applied"):
        debug["causal_expansion"] = causal_debug
    if cross_assoc_debug.get("applied"):
        debug["cross_type_association"] = cross_assoc_debug
    if engagement_debug.get("applied"):
        debug["engagement_boost"] = engagement_debug
    if continuity_debug.get("applied"):
        debug["session_continuity"] = continuity_debug
    if demotion_debug.get("applied"):
        debug["returned_demotion"] = demotion_debug
    if effective_top_k != top_k:
        debug["adaptive_top_k"] = {
            "original": top_k, "effective": effective_top_k,
            "route": enriched.route,
        }
    result.debug = debug

    result.query_ms = int((time.monotonic() - start) * 1000)
    return result


# ── 内部搜索函数 ──


async def _search_corpus(
    query: str,
    config: Config,
    db: Database,
    embedder: Embedder,
    collection: str | None,
    top_k: int,
    enriched: EnrichedQuery | None = None,
) -> tuple[list[CodeItem], str]:
    """搜索 corpus → (CodeItem 列表, 实际搜索模式)"""
    resp = await corpus_search(
        query, config, db, embedder,
        collection=collection, top_k=top_k, verbose=True,
        enriched=enriched,
    )

    items: list[CodeItem] = []
    for r in resp.results:
        # 获取符号（前 3 个结果）
        symbols: list[str] = []
        if len(items) < 3 and r.chunk_id:
            symbols = _get_chunk_symbols(db, r.chunk_id)

        items.append(CodeItem(
            id=r.chunk_id,
            score=r.score,
            source=r.source,
            section=r.section,
            content=(r.content or r.summary or "")[:500],
            symbols=symbols,
        ))

    # 使用 search.py 返回的实际模式（hybrid / bm25_only）
    mode = resp.mode if resp.mode else ("hybrid" if embedder.available else "bm25")
    return items, mode


async def _search_cognition(
    query: str,
    db_cog: object,
    embedder: Embedder | None,
    collection: str | None,
    top_k: int,
) -> list[FactItem]:
    """搜索 cognition facts → FactItem 列表"""
    from .cognitive.search_cog import recall

    resp = await recall(
        query, db_cog, embedder,
        collection=collection, top_k=top_k,
        include_universal=True,
    )

    items: list[FactItem] = []
    for f in resp.facts:
        # score = RRF relevance（由 search_cog._rrf_merge 计算 + MaxScore 归一化）
        # confidence = 事实质量（静态属性，仅用于阈值过滤，不参与排序）
        # 依赖: recall() 返回的 fact dict 上的 _rrf_score 内部字段
        items.append(FactItem(
            id=f["id"],
            score=round(f.get("_rrf_score", 0.5), 2),
            assertion=f.get("assertion", ""),
            category=f.get("category", "code"),
            confidence=f.get("confidence", 0.5),
            created_at=f.get("created_at"),
            session_id=f.get("session_id"),
            collection=f.get("collection"),
            access_count=f.get("access_count", 0) or 0,
            return_count=f.get("return_count", 0) or 0,
        ))

    return items


async def _search_tactics(
    query: str,
    db_cog: object,
    collection: str | None,
) -> list[TacticItem]:
    """搜索 tactics → TacticItem 列表"""
    try:
        tactics = db_cog.search_tactics(query, collection)  # type: ignore[union-attr]
    except Exception as e:
        logger.debug("tactics 搜索失败: %s", e)
        return []

    return [
        TacticItem(
            id=t["id"],
            trigger_pattern=t.get("trigger_pattern", ""),
            steps=t.get("steps", ""),
            success_rate=t.get("success_rate", 0.0),
        )
        for t in tactics
    ]


async def _supplementary_search(
    terms: list[str],
    db: Database,
    collection: str | None,
    top_k: int,
    exclude_ids: set[int],
    score_decay: float = 0.8,
) -> list[CodeItem]:
    """用扩展词做补充 BM25 搜索 — 结果 score 衰减

    仅用 FTS5（BM25），不走向量搜索（避免额外 Ollama 调用）。
    """
    if not terms or top_k <= 0:
        return []

    # 构造 FTS5 查询
    fts5_tokens: list[str] = []
    for term in terms[:10]:
        tokenized = tokenize_for_fts5(term)
        for t in tokenized.split():
            t = t.strip()
            if t:
                fts5_tokens.append(f'"{t}"')
    if not fts5_tokens:
        return []
    fts5_query = " OR ".join(fts5_tokens)

    # 多取一些用于过滤
    fetch_k = top_k + len(exclude_ids) + 5
    try:
        bm25_results = db.fts5_search(fts5_query, collection, fetch_k)
    except Exception as e:
        logger.debug("补充 BM25 搜索失败: %s", e)
        return []

    # 过滤已有结果 + 转换为 CodeItem
    items: list[CodeItem] = []
    for chunk_id, score in bm25_results:
        if chunk_id in exclude_ids:
            continue
        chunk = db.get_chunk(chunk_id)
        if not chunk:
            continue

        items.append(CodeItem(
            id=chunk.id,
            score=round(score * score_decay, 4),
            source=chunk.source,
            section=chunk.section,
            content=(chunk.content or chunk.summary or "")[:500],
        ))
        if len(items) >= top_k:
            break

    return items


# ── P1 搜索逻辑优化函数 ──


_CJK_CHAR_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]")


def _jaccard_similarity(a: str, b: str) -> float:
    """Jaccard 相似度 — 用于版本折叠去重

    CJK 感知：对中日韩文本按字符分词（空格分词对 CJK 无效）。
    """
    a_lower, b_lower = a.lower(), b.lower()
    tokens_a = _tokenize_for_jaccard(a_lower)
    tokens_b = _tokenize_for_jaccard(b_lower)
    if not tokens_a or not tokens_b:
        return 0.0
    intersection = tokens_a & tokens_b
    union = tokens_a | tokens_b
    return len(intersection) / len(union)


def _tokenize_for_jaccard(text: str) -> set[str]:
    """分词：CJK 文本按字符，英文按空格"""
    cjk_chars = set(_CJK_CHAR_RE.findall(text))
    words = set(text.split())
    return cjk_chars | words


def _fold_fact_versions(
    facts: list[FactItem],
    threshold: float = 0.6,
) -> list[FactItem]:
    """版本折叠：Jaccard 相似度 > threshold 的 facts 只保留 score 最高的

    同一知识的新旧版本（例如两条都提到 "ONNX 默认后端"）占多个 top-k 位置，
    折叠后只保留相关性最高的版本。
    """
    if len(facts) <= 1:
        return facts

    kept: list[FactItem] = []
    for f in facts:  # 已按 score 降序
        is_dup = False
        for k in kept:
            if _jaccard_similarity(f.assertion, k.assertion) > threshold:
                is_dup = True
                break
        if not is_dup:
            kept.append(f)
    return kept


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


def _expand_causal_chain(
    facts: list[FactItem],
    db_cog: object,
    config: Config,
) -> list[FactItem]:
    """1-hop 因果链扩展 — top-5 facts 沿 fact_edges 展开

    搜索结果中每条 fact 可能有因果关联（cause→effect、explains、contradicts），
    通过 fact_edges 表 1-hop 扩展把关联 facts 附加到结果中（score 衰减）。
    """
    max_expand = getattr(config, "causal_expansion_max", 3)
    min_conf = getattr(config, "causal_expansion_min_confidence", 0.6)

    existing_ids = {f.id for f in facts}
    expanded: list[FactItem] = []

    for fact in facts[:5]:
        try:
            edges = db_cog.get_fact_edges(fact.id)
        except Exception as e:
            logger.debug("get_fact_edges(%s) 失败: %s", fact.id, e)
            continue
        for edge in edges:
            if edge["confidence"] < min_conf:
                continue
            other_id = (
                edge["to_fact"] if edge["from_fact"] == fact.id
                else edge["from_fact"]
            )
            if other_id in existing_ids:
                continue
            existing_ids.add(other_id)
            # get_fact_edges JOIN 已返回 from_assertion/to_assertion
            other_assertion = (
                edge["to_assertion"] if edge["from_fact"] == fact.id
                else edge["from_assertion"]
            )
            expanded.append(FactItem(
                id=other_id,
                score=round(fact.score * edge["confidence"] * 0.5, 4),
                assertion=other_assertion or "",
                category="expanded",
                confidence=edge["confidence"],
            ))
            if len(expanded) >= max_expand:
                break
        if len(expanded) >= max_expand:
            break

    return facts + expanded


async def _rerank_facts(
    query: str,
    facts: list[FactItem],
    config: Config,
) -> dict:
    """Cross-encoder rerank for cognition facts — in-place score 更新

    用 fact.assertion 作为文档文本（自然语言 → 适合 cross-encoder token 级交互）。
    失败时保持原始 RRF 排序（L0 兜底）。

    Returns: debug_info dict（applied/reason/candidates）
    """
    if not config.rerank_enabled or len(facts) < 2:
        reason = "disabled" if not config.rerank_enabled else "too_few"
        return {"applied": False, "reason": reason}

    # CJK > 70% 跳过（MiniLM 是英文模型，纯中文无增益）
    chars = query.replace(" ", "")
    if chars:
        cjk_count = len(_CJK_CHAR_RE.findall(chars))
        if cjk_count / len(chars) > 0.7:
            return {"applied": False, "reason": "cjk_skip"}

    try:
        # lazy import — 复用 search.py 的 singleton reranker 和 executor
        from .search import _RERANK_EXECUTOR, _get_reranker

        reranker = _get_reranker(config.rerank_model)
        # assertion 是自然语言，截断到 ~500 字符（cross-encoder max_length=512 tokens）
        docs = [f.assertion[:500] for f in facts]

        loop = asyncio.get_running_loop()
        # 默认参数绑定避免闭包变量引用漂移
        scores: list[float] = await asyncio.wait_for(
            loop.run_in_executor(
                _RERANK_EXECUTOR,
                lambda q=query, d=docs: list(reranker.rerank(q, d)),
            ),
            timeout=0.5,
        )

        # min-max 归一化到 [0, 1]（cross-encoder logits 可为负数）
        min_s = min(scores)
        max_s = max(scores)
        if max_s > min_s:
            norm = [(s - min_s) / (max_s - min_s) for s in scores]
        else:
            norm = [1.0] * len(scores)

        for i, f in enumerate(facts):
            f.score = round(norm[i], 4)
        facts.sort(key=lambda x: x.score, reverse=True)

        return {"applied": True, "candidates": len(facts)}

    except TimeoutError:
        logger.warning("Facts rerank 超时 (>500ms)，使用 RRF 结果")
        return {"applied": False, "reason": "timeout"}
    except Exception as e:
        logger.warning("Facts rerank 失败: %s", e)
        return {"applied": False, "reason": str(e)[:100]}


def _filter_by_relative_score(
    items: list,
    min_ratio: float = 0.15,
    min_count: int = 1,
) -> list:
    """相对质量门控：低于 top_score * min_ratio 的结果不返回

    P1-1 + P1-6: 用相对指标替代绝对阈值。RRF 分数跨查询不可比，
    但同一查询内 top_score 与 low_score 的比值反映相关性差距。
    """
    if len(items) <= min_count:
        return items
    top_score = items[0].score if items else 0
    if top_score <= 0:
        return items
    threshold = top_score * min_ratio
    filtered = [it for it in items if it.score >= threshold]
    return filtered if len(filtered) >= min_count else items[:min_count]


_FALLBACK_QUALITY_THRESHOLD = 0.3  # MaxScore 归一化后，低于此值视为质量不足


def _should_fallback_cognition(facts: list[FactItem], top_k: int) -> bool:
    """判断是否需要跨集合 fallback — 数量 + 质量双重判断

    替代原有的纯数量判断 `len(facts) < top_k // 2`。
    """
    if len(facts) < 2:
        return True
    # 数量少且质量低 → fallback
    if len(facts) < top_k // 2 and facts[0].score < _FALLBACK_QUALITY_THRESHOLD:
        return True
    return False


# ── A2 策略辅助函数 ──

# Error/bug 关键词 — 跨集合经验共享价值高
_ERROR_KEYWORDS_RE = re.compile(
    r"\b(?:error|bug|crash|exception|panic|traceback|stacktrace)\b"
    r"|报错|崩溃|异常|堆栈",
    re.IGNORECASE,
)


def _wants_cross_collection(enriched: EnrichedQuery, query: str) -> bool:
    """判断查询是否需要跨集合搜索

    - symbol_lookup / graph_query → 只搜当前 collection
    - error/bug 关键词 → 跨集合（经验共享价值高）
    - 默认 → 当前 collection + universal
    """
    if enriched.route in ("symbol_lookup", "graph_query"):
        return False
    if _ERROR_KEYWORDS_RE.search(query):
        return True
    return False


def _cosine_similarity(a: list[float], b: list[float]) -> float:
    """纯 Python 余弦相似度 — 384 维约 0.1ms"""
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sum(x * x for x in a) ** 0.5
    norm_b = sum(x * x for x in b) ** 0.5
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


def _centroid_affinity(
    query_vec: list[float],
    centroids: dict[str, tuple[list[float], int]],
    collection: str,
) -> float:
    """语义亲和度：query 向量与 collection 质心的余弦相似度 → [0.3, 0.9]

    返回 -1.0 表示无质心数据（调用方 fall back 到 Session Affinity）。
    """
    entry = centroids.get(collection)
    if not entry:
        return -1.0
    centroid_vec, _ = entry
    sim = _cosine_similarity(query_vec, centroid_vec)
    sim = max(0.0, min(1.0, sim))  # clamp
    return round(0.3 + 0.6 * sim, 4)


def _combined_decay(
    f: FactItem,
    source_collection: str,
    session_ctx: SessionContext | None,
    query_vec: list[float] | None,
    centroids: dict[str, tuple[list[float], int]] | None,
) -> float:
    """计算组合衰减因子 — max(session_affinity, centroid_affinity)"""
    if f.collection == source_collection:
        return 1.0
    # Session Affinity (behavioral)
    if session_ctx and session_ctx.collection_counts:
        sa = session_ctx.collection_affinity(f.collection, source_collection)
    else:
        sa = 0.5
    # Centroid Affinity (semantic)
    ca = -1.0
    if query_vec and centroids:
        ca = _centroid_affinity(query_vec, centroids, f.collection)
    return max(sa, ca) if ca >= 0 else sa


def _apply_collection_boost(
    facts: list[FactItem],
    source_collection: str,
    session_ctx: SessionContext | None = None,
    query_vec: list[float] | None = None,
    centroids: dict[str, tuple[list[float], int]] | None = None,
) -> list[FactItem]:
    """对跨集合搜索结果施加 collection affinity boost

    - 当前 collection → 1.0（不衰减）
    - 两个信号取 max: Session Affinity [0.3-0.8] + Centroid Similarity [0.3-0.9]
    - 无信号时退化为固定 0.5（Phase 1 行为）
    """
    for f in facts:
        decay = _combined_decay(f, source_collection, session_ctx, query_vec, centroids)
        if decay < 1.0:
            f.score = round(f.score * decay, 4)
    facts.sort(key=lambda x: x.score, reverse=True)
    return facts


def _merge_cognition_fallback(
    primary: list[FactItem],
    fallback: list[FactItem],
    source_collection: str,
    top_k: int,
    session_ctx: SessionContext | None = None,
    query_vec: list[float] | None = None,
    centroids: dict[str, tuple[list[float], int]] | None = None,
) -> list[FactItem]:
    """合并跨集合 fallback 结果 — 去重 + boost + 截断"""
    seen_ids = {f.id for f in primary}
    for f in fallback:
        if f.id in seen_ids:
            continue
        seen_ids.add(f.id)
        decay = _combined_decay(f, source_collection, session_ctx, query_vec, centroids)
        if decay < 1.0:
            f.score = round(f.score * decay, 4)
        primary.append(f)
    primary.sort(key=lambda x: x.score, reverse=True)
    return primary[:top_k]


def _build_fts5_for_redde(query: str) -> str:
    """构造 ReDDE-lite 用的 FTS5 查询 — 简单 OR 分词"""
    cleaned = re.sub(r'[(){}*"^~:\-]', " ", query)
    tokens = [t for t in cleaned.split() if t.strip() and len(t) > 1]
    if not tokens:
        return query
    return " OR ".join(f'"{t}"' for t in tokens[:10])


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


def _adaptive_top_k(enriched: EnrichedQuery, user_top_k: int, config: Config) -> int:
    """按查询类型自适应调整 top_k — 精确查询缩小、复杂查询扩大

    symbol_lookup → min(user, min_config)   精确匹配无需多余结果
    graph_query   → min(user, min+2)        关系查询需少量上下文
    compressed    → max(user, max_config)   长查询/overview 需更多覆盖
    其他          → user_top_k              不变
    """
    tk_min = getattr(config, "adaptive_top_k_min", 3)
    tk_max = getattr(config, "adaptive_top_k_max", 20)
    if enriched.route == "symbol_lookup":
        return min(user_top_k, tk_min)
    if enriched.route == "graph_query":
        return min(user_top_k, tk_min + 2)
    if enriched.compressed:
        return max(user_top_k, tk_max)
    return user_top_k


# ── P3-3 Cross-Type Association（#191）──

# L0 正则：从 assertion 提取文件路径（复用 maintenance 的模式）
_RE_FULL_PATH = re.compile(
    r"(?:/[\w./-]+/)((?:src|tests|bin|lib|pkg|cmd)/[\w./-]+\.\w{1,4})"
)
_RE_CHANGED_IN = re.compile(
    r"(?:Changed (?:function|class|method) '[^']+' in |Modified |Created |Deleted )"
    r"(/[\w./-]+\.[\w]{1,4})"
)
_RE_SHORT_MODULE = re.compile(
    r"\b([\w-]+\.(?:py|rs|js|ts|jsx|tsx|go|java|rb|c|cpp|h|hpp|toml|yaml|yml|json|md))\b"
)

_CROSS_ASSOC_DECAY = 0.3  # 关联结果 score 衰减系数


def _extract_file_ref_l0(text: str) -> str | None:
    """L0 纯规则：从文本提取最具体的文件引用"""
    m = _RE_FULL_PATH.search(text)
    if m:
        return m.group(1)
    m = _RE_CHANGED_IN.search(text)
    if m:
        full_path = m.group(1)
        for prefix in ("src/", "tests/", "bin/", "lib/"):
            idx = full_path.find(prefix)
            if idx >= 0:
                return full_path[idx:]
        return full_path.rsplit("/", 1)[-1]
    if len(text) < 300:
        m = _RE_SHORT_MODULE.search(text)
        if m:
            return m.group(1)
    return None


def _expand_cross_type(
    code: list[CodeItem],
    facts: list[FactItem],
    db: "Database",
    db_cog: object | None,
    collection: str | None,
    config: "Config",
) -> tuple[list[CodeItem], dict]:
    """fact 引用的 file → corpus chunk 关联

    两级提取：(1) db_cog batch 查 context_json.file (2) L0 正则补充。
    查 corpus 获取首 chunk，score 按关联 fact 的 score 衰减。
    """
    debug: dict = {"applied": False, "reason": "no_facts"}
    max_add = getattr(config, "cross_association_max", 2)
    if max_add <= 0:
        debug["reason"] = "zero_max"
        return code, debug
    if not facts:
        return code, debug

    existing_sources = {item.source for item in code if item.source}
    existing_ids = {item.id for item in code}

    # ── 提取文件路径 ──
    top_facts = facts[:10]
    fact_ids = [f.id for f in top_facts]

    # (1) batch 查 context_json.file（主路径，更可靠）
    file_map: dict[str, float] = {}  # file_path → best_score
    covered_ids: set[int] = set()  # 已从 context_json 提取的 fact IDs
    if db_cog is not None:
        try:
            placeholders = ",".join("?" * len(fact_ids))
            rows = db_cog.conn.execute(
                f"SELECT id, json_extract(context_json, '$.file') FROM facts "
                f"WHERE id IN ({placeholders}) "
                f"AND json_extract(context_json, '$.file') IS NOT NULL",
                fact_ids,
            ).fetchall()
            for row_id, row_file in rows:
                if row_file:
                    covered_ids.add(row_id)
                    # 找到对应 fact 的 score
                    for f in top_facts:
                        if f.id == row_id and row_file not in existing_sources:
                            file_map[row_file] = max(file_map.get(row_file, 0.0), f.score)
                            break
        except Exception:
            pass  # db_cog 不可用，走 regex fallback

    # (2) L0 正则补充（无 context_json.file 的 facts）
    for f in top_facts:
        if f.id in covered_ids:
            continue
        fp = _extract_file_ref_l0(f.assertion)
        if fp and fp not in existing_sources:
            file_map[fp] = max(file_map.get(fp, 0.0), f.score)

    if not file_map:
        debug["reason"] = "no_file_refs"
        return code, debug

    # ── 查 corpus 获取关联 chunks ──
    # 按 score 降序排候选文件
    candidates = sorted(file_map.items(), key=lambda x: -x[1])
    added: list[CodeItem] = []

    for fp, ref_score in candidates:
        if len(added) >= max_add:
            break
        try:
            doc = db.resolve_document(fp, collection=collection)
            if not doc:
                continue
            row = db.conn.execute(
                "SELECT id, content, section FROM chunks "
                "WHERE doc_id = ? ORDER BY chunk_index LIMIT 1",
                (doc["id"],),
            ).fetchone()
            if not row:
                continue
            chunk_id, content, section = row
            if chunk_id in existing_ids:
                continue
            added.append(CodeItem(
                id=chunk_id,
                score=round(ref_score * _CROSS_ASSOC_DECAY, 4),
                source=doc["path"],
                section=section,
                content=(content or "")[:500],
            ))
        except Exception:
            continue

    if not added:
        debug["reason"] = "no_chunks_found"
        return code, debug

    code = code + added
    code.sort(key=lambda x: x.score, reverse=True)

    debug = {
        "applied": True,
        "candidates": len(file_map),
        "added": len(added),
    }
    return code, debug


# ── P3-9 Session Continuity Boost（#191）──


def _apply_session_continuity_boost(
    code: list[CodeItem],
    facts: list[FactItem],
    session_ctx: object | None,
    config: Config,
) -> tuple[list[CodeItem], list[FactItem], dict]:
    """多轮搜索连续性 — 最近操作的文件/实体 boost

    L0 纯规则：code.source 在 recent_files → score 提升，
    code.symbols 在 recent_entities → score 提升（半权重），
    fact.assertion 含 recent_file stem → score 提升（半权重）。
    """
    debug: dict = {"applied": False, "reason": "disabled"}
    if not getattr(config, "session_continuity_enabled", False):
        return code, facts, debug
    if session_ctx is None or not getattr(session_ctx, "is_warm", False):
        debug["reason"] = "cold_session"
        return code, facts, debug

    weight = getattr(config, "session_continuity_weight", 0.10)
    if weight <= 0:
        debug["reason"] = "zero_weight"
        return code, facts, debug

    recent_files: list[str] = getattr(session_ctx, "recent_files", []) or []
    recent_entities: list[str] = getattr(session_ctx, "recent_entities", []) or []
    if not recent_files and not recent_entities:
        debug["reason"] = "no_context"
        return code, facts, debug

    from pathlib import Path

    recent_set = set(recent_files)
    recent_stems = {Path(f).stem for f in recent_files if f}
    entity_set = set(recent_entities)
    boosted_code = 0
    boosted_facts = 0

    # Code boost: source 文件匹配 recent_files
    for item in code:
        if not item.source:
            continue
        if item.source in recent_set or Path(item.source).stem in recent_stems:
            item.score = round(item.score * (1.0 + weight), 4)
            boosted_code += 1
        elif item.symbols and any(s in entity_set for s in item.symbols):
            item.score = round(item.score * (1.0 + weight * 0.5), 4)
            boosted_code += 1
    if boosted_code:
        code.sort(key=lambda x: x.score, reverse=True)

    # Facts boost: assertion 含 recent_file stem
    for f in facts:
        # 只匹配有意义的 stem（>2 字符避免误匹配）
        if any(stem in f.assertion for stem in recent_stems if len(stem) > 2):
            f.score = round(f.score * (1.0 + weight * 0.5), 4)
            boosted_facts += 1
    if boosted_facts:
        facts.sort(key=lambda x: x.score, reverse=True)

    debug = {
        "applied": True,
        "boosted_code": boosted_code,
        "boosted_facts": boosted_facts,
    }
    return code, facts, debug


# ── P3-5 Returned Results Demotion（#191）──


def _apply_returned_demotion(
    code: list[CodeItem],
    facts: list[FactItem],
    session_ctx: object | None,
    config: Config,
) -> tuple[list[CodeItem], list[FactItem], dict]:
    """查询历史复用 — 降权本会话已返回过的结果

    不删除（用户可能需要再看），只降低 score 让新结果优先。
    """
    debug: dict = {"applied": False, "reason": "disabled"}
    if not getattr(config, "returned_demotion_enabled", False):
        return code, facts, debug
    if session_ctx is None:
        debug["reason"] = "no_session"
        return code, facts, debug

    returned_facts: set = getattr(session_ctx, "returned_fact_ids", set()) or set()
    returned_codes: set = getattr(session_ctx, "returned_code_ids", set()) or set()
    if not returned_facts and not returned_codes:
        debug["reason"] = "no_history"
        return code, facts, debug

    decay = getattr(config, "returned_demotion_decay", 0.5)
    demoted_code = 0
    demoted_facts = 0

    for item in code:
        if item.id in returned_codes:
            item.score = round(item.score * decay, 4)
            demoted_code += 1
    if demoted_code:
        code.sort(key=lambda x: x.score, reverse=True)

    for f in facts:
        if f.id in returned_facts:
            f.score = round(f.score * decay, 4)
            demoted_facts += 1
    if demoted_facts:
        facts.sort(key=lambda x: x.score, reverse=True)

    debug = {
        "applied": True,
        "demoted_code": demoted_code,
        "demoted_facts": demoted_facts,
    }
    return code, facts, debug


# ── Engagement Boost 信号函数（#190）──


# P2-5 意图→类型映射表（L0 规则）
_INTENT_CATEGORY_MAP: dict[str, list[str]] = {
    "error": ["root_cause", "revert", "config"],
    "architecture": ["architecture", "pattern", "tradeoff"],
    "decision": ["decision", "tradeoff", "preference"],
    "performance": ["performance", "optimization", "config"],
    "debug": ["root_cause", "error", "debug"],
}


def _time_decay_factor(created_at: str | None, halflife: int = 180) -> float:
    """P2-1: 时间衰减 — 2^(-age/halflife), 范围 [0, 1]

    halflife 天后衰减到 0.5，360 天后衰减到 0.25。
    created_at 为 None 或解析失败时返回 0.5（中性值）。
    """
    if not created_at or halflife <= 0:
        return 0.5
    try:
        dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        age_days = (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
        if age_days < 0:
            return 1.0
        return 2.0 ** (-age_days / halflife)
    except (ValueError, TypeError):
        return 0.5


def _access_rate_factor(access_count: int, created_at: str | None, min_age: int = 1) -> float:
    """P2-2: 归一化访问率 — sigmoid(log1p(access/age)), 范围 [0, 1]

    衡量每天的平均访问频率，用 sigmoid 归一化到 [0, 1]。
    age < min_age 天时按 min_age 算（避免除零）。
    """
    if access_count <= 0:
        return 0.0
    if not created_at:
        return 0.5

    try:
        dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        age_days = max(min_age, (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0)
    except (ValueError, TypeError):
        age_days = max(min_age, 30)  # 默认 30 天

    rate = access_count / age_days
    x = math.log1p(rate)
    # sigmoid 归一化: 2 / (1 + exp(-x)) - 1, 映射到 [0, 1]
    return 2.0 / (1.0 + math.exp(-x)) - 1.0


def _return_noise_factor(access_count: int, return_count: int, threshold: float = 0.5) -> float:
    """P2-3: 负反馈降噪 — (adoption_ratio - threshold) / threshold, 范围 [-1, 0]

    adoption_ratio = access_count / return_count (access 是选用次数，return 是返回次数)
    当初期 access_count == return_count 时, ratio=1.0, 结果 > 0 被 clamp 到 0（中性）。
    当 return_count >> access_count 时，ratio < threshold, 结果为负（降权）。
    """
    if return_count <= 0 or access_count < 0:
        return 0.0
    adoption_ratio = access_count / return_count
    signal = (adoption_ratio - threshold) / threshold
    return min(0.0, signal)  # clamp: 只允许负值或零


def _intent_category_match(
    category: str,
    enriched: EnrichedQuery | None,
    intent_map: dict[str, list[str]] | None = None,
) -> float:
    """P2-5: 意图-类型匹配 — route/entities 规则映射, [0, 1]

    route 匹配 → 1.0（强信号）, entities 关键词匹配 → 0.5（弱信号）, 无匹配 → 0.0。
    """
    if not enriched or not category:
        return 0.0
    if intent_map is None:
        intent_map = _INTENT_CATEGORY_MAP

    # Route 匹配（强信号）
    query_lower = enriched.normalized.lower() if enriched.normalized else ""
    for intent_key, categories in intent_map.items():
        if category in categories:
            if intent_key in query_lower:
                return 1.0

    # Entities 关键词匹配（弱信号）
    if enriched.entities:
        for entity in enriched.entities:
            entity_lower = entity.lower()
            for intent_key, categories in intent_map.items():
                if category in categories and intent_key in entity_lower:
                    return 0.5

    return 0.0


def _apply_engagement_boost(
    items: list[FactItem],
    enriched: EnrichedQuery | None,
    config: Config,
) -> tuple[list[FactItem], dict]:
    """组合 Engagement Boost — score * (1 + Σ(w_i * s_i))

    在 fold_versions 之后、relative_score_filter 之前调用。
    乘法 boost，小权重(0.05-0.10)不会颠覆相关性排序。

    Returns: (boosted items, debug dict)
    """
    debug_info: dict = {"applied": False, "reason": "disabled"}

    if not getattr(config, "engagement_boost_enabled", False):
        return items, debug_info

    if not items:
        debug_info["reason"] = "no_items"
        return items, debug_info

    debug_info["applied"] = True
    debug_info["reason"] = "ok"
    signals_applied: list[str] = []
    top_boost: float = 0.0

    original_scores: dict[int, float] = {}  # fact_id → pre-boost score（debug 回溯用）

    for f in items:
        original_scores[f.id] = f.score
        total_boost = 0.0

        # P2-1: 时间衰减
        w_td = config.time_decay_weight
        if w_td > 0:
            td = _time_decay_factor(f.created_at, config.time_decay_halflife)
            total_boost += w_td * td
            if "time_decay" not in signals_applied:
                signals_applied.append("time_decay")

        # P2-2: 归一化访问率
        w_ar = config.access_rate_weight
        if w_ar > 0:
            ar = _access_rate_factor(f.access_count, f.created_at)
            total_boost += w_ar * ar
            if "access_rate" not in signals_applied:
                signals_applied.append("access_rate")

        # P2-3: 负反馈降噪
        w_rn = config.return_noise_weight
        if w_rn > 0:
            rn = _return_noise_factor(f.access_count, f.return_count, config.return_noise_threshold)
            total_boost += w_rn * rn  # rn ∈ [-1,0]，负值→降权
            if "return_noise" not in signals_applied:
                signals_applied.append("return_noise")

        # P2-5: 意图-类型匹配
        w_im = config.intent_match_weight
        if w_im > 0:
            im = _intent_category_match(f.category, enriched)
            total_boost += w_im * im
            if "intent_match" not in signals_applied:
                signals_applied.append("intent_match")

        # 应用乘法 boost: score * (1 + total_boost)
        f.score = round(f.score * (1.0 + total_boost), 4)
        top_boost = max(top_boost, total_boost)

    # 按新 score 重排
    items.sort(key=lambda x: x.score, reverse=True)

    debug_info["signals"] = signals_applied
    debug_info["top_boost"] = round(top_boost, 4)
    if items:
        top = items[0]
        debug_info["top_fact"] = {
            "id": top.id,
            "original_score": original_scores.get(top.id, 0),
            "boosted_score": top.score,
        }
    return items, debug_info


# ── 辅助函数 ──


def _symbol_lookup_result(
    symbol_names: list[str],
    db: Database,
    collection: str | None,
) -> UnifiedResult:
    """符号路由：直接从 symbols 表查找，跳过 BM25/Vec"""
    result = UnifiedResult(mode="symbol_lookup")
    items: list[CodeItem] = []

    for sym_name in symbol_names[:10]:
        try:
            if collection:
                rows = db.conn.execute(
                    """SELECT s.chunk_id, s.name, s.kind, s.signature, d.path
                       FROM symbols s
                       JOIN documents d ON s.doc_id = d.id
                       WHERE s.name = ? AND d.collection = ?
                       LIMIT 5""",
                    (sym_name, collection),
                ).fetchall()
            else:
                rows = db.conn.execute(
                    """SELECT s.chunk_id, s.name, s.kind, s.signature, d.path
                       FROM symbols s
                       JOIN documents d ON s.doc_id = d.id
                       WHERE s.name = ?
                       LIMIT 5""",
                    (sym_name,),
                ).fetchall()

            for row in rows:
                chunk_id, name, kind, signature, path = row
                content = ""
                if chunk_id:
                    chunk = db.get_chunk(chunk_id)
                    if chunk:
                        content = chunk.content[:500]

                items.append(CodeItem(
                    id=chunk_id or 0,
                    score=1.0,
                    source=path,
                    section=f"{kind}: {name}",
                    content=content,
                    symbols=[f"{name} ({kind})"],
                ))
        except Exception as e:
            logger.debug("符号查找失败 %s: %s", sym_name, e)

    result.code = items
    return result


def _get_chunk_symbols(db: Database, chunk_id: int) -> list[str]:
    """获取 chunk 关联的符号名（最多 5 个）"""
    try:
        rows = db.conn.execute(
            "SELECT name FROM symbols WHERE chunk_id = ? LIMIT 5",
            (chunk_id,),
        ).fetchall()
        return [r[0] for r in rows]
    except Exception as e:
        logger.debug("获取 chunk %d 符号失败: %s", chunk_id, e)
        return []


def _find_model(
    code_items: list[CodeItem],
    db_cog: object,
    collection: str | None,
) -> dict | None:
    """从 code 结果的 source 推断 scope，查找对应 mental model"""
    coll = collection or "default"
    seen_scopes: set[str] = set()

    for item in code_items[:3]:
        if not item.source:
            continue
        # 提取目录作为 scope（如 src/index1/cognitive/ → cognitive/）
        parts = item.source.split("/")
        if len(parts) >= 2:
            scope = "/".join(parts[:-1]) + "/"
            if scope not in seen_scopes:
                seen_scopes.add(scope)
                try:
                    model = db_cog.get_mental_model(scope, coll)  # type: ignore[union-attr]
                    if model:
                        return {
                            "scope": model.get("scope", scope),
                            "subject": model.get("subject", ""),
                            "understanding_level": model.get("understanding_level", 0),
                        }
                except Exception:
                    pass

    return None


def _build_hint(result: UnifiedResult) -> str:
    """L0 规则模板生成导航提示"""
    parts: list[str] = []

    n_code = len(result.code)
    n_facts = len(result.facts)
    n_tactics = len(result.tactics)

    if n_facts > 0:
        parts.append(f"发现 {n_facts} 条相关经验")
    if n_tactics > 0:
        parts.append(f"找到 {n_tactics} 个可用策略")
    if result.model:
        level = result.model.get("understanding_level", 0)
        parts.append(f"对 {result.model.get('scope', '?')} 的理解程度 {int(level * 100)}%")
    if n_code > 0 and not parts:
        parts.append(f"找到 {n_code} 个代码片段")
    if not parts:
        parts.append("未找到相关结果")

    return "；".join(parts)
