//! Exit criteria integration tests for Milestone 1.
//!
//! Test 1: Index 1M documents, search with term query, verify _source
//! Test 2: BM25 correctness on small corpus with known term distributions
//! Test 3: Segment merge across two commit cycles with deletions

use luci_analysis::AnalyzerRegistry;
use luci_core::{DocId, FieldId, SegmentId};
use luci_index::deletion::DeletionMap;
use luci_index::merge::merge_segments;
use luci_index::reader::IndexReader;
use luci_index::search::bm25::bm25_idf;
use luci_index::search::searcher::Searcher;
use luci_index::segment::reader::SegmentReader;
use luci_index::writer::IndexWriter;
use luci_mapping::{FieldType,Mapping};
use luci_storage::SingleFileDirectory;

fn test_dir(name: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!(
        "luci_integration_{}_{name}",
        std::process::id()
    ));
    let _ = std::fs::remove_dir_all(&dir);
    dir
}

fn cleanup(path: &std::path::Path) {
    let _ = std::fs::remove_dir_all(path);
}

/// Exit Criterion 1: Index 1M documents, search with term query, return _source.
///
/// This test uses a smaller corpus (100K) by default for speed. Set the
/// LUCI_LARGE_TEST environment variable to run with 1M.
#[test]
fn exit_criterion_1_indexing_and_term_search() {
    let doc_count: usize = match std::env::var("LUCI_LARGE_TEST") {
        Ok(_) => 1_000_000,
        Err(_) => 100_000,
    };

    let path = test_dir("ec1");
    let storage = SingleFileDirectory::create(&path).unwrap();
    let schema = Mapping::builder()
        .field("title", FieldType::Text)
        .field("category", FieldType::Keyword)
        .build();
    let mut writer = IndexWriter::new(storage, schema, AnalyzerRegistry::new());
    // Use a larger budget to avoid too many tiny segments
    writer.set_memory_budget(32 * 1024 * 1024);

    let categories = ["tech", "science", "sports", "politics", "entertainment"];

    for i in 0..doc_count {
        let cat = categories[i % categories.len()];
        let doc = serde_json::json!({
            "title": format!("document number {i} about {cat} topics"),
            "category": cat,
        });
        writer.add(doc).unwrap();
    }
    writer.commit().unwrap();

    // Open and search
    let storage = SingleFileDirectory::open(&path).unwrap();
    let reader = IndexReader::open(&storage).unwrap();
    assert_eq!(reader.total_docs() as usize, doc_count);

    let searcher = reader.into_searcher();

    // Search for "tech" — should appear in every 5th doc's title
    let results = searcher.search_term(FieldId::new(0), "tech", 10);
    assert!(results.total_hits.value > 0);
    assert!(results.hits.len() <= 10);

    // Verify _source is returned and parseable
    for hit in &results.hits {
        let source = hit.source.as_ref().expect("_source should be present");
        assert!(source["title"].is_string());
        assert!(source["category"].is_string());
        // Title should contain "tech"
        let title = source["title"].as_str().unwrap();
        assert!(title.contains("tech"), "title should contain search term: {title}");
    }

    // Search for category keyword
    let results = searcher.search_term(FieldId::new(1), "science", 5);
    assert!(results.total_hits.value > 0);
    for hit in &results.hits {
        let source = hit.source.as_ref().unwrap();
        assert_eq!(source["category"], "science");
    }

    cleanup(&path);
}

/// Exit Criterion 2: BM25 scoring produces correct rankings.
///
/// Small corpus with known term distributions. Hand-compute BM25 scores
/// and verify Luci produces identical ranking order.
#[test]
fn exit_criterion_2_bm25_correctness() {
    let path = test_dir("ec2");
    let storage = SingleFileDirectory::create(&path).unwrap();
    let schema = Mapping::builder()
        .field("body", FieldType::Text)
        .build();
    let mut writer = IndexWriter::new(storage, schema, AnalyzerRegistry::new());

    // Corpus: 100 documents with controlled term distributions.
    // "search" appears in docs 0-9 (10 docs), with varying frequencies.
    // Other docs have filler text.
    let mut docs = Vec::new();
    for i in 0..100 {
        let body = if i < 10 {
            // Docs 0-9: contain "search" with tf = i+1
            let mut words = vec!["search"; (i + 1) as usize];
            // Pad to make field lengths vary
            for _ in 0..(10 - i) {
                words.push("filler");
            }
            words.join(" ")
        } else {
            // Docs 10-99: no "search"
            format!("document {i} with various other words and content")
        };
        docs.push(serde_json::json!({"body": body}));
    }

    for doc in &docs {
        writer.add(doc.clone()).unwrap();
    }
    writer.commit().unwrap();

    let storage = SingleFileDirectory::open(&path).unwrap();
    let reader = IndexReader::open(&storage).unwrap();
    let searcher = reader.into_searcher();

    let results = searcher.search_term(FieldId::new(0), "search", 10);
    assert_eq!(results.total_hits.value, 10, "exactly 10 docs contain 'search'");

    // Verify ranking order: higher TF should rank higher (for similar field lengths).
    // Doc 9 has tf=10, doc 8 has tf=9, ..., doc 0 has tf=1.
    // With BM25's length normalization and the varying field lengths, the exact
    // order may differ slightly, but the general trend should hold: docs with
    // higher tf and shorter length should rank higher.

    // At minimum, the top result should be one of the high-TF docs
    let top_doc = results.hits[0].doc_id.as_u32();
    assert!(
        top_doc < 10,
        "top result should be from the 'search' docs, got doc {top_doc}"
    );

    // Verify scores are in descending order
    for i in 0..results.hits.len() - 1 {
        assert!(
            results.hits[i].score >= results.hits[i + 1].score,
            "results not in descending score order at position {i}"
        );
    }

    // Verify BM25 scores against hand computation.
    // We can compute expected scores for doc 9 (tf=10, dl=10+1=11 with standard analyzer)
    // N=100, n=10
    let idf = bm25_idf(100, 10);
    assert!(idf > 0.0, "IDF should be positive");

    // Verify all scores are positive and finite
    for hit in &results.hits {
        assert!(hit.score > 0.0, "score should be positive");
        assert!(hit.score.is_finite(), "score should be finite");
    }
}

/// Exit Criterion 3: Segment merge with deletions.
///
/// Index into two segments (two commit cycles), delete some docs, merge,
/// verify merged segment has correct docs and correct search results.
#[test]
fn exit_criterion_3_segment_merge() {
    let path = test_dir("ec3");
    let storage = SingleFileDirectory::create(&path).unwrap();
    let schema = Mapping::builder()
        .field("body", FieldType::Text)
        .field("tag", FieldType::Keyword)
        .build();
    let mut writer = IndexWriter::new(storage, schema.clone(), AnalyzerRegistry::new());

    // Commit 1: 5 docs
    for i in 0..5 {
        writer
            .add(serde_json::json!({
                "body": format!("segment one document {i}"),
                "tag": "batch1",
            }))
            .unwrap();
    }
    writer.commit().unwrap();

    // Commit 2: 5 docs
    for i in 0..5 {
        writer
            .add(serde_json::json!({
                "body": format!("segment two document {i}"),
                "tag": "batch2",
            }))
            .unwrap();
    }
    writer.commit().unwrap();

    // Verify we have 2 segments
    let storage = SingleFileDirectory::open(&path).unwrap();
    assert_eq!(storage.segments().len(), 2);

    // Load both segments
    let seg1_id = storage.segments()[0].segment_id;
    let seg2_id = storage.segments()[1].segment_id;
    let reader1 = SegmentReader::open(storage.read_segment(seg1_id).unwrap()).unwrap();
    let reader2 = SegmentReader::open(storage.read_segment(seg2_id).unwrap()).unwrap();

    assert_eq!(reader1.doc_count(), 5);
    assert_eq!(reader2.doc_count(), 5);

    // Delete docs 1, 3 from segment 1 and doc 2 from segment 2
    let mut deletions = DeletionMap::new();
    deletions.mark_deleted(seg1_id, DocId::new(1));
    deletions.mark_deleted(seg1_id, DocId::new(3));
    deletions.mark_deleted(seg2_id, DocId::new(2));

    // Merge
    let merged_data = merge_segments(
        SegmentId::new(100),
        &[&reader1, &reader2],
        &deletions,
        &schema,
        &AnalyzerRegistry::new(),
    );

    let merged = SegmentReader::open(merged_data).unwrap();

    // Should have 10 - 3 = 7 documents
    assert_eq!(merged.doc_count(), 7);

    // "segment" should appear in all 7 docs
    assert_eq!(merged.doc_freq(FieldId::new(0), "segment"), 7);

    // "one" should appear in 3 docs (5 - 2 deleted)
    assert_eq!(merged.doc_freq(FieldId::new(0), "one"), 3);

    // "two" should appear in 4 docs (5 - 1 deleted)
    assert_eq!(merged.doc_freq(FieldId::new(0), "two"), 4);

    // Search on merged segment should work correctly
    let searcher = Searcher::new(vec![merged]);
    let results = searcher.search_term(FieldId::new(0), "document", 10);
    assert_eq!(results.total_hits.value, 7);

    // All results should have _source
    for hit in &results.hits {
        assert!(hit.source.is_some());
    }

    // Keyword search: "batch1" in 3 remaining docs, "batch2" in 4
    let results = searcher.search_term(FieldId::new(1), "batch1", 10);
    assert_eq!(results.total_hits.value, 3);

    let results = searcher.search_term(FieldId::new(1), "batch2", 10);
    assert_eq!(results.total_hits.value, 4);

    cleanup(&path);
}

/// Supplementary: end-to-end write → commit → search → verify source.
#[test]
fn end_to_end_write_search() {
    let path = test_dir("e2e");
    let storage = SingleFileDirectory::create(&path).unwrap();
    let schema = Mapping::builder()
        .field("title", FieldType::Text)
        .field("year", FieldType::Keyword)
        .build();
    let mut writer = IndexWriter::new(storage, schema, AnalyzerRegistry::new());

    let docs = vec![
        serde_json::json!({"title": "The Rust Programming Language", "year": "2015"}),
        serde_json::json!({"title": "Elasticsearch: The Definitive Guide", "year": "2015"}),
        serde_json::json!({"title": "Database Internals", "year": "2019"}),
        serde_json::json!({"title": "Designing Data-Intensive Applications", "year": "2017"}),
        serde_json::json!({"title": "Information Retrieval: Implementing and Evaluating Search Engines", "year": "2010"}),
    ];

    for doc in &docs {
        writer.add(doc.clone()).unwrap();
    }
    writer.commit().unwrap();

    let storage = SingleFileDirectory::open(&path).unwrap();
    let reader = IndexReader::open(&storage).unwrap();
    let searcher = reader.into_searcher();

    // Search for "rust" — should find 1 doc
    let results = searcher.search_term(FieldId::new(0), "rust", 10);
    assert_eq!(results.total_hits.value, 1);
    assert_eq!(
        results.hits[0].source.as_ref().unwrap()["title"],
        "The Rust Programming Language"
    );

    // Search for "the" — should find multiple
    let results = searcher.search_term(FieldId::new(0), "the", 10);
    assert!(results.total_hits.value >= 2);

    // Year keyword search
    let results = searcher.search_term(FieldId::new(1), "2015", 10);
    assert_eq!(results.total_hits.value, 2);

    cleanup(&path);
}
