//! `Index` — unified facade for creating, writing, and searching a Luci index.
//!
//! This is the primary entry point for using Luci. It wires together
//! storage, schema, analyzers, writer, and searcher into a single API.
//!
//! ```ignore
//! let index = Index::create("/tmp/my_index", schema)?;
//! index.add(json!({"title": "hello world", "status": "active"}))?;
//! index.commit()?;
//! let results = index.search(json!({"query": {"match": {"title": "hello"}}}), 10)?;
//! ```

use std::path::Path;

use luci_analysis::AnalyzerRegistry;
use luci_core::{LuciError, Result};
use luci_mapping::Mapping;
use luci_storage::SingleFileDirectory;

use crate::query::convert::ast_to_query;
use crate::query::parser::parse_query;
use crate::reader::IndexReader;
use crate::search::searcher::{SearchResults, Searcher};
use crate::segment::reader::SegmentReader;
use crate::writer::IndexWriter;

/// Parse `sort` from request JSON into sort field specs.
fn parse_sort(value: Option<&serde_json::Value>) -> Option<Vec<crate::search::SortField>> {
    use crate::search::{SortField, SortFieldType, SortOrder, MissingValue};

    let arr = match value {
        None => return None,
        Some(serde_json::Value::Array(a)) => a,
        Some(serde_json::Value::String(s)) => return Some(vec![parse_sort_item(&serde_json::Value::String(s.clone()))]),
        _ => return None,
    };

    Some(arr.iter().map(parse_sort_item).collect())
}

fn parse_sort_item(value: &serde_json::Value) -> crate::search::SortField {
    use crate::search::{SortField, SortFieldType, SortOrder, MissingValue};

    match value {
        serde_json::Value::String(s) => {
            let (field, default_order) = match s.as_str() {
                "_score" => (SortFieldType::Score, SortOrder::Desc),
                "_doc" => (SortFieldType::Doc, SortOrder::Asc),
                name => (SortFieldType::Field(name.to_string()), SortOrder::Asc),
            };
            SortField { field, order: default_order, missing: MissingValue::Last }
        }
        serde_json::Value::Object(obj) => {
            let (name, spec) = obj.iter().next().unwrap();
            let (field, default_order) = match name.as_str() {
                "_score" => (SortFieldType::Score, SortOrder::Desc),
                "_doc" => (SortFieldType::Doc, SortOrder::Asc),
                name => (SortFieldType::Field(name.to_string()), SortOrder::Asc),
            };
            let (order, missing) = match spec {
                serde_json::Value::String(o) => {
                    let ord = if o == "desc" { SortOrder::Desc } else { SortOrder::Asc };
                    (ord, MissingValue::Last)
                }
                serde_json::Value::Object(opts) => {
                    let ord = opts.get("order")
                        .and_then(|v| v.as_str())
                        .map(|o| if o == "desc" { SortOrder::Desc } else { SortOrder::Asc })
                        .unwrap_or(default_order);
                    let miss = opts.get("missing")
                        .and_then(|v| v.as_str())
                        .map(|m| if m == "_first" { MissingValue::First } else { MissingValue::Last })
                        .unwrap_or(MissingValue::Last);
                    (ord, miss)
                }
                _ => (default_order, MissingValue::Last),
            };
            SortField { field, order, missing }
        }
        _ => SortField {
            field: SortFieldType::Score,
            order: SortOrder::Desc,
            missing: MissingValue::Last,
        },
    }
}

/// Extract inner_hit specs from the query AST.
fn extract_inner_hit_specs(
    ast: &crate::query::ast::QueryAST,
    searcher: &crate::search::searcher::Searcher,
) -> Vec<crate::query::nested::InnerHitSpec> {
    use crate::query::ast::{QueryAST, InnerHitsConfig};
    use crate::query::nested::InnerHitSpec;

    let mut specs = Vec::new();
    collect_inner_hit_specs(ast, searcher, &mut specs);
    specs
}

fn collect_inner_hit_specs(
    ast: &crate::query::ast::QueryAST,
    searcher: &crate::search::searcher::Searcher,
    specs: &mut Vec<crate::query::nested::InnerHitSpec>,
) {
    use crate::query::ast::QueryAST;

    match ast {
        QueryAST::Nested { path, query, inner_hits: Some(config) } => {
            // Create a separate weight for inner hit resolution
            let inner_query = crate::query::convert::ast_to_query(query);
            if let Ok(weight) = inner_query.create_weight(searcher, luci_core::ScoreMode::Complete) {
                let name = config.name.clone().unwrap_or_else(|| path.clone());
                specs.push(crate::query::nested::InnerHitSpec {
                    name,
                    path: path.clone(),
                    config: config.clone(),
                    weight,
                });
            }
        }
        QueryAST::Bool { must, should, must_not, filter } => {
            for sub in must.iter().chain(should).chain(must_not).chain(filter) {
                collect_inner_hit_specs(sub, searcher, specs);
            }
        }
        QueryAST::Nested { query, .. } => {
            collect_inner_hit_specs(query, searcher, specs);
        }
        _ => {}
    }
}

/// Parse `search_after` cursor values from request JSON.
fn parse_search_after(value: Option<&serde_json::Value>) -> Option<Vec<crate::search::SortValue>> {
    use crate::search::SortValue;
    let arr = value?.as_array()?;
    Some(arr.iter().map(|v| match v {
        serde_json::Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                SortValue::I64(i)
            } else if let Some(f) = n.as_f64() {
                SortValue::F64(f)
            } else {
                SortValue::Null
            }
        }
        serde_json::Value::String(s) => SortValue::Str(s.clone()),
        serde_json::Value::Bool(b) => SortValue::Bool(*b),
        serde_json::Value::Null => SortValue::Null,
        _ => SortValue::Null,
    }).collect())
}

/// Parse `_source` from request JSON into a `SourceFilter`.
fn parse_source_filter(value: Option<&serde_json::Value>) -> crate::search::SourceFilter {
    use crate::search::SourceFilter;
    match value {
        None | Some(serde_json::Value::Bool(true)) => SourceFilter::Enabled,
        Some(serde_json::Value::Bool(false)) => SourceFilter::Disabled,
        Some(serde_json::Value::String(s)) => SourceFilter::Fields(vec![s.clone()]),
        Some(serde_json::Value::Array(arr)) => {
            let fields: Vec<String> = arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect();
            SourceFilter::Fields(fields)
        }
        Some(serde_json::Value::Object(obj)) => {
            let includes = obj.get("includes")
                .and_then(|v| v.as_array())
                .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
                .unwrap_or_default();
            let excludes = obj.get("excludes")
                .and_then(|v| v.as_array())
                .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
                .unwrap_or_default();
            SourceFilter::IncludeExclude { includes, excludes }
        }
        _ => SourceFilter::Enabled,
    }
}

/// A Luci search index.
///
/// Provides a unified API for indexing and searching JSON documents.
/// Owns the storage, schema, writer, and search state.
pub struct Index {
    storage_path: std::path::PathBuf,
    schema: Mapping,
    #[allow(dead_code)]
    analyzers: AnalyzerRegistry,
    writer: IndexWriter,
    /// Cached searcher, invalidated on commit.
    cached_searcher: Option<Searcher>,
    /// Generation counter — bumped on each commit.
    commit_generation: u64,
    /// Generation the cached searcher was built from.
    searcher_generation: u64,
}

impl Index {
    /// Create a new index at the given path with fully dynamic mappings.
    ///
    /// All field types are inferred from the first document that contains each
    /// field. Mappings are persisted to disk on commit.
    pub fn create(path: impl AsRef<Path>) -> Result<Self> {
        Self::create_with_mapping(path, Mapping::builder().build())
    }

    /// Create a new index with explicit field mappings.
    ///
    /// Unknown fields are handled according to the mapping's `dynamic` mode
    /// (default: `true`, meaning auto-map and index).
    pub fn create_with_mapping(path: impl AsRef<Path>, mut mapping: Mapping) -> Result<Self> {
        mapping.ensure_id_field();
        let path = path.as_ref().to_path_buf();
        let storage = SingleFileDirectory::create(&path)?;
        let analyzers = AnalyzerRegistry::new();
        let writer = IndexWriter::new(storage, mapping.clone(), AnalyzerRegistry::new());
        Ok(Self {
            storage_path: path,
            schema: mapping,
            analyzers,
            writer,
            cached_searcher: None,
            commit_generation: 0,
            searcher_generation: 0,
        })
    }

    /// Open an existing index at the given path.
    ///
    /// Mappings are loaded from disk (persisted on each commit). If the index
    /// has no persisted mappings (pre-persistence format), an empty mapping
    /// with `dynamic=true` is used.
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref().to_path_buf();
        let storage = SingleFileDirectory::open(&path)?;
        let generation = storage.generation();

        // Load persisted mapping from user metadata
        let mapping = if storage.user_metadata().is_empty() {
            Mapping::builder().build()
        } else {
            let json: serde_json::Value = serde_json::from_slice(storage.user_metadata())
                .map_err(|e| LuciError::IndexCorrupted(
                    format!("invalid mapping metadata: {e}")
                ))?;
            Mapping::from_json(&json)?
        };
        let mut mapping = mapping;
        mapping.ensure_id_field();

        let analyzers = AnalyzerRegistry::new();
        let writer = IndexWriter::new(storage, mapping.clone(), AnalyzerRegistry::new());
        Ok(Self {
            storage_path: path,
            schema: mapping,
            analyzers,
            writer,
            cached_searcher: None,
            commit_generation: generation,
            searcher_generation: 0,
        })
    }

    /// Add a JSON document to the index.
    ///
    /// The document is buffered in memory until [`commit`](Self::commit) is called.
    pub fn add(&mut self, doc: serde_json::Value) -> Result<()> {
        self.writer.add(doc)
    }

    /// Get a document by its `_id`. Returns None if not found.
    /// See [[feature-document-crud]].
    pub fn get(&mut self, id: &str) -> Result<Option<crate::search::searcher::SearchHit>> {
        // Use size=10 to account for deleted duplicates from update operations
        let results = self.search(serde_json::json!({"term": {"_id": id}}), 10)?;
        Ok(results.hits.into_iter().next())
    }

    /// Delete a document by its `_id`. Returns true if found and deleted.
    /// See [[feature-document-crud]].
    pub fn delete(&mut self, id: &str) -> Result<bool> {
        let results = self.search(serde_json::json!({"term": {"_id": id}}), 1)?;
        if let Some(hit) = results.hits.first() {
            self.writer.mark_deleted(hit.segment_id, hit.doc_id);
            return Ok(true);
        }
        Ok(false)
    }

    /// Update a document by its `_id` with a partial doc merge.
    /// Returns true if found and updated. See [[feature-document-crud]].
    pub fn update(&mut self, id: &str, partial_doc: serde_json::Value) -> Result<bool> {
        let existing = self.get(id)?;
        let Some(hit) = existing else { return Ok(false) };
        let Some(mut source) = hit.source else { return Ok(false) };

        // Merge partial doc into existing source
        if let (Some(existing_obj), Some(partial_obj)) = (source.as_object_mut(), partial_doc.as_object()) {
            for (k, v) in partial_obj {
                existing_obj.insert(k.clone(), v.clone());
            }
        }

        // Preserve the _id
        if let Some(obj) = source.as_object_mut() {
            obj.insert("_id".to_string(), serde_json::Value::String(id.to_string()));
        }

        // Delete old, add new
        self.delete(id)?;
        self.writer.add(source)?;
        Ok(true)
    }

    /// Delete all documents matching a query. Returns count deleted.
    /// See [[feature-document-crud]].
    pub fn delete_by_query(&mut self, query: serde_json::Value) -> Result<u64> {
        let results = self.search(serde_json::json!({"query": query, "size": 10000, "_source": false}), 10000)?;
        let count = results.hits.len() as u64;
        for hit in &results.hits {
            self.writer.mark_deleted(hit.segment_id, hit.doc_id);
        }
        // No commit_generation bump — deletions are filtered at search time
        Ok(count)
    }

    /// Count documents matching a query.
    /// See [[feature-document-crud]].
    pub fn count(&mut self, query: serde_json::Value) -> Result<u64> {
        // Use a large size so deletion filtering can count correctly.
        // Proper fix: integrate FilteredScorer into search pipeline.
        let results = self.search(serde_json::json!({"query": query, "_source": false, "size": 100000}), 100000)?;
        Ok(results.hits.len() as u64)
    }

    /// Commit all buffered documents, making them searchable.
    pub fn commit(&mut self) -> Result<()> {
        self.writer.commit()?;
        self.commit_generation += 1;
        Ok(())
    }

    /// Force-merge all segments down to at most `max_segments`.
    ///
    /// Call after bulk indexing is complete to optimize for query performance.
    /// Invalidates the cached searcher.
    pub fn force_merge(&mut self, max_segments: usize) -> Result<()> {
        self.writer.force_merge(max_segments)?;
        self.commit_generation += 1;
        Ok(())
    }

    /// Search the index with an ES-compatible JSON query.
    ///
    /// Accepts the full `{"query": {...}}` format or a bare query object.
    /// Supports `_source` filtering: `false`, `["field"]`, or
    /// `{"includes": [...], "excludes": [...]}`.
    ///
    /// ```ignore
    /// let results = index.search(json!({"query": {"match": {"title": "hello"}}}), 10)?;
    /// let results = index.search(json!({"term": {"status": "active"}}), 10)?;
    /// let results = index.search(json!({"query": {"match_all": {}}, "_source": false}), 10)?;
    /// ```
    pub fn search(&mut self, query_json: serde_json::Value, top_k: usize) -> Result<SearchResults> {
        // Extract "query", "knn", "aggs", and "size" from the request
        let is_structured = query_json.is_object()
            && (query_json.get("query").is_some()
                || query_json.get("knn").is_some()
                || query_json.get("aggs").is_some()
                || query_json.get("aggregations").is_some()
                || query_json.get("size").is_some()
                || query_json.get("from").is_some()
                || query_json.get("_source").is_some()
                || query_json.get("fields").is_some()
                || query_json.get("sort").is_some()
                || query_json.get("search_after").is_some()
                || query_json.get("explain").is_some()
                || query_json.get("collapse").is_some()
                || query_json.get("track_total_hits").is_some()
                || query_json.get("rescore").is_some());

        let rescore_json = if is_structured { query_json.get("rescore").cloned() } else { None };

        let (query_part, knn_part, aggs_part, size, from, source_filter, fields, sort, search_after, explain, collapse, track_hits) = if is_structured {
            let q = query_json.get("query").cloned();
            let k = query_json.get("knn").cloned();
            let a = query_json.get("aggs")
                .or_else(|| query_json.get("aggregations"))
                .cloned();
            let s = query_json.get("size").and_then(|v| v.as_u64())
                .map(|v| v as usize)
                .unwrap_or(top_k);
            let f = query_json.get("from").and_then(|v| v.as_u64())
                .map(|v| v as usize)
                .unwrap_or(0);
            let sf = parse_source_filter(query_json.get("_source"));
            let srt = parse_sort(query_json.get("sort"));
            let sa = parse_search_after(query_json.get("search_after"));
            let flds: Option<Vec<String>> = query_json.get("fields")
                .and_then(|v| v.as_array())
                .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
            let exp = query_json.get("explain").and_then(|v| v.as_bool()).unwrap_or(false);
            let col = query_json.get("collapse")
                .and_then(|v| v.get("field"))
                .and_then(|v| v.as_str())
                .map(String::from);
            let tth = match query_json.get("track_total_hits") {
                Some(serde_json::Value::Bool(true)) | None => crate::search::TrackTotalHits::Exact,
                Some(serde_json::Value::Bool(false)) => crate::search::TrackTotalHits::Disabled,
                Some(serde_json::Value::Number(n)) => crate::search::TrackTotalHits::UpTo(n.as_u64().unwrap_or(0)),
                _ => crate::search::TrackTotalHits::Exact,
            };
            (q, k, a, s, f, sf, flds, srt, sa, exp, col, tth)
        } else {
            (Some(query_json), None, None, top_k, 0, crate::search::SourceFilter::Enabled, None, None, None, false, None, crate::search::TrackTotalHits::Exact)
        };

        // Reuse cached searcher if no new commits since last build
        if self.cached_searcher.is_none() || self.searcher_generation != self.commit_generation {
            let storage = SingleFileDirectory::open(&self.storage_path)?;
            let reader = IndexReader::open(&storage)?;
            self.cached_searcher = Some(Searcher::with_analyzers(
                reader.into_segments(),
                AnalyzerRegistry::new(),
            ));
            self.searcher_generation = self.commit_generation;
        }
        let searcher = self.cached_searcher.as_ref().unwrap();

        // Execute text query
        let text_results = if let Some(q) = query_part {
            let ast = parse_query(&q)?;
            let query = ast_to_query(&ast);
            if let Some(aggs_json) = aggs_part {
                let agg_defs = crate::agg::parser::parse_aggs(&aggs_json)?;
                let factories = build_agg_factories(&agg_defs);
                Some(searcher.search_with_aggs(query.as_ref(), &factories, from + size, from, &source_filter, &fields))
            } else if let Some(ref collapse_field) = collapse {
                Some(searcher.search_query_collapsed(query.as_ref(), from + size, from, &source_filter, &fields, collapse_field, explain))
            } else if let Some(ref sort_spec) = sort {
                Some(searcher.search_query_sorted(query.as_ref(), from + size, from, &source_filter, &fields, sort_spec, &search_after))
            } else {
                // Build inner_hit specs from the AST if any nested queries have inner_hits
                let ih_specs = extract_inner_hit_specs(&ast, searcher);
                Some(searcher.search_query(query.as_ref(), from + size, from, &source_filter, &fields, explain, &ih_specs))
            }
        } else {
            None
        };

        // Execute kNN query
        let knn_results = if let Some(knn_json) = knn_part {
            let field = knn_json.get("field").and_then(|v| v.as_str())
                .ok_or_else(|| LuciError::InvalidQuery("knn requires 'field'".into()))?;
            let query_vector: Vec<f32> = knn_json.get("query_vector")
                .and_then(|v| v.as_array())
                .ok_or_else(|| LuciError::InvalidQuery("knn requires 'query_vector'".into()))?
                .iter()
                .filter_map(|v| v.as_f64().map(|f| f as f32))
                .collect();
            let k = knn_json.get("k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
            let num_candidates = knn_json.get("num_candidates").and_then(|v| v.as_u64())
                .unwrap_or(k as u64 * 2) as usize;

            let knn_query = crate::vector::query::KnnQuery {
                field: field.to_string(),
                query_vector,
                k,
                num_candidates,
            };
            Some(searcher.search_query(&knn_query, k, 0, &source_filter, &fields, false, &[]))
        } else {
            None
        };

        // Combine results
        let mut results = match (text_results, knn_results) {
            (Some(text), Some(knn)) => {
                // Hybrid: fuse with RRF
                let rank_constant = 60.0;
                crate::search::rrf::reciprocal_rank_fusion(
                    &[&text, &knn],
                    rank_constant,
                    size,
                )
            }
            (Some(text), None) => text,
            (None, Some(knn)) => knn,
            (None, None) => {
                // Neither query nor knn — match all
                let query = ast_to_query(&crate::query::ast::QueryAST::MatchAll);
                if let Some(ref sort_spec) = sort {
                    searcher.search_query_sorted(query.as_ref(), from + size, from, &source_filter, &fields, sort_spec, &search_after)
                } else {
                    searcher.search_query(query.as_ref(), from + size, from, &source_filter, &fields, explain, &[])
                }
            }
        };

        // Apply rescore if specified
        if let Some(ref rescore_val) = rescore_json {
            {
                let rescore_obj = rescore_val.get("query").or(Some(rescore_val));
                if let Some(rq) = rescore_obj.and_then(|o| o.get("rescore_query")) {
                    let window_size = rescore_val.get("window_size")
                        .and_then(|v| v.as_u64())
                        .map(|v| v as usize)
                        .unwrap_or(10);
                    let query_weight = rescore_val.get("query")
                        .and_then(|o| o.get("query_weight"))
                        .and_then(|v| v.as_f64())
                        .map(|v| v as f32)
                        .unwrap_or(1.0);
                    let rescore_query_weight = rescore_val.get("query")
                        .and_then(|o| o.get("rescore_query_weight"))
                        .and_then(|v| v.as_f64())
                        .map(|v| v as f32)
                        .unwrap_or(1.0);
                    let score_mode = match rescore_val.get("query")
                        .and_then(|o| o.get("score_mode"))
                        .and_then(|v| v.as_str())
                    {
                        Some("multiply") => crate::search::RescoreScoreMode::Multiply,
                        Some("avg") => crate::search::RescoreScoreMode::Avg,
                        Some("max") => crate::search::RescoreScoreMode::Max,
                        Some("min") => crate::search::RescoreScoreMode::Min,
                        _ => crate::search::RescoreScoreMode::Total,
                    };

                    if let Ok(ast) = parse_query(rq) {
                        let rescore_query = ast_to_query(&ast);
                        searcher.apply_rescore(
                            &mut results,
                            rescore_query.as_ref(),
                            window_size,
                            query_weight,
                            rescore_query_weight,
                            score_mode,
                        );
                    }
                }
            }
        }

        // Filter out deleted documents
        let deletions = self.writer.deletions();
        let pre_len = results.hits.len() as u64;
        results.hits.retain(|hit| !deletions.is_deleted(hit.segment_id, hit.doc_id));
        let removed = pre_len - results.hits.len() as u64;
        // Adjust total_hits: subtract filtered hits. For size=0 (count),
        // we can't know how many deleted docs matched, so we re-run with
        // a large size. This is a known limitation — proper fix requires
        // FilteredScorer integration in the search loop.
        if removed > 0 || results.total_hits.value > results.hits.len() as u64 {
            // Recalculate by subtracting removed from what we can observe
            results.total_hits = crate::search::TotalHits::exact(
                results.total_hits.value.saturating_sub(removed),
            );
        }

        // Apply track_total_hits capping
        results.total_hits = crate::search::TotalHits::resolve(
            results.total_hits.value,
            track_hits,
        );

        Ok(results)
    }

    /// Set the memory budget for auto-flush (bytes).
    ///
    /// When the in-memory buffer exceeds this size, it is automatically
    /// flushed to a new segment. Smaller values produce more segments
    /// (better parallelism, more merge work). Default: 64 MB.
    pub fn set_memory_budget(&mut self, budget: usize) {
        self.writer.set_memory_budget(budget);
    }

    /// Get the schema.
    pub fn schema(&self) -> &Mapping {
        &self.schema
    }

    /// Number of buffered (uncommitted) documents.
    pub fn buffered_doc_count(&self) -> u32 {
        self.writer.buffered_doc_count()
    }
}

/// Build aggregation collector factories from parsed AggDefs.
fn build_agg_factories(
    defs: &[crate::agg::AggDef],
) -> Vec<(String, Box<dyn crate::agg::AggCollectorFactory>)> {
    use crate::agg::{AggType, metric::{MetricAggFactory, MetricType}, bucket::*};

    defs.iter()
        .map(|def| {
            let factory: Box<dyn crate::agg::AggCollectorFactory> = match &def.agg_type {
                AggType::Avg { field } => Box::new(MetricAggFactory {
                    field_name: field.clone(),
                    metric_type: MetricType::Avg,
                }),
                AggType::Sum { field } => Box::new(MetricAggFactory {
                    field_name: field.clone(),
                    metric_type: MetricType::Sum,
                }),
                AggType::Min { field } => Box::new(MetricAggFactory {
                    field_name: field.clone(),
                    metric_type: MetricType::Min,
                }),
                AggType::Max { field } => Box::new(MetricAggFactory {
                    field_name: field.clone(),
                    metric_type: MetricType::Max,
                }),
                AggType::ValueCount { field } => Box::new(MetricAggFactory {
                    field_name: field.clone(),
                    metric_type: MetricType::ValueCount,
                }),
                AggType::Stats { field } => Box::new(MetricAggFactory {
                    field_name: field.clone(),
                    metric_type: MetricType::Stats,
                }),
                AggType::ExtendedStats { field } => Box::new(MetricAggFactory {
                    field_name: field.clone(),
                    metric_type: MetricType::ExtendedStats,
                }),
                AggType::Terms { field, size } => Box::new(TermsAggFactory {
                    field_name: field.clone(),
                    size: *size,
                    sub_agg_factories: build_agg_factories(&def.sub_aggs),
                }),
                AggType::Range { field, ranges } => Box::new(RangeAggFactory {
                    field_name: field.clone(),
                    ranges: ranges.clone(),
                }),
                AggType::Histogram { field, interval } => Box::new(HistogramAggFactory {
                    field_name: field.clone(),
                    interval: *interval,
                }),
                AggType::Filter { .. } => Box::new(FilterAggFactory {
                    query: Box::new(crate::query::convert::MatchAllQuery),
                }),
                AggType::Cardinality { field, precision_threshold } => {
                    // Map precision_threshold to HLL precision: p = ceil(log2(threshold * 1.5))
                    let p = ((*precision_threshold as f64 * 1.5).log2().ceil() as u8).clamp(4, 18);
                    Box::new(crate::agg::hll::CardinalityAggFactory {
                        field_name: field.clone(),
                        precision: p,
                    })
                }
                AggType::Percentiles { field, percents, compression } => {
                    Box::new(crate::agg::tdigest::PercentilesAggFactory {
                        field_name: field.clone(),
                        percents: percents.clone(),
                        compression: *compression,
                    })
                }
                AggType::GeoBounds { field } => Box::new(crate::agg::metric::GeoBoundsAggFactory {
                    field_name: field.clone(),
                }),
                AggType::GeoCentroid { field } => Box::new(crate::agg::metric::GeoCentroidAggFactory {
                    field_name: field.clone(),
                }),
                AggType::Nested { path } => Box::new(crate::agg::bucket::NestedAggFactory {
                    path: path.clone(),
                    sub_agg_factories: build_agg_factories(&def.sub_aggs),
                }),
                AggType::ReverseNested => Box::new(crate::agg::bucket::ReverseNestedAggFactory {
                    sub_agg_factories: build_agg_factories(&def.sub_aggs),
                }),
                AggType::GeohashGrid { field, precision, size } => Box::new(crate::agg::bucket::GeohashGridAggFactory {
                    field_name: field.clone(),
                    precision: *precision,
                    size: *size,
                }),
                AggType::TopHits { size } => Box::new(crate::agg::bucket::TopHitsAggFactory {
                    size: *size,
                }),
                AggType::DateHistogram { field, interval } => Box::new(crate::agg::bucket::DateHistogramAggFactory {
                    field_name: field.clone(),
                    interval: interval.clone(),
                }),
                AggType::DateRange { field, ranges } => Box::new(RangeAggFactory {
                    field_name: field.clone(),
                    ranges: ranges.clone(),
                }),
                AggType::Filters { filters } => {
                    let compiled: Vec<(String, Box<dyn crate::query::Query>)> = filters.iter()
                        .map(|(name, ast)| (name.clone(), crate::query::convert::ast_to_query(ast)))
                        .collect();
                    Box::new(FiltersAggFactory { filters: compiled })
                },
            };
            (def.name.clone(), factory)
        })
        .collect()
}

/// Helper to convert IndexReader into segments (for Searcher construction).
impl IndexReader {
    /// Consume the reader and return owned segment readers.
    pub fn into_segments(self) -> Vec<SegmentReader> {
        self.segments
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use luci_mapping::FieldType;

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

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

    fn test_schema() -> Mapping {
        Mapping::builder()
            .field("title", FieldType::Text)
            .field("body", FieldType::Text)
            .field("status", FieldType::Keyword)
            .build()
    }

    #[test]
    fn create_add_commit_search() {
        let path = test_dir("basic");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "Hello World", "body": "A greeting", "status": "published"})).unwrap();
        index.add(json!({"title": "Search Engine", "body": "Building search", "status": "draft"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"match": {"title": "hello"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1);
        assert_eq!(results.hits[0].source.as_ref().unwrap()["title"], "Hello World");

        cleanup(&path);
    }

    #[test]
    fn search_with_query_wrapper() {
        let path = test_dir("wrapper");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "Rust Programming", "status": "published"})).unwrap();
        index.add(json!({"title": "Go Programming", "status": "published"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"query": {"match": {"title": "rust"}}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1);

        cleanup(&path);
    }

    #[test]
    fn term_query_on_keyword() {
        let path = test_dir("keyword");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "A", "status": "published"})).unwrap();
        index.add(json!({"title": "B", "status": "draft"})).unwrap();
        index.add(json!({"title": "C", "status": "published"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"term": {"status": "published"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 2);

        cleanup(&path);
    }

    #[test]
    fn bool_query_end_to_end() {
        let path = test_dir("bool");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "Search Engine Design", "status": "published"})).unwrap();
        index.add(json!({"title": "Search Tips", "status": "draft"})).unwrap();
        index.add(json!({"title": "Database Design", "status": "published"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({
            "bool": {
                "must": [{"match": {"title": "search"}}],
                "filter": [{"term": {"status": "published"}}]
            }
        }), 10).unwrap();

        assert_eq!(results.total_hits.value, 1);
        let title = results.hits[0].source.as_ref().unwrap()["title"].as_str().unwrap();
        assert!(title.contains("Search Engine"));

        cleanup(&path);
    }

    #[test]
    fn phrase_query_end_to_end() {
        let path = test_dir("phrase");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"body": "the quick brown fox jumps"})).unwrap();
        index.add(json!({"body": "brown quick fox"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"match_phrase": {"body": "quick brown fox"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1);

        cleanup(&path);
    }

    #[test]
    fn match_all_query() {
        let path = test_dir("match_all");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "A"})).unwrap();
        index.add(json!({"title": "B"})).unwrap();
        index.add(json!({"title": "C"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"match_all": {}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 3);

        cleanup(&path);
    }

    #[test]
    fn open_existing_index() {
        let path = test_dir("reopen");

        {
            let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();
            index.add(json!({"title": "persistent doc", "status": "published"})).unwrap();
            index.commit().unwrap();
        }

        {
            let mut index = Index::open(&path).unwrap();
            let results = index.search(json!({"match": {"title": "persistent"}}), 10).unwrap();
            assert_eq!(results.total_hits.value, 1);
        }

        cleanup(&path);
    }

    #[test]
    fn multiple_commits_searchable() {
        let path = test_dir("multi_commit");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "first batch"})).unwrap();
        index.commit().unwrap();

        index.add(json!({"title": "second batch"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"match": {"title": "batch"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 2);

        cleanup(&path);
    }

    #[test]
    fn empty_index_search() {
        let path = test_dir("empty");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"match_all": {}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 0);

        cleanup(&path);
    }

    #[test]
    fn search_no_results() {
        let path = test_dir("no_results");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();
        index.add(json!({"title": "hello"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({"term": {"status": "nonexistent"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 0);

        cleanup(&path);
    }

    #[test]
    fn constant_score_end_to_end() {
        let path = test_dir("const_score");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();
        index.add(json!({"title": "test", "status": "published"})).unwrap();
        index.commit().unwrap();

        let results = index.search(json!({
            "constant_score": {
                "filter": {"term": {"status": "published"}},
                "boost": 3.14
            }
        }), 10).unwrap();
        assert_eq!(results.total_hits.value, 1);
        assert!((results.hits[0].score - 3.14).abs() < 0.01);

        cleanup(&path);
    }

    #[test]
    fn from_offset_pagination() {
        let path = test_dir("from_offset");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        // Index 20 docs with distinct titles
        for i in 0..20 {
            index.add(json!({"title": format!("document {i}"), "status": "published"})).unwrap();
        }
        index.commit().unwrap();

        // Page 1: from=0, size=5
        let page1 = index.search(json!({"query": {"match_all": {}}, "from": 0, "size": 5}), 10).unwrap();
        assert_eq!(page1.hits.len(), 5);
        assert_eq!(page1.total_hits.value, 20);

        // Page 2: from=5, size=5
        let page2 = index.search(json!({"query": {"match_all": {}}, "from": 5, "size": 5}), 10).unwrap();
        assert_eq!(page2.hits.len(), 5);
        assert_eq!(page2.total_hits.value, 20);

        // Pages should have no overlap
        let page1_ids: Vec<_> = page1.hits.iter().map(|h| h.doc_id).collect();
        let page2_ids: Vec<_> = page2.hits.iter().map(|h| h.doc_id).collect();
        for id in &page2_ids {
            assert!(!page1_ids.contains(id), "page 2 should not overlap page 1");
        }

        // from beyond total_hits → empty
        let empty = index.search(json!({"query": {"match_all": {}}, "from": 100, "size": 5}), 10).unwrap();
        assert_eq!(empty.hits.len(), 0);
        assert_eq!(empty.total_hits.value, 20);

        // from=0 should match default behavior
        let default = index.search(json!({"match_all": {}}), 5).unwrap();
        let explicit = index.search(json!({"query": {"match_all": {}}, "from": 0, "size": 5}), 10).unwrap();
        assert_eq!(default.hits.len(), explicit.hits.len());

        cleanup(&path);
    }

    #[test]
    fn source_filtering() {
        let path = test_dir("source_filter");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "Hello World", "body": "Full text", "status": "published"})).unwrap();
        index.add(json!({"title": "Search Engine", "body": "More text", "status": "draft"})).unwrap();
        index.commit().unwrap();

        // _source: false — no source at all
        let results = index.search(json!({"query": {"match_all": {}}, "_source": false}), 10).unwrap();
        assert_eq!(results.hits.len(), 2);
        assert!(results.hits[0].source.is_none());
        assert!(results.hits[1].source.is_none());

        // _source: ["title"] — only title field
        let results = index.search(json!({"query": {"match_all": {}}, "_source": ["title"]}), 10).unwrap();
        for hit in &results.hits {
            let src = hit.source.as_ref().unwrap();
            assert!(src.get("title").is_some());
            assert!(src.get("body").is_none());
            assert!(src.get("status").is_none());
        }

        // _source: {"excludes": ["body"]} — all except body
        let results = index.search(json!({
            "query": {"match_all": {}},
            "_source": {"excludes": ["body"]}
        }), 10).unwrap();
        for hit in &results.hits {
            let src = hit.source.as_ref().unwrap();
            assert!(src.get("title").is_some());
            assert!(src.get("status").is_some());
            assert!(src.get("body").is_none());
        }

        // _source: {"includes": ["title", "body"], "excludes": ["body"]} — exclude wins
        let results = index.search(json!({
            "query": {"match_all": {}},
            "_source": {"includes": ["title", "body"], "excludes": ["body"]}
        }), 10).unwrap();
        for hit in &results.hits {
            let src = hit.source.as_ref().unwrap();
            assert!(src.get("title").is_some());
            assert!(src.get("body").is_none());
        }

        // _source: true — full source (default)
        let results = index.search(json!({"query": {"match_all": {}}, "_source": true}), 10).unwrap();
        for hit in &results.hits {
            let src = hit.source.as_ref().unwrap();
            assert!(src.get("title").is_some());
            assert!(src.get("body").is_some());
            assert!(src.get("status").is_some());
        }

        cleanup(&path);
    }

    #[test]
    fn fields_retrieval() {
        let path = test_dir("fields_retrieval");
        let schema = Mapping::builder()
            .field("title", FieldType::Text)
            .field("tag", FieldType::Keyword)
            .field("price", FieldType::Float)
            .build();
        let mut index = Index::create_with_mapping(&path, schema).unwrap();

        index.add(json!({"title": "Hello World", "tag": "tech", "price": 9.99})).unwrap();
        index.add(json!({"title": "Search Engine", "tag": "science", "price": 19.99})).unwrap();
        index.commit().unwrap();

        // Retrieve keyword + numeric fields
        let results = index.search(json!({
            "query": {"match_all": {}},
            "fields": ["tag", "price"],
            "_source": false
        }), 10).unwrap();

        assert_eq!(results.hits.len(), 2);
        for hit in &results.hits {
            // _source disabled
            assert!(hit.source.is_none());
            // fields present
            let fields = hit.fields.as_ref().unwrap();
            assert!(fields.get("tag").is_some(), "tag field should be present");
            assert!(fields.get("price").is_some(), "price field should be present");
            // Values are arrays (ES compat)
            let tag = fields.get("tag").unwrap();
            assert!(tag.is_array(), "tag should be array");
        }

        // Text field silently omitted (no columnar store)
        let results = index.search(json!({
            "query": {"match_all": {}},
            "fields": ["title"],
            "_source": false
        }), 10).unwrap();
        for hit in &results.hits {
            let fields = hit.fields.as_ref().unwrap();
            assert!(fields.get("title").is_none(), "text field should be omitted");
        }

        // Fields + source together
        let results = index.search(json!({
            "query": {"match_all": {}},
            "fields": ["tag"],
            "_source": ["title"]
        }), 10).unwrap();
        for hit in &results.hits {
            assert!(hit.source.is_some());
            assert!(hit.fields.is_some());
        }

        cleanup(&path);
    }

    #[test]
    fn sort_by_field() {
        let path = test_dir("sort_by_field");
        let schema = Mapping::builder()
            .field("title", FieldType::Text)
            .field("tag", FieldType::Keyword)
            .field("price", FieldType::Float)
            .build();
        let mut index = Index::create_with_mapping(&path, schema).unwrap();

        index.add(json!({"title": "Expensive", "tag": "b", "price": 99.99})).unwrap();
        index.add(json!({"title": "Cheap", "tag": "a", "price": 1.99})).unwrap();
        index.add(json!({"title": "Mid", "tag": "c", "price": 49.99})).unwrap();
        index.commit().unwrap();

        // Sort by price ascending
        let results = index.search(json!({
            "query": {"match_all": {}},
            "sort": ["price"]
        }), 10).unwrap();
        assert_eq!(results.hits.len(), 3);
        let prices: Vec<f64> = results.hits.iter()
            .map(|h| h.sort_values.as_ref().unwrap()[0].to_json().as_f64().unwrap())
            .collect();
        assert!(prices[0] <= prices[1] && prices[1] <= prices[2],
            "prices should be ascending: {:?}", prices);

        // Sort by price descending
        let results = index.search(json!({
            "query": {"match_all": {}},
            "sort": [{"price": "desc"}]
        }), 10).unwrap();
        let prices: Vec<f64> = results.hits.iter()
            .map(|h| h.sort_values.as_ref().unwrap()[0].to_json().as_f64().unwrap())
            .collect();
        assert!(prices[0] >= prices[1] && prices[1] >= prices[2],
            "prices should be descending: {:?}", prices);

        // Sort by keyword field
        let results = index.search(json!({
            "query": {"match_all": {}},
            "sort": [{"tag": "asc"}]
        }), 10).unwrap();
        let tags: Vec<String> = results.hits.iter()
            .map(|h| h.sort_values.as_ref().unwrap()[0].to_json().as_str().unwrap().to_string())
            .collect();
        assert_eq!(tags, vec!["a", "b", "c"]);

        // Sort values present in response
        let results = index.search(json!({
            "query": {"match_all": {}},
            "sort": ["price"]
        }), 10).unwrap();
        for hit in &results.hits {
            assert!(hit.sort_values.is_some(), "sort values should be present");
        }

        // Sort by _score (should match default ordering)
        let results = index.search(json!({
            "query": {"match_all": {}},
            "sort": ["_score"]
        }), 10).unwrap();
        assert_eq!(results.hits.len(), 3);

        cleanup(&path);
    }

    #[test]
    fn search_after_pagination() {
        let path = test_dir("search_after");
        let schema = Mapping::builder()
            .field("title", FieldType::Text)
            .field("price", FieldType::Float)
            .build();
        let mut index = Index::create_with_mapping(&path, schema).unwrap();

        for i in 0..20 {
            index.add(json!({"title": format!("item {i}"), "price": i as f64})).unwrap();
        }
        index.commit().unwrap();

        // Page 1: sort by price, size=5
        let page1 = index.search(json!({
            "query": {"match_all": {}},
            "sort": ["price"],
            "size": 5
        }), 10).unwrap();
        assert_eq!(page1.hits.len(), 5);
        let last_sort = page1.hits.last().unwrap().sort_values.as_ref().unwrap();

        // Page 2: search_after with last sort values
        let page2 = index.search(json!({
            "query": {"match_all": {}},
            "sort": ["price"],
            "size": 5,
            "search_after": [last_sort[0].to_json()]
        }), 10).unwrap();
        assert_eq!(page2.hits.len(), 5);

        // No overlap between pages
        let p1_prices: Vec<f64> = page1.hits.iter()
            .map(|h| h.sort_values.as_ref().unwrap()[0].to_json().as_f64().unwrap())
            .collect();
        let p2_prices: Vec<f64> = page2.hits.iter()
            .map(|h| h.sort_values.as_ref().unwrap()[0].to_json().as_f64().unwrap())
            .collect();
        assert!(p1_prices.last().unwrap() < p2_prices.first().unwrap(),
            "page 2 should start after page 1: {:?} vs {:?}", p1_prices, p2_prices);

        // Iterate all pages
        let mut all_prices = Vec::new();
        let mut cursor: Option<Vec<serde_json::Value>> = None;
        loop {
            let mut req = json!({
                "query": {"match_all": {}},
                "sort": ["price"],
                "size": 7
            });
            if let Some(ref c) = cursor {
                req["search_after"] = serde_json::json!(c);
            }
            let page = index.search(req, 10).unwrap();
            if page.hits.is_empty() { break; }
            for hit in &page.hits {
                all_prices.push(hit.sort_values.as_ref().unwrap()[0].to_json().as_f64().unwrap());
            }
            cursor = Some(page.hits.last().unwrap().sort_values.as_ref().unwrap()
                .iter().map(|sv| sv.to_json()).collect());
        }
        assert_eq!(all_prices.len(), 20, "should iterate all 20 docs");
        // Verify ascending order and no duplicates
        for i in 1..all_prices.len() {
            assert!(all_prices[i] > all_prices[i-1],
                "should be strictly ascending: {} vs {}", all_prices[i-1], all_prices[i]);
        }

        // search_after beyond all results → empty
        let empty = index.search(json!({
            "query": {"match_all": {}},
            "sort": ["price"],
            "search_after": [999.0]
        }), 10).unwrap();
        assert!(empty.hits.is_empty());

        cleanup(&path);
    }

    #[test]
    fn explain_score() {
        let path = test_dir("explain");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        index.add(json!({"title": "hello world", "status": "published"})).unwrap();
        index.add(json!({"title": "hello hello hello", "status": "draft"})).unwrap();
        index.commit().unwrap();

        // Without explain — no explanation
        let results = index.search(json!({"query": {"match": {"title": "hello"}}}), 10).unwrap();
        assert!(results.hits[0].explanation.is_none());

        // With explain — explanation present
        let results = index.search(json!({
            "query": {"match": {"title": "hello"}},
            "explain": true
        }), 10).unwrap();
        assert_eq!(results.hits.len(), 2);
        for hit in &results.hits {
            let exp = hit.explanation.as_ref().expect("explanation should be present");
            assert!(exp.value > 0.0, "score should be positive");
            assert!(!exp.description.is_empty(), "description should be non-empty");
            assert!(!exp.details.is_empty(), "should have BM25 sub-details");
        }

        // Higher TF should have higher explanation value
        let scores: Vec<f32> = results.hits.iter()
            .map(|h| h.explanation.as_ref().unwrap().value)
            .collect();
        // hits are score-descending, so first should have higher explain value
        assert!(scores[0] >= scores[1]);

        cleanup(&path);
    }

    #[test]
    fn collapse_by_field() {
        let path = test_dir("collapse");
        let schema = Mapping::builder()
            .field("title", FieldType::Text)
            .field("author", FieldType::Keyword)
            .build();
        let mut index = Index::create_with_mapping(&path, schema).unwrap();

        // Multiple docs per author
        index.add(json!({"title": "post one by alice", "author": "alice"})).unwrap();
        index.add(json!({"title": "post two by alice", "author": "alice"})).unwrap();
        index.add(json!({"title": "post by bob", "author": "bob"})).unwrap();
        index.add(json!({"title": "another by bob", "author": "bob"})).unwrap();
        index.add(json!({"title": "post by carol", "author": "carol"})).unwrap();
        index.commit().unwrap();

        // Without collapse: all 5 docs
        let results = index.search(json!({"query": {"match_all": {}}}), 10).unwrap();
        assert_eq!(results.hits.len(), 5);

        // With collapse: 1 per author = 3 hits
        let results = index.search(json!({
            "query": {"match_all": {}},
            "collapse": {"field": "author"}
        }), 10).unwrap();
        assert_eq!(results.hits.len(), 3, "should have 3 unique authors");

        // Each hit should have the author field
        let mut authors: Vec<String> = results.hits.iter()
            .filter_map(|h| h.fields.as_ref())
            .filter_map(|f| f.get("author"))
            .filter_map(|v| v.as_array())
            .filter_map(|a| a.first())
            .filter_map(|v| v.as_str())
            .map(String::from)
            .collect();
        authors.sort();
        assert_eq!(authors, vec!["alice", "bob", "carol"]);

        // total_hits should be the uncollapsed count
        assert_eq!(results.total_hits.value, 5);

        cleanup(&path);
    }

    #[test]
    fn track_total_hits() {
        use crate::search::TotalHitsRelation;

        let path = test_dir("track_total_hits");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();
        for i in 0..100 {
            index.add(json!({"title": format!("document {i}"), "status": "published"})).unwrap();
        }
        index.commit().unwrap();

        // Default: exact count
        let results = index.search(json!({"query": {"match_all": {}}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 100);
        assert_eq!(results.total_hits.relation, TotalHitsRelation::EqualTo);

        // track_total_hits: true — same as default
        let results = index.search(json!({
            "query": {"match_all": {}},
            "track_total_hits": true
        }), 10).unwrap();
        assert_eq!(results.total_hits.value, 100);
        assert_eq!(results.total_hits.relation, TotalHitsRelation::EqualTo);

        // track_total_hits: false — disabled
        let results = index.search(json!({
            "query": {"match_all": {}},
            "track_total_hits": false
        }), 10).unwrap();
        assert_eq!(results.total_hits.value, 0);
        assert_eq!(results.total_hits.relation, TotalHitsRelation::GreaterThanOrEqualTo);

        // track_total_hits: 50 — cap below actual
        let results = index.search(json!({
            "query": {"match_all": {}},
            "track_total_hits": 50
        }), 10).unwrap();
        assert_eq!(results.total_hits.value, 50);
        assert_eq!(results.total_hits.relation, TotalHitsRelation::GreaterThanOrEqualTo);

        // track_total_hits: 200 — cap above actual
        let results = index.search(json!({
            "query": {"match_all": {}},
            "track_total_hits": 200
        }), 10).unwrap();
        assert_eq!(results.total_hits.value, 100);
        assert_eq!(results.total_hits.relation, TotalHitsRelation::EqualTo);

        cleanup(&path);
    }

    #[test]
    fn inner_hits_nested() {
        let path = test_dir("inner_hits");
        let schema = Mapping::builder()
            .field("product", FieldType::Text)
            .field("offers", FieldType::Nested)
            .field("offers.seller", FieldType::Keyword)
            .field("offers.price", FieldType::Keyword)
            .build();
        let mut index = Index::create_with_mapping(&path, schema).unwrap();

        index.add(json!({
            "product": "laptop",
            "offers": [
                {"seller": "Alice", "price": "999"},
                {"seller": "Bob", "price": "1299"}
            ]
        })).unwrap();
        index.commit().unwrap();

        // Nested query with inner_hits — should return which offer matched
        let results = index.search(json!({
            "nested": {
                "path": "offers",
                "query": {
                    "term": {"offers.seller": "Alice"}
                },
                "inner_hits": {}
            }
        }), 10).unwrap();

        assert_eq!(results.total_hits.value, 1);
        assert_eq!(results.hits.len(), 1);

        // Check inner_hits are present
        let hit = &results.hits[0];
        let inner = hit.inner_hits.as_ref().expect("inner_hits should be present");
        let offers = inner.get("offers").expect("should have 'offers' inner_hits group");

        let inner_hits_obj = offers.get("hits").unwrap();
        let inner_total = inner_hits_obj.get("total").unwrap().get("value").unwrap().as_u64().unwrap();
        assert_eq!(inner_total, 1, "should have 1 matching inner hit");

        let inner_docs = inner_hits_obj.get("hits").unwrap().as_array().unwrap();
        assert_eq!(inner_docs.len(), 1);

        // The inner hit should have Alice's offer as _source
        let inner_source = inner_docs[0].get("_source").unwrap();
        assert_eq!(inner_source.get("seller").unwrap(), "Alice");
        assert_eq!(inner_source.get("price").unwrap(), "999");

        cleanup(&path);
    }

    #[test]
    fn rescore_reranks() {
        let path = test_dir("rescore");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        // Doc 0: has "hello" (matches initial query)
        // Doc 1: has "hello world" (matches both initial and rescore phrase)
        // Doc 2: has "hello there world" (matches initial, weak phrase match)
        index.add(json!({"title": "hello", "status": "published"})).unwrap();
        index.add(json!({"title": "hello world", "status": "published"})).unwrap();
        index.add(json!({"title": "hello there world", "status": "published"})).unwrap();
        index.commit().unwrap();

        // Initial search: match "hello" — all 3 docs match
        let without_rescore = index.search(json!({
            "query": {"match": {"title": "hello"}}
        }), 10).unwrap();
        assert_eq!(without_rescore.hits.len(), 3);

        // With rescore: boost docs matching phrase "hello world"
        let with_rescore = index.search(json!({
            "query": {"match": {"title": "hello"}},
            "rescore": {
                "window_size": 10,
                "query": {
                    "rescore_query": {"match_phrase": {"title": "hello world"}},
                    "query_weight": 0.5,
                    "rescore_query_weight": 1.5
                }
            }
        }), 10).unwrap();
        assert_eq!(with_rescore.hits.len(), 3);

        // Doc with "hello world" should be boosted to top
        // (it matches both the initial match and the rescore phrase)
        let top_source = with_rescore.hits[0].source.as_ref().unwrap();
        assert_eq!(top_source["title"], "hello world",
            "phrase match should be boosted to top");

        // Rescore should change scores
        assert_ne!(
            without_rescore.hits[0].score,
            with_rescore.hits[0].score,
            "rescore should modify scores"
        );

        cleanup(&path);
    }

    #[test]
    fn multi_fields() {
        let path = test_dir("multi_fields");
        // title as text + title.raw as keyword
        let mapping_json = json!({
            "properties": {
                "title": {
                    "type": "text",
                    "fields": {
                        "raw": {"type": "keyword"}
                    }
                }
            }
        });
        let schema = Mapping::from_json(&mapping_json).unwrap();
        let mut index = Index::create_with_mapping(&path, schema).unwrap();

        index.add(json!({"title": "Hello World"})).unwrap();
        index.add(json!({"title": "Hello Luci"})).unwrap();
        index.add(json!({"title": "Goodbye World"})).unwrap();
        index.commit().unwrap();

        // Text search on parent field
        let results = index.search(json!({"match": {"title": "hello"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 2, "text search should match 2 docs");

        // Exact match on sub-field (keyword)
        let results = index.search(json!({"term": {"title.raw": "Hello World"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1, "exact keyword match on sub-field");

        // Term query on sub-field shouldn't match partial text
        let results = index.search(json!({"term": {"title.raw": "hello"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 0, "keyword sub-field is not analyzed");

        // Sort by sub-field
        let results = index.search(json!({
            "query": {"match_all": {}},
            "sort": [{"title.raw": "asc"}]
        }), 10).unwrap();
        assert_eq!(results.hits.len(), 3);
        let titles: Vec<String> = results.hits.iter()
            .map(|h| h.sort_values.as_ref().unwrap()[0].to_json().as_str().unwrap().to_string())
            .collect();
        assert_eq!(titles[0], "Goodbye World");
        assert_eq!(titles[1], "Hello Luci");
        assert_eq!(titles[2], "Hello World");

        // Aggregation on sub-field
        let results = index.search(json!({
            "query": {"match_all": {}},
            "aggs": {"titles": {"terms": {"field": "title.raw"}}},
            "size": 0
        }), 10).unwrap();
        assert!(results.aggregations.contains_key("titles"));

        cleanup(&path);
    }

    #[test]
    fn copy_to() {
        let path = test_dir("copy_to");
        let schema = Mapping::from_json(&json!({
            "properties": {
                "title": {"type": "text", "copy_to": "all_text"},
                "body": {"type": "text", "copy_to": "all_text"},
                "tag": {"type": "keyword"},
                "all_text": {"type": "text"}
            }
        })).unwrap();
        let mut index = Index::create_with_mapping(&path, schema).unwrap();

        index.add(json!({"title": "search engine", "body": "fast and embedded", "tag": "tech"})).unwrap();
        index.add(json!({"title": "database", "body": "columnar storage", "tag": "tech"})).unwrap();
        index.commit().unwrap();

        // Search on individual field
        let results = index.search(json!({"match": {"title": "search"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1);

        // Search on copy_to target — should find doc with "search" in title
        let results = index.search(json!({"match": {"all_text": "search"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1, "copy_to target should contain title content");

        // Search on copy_to target — should find doc with "embedded" in body
        let results = index.search(json!({"match": {"all_text": "embedded"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1, "copy_to target should contain body content");

        // Search on copy_to target — "columnar" from body of doc 2
        let results = index.search(json!({"match": {"all_text": "columnar"}}), 10).unwrap();
        assert_eq!(results.total_hits.value, 1, "copy_to target should contain body of second doc");

        // all_text should NOT appear in _source
        let results = index.search(json!({"match": {"all_text": "search"}}), 10).unwrap();
        let src = results.hits[0].source.as_ref().unwrap();
        assert!(src.get("all_text").is_none(), "copy_to target should not be in _source");

        cleanup(&path);
    }

    #[test]
    fn document_crud() {
        let path = test_dir("crud");
        let mut index = Index::create_with_mapping(&path, test_schema()).unwrap();

        // Add with user-provided _id
        index.add(json!({"_id": "doc1", "title": "Hello", "status": "published"})).unwrap();
        index.add(json!({"_id": "doc2", "title": "World", "status": "draft"})).unwrap();
        index.commit().unwrap();

        // Get by ID
        let hit = index.get("doc1").unwrap().expect("doc1 should exist");
        assert_eq!(hit.source.unwrap()["title"], "Hello");

        // Get missing
        assert!(index.get("nonexistent").unwrap().is_none());

        // Count
        assert_eq!(index.count(json!({"match_all": {}})).unwrap(), 2);

        // Delete by ID
        assert!(index.delete("doc1").unwrap());
        index.commit().unwrap();
        assert!(index.get("doc1").unwrap().is_none());
        assert_eq!(index.count(json!({"match_all": {}})).unwrap(), 1);

        // Delete missing
        assert!(!index.delete("nonexistent").unwrap());

        // Update by ID
        index.add(json!({"_id": "doc3", "title": "Original", "status": "published"})).unwrap();
        index.commit().unwrap();
        assert!(index.update("doc3", json!({"title": "Updated"})).unwrap());
        index.commit().unwrap();
        let hit = index.get("doc3").unwrap().expect("doc3 should exist after update");
        assert_eq!(hit.source.as_ref().unwrap()["title"], "Updated");
        assert_eq!(hit.source.as_ref().unwrap()["status"], "published"); // preserved

        // Delete by query
        index.add(json!({"_id": "d1", "title": "draft one", "status": "draft"})).unwrap();
        index.add(json!({"_id": "d2", "title": "draft two", "status": "draft"})).unwrap();
        index.commit().unwrap();
        let deleted = index.delete_by_query(json!({"term": {"status": "draft"}})).unwrap();
        assert!(deleted >= 2);
        index.commit().unwrap();

        // Auto-generated IDs
        index.add(json!({"title": "Auto ID doc"})).unwrap();
        index.commit().unwrap();
        let results = index.search(json!({"match": {"title": "auto"}}), 1).unwrap();
        assert_eq!(results.total_hits.value, 1);

        cleanup(&path);
    }
}
