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

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

from __future__ import annotations

import asyncio
import logging
import re
import time
from dataclasses import dataclass, field

from ._compat import asyncio_timeout
from .cache import QueryCache
from .config import Config
from .db import ChunkRow, Database, tokenize_for_fts5
from .embed import OllamaEmbedder

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

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 _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 _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 '""'


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

    Args:
        query: 查询文本
        config: 配置
        db: 数据库实例
        embedder: embedding 客户端
        collection: 限定搜索范围
        top_k: 返回数量
        verbose: 是否返回完整内容

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

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

    fetch_k = top_k * 2

    # CJK 动态调权
    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

    # BM25 搜索（先 CJK 分词，再转义 FTS5 语法）
    bm25_results: list[tuple[int, float]] = []
    fts5_query = _escape_fts5_query(tokenize_for_fts5(query))
    try:
        bm25_results = db.fts5_search(fts5_query, collection, fetch_k)
    except Exception as e:
        logger.warning("BM25 搜索失败: %s", e)

    # 向量搜索（需要 Ollama）
    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 融合
    if vector_results and bm25_results:
        fused = _rrf_fusion(
            bm25_results, vector_results,
            k=config.rrf_k,
            bm25_weight=bm25_weight,
            vector_weight=vector_weight,
        )
    elif vector_results:
        fused = [(cid, 1.0 / (config.rrf_k + rank + 1))
                 for rank, (cid, _) in enumerate(vector_results)]
    elif bm25_results:
        fused = [(cid, 1.0 / (config.rrf_k + rank + 1))
                 for rank, (cid, _) in enumerate(bm25_results)]
    else:
        elapsed = int((time.monotonic() - start) * 1000)
        return SearchResponse(
            results=[], total=0, query_ms=elapsed,
            mode=mode, hint=hint or "未找到结果",
        )

    # 可选 Rerank
    if config.rerank_enabled and len(fused) > 1:
        fused = await _rerank(query, fused, db, embedder, config)

    # 取 top_k 结果并填充详情
    top_fused = fused[:top_k]
    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:
        _cache.put_response(query, collection, top_k, response)

    return response


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: OllamaEmbedder,
    config: Config,
) -> list[tuple[int, float]]:
    """使用 Ollama rerank 模型精排结果

    失败时返回原始 fused 结果（不影响搜索流程）。
    """
    try:
        import httpx

        # 获取候选文本
        candidates = []
        for chunk_id, _score in fused:
            chunk = db.get_chunk(chunk_id)
            if chunk:
                candidates.append({"chunk_id": chunk_id, "text": chunk.content[:500]})

        if not candidates:
            return fused

        # 调用 Ollama rerank API (若模型不支持，降级)
        async with httpx.AsyncClient(
            base_url=config.ollama_url.rstrip("/"),
            timeout=10.0,
        ) as client:
            resp = await client.post(
                "/api/embed",
                json={
                    "model": config.rerank_model,
                    "input": [query] + [c["text"] for c in candidates],
                },
            )
            resp.raise_for_status()
            embeddings = resp.json()["embeddings"]

        # 计算 query 与每个候选的 cosine similarity
        query_emb = embeddings[0]
        reranked = []
        for i, cand in enumerate(candidates):
            cand_emb = embeddings[i + 1]
            dot = sum(a * b for a, b in zip(query_emb, cand_emb))
            norm_q = sum(a * a for a in query_emb) ** 0.5
            norm_c = sum(a * a for a in cand_emb) ** 0.5
            sim = dot / (norm_q * norm_c) if norm_q > 0 and norm_c > 0 else 0.0
            reranked.append((cand["chunk_id"], sim))

        reranked.sort(key=lambda x: x[1], reverse=True)
        return reranked

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


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