#!/usr/bin/env python3
"""
RAG v2: Hybrid search (BM25 + Embedding + RRF + Rerank)
Uses local Ollama bge-m3 for embedding (free, no API cost).
Falls back to BM25-only if embedding unavailable.

Lambda: Tool("hybrid_search", λq. Par(BM25(q), Vector(q)) >> RRF >> Rerank)
"""
import json
import math
import os
import pickle
import re
import struct
import time
import urllib.request
from collections import Counter, defaultdict
from pathlib import Path

# Import config + BM25
try:
    from config import cfg as _app_cfg
except ImportError:
    _app_cfg = None

try:
    from search_engine import search as bm25_search, _load_index as _load_bm25_index
except ImportError:
    bm25_search = None

# ── Config from config.py ──
OLLAMA_EMBED_URL = _app_cfg.embed_url if _app_cfg else os.environ.get('OLLAMA_EMBED_URL', 'http://127.0.0.1:11435/api/embed')
EMBED_MODEL = _app_cfg.embed_model if _app_cfg else os.environ.get('EMBED_MODEL', 'bge-m3')
VECTOR_SEARCH_DISABLED = os.environ.get('DISABLE_VECTOR_SEARCH', '').lower() in ('1', 'true', 'yes')
RRF_K = 60
RERANK_CANDIDATES = 20
SIMILARITY_THRESHOLD = 0.0

_vector_cache = {}


def _get_base_dir():
    if _app_cfg:
        return _app_cfg.base_dir
    return Path(os.environ.get('KNOWLEDGE_DIR', str(Path(__file__).parent)))


# ── Embedding via Ollama ──
def _embed_texts(texts, model=None):
    """Embed one or more texts via Ollama API. Returns list of vectors."""
    if model is None:
        model = EMBED_MODEL
    body = json.dumps({
        "model": model,
        "input": texts if isinstance(texts, list) else [texts],
    }).encode('utf-8')
    req = urllib.request.Request(
        OLLAMA_EMBED_URL,
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST"
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read())
            return data.get('embeddings', [])
    except Exception as e:
        print(f"[embed] Error: {e}")
        return []


def _embed_query(query):
    """Embed a single query. Returns vector or None."""
    vecs = _embed_texts(query)
    return vecs[0] if vecs else None


# ── Vector Index ──
def _load_vector_index():
    """Load pre-built vector index (numpy .npy + metadata pickle)"""
    if 'vectors' in _vector_cache:
        return _vector_cache

    base = _get_base_dir()
    vec_path = base / 'rag_vectors_v2.npy'
    meta_path = base / 'rag_vectors_meta_v2.pkl'

    if not vec_path.exists() or not meta_path.exists():
        return None

    try:
        import numpy as np
        vectors = np.load(str(vec_path), mmap_mode='r')  # memory-mapped for efficiency
        with open(meta_path, 'rb') as f:
            meta = pickle.load(f)
        _vector_cache['vectors'] = vectors
        _vector_cache['meta'] = meta
        _vector_cache['dim'] = vectors.shape[1]
        return _vector_cache
    except Exception as e:
        print(f"[vector] Load error: {e}")
        return None


def _cosine_similarity_batch(query_vec, matrix):
    """Compute cosine similarity between query and all vectors in matrix"""
    try:
        import numpy as np
        query = np.array(query_vec, dtype=np.float32)
        # Normalize
        query_norm = query / (np.linalg.norm(query) + 1e-10)
        # matrix is already loaded as numpy array
        norms = np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-10
        normalized = matrix / norms
        scores = normalized @ query_norm
        return scores
    except ImportError:
        # Pure Python fallback
        return _cosine_similarity_batch_python(query_vec, matrix)


def _cosine_similarity_batch_python(query_vec, matrix):
    """Pure Python fallback for cosine similarity"""
    import math
    q_norm = math.sqrt(sum(x*x for x in query_vec)) + 1e-10
    scores = []
    for row in matrix:
        dot = sum(a*b for a, b in zip(query_vec, row))
        r_norm = math.sqrt(sum(x*x for x in row)) + 1e-10
        scores.append(dot / (q_norm * r_norm))
    return scores


def _vector_search(query, top_k=20):
    """Search using embedding vectors"""
    if VECTOR_SEARCH_DISABLED:
        return []
    vidx = _load_vector_index()
    if vidx is None:
        return []

    query_vec = _embed_query(query)
    if query_vec is None:
        return []

    scores = _cosine_similarity_batch(query_vec, vidx['vectors'])
    meta = vidx['meta']

    # Get top-k indices
    try:
        import numpy as np
        top_indices = np.argsort(scores)[::-1][:top_k]
        results = []
        for idx in top_indices:
            idx = int(idx)
            score = float(scores[idx])
            if score < SIMILARITY_THRESHOLD:
                continue
            m = meta[idx]
            results.append({
                'source': m['source'],
                'chunk_id': m['chunk_id'],
                'text': m['text'],
                'score': round(score, 4),
                '_index': idx,
            })
        return results
    except ImportError:
        # Python fallback
        indexed = list(enumerate(scores))
        indexed.sort(key=lambda x: -x[1])
        results = []
        for idx, score in indexed[:top_k]:
            if score < SIMILARITY_THRESHOLD:
                continue
            m = meta[idx]
            results.append({
                'source': m['source'],
                'chunk_id': m['chunk_id'],
                'text': m['text'],
                'score': round(score, 4),
                '_index': idx,
            })
        return results


# ── RRF Fusion ──
def _rrf_fuse(bm25_results, vector_results, k=RRF_K):
    """Reciprocal Rank Fusion: combine two ranked lists"""
    scores = defaultdict(float)
    chunk_map = {}

    for rank, r in enumerate(bm25_results):
        key = f"{r['source']}#{r['chunk_id']}"
        scores[key] += 1.0 / (k + rank + 1)
        chunk_map[key] = r

    for rank, r in enumerate(vector_results):
        key = f"{r['source']}#{r['chunk_id']}"
        scores[key] += 1.0 / (k + rank + 1)
        if key not in chunk_map:
            chunk_map[key] = r

    sorted_keys = sorted(scores.items(), key=lambda x: -x[1])
    results = []
    for key, score in sorted_keys:
        r = chunk_map[key].copy()
        r['score'] = round(score, 4)
        results.append(r)

    return results


# ── Rerank via LLM (optional, uses vLLM) ──
def _rerank_llm(query, candidates, top_n=5):
    """
    Rerank candidates using vLLM local model.
    Fallback: skip rerank, return candidates as-is.
    """
    vllm_url = os.environ.get('VLLM_URL', 'http://127.0.0.1:8000/v1/chat/completions')

    if not candidates:
        return candidates

    # Build rerank prompt
    docs_text = ""
    for i, c in enumerate(candidates[:RERANK_CANDIDATES]):
        docs_text += f"\n[{i}] {c['text'][:300]}"

    prompt = f"""Given a query and candidate passages, rank them by relevance.
Output ONLY a JSON array of passage indices in order of relevance (most relevant first).
Example: [3, 0, 7, 1, 5]

Query: {query}

Passages:{docs_text}

Ranking (JSON array):"""

    try:
        body = json.dumps({
            "model": "qwen2.5-32b",
            "messages": [{"role": "user", "content": prompt}],
            "temperature": 0.0,
            "max_tokens": 100,
        }).encode('utf-8')
        req = urllib.request.Request(
            vllm_url, data=body,
            headers={"Content-Type": "application/json"},
            method="POST"
        )
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read())
            response_text = data["choices"][0]["message"]["content"].strip()

        # Parse ranking
        match = re.search(r'\[[\d,\s]+\]', response_text)
        if match:
            ranking = json.loads(match.group())
            reranked = []
            for idx in ranking[:top_n]:
                if 0 <= idx < len(candidates):
                    r = candidates[idx].copy()
                    r['score'] = round(1.0 - len(reranked) * 0.1, 2)  # Assign decreasing scores
                    reranked.append(r)
            return reranked
    except Exception as e:
        print(f"[rerank] Error: {e}, using RRF order")

    # Fallback: return top_n from RRF ordering
    return candidates[:top_n]


# ── Main Search Function ──
def search(query, top_k=5):
    """
    Hybrid search: BM25 + Vector + RRF + Rerank
    Drop-in replacement for search_engine.search()
    Returns: [{source, chunk_id, score, text}, ...]
    """
    t0 = time.time()

    # 1. BM25 search
    bm25_results = []
    if bm25_search:
        try:
            bm25_results = bm25_search(query, top_k=RERANK_CANDIDATES)
        except Exception as e:
            print(f"[bm25] Error: {e}")

    # 2. Vector search
    vector_results = _vector_search(query, top_k=RERANK_CANDIDATES)

    # 3. Decide fusion strategy
    if bm25_results and vector_results:
        # Both available → RRF fusion
        fused = _rrf_fuse(bm25_results, vector_results)
    elif vector_results:
        fused = vector_results
    else:
        # Fallback to BM25 only
        return bm25_results[:top_k] if bm25_results else []

    # 4. Rerank top candidates
    reranked = _rerank_llm(query, fused[:RERANK_CANDIDATES], top_n=top_k)

    elapsed = time.time() - t0
    # Add timing metadata to first result
    if reranked:
        reranked[0]['_search_time'] = round(elapsed, 3)

    return reranked


if __name__ == '__main__':
    import sys
    query = sys.argv[1] if len(sys.argv) > 1 else '海上交通安全法与水污染防治法'

    print(f"Query: {query}")
    print("=" * 60)

    t0 = time.time()
    results = search(query, top_k=5)
    t1 = time.time()
    print(f"Search time: {(t1-t0)*1000:.0f}ms")
    print(f"Results: {len(results)}")
    for i, r in enumerate(results):
        print(f"\n[{i+1}] score={r['score']} {r['source'][:60]}")
        print(f"    {r['text'][:150]}...")
