//! Reciprocal Rank Fusion: combine multiple ranked result lists.
//!
//! ```text
//! RRF_score(d) = Σ 1 / (k + rank_i(d))
//! ```
//!
//! See [[reciprocal-rank-fusion]] and [[architecture-overview|milestone-5]].

use std::collections::HashMap;
use luci_core::{DocId, SegmentId};
use crate::search::searcher::{SearchHit, SearchResults};

/// Default rank constant (Elasticsearch default).
pub const DEFAULT_RANK_CONSTANT: f32 = 60.0;

/// Combine multiple search result lists using Reciprocal Rank Fusion.
///
/// Each document's RRF score = sum of 1/(k + rank) across all lists where
/// it appears. Documents are identified by (segment_id, doc_id).
pub fn reciprocal_rank_fusion(
    result_lists: &[&SearchResults],
    rank_constant: f32,
    top_k: usize,
) -> SearchResults {
    // Accumulate RRF scores per document
    let mut scores: HashMap<(u64, u32), (f32, Option<serde_json::Value>)> = HashMap::new();

    for results in result_lists {
        for (rank, hit) in results.hits.iter().enumerate() {
            let key = (hit.segment_id.as_u64(), hit.doc_id.as_u32());
            let rrf_contribution = 1.0 / (rank_constant + (rank + 1) as f32);
            let entry = scores.entry(key).or_insert((0.0, None));
            entry.0 += rrf_contribution;
            if entry.1.is_none() {
                entry.1 = hit.source.clone();
            }
        }
    }

    // Sort by RRF score descending
    let mut ranked: Vec<_> = scores.into_iter().collect();
    ranked.sort_by(|a, b| b.1.0.partial_cmp(&a.1.0).unwrap_or(std::cmp::Ordering::Equal));
    ranked.truncate(top_k);

    let hits: Vec<SearchHit> = ranked
        .into_iter()
        .map(|((seg_id, doc_id), (score, source))| SearchHit {
            doc_id: DocId::new(doc_id),
            segment_id: SegmentId::new(seg_id),
            score,
            source,
            fields: None,
            sort_values: None,
            explanation: None,
            inner_hits: None,
        })
        .collect();

    let total_hits = hits.len() as u64;
    SearchResults {
        hits,
        total_hits: crate::search::TotalHits::exact(total_hits),
        aggregations: HashMap::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_results(docs: &[(u32, f32)]) -> SearchResults {
        let hits = docs.iter().map(|&(id, score)| SearchHit {
            doc_id: DocId::new(id),
            segment_id: SegmentId::new(1),
            score,
            source: Some(serde_json::json!({"id": id})),
            fields: None,
            sort_values: None,
            explanation: None,
            inner_hits: None,
        }).collect();
        SearchResults {
            hits,
            total_hits: crate::search::TotalHits::exact(docs.len() as u64),
            aggregations: HashMap::new(),
        }
    }

    #[test]
    fn rrf_single_list() {
        let r1 = make_results(&[(0, 3.0), (1, 2.0), (2, 1.0)]);
        let fused = reciprocal_rank_fusion(&[&r1], 60.0, 10);
        assert_eq!(fused.hits.len(), 3);
        // Doc 0 rank 1: 1/(60+1) = 0.01639
        assert!(fused.hits[0].doc_id == DocId::new(0));
    }

    #[test]
    fn rrf_two_lists_overlap() {
        // List 1: [A, B, C]  List 2: [B, C, A]
        let r1 = make_results(&[(0, 3.0), (1, 2.0), (2, 1.0)]); // A=0, B=1, C=2
        let r2 = make_results(&[(1, 3.0), (2, 2.0), (0, 1.0)]); // B=1, C=2, A=0

        let fused = reciprocal_rank_fusion(&[&r1, &r2], 60.0, 10);
        assert_eq!(fused.hits.len(), 3);

        // B (doc 1): rank 2 in r1 + rank 1 in r2 = 1/62 + 1/61 ≈ 0.0325
        // A (doc 0): rank 1 in r1 + rank 3 in r2 = 1/61 + 1/63 ≈ 0.0323
        // C (doc 2): rank 3 in r1 + rank 2 in r2 = 1/63 + 1/62 ≈ 0.0322
        // B should be first (highest combined rank)
        assert_eq!(fused.hits[0].doc_id, DocId::new(1));
    }

    #[test]
    fn rrf_disjoint_lists() {
        let r1 = make_results(&[(0, 1.0), (1, 0.5)]);
        let r2 = make_results(&[(2, 1.0), (3, 0.5)]);

        let fused = reciprocal_rank_fusion(&[&r1, &r2], 60.0, 10);
        assert_eq!(fused.hits.len(), 4);
        // Rank 1 docs from both lists should tie
        let top_ids: Vec<u32> = fused.hits[..2].iter().map(|h| h.doc_id.as_u32()).collect();
        assert!(top_ids.contains(&0) && top_ids.contains(&2));
    }

    #[test]
    fn rrf_top_k() {
        let r1 = make_results(&[(0, 1.0), (1, 0.5), (2, 0.3)]);
        let fused = reciprocal_rank_fusion(&[&r1], 60.0, 2);
        assert_eq!(fused.hits.len(), 2);
    }

    #[test]
    fn rrf_empty() {
        let r1 = make_results(&[]);
        let fused = reciprocal_rank_fusion(&[&r1], 60.0, 10);
        assert!(fused.hits.is_empty());
    }

    #[test]
    fn rrf_source_preserved() {
        let r1 = make_results(&[(42, 1.0)]);
        let fused = reciprocal_rank_fusion(&[&r1], 60.0, 10);
        assert_eq!(fused.hits[0].source.as_ref().unwrap()["id"], 42);
    }
}
