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

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

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

/// Read Tantivy index stats (segment count, doc count) from a directory without
/// acquiring write locks. Returns `None` if the directory doesn't exist or can't
/// be opened.
///
/// 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() {
        return None;
    }
    let index = Index::open_in_dir(dir).ok()?;
    let reader = index
        .reader_builder()
        .reload_policy(ReloadPolicy::Manual)
        .try_into()
        .ok()?;
    let searcher: tantivy::Searcher = reader.searcher();
    let segment_count = searcher.segment_readers().len();
    let doc_count = searcher
        .segment_readers()
        .iter()
        .map(|r| u64::from(r.num_docs()))
        .sum();
    Some((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 and field access.
/// Document insertion, search, and commit are added in subsequent phases.
#[allow(dead_code)] // Fields used by subsequent phases (insert, search, commit).
pub struct TextIndex {
    pub(crate) index: Index,
    pub(crate) writer: Mutex<IndexWriter>,
    pub(crate) reader: IndexReader,
    pub(crate) fields: Arc<TantivyFields>,
    pub(crate) config: TextIndexConfig,
    /// Maps `entity_id` -> `(segment_ord, doc_id)` for seek-based scoring.
    ///
    /// Rebuilt after every commit via [`rebuild_entity_map()`](Self::rebuild_entity_map).
    /// Used by [`score_candidates()`](super::collectors::score_candidates) to translate
    /// entity IDs into Tantivy doc addresses without a full search.
    pub(crate) entity_map: Arc<DashMap<u64, (u32, u32)>>,
}

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> {
        let (tv_schema, fields) = build_tantivy_schema(text_fields);

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

        let writer = index
            .writer(config.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.
        let mut merge_policy = LogMergePolicy::default();
        merge_policy.set_min_num_segments(4);
        merge_policy.set_max_docs_before_merge(5_000_000);
        merge_policy.set_del_docs_ratio_before_merge(0.3);
        writer.set_merge_policy(Box::new(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),
            config,
            entity_map: Arc::new(DashMap::new()),
        })
    }

    /// 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).
        let mut merge_policy = LogMergePolicy::default();
        merge_policy.set_min_num_segments(4);
        merge_policy.set_max_docs_before_merge(5_000_000);
        merge_policy.set_del_docs_ratio_before_merge(0.3);
        writer.set_merge_policy(Box::new(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),
            config: TextIndexConfig {
                index_dir: PathBuf::from("<ephemeral>"),
                heap_budget_bytes: heap_budget,
                commit_every_n_docs: 1000,
                commit_every_secs: 2,
            },
            entity_map: Arc::new(DashMap::new()),
        })
    }

    /// 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 with sequence 0.
    ///
    /// # 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(0)?;
        drop(w);
        Ok(())
    }

    /// Rebuild the Tantivy index from the entity store.
    ///
    /// Clears all existing documents, re-indexes every item provided, and
    /// commits with `last_seq` as the payload. Used for crash recovery and
    /// initial setup when the text index needs to be brought into sync with
    /// the entity store.
    ///
    /// # 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>)>,
        last_seq: u64,
    ) -> 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(last_seq)
    }

    /// 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
    }

    /// Rebuild the `entity_id -> (segment_ord, doc_id)` mapping.
    ///
    /// Scans all alive documents across every segment, reading the `entity_id`
    /// fast field to populate the map. Call this after every commit to keep the
    /// mapping current.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if the fast field cannot be read.
    pub fn rebuild_entity_map(&self) -> crate::Result<()> {
        self.entity_map.clear();
        let searcher = self.reader.searcher();
        for (seg_ord, segment_reader) in searcher.segment_readers().iter().enumerate() {
            let ff = segment_reader.fast_fields();
            let col = ff.u64("entity_id").map_err(|e| {
                TidalError::internal("text_rebuild_entity_map", format!("fast field: {e}"))
            })?;
            let col_vals = col.first_or_default_col(0);

            let seg_ord_u32 = seg_ord as u32;
            for doc_id in 0..segment_reader.max_doc() {
                if segment_reader.is_deleted(doc_id) {
                    continue;
                }
                let entity_id_val = col_vals.get_val(doc_id);
                self.entity_map.insert(entity_id_val, (seg_ord_u32, doc_id));
            }
        }
        Ok(())
    }

    /// 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) {
        let searcher = self.reader.searcher();
        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)
    }

    /// Look up a Tantivy `(segment_ord, doc_id)` for a given entity ID.
    ///
    /// Returns `None` if the entity is not in the current entity map. The map
    /// must be rebuilt after commits to stay current.
    #[must_use]
    pub fn entity_doc_address(&self, entity_id: EntityId) -> Option<(u32, u32)> {
        self.entity_map.get(&entity_id.as_u64()).map(|v| *v)
    }

    /// 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 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",
        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.clone(),
            ..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();
    }
}
