//! Item write/read operations and text index management on `TidalDb`.

use std::collections::HashMap;

use crate::schema::{EntityId, EntityKind, TidalError, Timestamp};
use crate::storage::vector::registry::{EmbeddingSlotState, EmbeddingSource, HnswParams};
use crate::storage::vector::{
    BruteForceIndex, QuantizationLevel, VectorIndexConfig, deserialize_embedding, insert_embedding,
};
use crate::storage::{Tag, encode_key};

use super::TidalDb;
use super::metadata::deserialize_metadata;

impl TidalDb {
    /// Write (or overwrite) item metadata and update in-memory indexes.
    ///
    /// This is the M2 replacement for `write_item` -- it persists metadata
    /// to storage AND inserts the entity into the bitmap, range, and universe
    /// indexes so it is discoverable by RETRIEVE queries.
    ///
    /// Recognized metadata keys for indexing:
    /// - `"category"` -> category bitmap index
    /// - `"format"` -> format bitmap index
    /// - `"creator_id"` -> creator bitmap index
    /// - `"tags"` -> tag bitmap index (comma-separated)
    /// - `"duration"` -> duration range index (seconds, parsed as u32)
    /// - `"created_at"` -> `created_at` range index (nanos, parsed as u64)
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no storage backend is wired.
    /// - `TidalError::Storage` on storage engine failure.
    #[allow(clippy::too_many_lines)]
    #[tracing::instrument(skip_all, fields(entity_id = id.as_u64()))]
    pub fn write_item_with_metadata(
        &self,
        id: EntityId,
        metadata: &HashMap<String, String>,
    ) -> crate::Result<()> {
        self.require_writeable("write_item_with_metadata")?;
        // Validate metadata size limits.
        const MAX_METADATA_KEYS: usize = 64;
        const MAX_KEY_BYTES: usize = 512;
        const MAX_VALUE_BYTES: usize = 8 * 1024; // 8 KB
        const MAX_TOTAL_BYTES: usize = 64 * 1024; // 64 KB

        if metadata.len() > MAX_METADATA_KEYS {
            let count = metadata.len();
            return Err(TidalError::invalid_input(format!(
                "metadata exceeds max key count: {count} > {MAX_METADATA_KEYS}"
            )));
        }
        let mut total = 0usize;
        for (k, v) in metadata {
            if k.len() > MAX_KEY_BYTES {
                let klen = k.len();
                return Err(TidalError::invalid_input(format!(
                    "metadata key too long: {klen} bytes > {MAX_KEY_BYTES}"
                )));
            }
            if v.len() > MAX_VALUE_BYTES {
                let vlen = v.len();
                return Err(TidalError::invalid_input(format!(
                    "metadata value too long: {vlen} bytes > {MAX_VALUE_BYTES}"
                )));
            }
            total += k.len() + v.len();
        }
        if total > MAX_TOTAL_BYTES {
            return Err(TidalError::invalid_input(format!(
                "metadata total size too large: {total} bytes > {MAX_TOTAL_BYTES}"
            )));
        }

        // Persist to storage.
        self.write_item(id, metadata)?;

        // Truncate entity ID to u32 for roaring bitmap. This limits the universe
        // to ~4 billion entities per instance, which is well beyond the single-node
        // target of 10M items.
        #[allow(clippy::cast_possible_truncation)]
        let id_u32 = id.as_u64() as u32;
        if id.as_u64() > u64::from(u32::MAX) {
            tracing::warn!(
                entity_id = id.as_u64(),
                "entity ID exceeds u32::MAX; universe bitmap entry will collide with a lower ID"
            );
        }

        // Insert into bitmap indexes.
        if let Some(val) = metadata.get("category") {
            self.category_index.insert(id_u32, val);
        }
        if let Some(val) = metadata.get("format") {
            self.format_index.insert(id_u32, val);
        }
        if let Some(val) = metadata.get("creator_id") {
            self.creator_index.insert(id_u32, val);
            // M3: populate creator-items bitmap for the `following` profile
            // and `unblocked` predicate.
            if let Ok(creator_id) = val.parse::<u64>() {
                self.creator_items.add_item(creator_id, id_u32);
            }
        }
        if let Some(tags) = metadata.get("tags") {
            for tag in tags.split(',') {
                let tag = tag.trim();
                if !tag.is_empty() {
                    self.tag_index.insert(id_u32, tag);
                }
            }
        }

        // Insert into range indexes.
        if let Some(val) = metadata.get("duration") {
            match val.parse::<u32>() {
                Ok(dur) => self.duration_index.insert(id_u32, dur),
                Err(e) => tracing::warn!(
                    entity_id = id.as_u64(),
                    value = %val,
                    error = %e,
                    "failed to parse 'duration' metadata; item will not be indexed by duration"
                ),
            }
        }
        let has_created_at =
            metadata
                .get("created_at")
                .is_some_and(|val| match val.parse::<u64>() {
                    Ok(ts) => {
                        self.created_at_index.insert(id_u32, ts);
                        true
                    }
                    Err(e) => {
                        tracing::warn!(
                            entity_id = id.as_u64(),
                            value = %val,
                            error = %e,
                            "failed to parse 'created_at' metadata; defaulting to current time"
                        );
                        false
                    }
                });
        if !has_created_at {
            // Default: use current time as created_at so the item is discoverable
            // by range queries and sortable by recency.
            self.created_at_index
                .insert(id_u32, Timestamp::now().as_nanos());
        }

        // Insert into universe bitmap.
        if let Ok(mut bm) = self.universe.write() {
            bm.insert(id_u32);
        }

        // Enqueue for text index (best-effort -- channel missing or dropped is non-fatal).
        if let Ok(guard) = self.text_tx.lock()
            && let Some(tx) = guard.as_ref()
        {
            let _ = tx.send(crate::text::PendingWrite {
                entity_id: id,
                metadata: metadata.clone(),
                seq: 0, // seq tracking is best-effort for now
                deleted: false,
            });
        }

        // M6p5: index title terms for autocomplete suggestions.
        if let Some(title) = metadata.get("title") {
            self.suggestion_index.index_title(title);
        }

        Ok(())
    }

    /// Returns the number of items in the universe bitmap.
    ///
    /// This counts items written via `write_item_with_metadata`, not raw
    /// storage entries.
    #[must_use]
    pub fn item_count(&self) -> u64 {
        self.universe.read().map_or(0, |bm| bm.len())
    }

    /// Read item metadata for a given entity ID.
    ///
    /// Returns `None` if the entity does not exist in storage.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no storage backend is wired.
    /// - `TidalError::Storage` on storage engine failure.
    pub fn get_item_metadata(
        &self,
        id: EntityId,
    ) -> crate::Result<Option<HashMap<String, String>>> {
        let storage = self.storage()?;
        let key = encode_key(id, Tag::Meta, b"");
        match storage.items_engine().get(&key) {
            Ok(Some(bytes)) => Ok(Some(deserialize_metadata(&bytes))),
            Ok(None) => Ok(None),
            Err(e) => Err(TidalError::from(e)),
        }
    }

    /// Write (or overwrite) a pre-computed embedding for an item entity.
    ///
    /// L2-normalizes the embedding, stores it in the item entity store (source
    /// of truth), and inserts it into the `(EntityKind::Item, "content")` HNSW
    /// slot. The slot is auto-registered if not yet present, using
    /// `embedding.len()` as the dimension count.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no storage backend is wired or lock is poisoned.
    /// - `TidalError::Storage` on storage engine failure.
    /// - `TidalError::Internal` if the embedding has zero norm.
    #[allow(clippy::significant_drop_tightening)] // lock must be held across insert_embedding call
    pub fn write_item_embedding(&self, id: EntityId, embedding: &[f32]) -> crate::Result<()> {
        self.require_writeable("write_item_embedding")?;
        let storage = self.storage()?;

        // Auto-register the item "content" slot if absent.
        {
            let mut registry = self.embedding_registry.write().map_err(|_| {
                TidalError::internal(
                    "write_item_embedding",
                    "embedding_registry write lock poisoned",
                )
            })?;
            if registry.get(EntityKind::Item, "content").is_none() {
                let config = VectorIndexConfig {
                    dimensions: embedding.len(),
                    ..VectorIndexConfig::default()
                };
                let state = EmbeddingSlotState {
                    index: Box::new(BruteForceIndex::new(config)),
                    dimensions: embedding.len(),
                    quantization: QuantizationLevel::F32,
                    source: EmbeddingSource::External,
                    params: HnswParams::default(),
                };
                registry
                    .register(EntityKind::Item, "content".to_string(), state)
                    .map_err(|e| {
                        TidalError::internal(
                            "write_item_embedding",
                            format!("slot registration failed: {e}"),
                        )
                    })?;
            }
        }

        // Insert into the HNSW index and store in entity engine.
        let dimensions = {
            let registry = self.embedding_registry.read().map_err(|_| {
                TidalError::internal(
                    "write_item_embedding",
                    "embedding_registry read lock poisoned",
                )
            })?;
            registry
                .get(EntityKind::Item, "content")
                .ok_or_else(|| {
                    TidalError::internal("write_item_embedding", "item content slot missing")
                })?
                .dimensions
        };
        {
            let registry = self.embedding_registry.read().map_err(|_| {
                TidalError::internal(
                    "write_item_embedding",
                    "embedding_registry read lock poisoned",
                )
            })?;
            let slot = registry.get(EntityKind::Item, "content").ok_or_else(|| {
                TidalError::internal("write_item_embedding", "item content slot missing")
            })?;
            insert_embedding(
                id,
                "content",
                embedding,
                dimensions,
                slot.index.as_ref(),
                storage.items_engine(),
            )
            .map_err(|e| TidalError::internal("write_item_embedding", format!("{e}")))?;
        }

        Ok(())
    }

    /// Read the stored content embedding for an item.
    ///
    /// Returns `None` if no embedding has been written for this item.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no storage backend is wired.
    /// - `TidalError::Storage` on storage engine failure.
    pub fn read_item_embedding(&self, id: EntityId) -> crate::Result<Option<Vec<f32>>> {
        let storage = self.storage()?;
        let key = crate::storage::vector::embedding_store_key(id, "content");
        match storage.items_engine().get(&key) {
            Ok(Some(bytes)) => {
                let v = deserialize_embedding(&bytes)
                    .map_err(|e| TidalError::internal("read_item_embedding", format!("{e}")))?;
                Ok(Some(v))
            }
            Ok(None) => Ok(None),
            Err(e) => Err(TidalError::from(e)),
        }
    }

    /// Reload the text index reader to pick up recent commits.
    ///
    /// Useful in tests and benchmarks after writing a batch of items, to make
    /// them visible to `search()`. On-disk databases use
    /// `ReloadPolicy::OnCommitWithDelay` and do not require this call, but
    /// calling it is always safe. Ephemeral databases use `Manual` reload
    /// policy and require an explicit reload to see new documents.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if Tantivy fails to reload the reader.
    pub fn reload_text_index(&self) -> crate::Result<()> {
        self.text_index
            .as_ref()
            .map_or(Ok(()), |idx| idx.reload_reader())
    }

    /// Return the current number of Tantivy segments in the item text index.
    ///
    /// Used to verify that the `LogMergePolicy` keeps segments < 20 at steady
    /// state after large-scale ingestion. Returns 0 if no text index is open.
    #[must_use]
    pub fn text_segment_count(&self) -> usize {
        self.text_index
            .as_ref()
            .map_or(0, |idx| idx.segment_count())
    }

    /// Return the current number of Tantivy segments in the creator text index.
    ///
    /// Returns 0 if no creator text index is open.
    #[must_use]
    pub fn creator_text_segment_count(&self) -> usize {
        self.creator_text_index
            .as_ref()
            .map_or(0, |idx| idx.segment_count())
    }

    /// Force the creator text index reader to reload after a commit.
    ///
    /// In production this is automatic. In tests with ephemeral mode, call
    /// this after writing creators and sleeping >=2.5s to see them in search
    /// results (or after sleeping 500ms when writing >=1000 creators).
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if Tantivy's reader reload fails.
    pub fn reload_creator_text_index(&self) -> crate::Result<()> {
        self.creator_text_index
            .as_ref()
            .map_or(Ok(()), |idx| idx.reload_reader())
    }

    /// Force a synchronous commit of all pending item text index writes.
    ///
    /// Sends a flush request to the background text syncer thread and blocks
    /// until the syncer acknowledges the commit. Then reloads the reader so
    /// the committed documents are immediately visible to `search()`.
    ///
    /// Replaces the sleep-based synchronization pattern (`sleep(2.5s)` +
    /// `reload_text_index()`) with a deterministic, channel-based protocol.
    /// Useful in tests and benchmarks.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if Tantivy fails to reload the reader.
    pub fn flush_text_index(&self) -> crate::Result<()> {
        if let Some(ref flush_tx) = self.text_flush_tx {
            let (ack_tx, ack_rx) = crossbeam::channel::bounded(1);
            let _ = flush_tx.send(ack_tx);
            let _ = ack_rx.recv_timeout(std::time::Duration::from_secs(10));
        }
        self.reload_text_index()
    }

    /// Force a synchronous commit of all pending creator text index writes.
    ///
    /// Same protocol as [`flush_text_index`](Self::flush_text_index) but for
    /// the creator text syncer. Blocks until the background syncer acknowledges
    /// the commit, then reloads the creator text reader.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if Tantivy fails to reload the reader.
    pub fn flush_creator_text_index(&self) -> crate::Result<()> {
        if let Some(ref flush_tx) = self.creator_text_flush_tx {
            let (ack_tx, ack_rx) = crossbeam::channel::bounded(1);
            let _ = flush_tx.send(ack_tx);
            let _ = ack_rx.recv_timeout(std::time::Duration::from_secs(10));
        }
        self.reload_creator_text_index()
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::collections::HashMap;

    use crate::TidalDb;
    use crate::schema::EntityId;

    #[test]
    fn write_item_rejects_oversized_metadata_value() {
        let db = TidalDb::builder().ephemeral().open().unwrap();
        let mut meta = HashMap::new();
        // Insert a value that exceeds the 8 KB per-value limit.
        meta.insert("big_key".to_string(), "x".repeat(9 * 1024));
        let err = db
            .write_item_with_metadata(EntityId::new(1), &meta)
            .unwrap_err();
        assert!(
            err.to_string().contains("metadata value too long"),
            "expected value-too-long error, got: {err}"
        );
        db.close().unwrap();
    }

    #[test]
    fn write_item_rejects_too_many_metadata_keys() {
        let db = TidalDb::builder().ephemeral().open().unwrap();
        let mut meta = HashMap::new();
        for i in 0..65 {
            meta.insert(format!("key_{i}"), "val".to_string());
        }
        let err = db
            .write_item_with_metadata(EntityId::new(1), &meta)
            .unwrap_err();
        assert!(
            err.to_string().contains("max key count"),
            "expected key-count error, got: {err}"
        );
        db.close().unwrap();
    }

    #[test]
    fn write_item_rejects_oversized_total_metadata() {
        let db = TidalDb::builder().ephemeral().open().unwrap();
        let mut meta = HashMap::new();
        // 10 keys x 7 KB values = 70 KB > 64 KB limit.
        for i in 0..10 {
            meta.insert(format!("k{i}"), "x".repeat(7 * 1024));
        }
        let err = db
            .write_item_with_metadata(EntityId::new(1), &meta)
            .unwrap_err();
        assert!(
            err.to_string().contains("total size too large"),
            "expected total-size error, got: {err}"
        );
        db.close().unwrap();
    }
}
