//! HNSW error path tests
//!
//! Tests for corruption detection and error handling in the HNSW index.

use cqs::embedder::Embedding;
use cqs::hnsw::HnswIndex;
use tempfile::TempDir;

const EMBEDDING_DIM: usize = cqs::EMBEDDING_DIM;

fn make_embedding(seed: u32) -> Embedding {
    let mut v = vec![0.0f32; EMBEDDING_DIM];
    for (i, val) in v.iter_mut().enumerate() {
        *val = ((seed as f32 * 0.1) + (i as f32 * 0.001)).sin();
    }
    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
    if norm > 0.0 {
        for val in &mut v {
            *val /= norm;
        }
    }
    Embedding::new(v)
}

/// Local mirror of the lib-side `assert_self_match_reachable` policy (the
/// crate-private helper in `src/hnsw/mod.rs` is unreachable from this
/// integration binary): assert that the exact-match vector `want` is
/// reachable in the top-`k` results of an index produced by `build`,
/// retrying the build up to 8 times to absorb the rare degenerate
/// concurrent-build graph (`parallel_insert_data` under CPU contention
/// yields a self-unreachable node on ~1-2% of builds — hnswlib-rs#32).
/// A systematic recall bug (miss on every build) still fails
/// deterministically. No env lock needed here: nothing in this test binary
/// mutates CQS_HNSW_* vars, and lib tests run in a separate process.
fn assert_self_match_reachable(
    build: impl Fn() -> HnswIndex,
    query: &Embedding,
    want: &str,
    k: usize,
) {
    let mut last_ids = Vec::new();
    for _ in 0..8 {
        let index = build();
        let results = index.search(query, k);
        assert!(!results.is_empty(), "search returned no results");
        last_ids = results.iter().map(|r| r.id.clone()).collect();
        if results.iter().any(|r| r.id == want) {
            return;
        }
    }
    panic!("{want:?} unreachable across 8 builds (real recall bug, not noise); last top-{k} = {last_ids:?}");
}

#[test]
fn test_truncated_data_file_detected() {
    let tmp = TempDir::new().unwrap();

    // Build and save a valid index
    let embeddings: Vec<_> = (1..=5)
        .map(|i| (format!("chunk{}", i), make_embedding(i)))
        .collect();
    let index = HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM).unwrap();
    index.save(tmp.path(), "test").unwrap();

    // Truncate the data file (corrupt it)
    let data_path = tmp.path().join("test.hnsw.data");
    let original = std::fs::read(&data_path).unwrap();
    // Write only first half of the file
    std::fs::write(&data_path, &original[..original.len() / 2]).unwrap();

    // Loading should fail with checksum mismatch
    let result = HnswIndex::load_with_dim(tmp.path(), "test", cqs::EMBEDDING_DIM);
    match result {
        Ok(_) => panic!("Truncated file should cause load to fail"),
        Err(e) => {
            let err_msg = e.to_string();
            assert!(
                err_msg.contains("Checksum") || err_msg.contains("checksum"),
                "Error should mention checksum: {}",
                err_msg
            );
        }
    }
}

#[test]
fn test_checksum_mismatch_detected() {
    let tmp = TempDir::new().unwrap();

    // Build and save a valid index
    let embeddings = vec![
        ("a".to_string(), make_embedding(1)),
        ("b".to_string(), make_embedding(2)),
    ];
    let index = HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM).unwrap();
    index.save(tmp.path(), "test").unwrap();

    // Corrupt a single byte in the graph file
    let graph_path = tmp.path().join("test.hnsw.graph");
    let mut data = std::fs::read(&graph_path).unwrap();
    if !data.is_empty() {
        // Flip a bit in the middle of the file
        let mid = data.len() / 2;
        data[mid] ^= 0xFF;
        std::fs::write(&graph_path, &data).unwrap();
    }

    // Loading should fail with checksum mismatch
    let result = HnswIndex::load_with_dim(tmp.path(), "test", cqs::EMBEDDING_DIM);
    match result {
        Ok(_) => panic!("Corrupted file should cause load to fail"),
        Err(e) => {
            let err_msg = e.to_string();
            assert!(
                err_msg.contains("Checksum") || err_msg.contains("checksum"),
                "Error should mention checksum: {}",
                err_msg
            );
        }
    }
}

#[test]
fn test_missing_files_detected() {
    let tmp = TempDir::new().unwrap();

    // Build and save a valid index
    let embeddings = vec![("x".to_string(), make_embedding(42))];
    let index = HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM).unwrap();
    index.save(tmp.path(), "test").unwrap();

    // Delete one of the required files
    std::fs::remove_file(tmp.path().join("test.hnsw.ids")).unwrap();

    // Loading should fail with not found
    let result = HnswIndex::load_with_dim(tmp.path(), "test", cqs::EMBEDDING_DIM);
    match result {
        Ok(_) => panic!("Missing file should cause load to fail"),
        Err(e) => {
            let err_msg = e.to_string();
            assert!(
                err_msg.contains("not found") || err_msg.contains("NotFound"),
                "Error should mention not found: {}",
                err_msg
            );
        }
    }
}

#[test]
fn test_corrupted_id_map_json() {
    let tmp = TempDir::new().unwrap();

    // Build and save a valid index
    let embeddings = vec![("y".to_string(), make_embedding(99))];
    let index = HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM).unwrap();
    index.save(tmp.path(), "test").unwrap();

    // Corrupt the ID map JSON
    let id_map_path = tmp.path().join("test.hnsw.ids");
    std::fs::write(&id_map_path, "{ invalid json [[[").unwrap();

    // Loading should fail (either checksum or parse error)
    let result = HnswIndex::load_with_dim(tmp.path(), "test", cqs::EMBEDDING_DIM);
    assert!(result.is_err(), "Corrupted JSON should cause load to fail");
}

#[test]
fn test_id_map_size_mismatch_rejected() {
    let tmp = TempDir::new().unwrap();

    // Build and save a valid index with 3 vectors
    let embeddings: Vec<_> = (1..=3)
        .map(|i| (format!("chunk{}", i), make_embedding(i)))
        .collect();
    let index = HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM).unwrap();
    index.save(tmp.path(), "test").unwrap();

    // Modify id_map to have wrong count (2 instead of 3)
    let id_map_path = tmp.path().join("test.hnsw.ids");
    std::fs::write(&id_map_path, r#"["chunk1", "chunk2"]"#).unwrap();

    // Loading should fail due to size mismatch
    let result = HnswIndex::load_with_dim(tmp.path(), "test", cqs::EMBEDDING_DIM);
    // Note: checksum verification may catch this first, but if bypassed, size check will catch it
    assert!(
        result.is_err(),
        "ID map size mismatch should cause load to fail"
    );
}

#[test]
fn test_dimension_mismatch_rejected() {
    // Try to build with wrong dimension embedding
    let wrong_dim = Embedding::new(vec![1.0; 100]); // Should be 768
    let embeddings = vec![("wrong".to_string(), wrong_dim)];

    let result = HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM);
    match result {
        Ok(_) => panic!("Wrong dimension should fail"),
        Err(e) => {
            let err_msg = e.to_string();
            assert!(
                err_msg.contains("mismatch") || err_msg.contains("Dimension"),
                "Error should mention dimension: {}",
                err_msg
            );
        }
    }
}

#[test]
fn test_query_dimension_mismatch_returns_empty() {
    let embeddings = vec![("good".to_string(), make_embedding(1))];
    let index = HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM).unwrap();

    // Query with wrong dimension should return empty (graceful degradation)
    let wrong_query = Embedding::new(vec![1.0; 100]);
    let results = index.search(&wrong_query, 5);
    assert!(
        results.is_empty(),
        "Wrong dimension query should return empty"
    );
}

// ===== build_batched error path tests (T14) =====

#[test]
fn test_build_batched_dimension_mismatch() {
    // First batch has correct dimension, second has wrong dimension
    let good_batch: Vec<(String, Embedding)> = vec![
        ("good1".to_string(), make_embedding(1)),
        ("good2".to_string(), make_embedding(2)),
    ];

    let wrong_dim = Embedding::new(vec![1.0; 100]); // Should be 768
    let bad_batch: Vec<(String, Embedding)> = vec![("bad".to_string(), wrong_dim)];

    let batches: Vec<Result<Vec<(String, Embedding)>, &str>> = vec![Ok(good_batch), Ok(bad_batch)];

    let result = HnswIndex::build_batched_with_dim(batches.into_iter(), 3, cqs::EMBEDDING_DIM);
    match result {
        Ok(_) => panic!("Dimension mismatch in batch should fail"),
        Err(e) => {
            let err_msg = e.to_string();
            assert!(
                err_msg.contains("mismatch") || err_msg.contains("Dimension"),
                "Error should mention dimension: {}",
                err_msg
            );
        }
    }
}

#[test]
fn test_build_batched_empty_batches() {
    // All batches are empty
    let batches: Vec<Result<Vec<(String, Embedding)>, &str>> =
        vec![Ok(vec![]), Ok(vec![]), Ok(vec![])];

    let result = HnswIndex::build_batched_with_dim(batches.into_iter(), 0, cqs::EMBEDDING_DIM);

    // Empty input should either succeed with empty index or fail gracefully
    // Current implementation should handle this - empty HNSW is valid
    match result {
        Ok(index) => {
            // Empty index - searching should return empty
            let query = make_embedding(1);
            let results = index.search(&query, 5);
            assert!(results.is_empty(), "Empty index should return no results");
        }
        Err(e) => {
            // If it fails, error should be meaningful
            let err_msg = e.to_string();
            assert!(
                !err_msg.is_empty(),
                "Error message should not be empty: {}",
                err_msg
            );
        }
    }
}

// ===== HNSW Batch Build Tests (#239) =====

#[test]
fn test_build_batched_handles_rebuild_after_initial_build() {
    // Simulate rebuilding: build index, then build again with different data
    // This tests if the batch logic properly handles starting fresh

    // First build
    let batch1: Vec<Result<Vec<(String, Embedding)>, &str>> = vec![Ok(vec![
        ("chunk1".to_string(), make_embedding(1)),
        ("chunk2".to_string(), make_embedding(2)),
    ])];

    let index1 =
        HnswIndex::build_batched_with_dim(batch1.into_iter(), 2, cqs::EMBEDDING_DIM).unwrap();
    assert_eq!(index1.len(), 2);

    // Second build with different data (simulates rebuild)
    let build = || {
        let batch2: Vec<Result<Vec<(String, Embedding)>, &str>> = vec![Ok(vec![
            ("chunk3".to_string(), make_embedding(3)),
            ("chunk4".to_string(), make_embedding(4)),
            ("chunk5".to_string(), make_embedding(5)),
        ])];

        let index2 =
            HnswIndex::build_batched_with_dim(batch2.into_iter(), 3, cqs::EMBEDDING_DIM).unwrap();
        assert_eq!(index2.len(), 3);
        index2
    };

    // Verify search works on rebuilt index: the exact-match query must win
    // rank-1 (k=1 containment). Retried via the helper because a single
    // un-retried parallel build can be degenerate (self-match unreachable).
    assert_self_match_reachable(build, &make_embedding(3), "chunk3", 1);
}

#[test]
fn test_build_batched_large_number_of_batches() {
    // Test with many small batches (stress test batch iteration)
    let batches: Vec<Result<Vec<(String, Embedding)>, &str>> = (0..50)
        .map(|i| {
            Ok(vec![
                (format!("chunk{}_1", i), make_embedding(i * 2 + 1)),
                (format!("chunk{}_2", i), make_embedding(i * 2 + 2)),
            ])
        })
        .collect();

    let index =
        HnswIndex::build_batched_with_dim(batches.into_iter(), 100, cqs::EMBEDDING_DIM).unwrap();
    assert_eq!(
        index.len(),
        100,
        "Should handle 50 batches with 2 items each"
    );

    // Verify search works correctly
    let query = make_embedding(1);
    let results = index.search(&query, 5);
    assert!(!results.is_empty(), "Search should return results");
}

#[test]
fn test_build_batched_uneven_batch_sizes() {
    // Test with varying batch sizes (realistic scenario)
    let build = || {
        let batches: Vec<Result<Vec<(String, Embedding)>, &str>> = vec![
            Ok(vec![
                ("a".to_string(), make_embedding(1)),
                ("b".to_string(), make_embedding(2)),
                ("c".to_string(), make_embedding(3)),
            ]),
            Ok(vec![("d".to_string(), make_embedding(4))]), // Small batch
            Ok(vec![
                ("e".to_string(), make_embedding(5)),
                ("f".to_string(), make_embedding(6)),
                ("g".to_string(), make_embedding(7)),
                ("h".to_string(), make_embedding(8)),
                ("i".to_string(), make_embedding(9)),
            ]), // Large batch
            Ok(vec![
                ("j".to_string(), make_embedding(10)),
                ("k".to_string(), make_embedding(11)),
            ]),
        ];

        let index =
            HnswIndex::build_batched_with_dim(batches.into_iter(), 11, cqs::EMBEDDING_DIM).unwrap();
        assert_eq!(index.len(), 11);
        index
    };

    // Verify IDs are correctly mapped across uneven batches: the exact-match
    // query for "e" (mid-large-batch) must win rank-1 (k=1 containment).
    // Retried via the helper because a single un-retried parallel build can
    // be degenerate (self-match unreachable).
    assert_self_match_reachable(build, &make_embedding(5), "e", 1);
}

#[test]
fn test_build_batched_with_batch_errors() {
    // Test error handling when a batch fails mid-stream
    let batches: Vec<Result<Vec<(String, Embedding)>, &str>> = vec![
        Ok(vec![
            ("good1".to_string(), make_embedding(1)),
            ("good2".to_string(), make_embedding(2)),
        ]),
        Err("Batch fetch failed"),
        Ok(vec![("good3".to_string(), make_embedding(3))]),
    ];

    let result = HnswIndex::build_batched_with_dim(batches.into_iter(), 5, cqs::EMBEDDING_DIM);
    assert!(result.is_err(), "Should propagate error from failed batch");

    match result {
        Err(e) => {
            let err_msg = e.to_string();
            assert!(
                err_msg.contains("Batch fetch failed"),
                "Error should mention batch failure"
            );
        }
        Ok(_) => panic!("Expected error, got Ok"),
    }
}

#[test]
fn test_build_batched_search_quality() {
    // Verify that batched build produces usable search results
    // Build with batches
    let all_embeddings: Vec<(String, Embedding)> = (1..=30)
        .map(|i| (format!("item{}", i), make_embedding(i)))
        .collect();

    let batches: Vec<Result<Vec<(String, Embedding)>, &str>> = all_embeddings
        .chunks(5)
        .map(|chunk| Ok(chunk.to_vec()))
        .collect();

    let index =
        HnswIndex::build_batched_with_dim(batches.into_iter(), 30, cqs::EMBEDDING_DIM).unwrap();

    // Search for each item and verify it can be found.
    // Use top 15 (half the index) since HNSW is approximate — batched builds
    // with small datasets can have suboptimal graph quality.
    for i in [1, 10, 20, 30] {
        let query = make_embedding(i);
        let results = index.search(&query, 15);
        assert!(!results.is_empty(), "Should find results for item{}", i);
        let found = results.iter().any(|r| r.id == format!("item{}", i));
        assert!(found, "Should find item{} in top 15 results", i);
    }
}

// TC-ADV-2: HnswIndex::search must not panic on non-finite query vectors.
//
// Callers assemble the query embedding from user input (dense encoder,
// SPLADE, HyDE). A NaN/Inf leak from the encoder or a corrupt query cache
// entry could reach `search()` as a poisoned vector. The dense library
// (hnsw_rs) propagates NaN through cosine distance, so the returned
// `distance` would be NaN and `1.0 - NaN` is still NaN. The current
// implementation already filters non-finite scores before returning, so
// the contract is: no panic, no NaN results — either an empty vec or all
// finite scores.
//
// This guards the invariant with an explicit regression test so a future
// refactor of `search_impl` can't silently reintroduce a NaN result leak.

fn build_small_index() -> HnswIndex {
    let embeddings: Vec<_> = (1..=8)
        .map(|i| (format!("chunk{}", i), make_embedding(i)))
        .collect();
    HnswIndex::build_with_dim(embeddings, cqs::EMBEDDING_DIM).unwrap()
}

#[test]
fn test_search_all_nan_query_returns_empty() {
    let index = build_small_index();

    // Bypass `Embedding::try_new` (which rejects NaN) to simulate the worst
    // case where a poisoned vector somehow reaches `search()`.
    let nan_query = Embedding::new(vec![f32::NAN; EMBEDDING_DIM]);

    // Must not panic. Guard at the top of search_impl short-circuits
    // non-finite queries to an empty result.
    let results = index.search(&nan_query, 5);
    assert!(
        results.is_empty(),
        "NaN query must return empty results, got {} entries",
        results.len()
    );
}

#[test]
fn test_search_mixed_nan_query_returns_empty() {
    let index = build_small_index();

    // A single NaN in an otherwise valid vector is the more realistic
    // corruption (bit rot, partial overwrite, encoder edge case).
    let mut v = make_embedding(1).as_slice().to_vec();
    v[EMBEDDING_DIM / 3] = f32::NAN;
    let poisoned = Embedding::new(v);

    let results = index.search(&poisoned, 5);
    assert!(
        results.is_empty(),
        "query with a single NaN element must return empty results, got {} entries",
        results.len()
    );
}

#[test]
fn test_search_positive_infinity_query_returns_empty() {
    let index = build_small_index();

    // +Inf cosine numerator would previously panic inside anndists when
    // `dist_unchecked >= -0.00002` assertion failed. Guard converts to empty.
    let inf_query = Embedding::new(vec![f32::INFINITY; EMBEDDING_DIM]);

    let results = index.search(&inf_query, 5);
    assert!(
        results.is_empty(),
        "+Inf query must return empty results, got {} entries",
        results.len()
    );
}

#[test]
fn test_search_negative_infinity_query_returns_empty() {
    let index = build_small_index();

    // -Inf triggers the same `dist_unchecked >= -0.00002` panic inside
    // anndists on the raw library. Guard converts to empty.
    let neg_inf_query = Embedding::new(vec![f32::NEG_INFINITY; EMBEDDING_DIM]);

    let results = index.search(&neg_inf_query, 5);
    assert!(
        results.is_empty(),
        "-Inf query must return empty results, got {} entries",
        results.len()
    );
}

#[test]
fn test_search_filtered_nan_query_returns_empty() {
    let index = build_small_index();

    let nan_query = Embedding::new(vec![f32::NAN; EMBEDDING_DIM]);

    // Predicate always true — exercises the filtered branch of search_impl.
    let results = index.search_filtered(&nan_query, 5, &|_| true);
    assert!(
        results.is_empty(),
        "search_filtered with NaN query must return empty results, got {} entries",
        results.len()
    );
}

#[test]
fn test_search_valid_query_still_works_after_nan_guard() {
    let index = build_small_index();

    // Regression: the NaN/Inf guard must not break the happy path.
    let good = make_embedding(1);
    let results = index.search(&good, 3);
    assert!(!results.is_empty(), "valid query must still return results");
    for r in &results {
        assert!(r.score.is_finite(), "valid scores must be finite");
    }
}
