"""BM25 + Vector + RRF 融合搜索引擎

查询流程: embed(query) → parallel(BM25, Vector) → RRF fusion → results
"""

from __future__ import annotations

import asyncio
import hashlib
import logging
import re
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Any

from ._compat import asyncio_timeout
from .cache import QueryCache
from .config import Config
from .db import ChunkRow, Database, tokenize_for_fts5
from .embed import Embedder
from .query import EnrichedQuery

# 全局缓存实例
_cache = QueryCache()

# Cross-encoder reranker — lazy singleton + 单线程 executor
_reranker = None
_reranker_model: str = ""
_RERANK_EXECUTOR = ThreadPoolExecutor(max_workers=1)

logger = logging.getLogger(__name__)

# CJK 字符检测
_CJK_PATTERN = re.compile(r"[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]")


@dataclass
class SearchResult:
    """单条搜索结果"""

    chunk_id: int
    score: float
    source: str | None
    section: str | None
    summary: str | None
    relevance: str  # high / medium / low
    content: str | None = None  # verbose 模式时填充
    token_count: int | None = None


@dataclass
class SearchResponse:
    """搜索响应"""

    results: list[SearchResult] = field(default_factory=list)
    total: int = 0
    query_ms: int = 0
    mode: str = "hybrid"  # hybrid / bm25_only
    hint: str | None = None


def _has_cjk(text: str) -> bool:
    """检测文本是否包含 CJK 字符"""
    return bool(_CJK_PATTERN.search(text))


def _cjk_ratio(text: str) -> float:
    """计算文本中 CJK 字符占比（去空格后）"""
    chars = text.replace(" ", "")
    if not chars:
        return 0.0
    cjk_count = len(_CJK_PATTERN.findall(chars))
    return cjk_count / len(chars)


def _get_reranker(model_name: str) -> Any:
    """Lazy singleton — 首次调用时加载模型 + ONNX warm-up

    支持运行时 model hot-swap（config 变更时自动重载）。
    """
    global _reranker, _reranker_model
    if _reranker is None or _reranker_model != model_name:
        from fastembed.rerank.cross_encoder import TextCrossEncoder

        _reranker = TextCrossEncoder(model_name=model_name)
        _reranker_model = model_name
        # warm-up: 消除首次推理的 ONNX JIT 开销（<5ms）
        list(_reranker.rerank("warmup", ["warmup"]))
    return _reranker


def _rrf_fusion(
    bm25_results: list[tuple[int, float]],
    vector_results: list[tuple[int, float]],
    k: int = 60,
    bm25_weight: float = 0.5,
    vector_weight: float = 0.5,
) -> list[tuple[int, float]]:
    """RRF (Reciprocal Rank Fusion) 融合两路搜索结果

    score = w_bm25 * 1/(k + rank_bm25) + w_vec * 1/(k + rank_vec)
    """
    scores: dict[int, float] = {}

    for rank, (chunk_id, _score) in enumerate(bm25_results):
        scores[chunk_id] = scores.get(chunk_id, 0.0) + bm25_weight / (k + rank + 1)

    for rank, (chunk_id, _dist) in enumerate(vector_results):
        scores[chunk_id] = scores.get(chunk_id, 0.0) + vector_weight / (k + rank + 1)

    # 按分数降序排列
    sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results


def _rrf_fusion_multi(
    branches: list[tuple[list[tuple[int, float]], float]],
    k: int = 60,
) -> list[tuple[int, float]]:
    """多分支 RRF 融合 — 支持 2-N 路搜索结果

    Args:
        branches: [(results, weight), ...] 每路结果和权重
        k: RRF 常数

    score = sum(w_i * 1/(k + rank_i)) for each branch
    """
    scores: dict[int, float] = {}

    for results, weight in branches:
        if not results or weight <= 0:
            continue
        for rank, (chunk_id, _score) in enumerate(results):
            scores[chunk_id] = scores.get(chunk_id, 0.0) + weight / (k + rank + 1)

    sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results


def _escape_fts5_query(query: str) -> str:
    """转义 FTS5 查询中的特殊字符

    FTS5 的 MATCH 不能包含 SQL 特殊字符，需要用双引号包裹每个 token。
    """
    # 移除 FTS5 特殊字符和运算符
    cleaned = re.sub(r'[(){}*"^~:\-]', " ", query)
    cleaned = re.sub(r"\b(AND|OR|NOT|NEAR)\b", " ", cleaned, flags=re.IGNORECASE)
    tokens = cleaned.split()
    if not tokens:
        return '""'
    # 用 OR 连接每个 token（双引号内的内容是字面值，不会被解释为运算符）
    escaped = " OR ".join(f'"{t}"' for t in tokens if t.strip())
    return escaped or '""'


def _should_cache(response: SearchResponse) -> bool:
    """判断搜索结果是否应缓存 — 降级结果和空结果不缓存

    防止缓存中毒：Ollama 临时不可用时的 bm25_only 结果
    不应占据缓存位，否则恢复后仍返回降级结果。
    """
    if response.total == 0:
        return False
    if response.mode == "bm25_only" and response.hint:
        return False
    return True


async def search(
    query: str,
    config: Config,
    db: Database,
    embedder: Embedder,
    *,
    collection: str | None = None,
    top_k: int | None = None,
    verbose: bool = False,
    enriched: EnrichedQuery | None = None,
) -> SearchResponse:
    """执行混合搜索

    Args:
        query: 查询文本
        config: 配置
        db: 数据库实例
        embedder: embedding 客户端
        collection: 限定搜索范围
        top_k: 返回数量
        verbose: 是否返回完整内容
        enriched: EnrichedQuery 对象（可选，由 query.py 生成）

    Returns:
        SearchResponse
    """
    start = time.monotonic()
    top_k = min(top_k or config.top_k, 100)  # 限制最大值

    # cache key：enriched 改变了 FTS5 查询和权重，必须通过 hash 区分
    _enriched_hash = ""
    if enriched is not None:
        _raw = (
            f"{enriched.token_query}"
            f"|{enriched.phrase_query or ''}"
            f"|{sorted(enriched.branch_weights.items())}"
        )
        _enriched_hash = hashlib.sha256(_raw.encode()).hexdigest()[:16]

    # L1 缓存命中：跳过整条管线
    cached = _cache.get_response(query, collection, top_k, _enriched_hash)
    if cached is not None and not verbose:
        cached.query_ms = 0
        return cached

    fetch_k = top_k * 2

    # 自适应 fetch_k：rerank 启用时多给候选，按查询特征调整
    if config.rerank_enabled:
        if enriched is not None and enriched.short_query:
            fetch_k = min(top_k * 4, 50)  # 短查询语义模糊，多给候选
        elif enriched is not None and enriched.phrases:
            fetch_k = min(top_k * 2, 50)  # 短语已精准匹配，少量足够
        else:
            fetch_k = min(top_k * 3, 50)  # 标准扩展，>50 边际递减

    # 如果有 enriched query，使用它的 FTS5 查询和权重
    if enriched is not None:
        fts5_query = enriched.token_query
        phrase_fts5 = enriched.phrase_query
        weights = enriched.branch_weights
        bm25_weight = weights.get("token", 0.5)
        phrase_weight = weights.get("phrase", 0.0)
        vector_weight = weights.get("vector", 0.5)
    else:
        # 向后兼容：无 enriched 时走原有逻辑
        is_cjk = _has_cjk(query)
        bm25_weight = 0.2 if is_cjk else 0.5
        vector_weight = 0.8 if is_cjk else 0.5
        phrase_weight = 0.0
        fts5_query = _escape_fts5_query(tokenize_for_fts5(query))
        phrase_fts5 = None

    # FTS5 列权重 — 标题/标签匹配加权
    col_weights = config.fts5_column_weights

    # BM25 token 分支
    bm25_results: list[tuple[int, float]] = []
    try:
        bm25_results = db.fts5_search(fts5_query, collection, fetch_k, col_weights)
    except Exception as e:
        logger.warning("BM25 搜索失败: %s", e)

    # BM25 phrase 分支（仅 enriched 模式有短语时执行）
    phrase_results: list[tuple[int, float]] = []
    if phrase_fts5 and phrase_weight > 0:
        try:
            phrase_results = db.fts5_search(phrase_fts5, collection, fetch_k, col_weights)
        except Exception as e:
            logger.debug("Phrase 搜索失败（降级忽略）: %s", e)

    # BM25 proximity 分支（#190 P2-4 — NEAR() 邻近度搜索）
    proximity_results: list[tuple[int, float]] = []
    proximity_weight = weights.get("proximity", 0.0) if enriched is not None else 0.0
    if enriched is not None and enriched.proximity_query and proximity_weight > 0:
        try:
            proximity_results = db.fts5_search(
                enriched.proximity_query, collection, fetch_k, col_weights,
            )
        except Exception as e:
            logger.debug("Proximity 搜索失败（降级忽略）: %s", e)

    # 向量搜索（需要 Ollama/ONNX）
    vector_results: list[tuple[int, float]] = []
    mode = "bm25_only"
    hint = None

    if embedder.available or await embedder.check_availability():
        try:
            # 向量搜索整体超时 10s，防止 Ollama 卡死拖垮整条管线
            async with asyncio_timeout(10):
                # L2 缓存：跳过 Ollama 调用
                query_vec = _cache.get_embedding(query)
                if query_vec is None:
                    query_vec = await embedder.embed_one(query)
                    _cache.put_embedding(query, query_vec)
                vector_results = db.vector_search(query_vec, collection, fetch_k)
            mode = "hybrid"
        except TimeoutError:
            logger.warning("向量搜索超时（10s），降级为关键词搜索")
            hint = "向量搜索超时，已降级为关键词搜索"
        except Exception as e:
            logger.warning("向量搜索失败: %s", e)
            hint = "向量搜索不可用，已降级为关键词搜索"
    else:
        hint = f"Ollama 不可用: {embedder.unavailable_reason}"

    # RRF 融合（多分支）
    branches: list[tuple[list[tuple[int, float]], float]] = []
    if bm25_results:
        branches.append((bm25_results, bm25_weight))
    if phrase_results:
        branches.append((phrase_results, phrase_weight))
    if proximity_results:
        branches.append((proximity_results, proximity_weight))
    if vector_results:
        branches.append((vector_results, vector_weight))

    if branches:
        fused = _rrf_fusion_multi(branches, k=config.rrf_k)
    else:
        elapsed = int((time.monotonic() - start) * 1000)
        return SearchResponse(
            results=[], total=0, query_ms=elapsed,
            mode=mode, hint=hint or "未找到结果",
        )

    if enriched is not None and phrase_results:
        mode = mode + "+phrase"
    if proximity_results:
        mode = mode + "+proximity"

    # 可选 Rerank（cross-encoder 精排）
    if config.rerank_enabled and len(fused) > 1:
        fused_before = fused
        fused = await _rerank(query, fused, db, embedder, config)
        if fused is not fused_before:
            mode = mode + "+rerank"

    # 代码图增强（可选）
    if config.graph_enabled:
        fused, graph_mode = _apply_graph_boost(fused, db, config)
        if graph_mode:
            mode = mode + "+graph"

    # 文档级去重 + top_k 截断
    top_fused, deduped = _dedup_by_file(fused, db, top_k)
    if deduped:
        mode = mode + "+dedup"
    max_score = top_fused[0][1] if top_fused else 1.0

    results: list[SearchResult] = []
    for chunk_id, score in top_fused:
        chunk = db.get_chunk(chunk_id)
        if not chunk:
            continue

        # 归一化分数 [0, 1]
        norm_score = round(score / max_score, 4) if max_score > 0 else 0.0
        relevance = _score_to_relevance(norm_score)

        result = SearchResult(
            chunk_id=chunk.id,
            score=norm_score,
            source=chunk.source,
            section=chunk.section,
            summary=chunk.summary,
            relevance=relevance,
            content=chunk.content if verbose else None,
            token_count=chunk.token_count,
        )
        results.append(result)

    elapsed = int((time.monotonic() - start) * 1000)

    response = SearchResponse(
        results=results,
        total=len(results),
        query_ms=elapsed,
        mode=mode,
        hint=hint,
    )

    # 写入 L1 缓存（verbose / 空结果 / 降级结果不缓存）
    if not verbose and _should_cache(response):
        _cache.put_response(query, collection, top_k, response, _enriched_hash)

    return response


def _apply_graph_boost(
    fused: list[tuple[int, float]],
    db: Database,
    config: Config,
) -> tuple[list[tuple[int, float]], bool]:
    """代码图增强 — 结合 PageRank + 结构扩展重新排序

    Returns:
        (重排后的列表, 是否有图数据可用)
    """
    try:
        from .graph import expand_context

        # 取 top-5 种子 chunks 做图扩展
        seed_ids = [cid for cid, _ in fused[:5]]
        expanded = expand_context(
            db, seed_ids,
            hop=config.graph_hop,
            min_confidence=config.graph_min_confidence,
        )
    except Exception:
        return fused, False

    # 检查是否有 symbols 数据
    try:
        stats = db.get_symbol_stats()
        if stats.get("symbol_count", 0) == 0:
            return fused, False
    except Exception:
        return fused, False

    # 构建扩展候选 map: chunk_id → (hop_distance, score)
    expanded_map: dict[int, tuple[int, float]] = {}
    for item in expanded:
        cid = item["chunk_id"]
        if cid not in expanded_map:
            expanded_map[cid] = (item["hop_distance"], item["score"])

    # 收集 chunk_id → pagerank
    chunk_pageranks: dict[int, float] = {}
    all_chunk_ids = set(cid for cid, _ in fused) | set(expanded_map.keys())
    try:
        for cid in all_chunk_ids:
            rows = db.conn.execute(
                "SELECT pagerank FROM symbols WHERE chunk_id = ? AND pagerank > 0 LIMIT 1",
                (cid,),
            ).fetchall()
            if rows:
                chunk_pageranks[cid] = rows[0][0]
    except Exception:
        pass

    if not expanded_map and not chunk_pageranks:
        return fused, False

    # PageRank 归一化到 [0, 1]
    max_pr = max(chunk_pageranks.values()) if chunk_pageranks else 1.0
    if max_pr <= 0:
        max_pr = 1.0

    # 合并语义候选 + 扩展候选（去重）
    all_candidates: dict[int, float] = {}
    max_semantic = fused[0][1] if fused else 1.0
    if max_semantic <= 0:
        max_semantic = 1.0

    w_s = config.graph_weight_semantic
    w_p = config.graph_weight_pagerank
    w_g = config.graph_weight_structural

    for cid, sem_score in fused:
        norm_sem = sem_score / max_semantic
        pr = chunk_pageranks.get(cid, 0.0) / max_pr
        hop_info = expanded_map.get(cid)
        structural = 1.0 / (1.0 + hop_info[0]) if hop_info else 0.0
        all_candidates[cid] = w_s * norm_sem + w_p * pr + w_g * structural

    # 添加纯图扩展候选（语义分 0）
    for cid, (hop_dist, exp_score) in expanded_map.items():
        if cid not in all_candidates:
            pr = chunk_pageranks.get(cid, 0.0) / max_pr
            structural = 1.0 / (1.0 + hop_dist)
            all_candidates[cid] = w_p * pr + w_g * structural

    # 按最终分数排序
    result = sorted(all_candidates.items(), key=lambda x: x[1], reverse=True)
    return result, True


def _dedup_by_file(
    fused: list[tuple[int, float]],
    db: Database,
    top_k: int,
    max_per_file: int = 2,
) -> tuple[list[tuple[int, float]], bool]:
    """文档级去重 — 同文件最多保留 max_per_file 个 chunk

    解决巨型文件（CHANGELOG 257 chunks）在 RRF 融合后淹没其他文件的问题。
    MaxP 策略（Dai & Callan, SIGIR 2019）— 同文件取最高分 chunk。
    文件类型感知：CHANGELOG/LICENSE 等噪音文件 max=1。

    Returns:
        (去重后的列表, 是否有 chunk 被去重)
    """
    if not fused:
        return [], False

    # 批量查询 chunk_id → file path（一次 DB 交互替代逐条查询）
    all_ids = [cid for cid, _ in fused]
    path_map: dict[int, str] = {}
    try:
        placeholders = ",".join("?" * len(all_ids))
        rows = db.conn.execute(
            f"SELECT c.id, d.path FROM chunks c JOIN documents d ON c.doc_id = d.id "
            f"WHERE c.id IN ({placeholders})",
            all_ids,
        ).fetchall()
        path_map = {cid: path for cid, path in rows if path}
    except Exception:
        pass

    file_counts: dict[str, int] = {}
    result: list[tuple[int, float]] = []
    skipped = False

    for chunk_id, score in fused:
        if len(result) >= top_k:
            break

        source = path_map.get(chunk_id)
        if not source:
            result.append((chunk_id, score))
            continue

        max_allowed = _max_for_file(source, max_per_file)
        count = file_counts.get(source, 0)

        if count >= max_allowed:
            skipped = True
            continue

        file_counts[source] = count + 1
        result.append((chunk_id, score))

    return result, skipped


# 噪音文件类型检测 — 这些文件几乎包含所有关键词，容易淹没搜索结果
_NOISE_BASENAMES = ("CHANGELOG", "HISTORY", "CHANGES", "LICENSE", "COPYING")
_NOISE_SUFFIXES = (".lock", ".min.js", ".min.css")
_NOISE_LOCK_RE = re.compile(r"(?:^|[-.])(lock)\b", re.IGNORECASE)


def _max_for_file(source: str, default_max: int) -> int:
    """文件类型感知的 max_per_file — 噪音文件限制为 1"""
    basename = source.rsplit("/", 1)[-1] if "/" in source else source
    name_upper = basename.upper().split(".")[0]  # "CHANGELOG.md" → "CHANGELOG"
    if name_upper in _NOISE_BASENAMES:
        return 1
    if any(basename.endswith(ext) for ext in _NOISE_SUFFIXES):
        return 1
    # lock 文件: package-lock.json, pnpm-lock.yaml, yarn.lock, Gemfile.lock
    if _NOISE_LOCK_RE.search(basename):
        return 1
    return default_max


def _score_to_relevance(score: float) -> str:
    """分数 → 相关度等级"""
    if score >= 0.7:
        return "high"
    elif score >= 0.4:
        return "medium"
    return "low"


async def _rerank(
    query: str,
    fused: list[tuple[int, float]],
    db: Database,
    embedder: Embedder,
    config: Config,
) -> list[tuple[int, float]]:
    """Cross-encoder rerank — fastembed TextCrossEncoder

    联合编码 (query, document) 对，捕获 token 级交互。
    失败时返回原始 fused 结果（L0 RRF 兜底）。
    """
    # CJK > 70% 跳过（MiniLM 是英文模型，纯中文无增益）
    if _cjk_ratio(query) > 0.7:
        logger.debug("Rerank 跳过: CJK 占比 > 70%%")
        return fused

    try:
        reranker = _get_reranker(config.rerank_model)

        # 获取候选文本（截断到 ~500 字符，cross-encoder max_length=512 tokens）
        candidates: list[tuple[int, str]] = []
        for chunk_id, _score in fused:
            chunk = db.get_chunk(chunk_id)
            if chunk and chunk.content:
                candidates.append((chunk_id, chunk.content[:500]))

        if len(candidates) < 2:
            return fused

        docs = [text for _, text in candidates]

        # ONNX 推理是同步的，用 executor 避免阻塞事件循环
        # 500ms 超时保护（正常 ~20ms，超时说明系统异常）
        loop = asyncio.get_running_loop()
        scores: list[float] = await asyncio.wait_for(
            loop.run_in_executor(
                _RERANK_EXECUTOR,
                lambda: list(reranker.rerank(query, docs)),
            ),
            timeout=0.5,
        )

        # min-max 归一化到 [0, 1]
        # cross-encoder 返回原始 logits（可为负数），下游需要 [0, 1]
        min_s = min(scores)
        max_s = max(scores)
        if max_s > min_s:
            norm_scores = [(s - min_s) / (max_s - min_s) for s in scores]
        else:
            norm_scores = [1.0] * len(scores)

        scored = [
            (candidates[i][0], norm_scores[i])
            for i in range(len(candidates))
        ]
        scored.sort(key=lambda x: x[1], reverse=True)
        return scored

    except TimeoutError:
        logger.warning("Rerank 超时 (>500ms)，使用 RRF 结果")
        return fused
    except Exception as e:
        logger.warning("Rerank 失败，使用 RRF 结果: %s", e)
        return fused


def get_cache() -> QueryCache:
    """获取全局缓存实例"""
    return _cache
