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

use std::collections::HashMap;

use super::{TidalDb, metadata::deserialize_metadata};
use crate::{
    schema::{EntityId, EntityKind, TidalError, Timestamp},
    storage::{Tag, encode_key, vector::deserialize_embedding},
};

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.
    ///
    /// # Item-universe limit
    ///
    /// The in-memory candidate-generation indexes (universe bitmap, category /
    /// format / creator / tag bitmaps, range indexes) are keyed on a `u32`
    /// entity ID, which bounds the item universe to `u32::MAX` (~4 billion)
    /// items per instance. This is well beyond the single-node target of 10M
    /// items, but it is a hard contract: an `EntityId` whose `as_u64()` exceeds
    /// `u32::MAX` would alias a lower ID in every bitmap, silently colliding
    /// query results across distinct items. Such an ID is therefore rejected at
    /// the write boundary with `TidalError::invalid_input` rather than truncated
    /// — embedders must allocate dense item IDs within the `u32` range. If a
    /// larger universe is ever required, widen the bitmap key type behind the
    /// index abstraction instead of relaxing this guard.
    ///
    /// 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::InvalidInput` if `id.as_u64()` exceeds the `u32`
    ///   item-universe limit.
    /// - `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")?;

        // Enforce the u32 item-universe limit BEFORE any durable write. The
        // in-memory candidate-generation indexes key on a u32 id, so an id past
        // u32::MAX would alias a lower id in every bitmap (silent cross-id
        // collision in query results). Reject it here rather than truncate so a
        // rejected item never lands in storage in an unindexable state. The
        // `signal_with_context` durable-row write path applies the same up-front
        // rejection, and the `Hide` relationship path routes through the
        // observable `narrow_item_slot` helper, so every item-side narrowing is
        // now either rejected or logged — no silent truncation remains.
        if id.as_u64() > u64::from(u32::MAX) {
            let raw = id.as_u64();
            return Err(TidalError::invalid_input(format!(
                "item entity id {raw} exceeds the u32 item-universe limit ({}); \
                 allocate dense item IDs within the u32 range",
                u32::MAX
            )));
        }

        // 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}"
            )));
        }

        // Materialize a defaulted `created_at` INTO the persisted metadata when
        // absent or unparseable, then both persist and index from this same map.
        // Persisting the default (rather than only inserting it into the live
        // range index) is what lets the restart rebuild reproduce the identical
        // recency ordering: the rebuild reads `created_at` from metadata and
        // there is no second, divergent code path. `Cow` avoids cloning the
        // common case where `created_at` is already a valid value.
        let needs_default = metadata
            .get("created_at")
            .is_none_or(|v| v.parse::<u64>().is_err());
        let stored_metadata: std::borrow::Cow<'_, HashMap<String, String>> = if needs_default {
            let mut m = metadata.clone();
            if let Some(bad) = m.get("created_at") {
                tracing::warn!(
                    entity_id = id.as_u64(),
                    value = %bad,
                    "unparseable 'created_at' metadata; defaulting to current time"
                );
            }
            m.insert(
                "created_at".to_string(),
                Timestamp::now().as_nanos().to_string(),
            );
            std::borrow::Cow::Owned(m)
        } else {
            std::borrow::Cow::Borrowed(metadata)
        };

        // Read the PRIOR persisted metadata before the durable write overwrites
        // it, so the re-index path below can scrub the entity's old index entries
        // on an overwrite (W22). `None` on a first write — the scrub is then
        // skipped. A single point lookup, dwarfed by the durable write that
        // follows.
        let prior_metadata = self.get_item_metadata(id)?;

        // Persist the (possibly augmented) metadata to storage. This is the raw
        // durable-write step; all indexing happens below via the shared path.
        self.persist_item_metadata(id, &stored_metadata)?;

        // Narrow the entity ID to u32 for the roaring bitmap / range indexes.
        // The boundary guard above already rejected any id > u32::MAX, so this
        // cast is lossless: no truncation, no cross-id collision is possible.
        let id_u32 = id.as_u64() as u32;

        // Index via the SAME shared path the restart rebuild uses, so the two
        // can never diverge (created_at default, creator_items population, etc.).
        let indexes = crate::db::state_rebuild::ItemIndexes {
            category: &self.category_index,
            format: &self.format_index,
            creator: &self.creator_index,
            tag: &self.tag_index,
            duration: &self.duration_index,
            created_at: &self.created_at_index,
            creator_items: &self.creator_items,
        };
        // On an OVERWRITE, scrub the prior values from every in-memory index
        // BEFORE inserting the new ones. The bitmap/range indexes only ever
        // INSERT, so without this a changed category/format/creator/tag/duration/
        // created_at value would leave the entity indexed under BOTH the old and
        // the new value — a phantom hit on RETRIEVE/SEARCH metadata filters and a
        // corrupted recency order (the durable store is correct; only the live
        // index diverges). See W22.
        if let Some(prior) = &prior_metadata {
            indexes.scrub(id_u32, prior);
        }
        indexes.index(id_u32, &stored_metadata);

        // Insert into universe bitmap. A poisoned lock after a successful durable
        // write would silently drop the item from query candidate generation —
        // log it and recover (re-init the bitmap) rather than skip silently.
        match self.universe.write() {
            Ok(mut bm) => {
                bm.insert(id_u32);
            }
            Err(poisoned) => {
                tracing::error!(
                    entity_id = id.as_u64(),
                    "universe bitmap lock poisoned after durable write; \
                     recovering and inserting so the item stays queryable"
                );
                poisoned.into_inner().insert(id_u32);
                self.universe.clear_poison();
            }
        }

        // Enqueue for the async text syncer (best-effort -- channel missing or
        // dropped is non-fatal). The durable entity store is the source of
        // truth; the text index is derived state, and any write the syncer has
        // not yet committed before a crash is recovered by the unconditional
        // rebuild-from-store at open (see
        // `crate::db::state_rebuild::rebuild_text_indexes_at_open`).
        //
        // Clone the Sender OUT of the guard and DROP the guard before enqueuing,
        // then use the non-blocking `try_send`. Holding `text_tx` across a
        // blocking `send` would stall every concurrent item write the moment the
        // bounded outbox fills (e.g. a sustained Tantivy commit outage where the
        // syncer retries forever and stops draining) — escalating a degraded-
        // text-search incident into a full halt of the durable item write API.
        // The durable write above has already completed, so dropping the enqueue
        // here costs zero durability: the unconditional rebuild-from-store at
        // open re-indexes anything dropped. crossbeam `Sender` is cheap to clone.
        let tx = self
            .text_tx
            .lock()
            .ok()
            .and_then(|guard| guard.as_ref().cloned());
        if let Some(tx) = tx
            && let Err(e) = tx.try_send(crate::text::PendingWrite {
                entity_id: id,
                metadata: (*stored_metadata).clone(),
                deleted: false,
            })
        {
            // Full/Disconnected: do NOT block. Drop the enqueue and warn — the
            // rebuild-from-store at open re-indexes it, so correctness is
            // preserved while the write path stays non-blocking under a text
            // syncer outage.
            tracing::warn!(
                entity_id = id.as_u64(),
                error = %e,
                "text-index outbox enqueue dropped (syncer stalled or gone); \
                 rebuild-from-store at open will re-index this item"
            );
        }

        // M6p5: index title terms for autocomplete suggestions.
        if let Some(title) = stored_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)),
        }
    }

    /// Enumerate every persisted item's `(EntityId, metadata)`.
    ///
    /// Scans the items storage for `Tag::Meta` keys and deserializes each
    /// metadata map. Intended for boot-time rebuild of an embedder's external
    /// `EntityId`-to-domain-key mapping after a persistent restart: the caller
    /// stored its own key (e.g. a UUID) in the metadata, so iterating the
    /// recovered shard reconstructs the mapping that the recovered bitmaps key
    /// on. Returns items in storage (lexicographic key) order.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no storage backend is wired.
    /// - `TidalError::Storage` on storage engine failure.
    pub fn all_item_metadata(&self) -> crate::Result<Vec<(EntityId, HashMap<String, String>)>> {
        use crate::storage::keys::parse_key;
        let storage = self.storage()?;
        let mut out = Vec::new();
        for entry in storage.items_engine().scan_prefix(&[]) {
            let (key, value) = entry.map_err(TidalError::from)?;
            if let Some((entity_id, Tag::Meta, suffix)) = parse_key(&key) {
                // Skip embedding rows: they share Tag::Meta but their value is a
                // serialized vector, not a metadata map. Deserializing those as
                // metadata yields garbage that must never reach callers (text
                // rebuild, embedder key-mapping reconstruction).
                if suffix.starts_with(b"EMB:") {
                    continue;
                }
                out.push((entity_id, deserialize_metadata(&value)));
            }
        }
        Ok(out)
    }

    /// 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 item content embedding slot's HNSW
    /// index. The slot name is resolved by [`Self::item_embedding_slot`] (the
    /// schema's first Item embedding slot, falling back to "content"); it 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.
    pub fn write_item_embedding(&self, id: EntityId, embedding: &[f32]) -> crate::Result<()> {
        self.require_writeable("write_item_embedding")?;
        let storage = self.storage()?;
        let slot_name = self.item_embedding_slot();
        self.write_entity_embedding(
            id,
            EntityKind::Item,
            slot_name,
            embedding,
            storage.items_engine(),
            "write_item_embedding",
        )
    }

    /// Read the stored content embedding for an item.
    ///
    /// Returns `None` if no embedding has been written for this item. The slot
    /// name is resolved by [`Self::item_embedding_slot`].
    ///
    /// # 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, self.item_embedding_slot());
        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.
    ///
    /// After the flush, the syncer's health is consulted. An unhealthy syncer
    /// means the commit silently did not land (an unrecoverable error or an
    /// exhausted retry budget), so returning `Ok(())` unconditionally would
    /// report success against a stale index. Instead this attempts an in-line
    /// [`rebuild_from`](crate::text::TextIndex::rebuild_from) over the durable
    /// item store to resync, and on a still-failing rebuild returns a degraded
    /// error so the caller does not trust a stale search index.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if Tantivy fails to reload the reader.
    /// - `TidalError::Internal` if the text syncer is unhealthy and the
    ///   resync rebuild also fails.
    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);
            // A failed send (syncer thread gone) or a recv timeout means the
            // commit was NOT acknowledged. Don't silently drop the Result —
            // log it. We still fall through to the post-flush health check
            // below, which detects the not-landed commit and triggers the
            // rebuild-from-storage resync (so a dropped flush surfaces as a
            // degraded error rather than a false success).
            if flush_tx.send(ack_tx).is_err() {
                tracing::warn!(
                    "item text flush request not delivered (syncer thread gone); \
                     relying on post-flush health check to detect a stale index"
                );
            } else if let Err(e) = ack_rx.recv_timeout(std::time::Duration::from_secs(10)) {
                tracing::warn!(
                    error = %e,
                    "item text flush not acknowledged within timeout; \
                     relying on post-flush health check to detect a stale index"
                );
            }
        }
        self.reload_text_index()?;

        // Consult syncer health after the flush. A healthy syncer means the
        // commit landed; an unhealthy one means it did not — try to resync.
        if let Some(idx) = self.text_index.as_ref()
            && !idx.is_healthy()
        {
            tracing::warn!(
                "item text syncer unhealthy after flush; rebuilding text index \
                 from durable storage to resync"
            );
            if let Err(e) = self.rebuild_item_text_index() {
                tracing::error!(
                    error = %e,
                    "item text index rebuild failed; search results may be stale"
                );
                self.text_index_unhealthy
                    .store(true, std::sync::atomic::Ordering::Release);
                return Err(TidalError::internal(
                    "flush_text_index",
                    "text syncer unhealthy and rebuild failed; search index is stale",
                ));
            }
        }
        Ok(())
    }

    /// Rebuild the item text index from the durable item store.
    ///
    /// Streams every persisted item's `(EntityId, metadata)` (skipping `EMB:`
    /// embedding rows, which are not searchable text) into
    /// [`TextIndex::rebuild_from`](crate::text::TextIndex::rebuild_from), which
    /// clears and re-indexes atomically. Used by [`flush_text_index`] and the
    /// periodic health refresher to recover from a syncer that gave up. A
    /// successful rebuild clears the latched-unhealthy flag.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal`/`TidalError::Storage` from reading the item store.
    /// - `TidalError::Internal` from Tantivy on rebuild failure.
    pub(super) fn rebuild_item_text_index(&self) -> crate::Result<()> {
        let Some(idx) = self.text_index.as_ref() else {
            return Ok(()); // no text fields declared -> nothing to rebuild
        };
        // Materialize first so the durable scan completes before touching the
        // Tantivy writer (rebuild_from deletes all docs up front).
        // `all_item_metadata` already excludes `EMB:` embedding rows.
        let items = self.all_item_metadata()?;
        idx.rebuild_from(items.into_iter())?;
        idx.reload_reader()?;
        // Resynced: clear the latch (the live is_healthy() flag is owned and
        // reset by the syncer itself once it recovers).
        self.text_index_unhealthy
            .store(false, std::sync::atomic::Ordering::Release);
        Ok(())
    }

    /// 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);
            // As in `flush_text_index`, a failed send or recv timeout means the
            // commit was not acknowledged — log it rather than dropping the
            // Result silently, so an unacknowledged creator flush is observable.
            if flush_tx.send(ack_tx).is_err() {
                tracing::warn!("creator text flush request not delivered (syncer thread gone)");
            } else if let Err(e) = ack_rx.recv_timeout(std::time::Duration::from_secs(10)) {
                tracing::warn!(
                    error = %e,
                    "creator text flush not acknowledged within timeout"
                );
            }
        }
        self.reload_creator_text_index()
    }
}

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

    use crate::{TidalDb, schema::EntityId};

    /// Minimal valid schema so ephemeral mode wires in-memory storage (needed by
    /// any test that actually persists an item).
    fn minimal_schema() -> crate::schema::Schema {
        use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Window};
        let mut b = SchemaBuilder::new();
        let _ = b
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: std::time::Duration::from_secs(3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        b.build().expect("schema must be valid")
    }

    #[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();
    }

    #[test]
    fn write_item_rejects_id_above_u32_universe_limit() {
        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(minimal_schema())
            .open()
            .unwrap();
        let meta = HashMap::new();
        // u32::MAX + 1: the smallest id that would alias a lower id once
        // narrowed to u32 for the candidate-generation bitmaps.
        let oversized = EntityId::new(u64::from(u32::MAX) + 1);
        let err = db.write_item_with_metadata(oversized, &meta).unwrap_err();
        assert!(
            err.to_string().contains("u32 item-universe limit"),
            "expected u32 item-universe rejection, got: {err}"
        );
        // The rejected item must not have leaked into storage or the universe
        // bitmap — a rejected write leaves no trace.
        assert_eq!(db.item_count(), 0, "rejected item must not enter universe");
        assert!(
            db.get_item_metadata(oversized).unwrap().is_none(),
            "rejected item must not be persisted"
        );
        db.close().unwrap();
    }

    #[test]
    fn write_item_accepts_id_at_u32_universe_boundary() {
        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(minimal_schema())
            .open()
            .unwrap();
        let meta = HashMap::new();
        // u32::MAX is the largest in-bounds id: narrowing is lossless.
        let boundary = EntityId::new(u64::from(u32::MAX));
        db.write_item_with_metadata(boundary, &meta).unwrap();
        assert_eq!(db.item_count(), 1, "boundary id must be accepted");
        db.close().unwrap();
    }

    #[test]
    fn overwrite_scrubs_stale_index_entries() {
        // W22 regression: write_item_with_metadata is "write OR overwrite". Before
        // the re-index scrub, the in-memory bitmap/range indexes only ever
        // INSERTED, so overwriting an item with new values left it indexed under
        // BOTH the old and new value — a phantom hit on metadata-filtered
        // RETRIEVE/SEARCH and a corrupted recency order. The durable store is
        // always correct; this exercises the LIVE index, which is what diverged.
        use std::ops::Bound;

        let make = |pairs: &[(&str, &str)]| -> HashMap<String, String> {
            pairs
                .iter()
                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
                .collect()
        };

        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(minimal_schema())
            .open()
            .unwrap();
        let id = EntityId::new(1);

        // First write under the OLD values.
        db.write_item_with_metadata(
            id,
            &make(&[
                ("category", "news"),
                ("creator_id", "10"),
                ("tags", "alpha,beta"),
                ("duration", "300"),
            ]),
        )
        .unwrap();
        assert!(
            db.category_index.get("news").is_some_and(|b| b.contains(1)),
            "item must be indexed under its initial category"
        );
        assert!(db.creator_items.get(10).is_some_and(|b| b.contains(1)));

        // Overwrite with ALL-NEW values.
        db.write_item_with_metadata(
            id,
            &make(&[
                ("category", "sports"),
                ("creator_id", "20"),
                ("tags", "gamma"),
                ("duration", "600"),
            ]),
        )
        .unwrap();

        // No stale entry survives under any OLD value (the phantom-hit bug).
        assert!(
            db.category_index.get("news").is_none_or(|b| !b.contains(1)),
            "stale category 'news' must be scrubbed on overwrite"
        );
        assert!(
            db.creator_index.get("10").is_none_or(|b| !b.contains(1)),
            "stale creator '10' must be scrubbed"
        );
        assert!(
            db.tag_index.get("alpha").is_none_or(|b| !b.contains(1)),
            "stale tag 'alpha' must be scrubbed"
        );
        assert!(
            db.creator_items.get(10).is_none_or(|b| !b.contains(1)),
            "stale creator_items[10] association must be scrubbed"
        );
        assert!(
            !db.duration_index
                .range(Bound::Included(&300), Bound::Included(&300))
                .contains(1),
            "stale duration 300 must be scrubbed"
        );

        // The NEW values are all present.
        assert!(
            db.category_index
                .get("sports")
                .is_some_and(|b| b.contains(1))
        );
        assert!(db.creator_index.get("20").is_some_and(|b| b.contains(1)));
        assert!(db.tag_index.get("gamma").is_some_and(|b| b.contains(1)));
        assert!(db.creator_items.get(20).is_some_and(|b| b.contains(1)));
        assert!(
            db.duration_index
                .range(Bound::Included(&600), Bound::Included(&600))
                .contains(1),
            "new duration 600 must be indexed"
        );
        db.close().unwrap();
    }

    #[test]
    fn cluster_node_stamps_configured_partition_id() {
        // W13 regression: the construction site must call `set_partition_id` with
        // the configured shard, so each node's identity metric lines carry its own
        // `partition_id` instead of the default `0` (otherwise cluster series
        // collide when scraped into one Prometheus). Exercises the real build
        // wiring end-to-end, not just the setter.
        use crate::db::config::NodeConfig;
        use crate::replication::ShardId;

        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(minimal_schema())
            .with_cluster(NodeConfig {
                shard_id: ShardId(3),
                ..NodeConfig::default()
            })
            .open()
            .unwrap();

        let out = db.metrics().render_prometheus();
        assert!(
            out.contains("tidaldb_health_ok{partition_id=\"3\"}"),
            "node must stamp its configured shard id (3) on identity lines: {out}"
        );
        assert!(
            out.contains("tidaldb_uptime_seconds{partition_id=\"3\"}"),
            "uptime line must carry the configured partition_id: {out}"
        );
        assert!(
            !out.contains("partition_id=\"0\""),
            "a shard-3 node must not emit the single-node default partition_id=0: {out}"
        );
        db.close().unwrap();
    }
}
