"""M3 RAG Engine - three-pillar search + ingest.

The missing module that app/rag/service.py was importing (the legacy
app.rag_engine was nuked during consolidation but never replaced).

Three-Pillar Search (per 2026 RAG standards):
  Pillar 1: ANN vector similarity (semantic match)
  Pillar 2: BM25-lite keyword search (lexical match)
  Pillar 3: Metadata filter (structured constraints)
  Fusion: Reciprocal Rank Fusion (RRF) - k=60

Ingest:
  - Chunk → embed → store in ANN index + Redis doc store
  - MD5 dedup per collection
  - Per-chunk doc_id = "{collection}:{hash}:{idx}"
"""
from __future__ import annotations

import logging
import time
from typing import Any

from app.rag.ann_index import Hit, get_index
from app.rag.chunking import chunk_text, content_hash, is_duplicate, mark_ingested
from app.rag.embeddings import current_backend, get_embedding

log = logging.getLogger(__name__)

# RRF constant
_RRF_K = 60


# ── Three-pillar search ──────────────────────────────────────────────
async def three_pillar_search(
    query: str,
    collection: str = "scam_intel",
    top_k: int = 5,
    min_similarity: float = 0.0,
    filters: dict[str, Any] | None = None,
) -> list[dict]:
    """Run the three-pillar search and return RRF-fused hits.

    Each returned hit: {doc_id, score, text, metadata, source_pillars}
    """
    start = time.monotonic()

    # Pillar 1: ANN vector search
    qvec = await get_embedding(query)
    idx = get_index(collection)
    ann_hits = idx.search(qvec, top_k=top_k * 3, min_similarity=min_similarity)

    # Pillar 2: keyword (BM25-lite via Redis text scan on stored docs)
    keyword_hits = await _keyword_search(query, collection, top_k * 3)

    # Pillar 3: metadata filter (apply to ann+keyword results)
    _apply_filters(ann_hits + keyword_hits, filters or {})

    # RRF fusion
    fused = _reciprocal_rank_fusion([ann_hits, keyword_hits], top_k=top_k)

    # Apply filters post-fusion
    if filters:
        fused = [h for h in fused if _matches_filters(h, filters)]

    took_ms = int((time.monotonic() - start) * 1000)
    log.info(
        "rag_search_done collection=%s ann=%d kw=%d fused=%d took_ms=%d backend=%s",
        collection, len(ann_hits), len(keyword_hits), len(fused), took_ms, current_backend(),
    )
    return [
        {
            "doc_id": h.doc_id,
            "score": h.score,
            "text": h.text,
            "metadata": h.metadata,
        }
        for h in fused
    ]


async def _keyword_search(
    query: str, collection: str, limit: int
) -> list[Hit]:
    """BM25-lite keyword search. Simple TF scoring on stored text.

    Returns Hits with score in [0, 1] (normalized). We don't pretend this
    is real BM25 - but it's good enough for a fallback that surfaces
    lexically-matching docs the ANN might miss.
    """
    try:
        from app.core.redis import get_redis

        r = get_redis()
        # Pull all doc texts for this collection's ANN store
        raw = r.hgetall(f"rag:ann:{collection}:docs")
    except Exception as e:
        log.debug("keyword_search_redis_failed: %s", e)
        return []

    if not raw:
        return []

    import json
    terms = [t.lower() for t in query.split() if len(t) > 2]
    if not terms:
        return []

    scored: list[tuple[float, str, str, dict]] = []
    for doc_id, blob in raw.items():
        try:
            entry = json.loads(blob)
        except Exception:
            continue
        text = entry.get("text", "")
        if not text:
            continue
        text_l = text.lower()
        # Simple TF
        tf = sum(text_l.count(t) for t in terms)
        if tf == 0:
            continue
        # Normalize by length
        score = tf / max(10, len(text_l.split()))
        scored.append((score, doc_id, text, entry.get("metadata", {})))

    scored.sort(key=lambda x: -x[0])
    out: list[Hit] = []
    max_score = scored[0][0] if scored else 1.0
    for score, doc_id, text, metadata in scored[:limit]:
        out.append(
            Hit(
                doc_id=doc_id,
                score=min(1.0, score / max_score) if max_score > 0 else 0.0,
                text=text,
                metadata=metadata,
            )
        )
    return out


def _apply_filters(hits: list[Hit], filters: dict[str, Any]) -> list[Hit]:
    """Pre-filter: keep hits whose metadata matches all filter key=val pairs."""
    if not filters:
        return hits
    return [h for h in hits if _matches_filters(h, filters)]


def _matches_filters(hit: Hit, filters: dict[str, Any]) -> bool:
    return all(hit.metadata.get(k) == v for k, v in filters.items())


def _reciprocal_rank_fusion(
    pillar_hits: list[list[Hit]], top_k: int, k: int = _RRF_K
) -> list[Hit]:
    """Reciprocal Rank Fusion across multiple ranked lists.

    RRF score(d) = sum( 1 / (k + rank_i(d)) ) for each pillar that contains d.
    """
    scores: dict[str, float] = {}
    by_id: dict[str, Hit] = {}
    for pillar in pillar_hits:
        for rank, h in enumerate(pillar, start=1):
            scores[h.doc_id] = scores.get(h.doc_id, 0.0) + 1.0 / (k + rank)
            if h.doc_id not in by_id:
                by_id[h.doc_id] = h

    ranked = sorted(scores.items(), key=lambda x: -x[1])
    out: list[Hit] = []
    for doc_id, rrf_score in ranked[:top_k]:
        h = by_id[doc_id]
        # Normalize to [0, 1] roughly - RRF max is ~3/k for 3 pillars
        norm = min(1.0, rrf_score * k / 3.0)
        out.append(
            Hit(
                doc_id=h.doc_id,
                score=norm,
                text=h.text,
                metadata=h.metadata,
            )
        )
    return out


# ── Ingest ───────────────────────────────────────────────────────────
async def ingest_document(
    collection: str,
    doc_id: str,
    content: str,
    metadata: dict[str, Any] | None = None,
    chunk: bool = True,
) -> dict:
    """Ingest a document into the RAG system.

    - Chunks the content (recursive split, dedup)
    - Embeds each chunk
    - Adds to the collection's ANN index
    - Marks each chunk's hash as ingested for dedup
    """
    if not content or not content.strip():
        return {"doc_id": doc_id, "collection": collection, "status": "empty", "chunks": 0}

    metadata = metadata or {}
    idx = get_index(collection)
    chunks = chunk_text(content) if chunk else [
        # Single-chunk path: still dedup
        __import__("app.rag.chunking", fromlist=["Chunk"]).Chunk(
            text=content, content_hash=content_hash(content), index=0, quality=1.0
        )
    ]

    # Dedup
    new_chunks = [c for c in chunks if not is_duplicate(c.content_hash, collection)]
    skipped = len(chunks) - len(new_chunks)
    if not new_chunks:
        return {
            "doc_id": doc_id,
            "collection": collection,
            "status": "duplicate",
            "chunks": 0,
            "skipped": skipped,
        }

    # Embed + insert
    added = 0
    for c in new_chunks:
        vec = await get_embedding(c.text)
        chunk_doc_id = f"{doc_id}:{c.content_hash[:8]}:{c.index}"
        chunk_meta = {
            **metadata,
            "chunk_index": c.index,
            "content_hash": c.content_hash,
            "quality": c.quality,
        }
        idx.add(chunk_doc_id, vec, chunk_meta, text=c.text)
        mark_ingested(c.content_hash, collection)
        added += 1

    return {
        "doc_id": doc_id,
        "collection": collection,
        "status": "ok",
        "chunks": added,
        "skipped": skipped,
    }


# ── Stats ────────────────────────────────────────────────────────────
def get_collection_stats(collection: str) -> dict:
    """Get stats for one collection: vector count + dedup hashes count."""
    idx = get_index(collection)
    count = idx.count()
    try:
        from app.core.redis import get_redis

        hashes = get_redis().scard(f"rag:hashes:{collection}")
    except Exception:
        hashes = 0
    return {
        "collection": collection,
        "vector_count": count,
        "dedup_hashes": hashes,
    }


def get_stats(collections: list[str] | None = None) -> dict:
    """Get stats for all (or specified) collections + the active embedder backend."""
    from app.rag.models import COLLECTIONS as DEFAULT_COLLECTIONS

    cols = collections or DEFAULT_COLLECTIONS
    return {
        "total_docs": sum(get_collection_stats(c)["vector_count"] for c in cols),
        "backend": current_backend(),
        "collections": [get_collection_stats(c) for c in cols],
    }


# ── Background helpers (used by ingest_cron worker) ──────────────────
async def bulk_ingest(
    items: list[dict],
    collection: str = "scam_intel",
) -> dict:
    """Ingest many items sequentially. Returns summary counts."""
    if not items:
        return {"total": 0, "ok": 0, "duplicate": 0, "empty": 0, "errors": 0}

    counts = {"total": len(items), "ok": 0, "duplicate": 0, "empty": 0, "errors": 0}
    for it in items:
        try:
            r = await ingest_document(
                collection=collection,
                doc_id=it.get("doc_id") or it.get("id", f"bulk:{int(time.time()*1000)}"),
                content=it.get("content") or it.get("text", ""),
                metadata=it.get("metadata") or {},
            )
            if r["status"] == "ok":
                counts["ok"] += 1
            elif r["status"] == "duplicate":
                counts["duplicate"] += 1
            elif r["status"] == "empty":
                counts["empty"] += 1
        except Exception as e:
            log.warning("bulk_ingest_item_failed: %s", e)
            counts["errors"] += 1
    return counts
