#!/usr/bin/env python3
"""
MMR (Maximal Marginal Relevance) Deduplication for RRF Results
================================================================
Re-ranks fused RRF results to maximize relevance while minimizing redundancy.

MMR formula:
  MMR = argmax_{d∈R\\S} [ λ·sim(d,q) - (1-λ)·max_{d'∈S} sim(d,d') ]

Where:
  R = all candidate results
  S = already-selected results
  q = original query
  sim(d,q) = relevance to query (RRF score)
  sim(d,d') = similarity between documents (embedding cosine)
  λ = trade-off (0.5-0.7 typical; higher = more diverse)

Used after _rrf_fuse() in rag_service.py to ensure the final
top-K results contain minimal duplicate/near-duplicate content.
"""

import contextlib
import json
import logging
import math
from typing import Any

logger = logging.getLogger(__name__)


def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
    """Compute cosine similarity between two vectors."""
    if not vec_a or not vec_b or len(vec_a) != len(vec_b):
        return 0.0

    # Handle different-length vectors by truncating to shorter
    min_len = min(len(vec_a), len(vec_b))
    a = vec_a[:min_len]
    b = vec_b[:min_len]

    dot = sum(x * y for x, y in zip(a, b, strict=False))
    mag_a = math.sqrt(sum(x * x for x in a))
    mag_b = math.sqrt(sum(x * x for x in b))

    if mag_a == 0 or mag_b == 0:
        return 0.0

    return dot / (mag_a * mag_b)


def text_jaccard_similarity(text_a: str, text_b: str) -> float:
    """
    Token-level Jaccard similarity for text dedup when embeddings unavailable.
    Fast and good enough for surface-level dedup.
    """
    if not text_a or not text_b:
        return 0.0

    # Tokenize with simple split + lowercase
    tokens_a = set(text_a.lower().split())
    tokens_b = set(text_b.lower().split())

    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) if union else 0.0


def content_hash_dedup(
    results: list[dict[str, Any]],
    hash_field: str = "doc_id",
    id_field: str = "id",
) -> list[dict[str, Any]]:
    """
    Remove exact duplicates based on content hash or ID.
    Fast pre-filter before MMR.
    """
    seen: set[str] = set()
    deduped = []

    for r in results:
        # Use doc_id or id as dedup key
        key = r.get(hash_field) or r.get(id_field) or ""
        if not key:
            # Fallback: hash the content
            content = r.get("content", "")[:500]
            key = f"hash:{hash(content)}" if content else ""

        if key and key in seen:
            continue
        if key:
            seen.add(key)
        deduped.append(r)

    return deduped


def mmr_rerank(
    results: list[dict[str, Any]],
    query_score_field: str = "rrf_score",
    content_field: str = "content",
    embedding_field: str = "embedding",
    lambda_param: float = 0.6,
    top_k: int = 20,
    similarity_threshold: float = 0.85,
) -> list[dict[str, Any]]:
    """
    Maximal Marginal Relevance reranking.

    Args:
        results: fused results from _rrf_fuse(), sorted by rrf_score desc
        query_score_field: field name for query relevance (rrf_score)
        content_field: field name for text content (used for Jaccard sim)
        embedding_field: field name for vector embeddings (used for cosine sim)
        lambda_param: relevance-diversity trade-off (0=all diverse, 1=all relevant)
        top_k: number of results to return
        similarity_threshold: skip MMR for results below this relevance threshold

    Returns:
        Re-ranked list with up to top_k results, maximizing relevance + diversity.
    """
    if not results:
        return []

    # Pre-filter: remove exact duplicates
    deduped = content_hash_dedup(results)
    if len(deduped) <= 1:
        return deduped[:top_k]

    # Normalize query relevance scores to [0, 1]
    max_score = max(r.get(query_score_field, 0) for r in deduped)
    if max_score == 0:
        return deduped[:top_k]

    for r in deduped:
        r["_mmr_relevance"] = r.get(query_score_field, 0) / max_score

    # Greedy MMR selection
    selected: list[dict[str, Any]] = []
    remaining: list[dict[str, Any]] = list(deduped)

    # First result: highest relevance
    remaining.sort(key=lambda x: x.get("_mmr_relevance", 0), reverse=True)
    selected.append(remaining.pop(0))

    while len(selected) < top_k and remaining:
        best_mmr = -1.0
        best_idx = 0

        for i, candidate in enumerate(remaining):
            relevance = candidate.get("_mmr_relevance", 0)

            # Skip candidates below threshold
            if relevance < 0.01:
                continue

            # Maximum similarity to already-selected results
            max_sim = 0.0
            for sel in selected:
                # Try embedding similarity first (most accurate)
                c_emb = candidate.get(embedding_field)
                s_emb = sel.get(embedding_field)
                if c_emb and s_emb and isinstance(c_emb, list) and isinstance(s_emb, list):
                    sim = cosine_similarity(c_emb, s_emb)
                else:
                    # Fall back to text Jaccard similarity
                    c_text = candidate.get(content_field, "") or ""
                    s_text = sel.get(content_field, "") or ""
                    sim = text_jaccard_similarity(c_text, s_text)

                max_sim = max(max_sim, sim)

            # MMR score: λ·relevance - (1-λ)·max_similarity
            mmr_score = lambda_param * relevance - (1 - lambda_param) * max_sim

            if mmr_score > best_mmr:
                best_mmr = mmr_score
                best_idx = i

        if best_mmr <= -1.0:
            # No valid candidates left
            break

        selected.append(remaining.pop(best_idx))

    # Clean up temporary fields
    for r in deduped:
        r.pop("_mmr_relevance", None)

    # Add MMR metadata
    for i, r in enumerate(selected):
        r["mmr_rank"] = i + 1
        r["mmr_selected"] = True

    return selected


async def mmr_dedup_results(
    results: list[dict[str, Any]],
    query_score_field: str = "rrf_score",
    content_field: str = "content",
    lambda_param: float = 0.6,
    top_k: int = 20,
) -> list[dict[str, Any]]:
    """
    Async wrapper for MMR deduplication.
    This is the main entry point used by rag_service.py.

    Attempts to load embeddings from Redis for similarity computation.
    Falls back to text Jaccard if embeddings are unavailable.
    """
    try:
        # Try to enrich results with embeddings from Redis for better dedup
        enriched = await _enrich_with_embeddings(results)
        return mmr_rerank(
            enriched,
            query_score_field=query_score_field,
            content_field=content_field,
            embedding_field="_embedding",
            lambda_param=lambda_param,
            top_k=top_k,
        )
    except Exception as e:
        # Fall back to text-only dedup
        logger.debug(f"MMR embedding enrichment failed, using text-only: {e}")
        return mmr_rerank(
            results,
            query_score_field=query_score_field,
            content_field=content_field,
            lambda_param=lambda_param,
            top_k=top_k,
        )


async def _enrich_with_embeddings(
    results: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """
    Try to load embedding vectors from Redis for each result.
    Used for more accurate MMR similarity computation.
    Falls back gracefully if Redis/embeddings aren't available.
    """
    import os

    import redis.asyncio as aioredis

    if not results:
        return results

    # Only enrich if we have doc_ids and collection info
    docs_with_ids = [(i, r) for i, r in enumerate(results) if r.get("doc_id") or r.get("id")]

    if not docs_with_ids:
        return results

    r = None
    try:
        r = aioredis.Redis(
            host=os.getenv("REDIS_HOST", "rmi-redis"),
            port=int(os.getenv("REDIS_PORT", "6379")),
            password=os.getenv("REDIS_PASSWORD", ""),
            decode_responses=False,  # binary for vectors
        )

        # Batch fetch embeddings
        pipe = r.pipeline()
        keys_to_fetch = []
        for idx, result in docs_with_ids:
            doc_id = result.get("doc_id") or result.get("id")
            coll = result.get("collection", "wallet_profiles")
            key = f"rag:{coll}:{doc_id}"
            pipe.get(key)
            keys_to_fetch.append((idx, key))

        raw_docs = await pipe.execute()

        for (idx, key), data in zip(keys_to_fetch, raw_docs, strict=False):  # noqa: B007
            if data:
                try:
                    doc = json.loads(data)
                    embedding = doc.get("vector", [])
                    if isinstance(embedding, list) and len(embedding) > 0:
                        # Store embedding for MMR similarity computation
                        # Use first 128 dims for speed (cosine sim is preserved in high dims)
                        results[idx]["_embedding"] = embedding[:128]
                except (json.JSONDecodeError, Exception):
                    pass

    except Exception as e:
        logger.debug(f"Embedding enrichment from Redis failed: {e}")
    finally:
        if r is not None:
            with contextlib.suppress(Exception):
                await r.aclose()

    return results


def cluster_similar_results(
    results: list[dict[str, Any]],
    content_field: str = "content",
    similarity_threshold: float = 0.7,
) -> list[list[dict[str, Any]]]:
    """
    Group results into clusters of similar content.
    Each cluster contains near-duplicate results.

    Returns list of clusters, where each cluster is a list of similar results.
    The first result in each cluster is the "representative" (highest score).
    """
    if not results:
        return []

    clusters: list[list[dict[str, Any]]] = []
    assigned: set[int] = set()

    for i, result in enumerate(results):
        if i in assigned:
            continue

        cluster = [result]
        assigned.add(i)

        for j, other in enumerate(results):
            if j in assigned or j == i:
                continue

            sim = text_jaccard_similarity(
                result.get(content_field, ""),
                other.get(content_field, ""),
            )
            if sim >= similarity_threshold:
                cluster.append(other)
                assigned.add(j)

        clusters.append(cluster)

    # Sort clusters by highest-scoring result
    clusters.sort(key=lambda c: max(r.get("rrf_score", 0) for r in c), reverse=True)

    return clusters
