use std::{
    collections::HashMap,
    path::{Path, PathBuf},
    sync::{
        Arc, Mutex,
        atomic::{AtomicBool, Ordering},
    },
};

use tantivy::{
    Index, IndexReader, IndexWriter, ReloadPolicy, merge_policy::LogMergePolicy,
    schema as tv_schema,
};

use crate::{
    TidalError,
    schema::{EntityId, TextFieldDef, TextFieldType},
};

/// Canonical column name for the always-present `entity_id` fast field.
///
/// Defined exactly once here and shared by both the writer side
/// ([`build_tantivy_schema`], which creates the column) and the read side
/// ([`AllScoresCollector`](crate::text::AllScoresCollector), which resolves the
/// column from its typed `Field` handle and asserts it against this name in
/// tests). Centralizing the literal keeps the schema and every reader from
/// drifting onto different strings.
pub(crate) const ENTITY_ID_FIELD: &str = "entity_id";

/// Minimum number of segments the [`LogMergePolicy`] will merge in one pass.
///
/// Holding this at 4 keeps merges off the critical path for small commits
/// (1–3 fresh segments stay un-merged) while still collapsing the index once a
/// handful of segments accumulate. Combined with [`MERGE_MAX_DOCS_BEFORE_MERGE`]
/// it keeps the steady-state segment count below ~20 after a 1M-item ingest.
const MERGE_MIN_NUM_SEGMENTS: usize = 4;

/// Upper bound on the document count of a segment that the merge policy will
/// still consider for merging.
///
/// Above this size a segment is "frozen": merging it would copy millions of
/// docs for little reduction in segment count, so the policy leaves it alone.
/// 5M docs is comfortably larger than any single commit batch yet small enough
/// that a fully-loaded index settles into a few large frozen segments.
const MERGE_MAX_DOCS_BEFORE_MERGE: usize = 5_000_000;

/// Fraction of deleted documents in a segment that triggers a reclaiming merge.
///
/// Deletes in Tantivy are tombstones until a merge rewrites the segment. At 30%
/// dead docs the space/scan overhead is worth a rewrite; below it the merge cost
/// is not justified. This bounds read amplification from churned (re-indexed or
/// deleted) items without merging on every small delete.
const MERGE_DEL_DOCS_RATIO_BEFORE_MERGE: f32 = 0.3;

/// Read Tantivy index stats (segment count, doc count) from a directory without
/// acquiring write locks.
///
/// Returns `None` in two distinct situations, which this function deliberately
/// does NOT collapse onto a single silent path:
///
/// - **Directory absent** -> `None`, no log. A store that never indexed any text
///   has no directory; that is the healthy-empty case, not a fault. Callers that
///   want to render "empty" map this `None` to `(0, 0)` themselves
///   (`read_text_index_stats` in `tidalctl`, `unwrap_or((0, 0))` in tests).
/// - **Directory present but unreadable** (corruption, partial write, Tantivy
///   open/reader failure) -> `None`, after a `tracing::warn!` carrying the path
///   and underlying error. This is a real degradation that must be observable;
///   silently aliasing it onto the absent case would let an operator misread a
///   broken index as "never indexed".
///
/// The shared `None` return is forced by the public, additive-only signature
/// (`tidalctl` and the engine integration tests bind it), so the absent/unreadable
/// distinction is surfaced via logging rather than the return type.
///
/// This is a standalone function that does not require a [`TextIndex`] instance.
/// Useful for CLI tools (e.g. `tidalctl diagnostics`) that need to inspect
/// index health without opening a full `TidalDb`.
#[must_use]
pub fn read_stats_from_dir(dir: &Path) -> Option<(usize, u64)> {
    if !dir.exists() {
        // Absent: healthy-empty, not a fault. Stay silent.
        return None;
    }
    // Present but failing to open/read is a degradation, not absence — log it so
    // it is not silently conflated with the never-indexed case above.
    let index = match Index::open_in_dir(dir) {
        Ok(index) => index,
        Err(e) => {
            tracing::warn!(
                dir = %dir.display(),
                error = %e,
                "read_stats_from_dir: text index directory present but unreadable (tantivy open failed)"
            );
            return None;
        }
    };
    let reader: IndexReader = match index
        .reader_builder()
        .reload_policy(ReloadPolicy::Manual)
        .try_into()
    {
        Ok(reader) => reader,
        Err(e) => {
            tracing::warn!(
                dir = %dir.display(),
                error = %e,
                "read_stats_from_dir: text index opened but reader build failed"
            );
            return None;
        }
    };
    Some(searcher_stats(&reader.searcher()))
}

/// Count segments and alive documents in a searcher snapshot.
///
/// The single source of truth for the `(segment_count, doc_count)` shape used by
/// both [`read_stats_from_dir`] (off a freshly opened directory) and
/// [`TextIndex::index_stats`] (off the live reader). The doc count excludes
/// tombstoned (deleted-but-not-yet-merged) documents, since `num_docs` already
/// reports alive docs per segment.
fn searcher_stats(searcher: &tantivy::Searcher) -> (usize, u64) {
    let segment_count = searcher.segment_readers().len();
    let doc_count = searcher
        .segment_readers()
        .iter()
        .map(|r| u64::from(r.num_docs()))
        .sum();
    (segment_count, doc_count)
}

/// Configuration for the text index.
///
/// Controls the on-disk location, memory budget for Tantivy's indexing heap,
/// and auto-commit thresholds. The defaults are tuned for a single-node
/// deployment handling moderate write throughput.
#[derive(Debug, Clone)]
pub struct TextIndexConfig {
    /// Directory where the Tantivy index segments are stored.
    pub index_dir: PathBuf,
    /// Memory budget (bytes) for the Tantivy `IndexWriter` heap.
    /// Larger budgets allow more documents to be buffered before flushing.
    pub heap_budget_bytes: usize,
    /// Number of documents after which the writer auto-commits.
    pub commit_every_n_docs: usize,
    /// Wall-clock seconds between auto-commits.
    pub commit_every_secs: u64,
}

impl Default for TextIndexConfig {
    fn default() -> Self {
        Self {
            index_dir: PathBuf::from("data/text_index"),
            heap_budget_bytes: 50 * 1024 * 1024,
            commit_every_n_docs: 1000,
            commit_every_secs: 2,
        }
    }
}

/// Resolved Tantivy fields from the tidalDB schema.
///
/// Every text index always has an `entity_id` field (u64, FAST|STORED) for
/// joining back to the entity store. Additional fields are created from the
/// schema's `TextFieldDef` declarations.
pub struct TantivyFields {
    /// The `entity_id` field, always present (u64, FAST | STORED).
    pub entity_id: tv_schema::Field,
    /// Mapped text fields: `(metadata_key, tantivy_field, field_type)`.
    pub text_fields: Vec<(String, tv_schema::Field, TextFieldType)>,
}

/// The text index. Wraps Tantivy's `Index`, `IndexWriter`, and `IndexReader`.
///
/// The public interface is intentionally narrow: open/close, writes, search,
/// commit, and field access.
pub struct TextIndex {
    pub(crate) index: Index,
    pub(crate) writer: Mutex<IndexWriter>,
    pub(crate) reader: IndexReader,
    pub(crate) fields: Arc<TantivyFields>,
    /// Liveness flag for the background [`TextIndexSyncer`](super::TextIndexSyncer).
    ///
    /// Starts `true`. The syncer sets it `false` if it hits an unrecoverable
    /// error and exits its loop, or if it gives up retrying a transient commit
    /// failure. The DB reads it via [`is_healthy()`](Self::is_healthy) to surface
    /// a degraded text-search state (`health_check`, `flush_text_index`) instead
    /// of silently serving results that diverge from the entity store.
    ///
    /// Shared with the syncer thread through the index `Arc`, so a single
    /// allocation backs both the writer's view and every reader's view.
    healthy: Arc<AtomicBool>,
}

impl TextIndex {
    /// Open or create an on-disk text index at `config.index_dir`.
    ///
    /// If the directory already contains a valid Tantivy index, it is opened.
    /// Otherwise a new index is created. The writer is allocated with the
    /// configured heap budget, and the reader reloads on commit with a
    /// short delay.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if Tantivy fails to open/create the
    /// index, allocate the writer, or build the reader.
    #[tracing::instrument(skip(text_fields), fields(dir = %config.index_dir.display()))]
    pub fn open(config: TextIndexConfig, text_fields: &[TextFieldDef]) -> crate::Result<Self> {
        // Consume `config` by value: callers hand the index ownership of its
        // settings. `index_dir` is moved out (rather than borrowed) so the rest
        // of the function operates on owned data and the caller's clone is freed.
        let TextIndexConfig {
            index_dir,
            heap_budget_bytes,
            ..
        } = config;

        let (tv_schema, fields) = build_tantivy_schema(text_fields);

        let index = if index_dir.exists() {
            Index::open_in_dir(&index_dir).map_err(|e| {
                TidalError::internal("text_index_open", format!("tantivy open: {e}"))
            })?
        } else {
            std::fs::create_dir_all(&index_dir).map_err(|e| {
                TidalError::internal("text_index_open", format!("create index dir: {e}"))
            })?;
            Index::create_in_dir(&index_dir, tv_schema).map_err(|e| {
                TidalError::internal("text_index_open", format!("tantivy create: {e}"))
            })?
        };

        let writer = index
            .writer(heap_budget_bytes)
            .map_err(|e| TidalError::internal("text_index_open", format!("tantivy writer: {e}")))?;

        // Configure log merge policy for steady-state segment count < 20.
        writer.set_merge_policy(Box::new(steady_state_merge_policy()));

        let reader: IndexReader = index
            .reader_builder()
            .reload_policy(ReloadPolicy::OnCommitWithDelay)
            .try_into()
            .map_err(|e| TidalError::internal("text_index_open", format!("tantivy reader: {e}")))?;

        Ok(Self {
            index,
            writer: Mutex::new(writer),
            reader,
            fields: Arc::new(fields),
            healthy: Arc::new(AtomicBool::new(true)),
        })
    }

    /// Create an ephemeral (in-RAM) text index for tests and benchmarks.
    ///
    /// Uses `ReloadPolicy::Manual` so tests must explicitly call `reader.reload()`
    /// after committing to see new documents. Uses the minimum viable heap budget
    /// (15 MB).
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if Tantivy fails to allocate the writer
    /// or build the reader.
    #[tracing::instrument(skip(text_fields))]
    pub fn ephemeral(text_fields: &[TextFieldDef]) -> crate::Result<Self> {
        let (tv_schema, fields) = build_tantivy_schema(text_fields);

        let index = Index::create_in_ram(tv_schema);

        let heap_budget = 15 * 1024 * 1024;
        let writer = index.writer(heap_budget).map_err(|e| {
            TidalError::internal("text_index_ephemeral", format!("tantivy writer: {e}"))
        })?;

        // Configure log merge policy (same settings as on-disk).
        writer.set_merge_policy(Box::new(steady_state_merge_policy()));

        let reader: IndexReader = index
            .reader_builder()
            .reload_policy(ReloadPolicy::Manual)
            .try_into()
            .map_err(|e| {
                TidalError::internal("text_index_ephemeral", format!("tantivy reader: {e}"))
            })?;

        Ok(Self {
            index,
            writer: Mutex::new(writer),
            reader,
            fields: Arc::new(fields),
            healthy: Arc::new(AtomicBool::new(true)),
        })
    }

    /// Shut down the text index, waiting for background merge threads to finish.
    ///
    /// This consumes the `TextIndex`. After this call the index is no longer
    /// usable. All pending merges are completed before returning.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if the writer mutex is poisoned or if
    /// Tantivy merge threads fail.
    #[tracing::instrument(skip(self))]
    pub fn close(self) -> crate::Result<()> {
        let writer = self.writer.into_inner().map_err(|e| {
            TidalError::internal("text_index_close", format!("writer mutex poisoned: {e}"))
        })?;
        writer.wait_merging_threads().map_err(|e| {
            TidalError::internal("text_index_close", format!("tantivy merge threads: {e}"))
        })?;
        Ok(())
    }

    /// Acquire the writer lock and return a [`TextIndexWriter`] for batch operations.
    ///
    /// The returned `TextIndexWriter` holds the mutex lock for its lifetime.
    /// Only one `TextIndexWriter` can exist at a time per `TextIndex`.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if the writer mutex is poisoned.
    pub fn writer_guard(&self) -> crate::Result<crate::text::writer::TextIndexWriter<'_>> {
        let writer = self.writer.lock().map_err(|e| {
            TidalError::internal("text_writer_guard", format!("writer mutex poisoned: {e}"))
        })?;
        Ok(crate::text::writer::TextIndexWriter {
            writer,
            fields: &self.fields,
        })
    }

    /// Delete all documents from the index and commit immediately.
    ///
    /// Convenience method for the rebuild use case. Acquires the writer lock,
    /// deletes all documents, and commits.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` on writer mutex poisoning or commit failure.
    pub fn delete_all(&self) -> crate::Result<()> {
        let mut w = self.writer_guard()?;
        w.delete_all()?;
        w.commit()?;
        drop(w);
        Ok(())
    }

    /// Rebuild the Tantivy index from the entity store.
    ///
    /// Clears all existing documents, re-indexes every item provided, and
    /// commits. The durable entity store is the source of truth; this is the
    /// crash-recovery primitive the DB calls unconditionally at open to bring a
    /// derived (and possibly lagging) text index back into sync with that store
    /// — exactly as the vector index is rebuilt from the durable embeddings.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` on writer mutex poisoning, document
    /// insertion failure, or commit failure.
    pub fn rebuild_from(
        &self,
        items: impl Iterator<Item = (EntityId, HashMap<String, String>)>,
    ) -> crate::Result<()> {
        let mut writer = self.writer_guard()?;
        writer.delete_all()?;
        for (entity_id, metadata) in items {
            writer.index_item(entity_id, &metadata)?;
        }
        writer.commit()
    }

    /// Create a [`TextQueryParser`](crate::text::query::TextQueryParser) configured
    /// for this index.
    ///
    /// The parser uses `TextFieldType::Text` fields as default search targets and
    /// AND as the default conjunction mode.
    #[must_use]
    pub fn query_parser(&self) -> crate::text::query::TextQueryParser {
        crate::text::query::TextQueryParser::new(&self.index, &self.fields)
    }

    /// The resolved Tantivy field handles for this index.
    #[must_use]
    pub const fn fields(&self) -> &Arc<TantivyFields> {
        &self.fields
    }

    /// Whether the background syncer is still healthy.
    ///
    /// Returns `true` while the [`TextIndexSyncer`](super::TextIndexSyncer) is
    /// draining its channel and committing normally. Returns `false` once the
    /// syncer has set the flag on an unrecoverable error or after exhausting its
    /// retry budget for a transient commit failure.
    ///
    /// `Acquire` ordering pairs with the syncer's `Release` store so a caller
    /// that observes `false` also observes everything the syncer wrote before it
    /// gave up. The DB consults this to surface a degraded text-search state.
    #[must_use]
    pub fn is_healthy(&self) -> bool {
        // Acquire: see the syncer's pre-failure writes if we observe `false`.
        self.healthy.load(Ordering::Acquire)
    }

    /// Clone the shared health flag for the background syncer to own.
    ///
    /// The returned `Arc` aliases the same `AtomicBool` that [`is_healthy()`](Self::is_healthy)
    /// reads, so a `false` store from the syncer is immediately visible to the DB.
    /// Intended for the `open_text_syncer`/`spawn` wiring and tests that drive the
    /// syncer directly.
    #[must_use]
    pub(crate) fn health_flag(&self) -> Arc<AtomicBool> {
        Arc::clone(&self.healthy)
    }

    /// Return the current number of segments in the index.
    ///
    /// Reads from the current reader snapshot. Used to verify that the
    /// `LogMergePolicy` is keeping segment count below the target (< 20) at
    /// steady state after 1M-item ingestion.
    #[must_use]
    pub fn segment_count(&self) -> usize {
        self.reader.searcher().segment_readers().len()
    }

    /// Return the number of segments and total indexed (alive) documents.
    ///
    /// Reads from the current `IndexReader` snapshot. Thread-safe. The doc
    /// count excludes deleted documents within each segment.
    #[must_use]
    pub fn index_stats(&self) -> (usize, u64) {
        searcher_stats(&self.reader.searcher())
    }

    /// Return a [`tantivy::Searcher`] over the current committed state.
    ///
    /// The searcher is a lightweight snapshot — it does not see documents
    /// committed after it was created. Call [`reload_reader`](Self::reload_reader)
    /// first to pick up recent commits.
    #[must_use]
    pub fn searcher(&self) -> tantivy::Searcher {
        self.reader.searcher()
    }

    /// Force the reader to reload the latest committed segments.
    ///
    /// In production, the reader reloads automatically after each commit
    /// (with a short delay). Call this explicitly in tests or benchmarks
    /// that need immediate consistency after a manual commit.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if Tantivy fails to reload the reader.
    pub fn reload_reader(&self) -> crate::Result<()> {
        self.reader
            .reload()
            .map_err(|e| TidalError::internal("text_reload_reader", format!("reader reload: {e}")))
    }
}

/// Build the shared [`LogMergePolicy`] used by both on-disk and ephemeral
/// indexes.
///
/// Centralizing this keeps `open()` and `ephemeral()` byte-for-byte identical in
/// their merge behavior — a prior duplication that risked the two paths drifting.
/// The constants are documented at module scope; see [`MERGE_MIN_NUM_SEGMENTS`],
/// [`MERGE_MAX_DOCS_BEFORE_MERGE`], and [`MERGE_DEL_DOCS_RATIO_BEFORE_MERGE`].
fn steady_state_merge_policy() -> LogMergePolicy {
    let mut policy = LogMergePolicy::default();
    policy.set_min_num_segments(MERGE_MIN_NUM_SEGMENTS);
    policy.set_max_docs_before_merge(MERGE_MAX_DOCS_BEFORE_MERGE);
    policy.set_del_docs_ratio_before_merge(MERGE_DEL_DOCS_RATIO_BEFORE_MERGE);
    policy
}

/// Build a Tantivy schema and resolved field handles from tidalDB text field
/// definitions.
///
/// Always adds an `entity_id` field (u64, FAST | STORED). Then for each
/// `TextFieldDef`:
/// - `TextFieldType::Text` -> `TEXT | STORED` (tokenized, searchable)
/// - `TextFieldType::Keyword` -> `STRING | STORED` (raw, exact-match only)
fn build_tantivy_schema(text_fields: &[TextFieldDef]) -> (tv_schema::Schema, TantivyFields) {
    let mut builder = tv_schema::Schema::builder();

    let entity_id_field = builder.add_u64_field(
        ENTITY_ID_FIELD,
        tv_schema::INDEXED | tv_schema::FAST | tv_schema::STORED,
    );

    let mut resolved = Vec::with_capacity(text_fields.len());
    for def in text_fields {
        let field = match def.field_type {
            TextFieldType::Text => {
                builder.add_text_field(&def.key, tv_schema::TEXT | tv_schema::STORED)
            }
            TextFieldType::Keyword => {
                builder.add_text_field(&def.key, tv_schema::STRING | tv_schema::STORED)
            }
        };
        resolved.push((def.key.clone(), field, def.field_type.clone()));
    }

    let schema = builder.build();
    let fields = TantivyFields {
        entity_id: entity_id_field,
        text_fields: resolved,
    };

    (schema, fields)
}

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

    fn sample_text_fields() -> Vec<TextFieldDef> {
        vec![
            TextFieldDef {
                key: "title".to_owned(),
                field_type: TextFieldType::Text,
            },
            TextFieldDef {
                key: "description".to_owned(),
                field_type: TextFieldType::Text,
            },
            TextFieldDef {
                key: "category".to_owned(),
                field_type: TextFieldType::Keyword,
            },
        ]
    }

    #[test]
    fn ephemeral_creates_valid_index() {
        let fields = sample_text_fields();
        let idx = TextIndex::ephemeral(&fields).unwrap();

        // Verify all three text fields plus entity_id are resolved.
        assert_eq!(idx.fields().text_fields.len(), 3);

        // Close cleanly.
        idx.close().unwrap();
    }

    #[test]
    fn open_and_close_on_disk() {
        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join("text_index");

        let config = TextIndexConfig {
            index_dir: dir,
            ..TextIndexConfig::default()
        };

        // First open: creates.
        let fields = sample_text_fields();
        let idx = TextIndex::open(config.clone(), &fields).unwrap();
        idx.close().unwrap();

        // Second open: reopens existing.
        let idx2 = TextIndex::open(config, &fields).unwrap();
        idx2.close().unwrap();
    }

    #[test]
    fn schema_has_entity_id_field() {
        let idx = TextIndex::ephemeral(&[]).unwrap();
        let tv_schema = idx.index.schema();
        let entity_field = tv_schema.get_field("entity_id");
        assert!(entity_field.is_ok(), "entity_id field must exist in schema");
        idx.close().unwrap();
    }

    #[test]
    fn text_fields_use_correct_options() {
        let fields = vec![TextFieldDef {
            key: "body".to_owned(),
            field_type: TextFieldType::Text,
        }];
        let idx = TextIndex::ephemeral(&fields).unwrap();
        let tv_schema = idx.index.schema();

        let body_field = tv_schema.get_field("body").unwrap();
        let entry = tv_schema.get_field_entry(body_field);

        // TEXT fields are indexed (searchable) and stored.
        assert!(entry.is_indexed(), "TEXT field must be indexed");
        assert!(entry.is_stored(), "TEXT field must be stored");

        idx.close().unwrap();
    }

    #[test]
    fn keyword_fields_use_correct_options() {
        let fields = vec![TextFieldDef {
            key: "lang".to_owned(),
            field_type: TextFieldType::Keyword,
        }];
        let idx = TextIndex::ephemeral(&fields).unwrap();
        let tv_schema = idx.index.schema();

        let lang_field = tv_schema.get_field("lang").unwrap();
        let entry = tv_schema.get_field_entry(lang_field);

        // STRING fields are indexed but NOT tokenized (raw).
        assert!(entry.is_indexed(), "STRING field must be indexed");
        assert!(entry.is_stored(), "STRING field must be stored");

        idx.close().unwrap();
    }

    #[test]
    fn empty_text_fields_is_valid() {
        // An index with zero text fields should still work -- just entity_id.
        let idx = TextIndex::ephemeral(&[]).unwrap();
        assert_eq!(idx.fields().text_fields.len(), 0);

        let tv_schema = idx.index.schema();
        // Only entity_id exists.
        assert!(tv_schema.get_field("entity_id").is_ok());

        idx.close().unwrap();
    }

    #[test]
    fn index_stats_empty() {
        let idx = TextIndex::ephemeral(&sample_text_fields()).unwrap();
        let (segments, docs) = idx.index_stats();
        assert_eq!(docs, 0);
        // An empty ephemeral index may have 0 or 1 segments.
        assert!(segments <= 1);
        idx.close().unwrap();
    }

    #[test]
    fn index_stats_consistent_with_segment_count() {
        let idx = TextIndex::ephemeral(&sample_text_fields()).unwrap();
        let (segments, _docs) = idx.index_stats();
        assert_eq!(segments, idx.segment_count());
        idx.close().unwrap();
    }

    #[test]
    fn send_sync_check() {
        fn is_send_sync<T: Send + Sync>() {}
        is_send_sync::<TextIndex>();
    }

    #[test]
    fn fields_accessor_returns_arc() {
        let idx = TextIndex::ephemeral(&sample_text_fields()).unwrap();
        let f1 = idx.fields().clone();
        let f2 = idx.fields().clone();
        // Both Arcs point to the same allocation.
        assert!(Arc::ptr_eq(&f1, &f2));
        idx.close().unwrap();
    }

    /// Regression: `read_stats_from_dir` must distinguish an **absent** directory
    /// (healthy-empty, silent `None`) from a **present-but-unreadable** one
    /// (degradation). Both still return `None` (the public signature is fixed by
    /// external callers), but the unreadable case is logged rather than silently
    /// conflated with absence. This asserts the three observable cases:
    /// absent -> `None`, valid index -> `Some(stats)`, present-but-not-an-index
    /// -> `None` (the path that now logs).
    #[test]
    fn read_stats_distinguishes_absent_from_unreadable() {
        let tmp = tempfile::tempdir().unwrap();

        // 1. Absent directory: None (healthy-empty), no fault.
        let absent = tmp.path().join("never_indexed");
        assert!(
            read_stats_from_dir(&absent).is_none(),
            "absent directory must read as None"
        );

        // 2. A real, committed on-disk index: Some(stats).
        let valid = tmp.path().join("valid_index");
        {
            let cfg = TextIndexConfig {
                index_dir: valid.clone(),
                ..TextIndexConfig::default()
            };
            let idx = TextIndex::open(cfg, &sample_text_fields()).unwrap();
            idx.close().unwrap();
        }
        assert!(
            read_stats_from_dir(&valid).is_some(),
            "a valid on-disk index must read as Some(stats)"
        );

        // 3. Present-but-unreadable: a directory that exists but holds no valid
        //    Tantivy index (no meta.json). This now travels the logged
        //    `Index::open_in_dir` error branch instead of the silent absent path,
        //    yet still returns None for the public contract.
        let garbage = tmp.path().join("not_an_index");
        std::fs::create_dir_all(&garbage).unwrap();
        std::fs::write(garbage.join("stray.txt"), b"not a tantivy index").unwrap();
        assert!(
            garbage.exists(),
            "precondition: the unreadable directory must exist"
        );
        assert!(
            read_stats_from_dir(&garbage).is_none(),
            "a present-but-unreadable directory must read as None (and is logged)"
        );
    }
}
