//! Entity state rebuild from durable storage and periodic checkpoint thread.

use std::{
    collections::HashMap,
    path::PathBuf,
    sync::{
        Arc,
        atomic::{AtomicBool, AtomicU64, Ordering},
    },
    time::Duration,
};

use roaring::RoaringBitmap;

use super::{metadata::deserialize_metadata, metrics::MetricsState, storage_box::StorageBox};
use crate::{
    cohort::CohortSignalLedger,
    query::suggest::SuggestionIndex,
    replication::{ShardId, state::ReplicationState},
    schema::{EntityId, TidalError, Timestamp},
    signals::{DEFAULT_MAX_SIGNAL_ENTRIES, SignalLedger, trim_cold_entries},
    storage::{
        StorageEngine, Tag, encode_key,
        indexes::{bitmap::BitmapIndex, range::RangeIndex},
    },
};

/// Suffix for the single follower-replication-state checkpoint row.
const REPLICATION_STATE_SUFFIX: &[u8] = b"hwm";

/// Storage key for the follower replication high-water-mark checkpoint.
///
/// One row at entity ID 0 under [`Tag::ReplicationState`], holding the
/// JSON-serialized per-shard `applied_seqno`.
#[must_use]
fn replication_state_key() -> Vec<u8> {
    encode_key(
        EntityId::new(0),
        Tag::ReplicationState,
        REPLICATION_STATE_SUFFIX,
    )
}

/// Durably persist the follower's per-shard replication high-water-mark.
///
/// Serializes [`ReplicationState::to_checkpoint_bytes`] and commits it (with an
/// fsync via `flush`) so a follower crash cannot reset its idempotency boundary
/// to 0. Called from the periodic checkpoint thread and from shutdown. Failure
/// is non-fatal at the call site: a missed checkpoint means the next open
/// restores an older high-water-mark (correct, just re-applies a few re-shipped
/// segments idempotently), but it still signals a failing disk.
///
/// # Errors
///
/// Returns `TidalError::Storage` if the write or flush fails.
pub(super) fn checkpoint_replication_state(
    storage: &dyn StorageEngine,
    replication_state: &ReplicationState,
) -> crate::Result<()> {
    let bytes = replication_state.to_checkpoint_bytes();
    let mut batch = crate::storage::WriteBatch::new();
    batch.put(replication_state_key(), bytes);
    storage.write_batch(batch)?;
    storage.flush()?;
    Ok(())
}

/// Restore a follower [`ReplicationState`] from its durable checkpoint.
///
/// Reads the persisted per-shard high-water-mark and reconstructs a
/// `ReplicationState` tracking `shards`. Shards absent from the checkpoint (or
/// the first-boot / corrupt-bytes cases) start at 0 — replaying from 0 is
/// always correct, just slower. The returned state is the SINGLE Arc the
/// receiver advances and the lag gauge reads (obs-REPL-1): the caller must not
/// build a second `ReplicationState` for the same node.
#[must_use]
pub(super) fn restore_replication_state(
    storage: &dyn StorageEngine,
    shards: &[ShardId],
) -> ReplicationState {
    match storage.get(&replication_state_key()) {
        Ok(Some(bytes)) => {
            let restored = ReplicationState::from_checkpoint_bytes(&bytes, shards);
            tracing::info!(
                shards = shards.len(),
                "follower replication high-water-mark restored from checkpoint"
            );
            restored
        }
        Ok(None) => ReplicationState::new(shards),
        Err(e) => {
            tracing::warn!(
                error = %e,
                "replication-state checkpoint read failed; \
                 follower starts at applied seqno 0 (re-applies re-shipped segments)"
            );
            ReplicationState::new(shards)
        }
    }
}

// ── Index health metrics handles ────────────────────────────────────────────

/// Handles to live index instances for the periodic checkpoint-thread refresh.
///
/// Always carries the text-index liveness handles (item + creator syncer and
/// the shared unhealthy latch) so the thread can poll text-search health and
/// schedule a resync rebuild regardless of the `metrics` feature — text-search
/// liveness is a correctness signal, not a monitoring nicety.
///
/// When the `metrics` feature is enabled it additionally carries `Arc`/clone
/// references to the embedding registry and bitmap indexes so the thread can
/// read their current sizes for the Prometheus gauges.
pub(super) struct IndexMetricsHandles {
    /// Item text index, for `is_healthy()` polling and rebuild scheduling.
    pub text_index: Option<Arc<crate::text::TextIndex>>,
    /// Creator text index, polled identically to the item index.
    pub creator_text_index: Option<Arc<crate::text::TextIndex>>,
    /// Latched true by this thread once either syncer reports unhealthy; read by
    /// `TidalDb::health_check` to flip the DB to degraded.
    pub text_unhealthy: Arc<AtomicBool>,
    #[cfg(feature = "metrics")]
    pub embedding_registry:
        Arc<std::sync::RwLock<crate::storage::vector::registry::EmbeddingSlotRegistry>>,
    #[cfg(feature = "metrics")]
    pub bitmap_category: crate::storage::indexes::bitmap::BitmapIndex,
    #[cfg(feature = "metrics")]
    pub bitmap_format: crate::storage::indexes::bitmap::BitmapIndex,
    #[cfg(feature = "metrics")]
    pub bitmap_creator: crate::storage::indexes::bitmap::BitmapIndex,
    #[cfg(feature = "metrics")]
    pub bitmap_tag: crate::storage::indexes::bitmap::BitmapIndex,
}

/// Rebuild in-memory entity state from durable storage on restart.
///
/// Scans the users keyspace for relationship edges. Populates:
/// 1. `user_state.blocked` from `RelationshipType::Blocks` edges
/// 2. `user_state.seen` (hidden items) from `RelationshipType::Hide` edges
/// 3. `user_state.follows` from `RelationshipType::Follows` edges
/// 4. `interaction_ledger` from `RelationshipType::InteractionWeight` edges
///
/// The `creator_items` bitmap is NOT rebuilt here: it is populated by the shared
/// [`ItemIndexes::index`] (called from [`rebuild_item_indexes`]) so the live
/// write path and the rebuild path use one implementation and cannot drift.
///
/// For ephemeral mode, all engines are empty, so this is effectively a no-op.
pub(super) fn rebuild_entity_state(
    storage: &StorageBox,
    user_state: &crate::entities::UserStateIndex,
    interaction_ledger: &crate::entities::InteractionLedger,
) -> crate::Result<()> {
    use crate::{
        entities::relationship::{
            RelationshipType, deserialize_relationship_value, parse_relationship_to,
        },
        storage::keys::parse_key,
    };

    // Scan the users keyspace for all relationship edges.
    // The relationship key format is:
    //   [from_entity_id: 8 BE][0x00][Tag::Rel (0x04)][rel_type: 1][to_entity_id: 8 BE]
    // We scan with an empty prefix to get all keys, then filter for Tag::Rel.
    let mut rel_count = 0u64;
    for entry in storage.users_engine().scan_prefix(&[]) {
        let (key, value) = entry.map_err(TidalError::from)?;

        // Only process relationship keys (Tag::Rel = 0x04).
        if let Some((from_id, Tag::Rel, suffix)) = parse_key(&key) {
            // suffix = [rel_type: 1 byte][to_entity_id: 8 BE]
            if suffix.is_empty() {
                continue;
            }
            let rel_type_byte = suffix[0];
            let Some(rel_type) = RelationshipType::from_byte(rel_type_byte) else {
                continue;
            };
            let Some(to_id) = parse_relationship_to(&key) else {
                continue;
            };
            let from_id_u64 = from_id.as_u64();

            match rel_type {
                RelationshipType::Blocks => {
                    user_state.add_block_creator(from_id_u64, to_id.as_u64());
                    rel_count += 1;
                }
                RelationshipType::Hide => {
                    user_state.add_hide_item(from_id_u64, to_id.as_u64());
                    rel_count += 1;
                }
                RelationshipType::Follows => {
                    // Forward: user -> followed creator
                    user_state.add_follow(from_id_u64, to_id.as_u64());
                    // Reverse: creator -> follower users
                    user_state.add_creator_follower(to_id.as_u64(), from_id_u64);
                    rel_count += 1;
                }
                RelationshipType::InteractionWeight => {
                    // Reconstruct interaction weight from the stored edge value.
                    if let Some((weight, ts_nanos)) = deserialize_relationship_value(&value) {
                        interaction_ledger.record(from_id_u64, to_id.as_u64(), weight, ts_nanos);
                        rel_count += 1;
                    }
                }
                RelationshipType::Mute => {
                    // Mute edges do not have in-memory state (yet).
                    rel_count += 1;
                }
            }
        }
    }

    if rel_count > 0 {
        tracing::info!(
            relationships = rel_count,
            "entity state rebuilt from durable storage"
        );
    }

    Ok(())
}

/// Classify a storage key as an indexable item-metadata row.
///
/// Returns the row's [`EntityId`] iff the key is a `Tag::Meta` key whose suffix
/// is *not* an `EMB:` embedding row (those share `Tag::Meta` but carry a
/// serialized vector, not searchable/index-able metadata). This is the
/// load-bearing "which rows are item metadata" rule that every item-scan loop
/// (`rebuild_item_indexes`, the text-index rebuilds, the suggestion rebuild)
/// must apply identically — factoring it here makes it impossible for one loop
/// to silently drift (e.g. forget the `EMB:` skip and index raw vectors).
fn item_meta_entity_id(key: &[u8]) -> Option<EntityId> {
    use crate::storage::keys::parse_key;

    match parse_key(key) {
        Some((entity_id, Tag::Meta, suffix)) if !suffix.starts_with(b"EMB:") => Some(entity_id),
        _ => None,
    }
}

/// Borrowed references to every in-memory item index, bundled so the write path
/// and the restart-rebuild path share one indexing implementation.
///
/// The two paths previously parsed the same metadata keys
/// (`category`/`format`/`creator_id`/`tags`/`duration`/`created_at`)
/// independently and had already drifted — the write path defaulted a missing
/// `created_at` and populated `creator_items`, the rebuild path did neither.
/// Drift here is a correctness bug: an item's recency ordering or
/// creator-following set silently changes across a restart. Routing both paths
/// through [`index_item_metadata`] makes divergence impossible by construction.
pub(super) struct ItemIndexes<'a> {
    pub category: &'a BitmapIndex,
    pub format: &'a BitmapIndex,
    pub creator: &'a BitmapIndex,
    pub tag: &'a BitmapIndex,
    pub duration: &'a RangeIndex<u32>,
    pub created_at: &'a RangeIndex<u64>,
    pub creator_items: &'a crate::entities::CreatorItemsBitmap,
}

impl ItemIndexes<'_> {
    /// Scrub every prior index entry for `id_u32` before re-indexing it under
    /// new metadata.
    ///
    /// The bitmap/range indexes ([`BitmapIndex`]/[`RangeIndex`]) only ever
    /// *insert*, so on an item overwrite a changed 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 (W22). The
    /// scrub is value-agnostic: [`delete_entity`](crate::storage::indexes::RangeIndex::delete_entity)
    /// removes *all* of the entity's entries and is a no-op when absent.
    /// `creator_items` is keyed by creator id rather than item id, so the OLD
    /// creator is taken from `old_metadata`. Call this on overwrite immediately
    /// before [`index`](Self::index); the durable store is overwritten
    /// separately, so only the live in-memory index needs reconciling.
    pub fn scrub(&self, id_u32: u32, old_metadata: &HashMap<String, String>) {
        self.category.delete_entity(id_u32);
        self.format.delete_entity(id_u32);
        self.creator.delete_entity(id_u32);
        self.tag.delete_entity(id_u32);
        self.duration.delete_entity(id_u32);
        self.created_at.delete_entity(id_u32);
        if let Some(creator_id) = old_metadata
            .get("creator_id")
            .and_then(|v| v.parse::<u64>().ok())
        {
            self.creator_items.remove_item(creator_id, id_u32);
        }
    }

    /// Index one item's metadata into every in-memory index.
    ///
    /// `metadata` must already carry a `created_at` value (the write path
    /// materializes the `Timestamp::now()` default into the persisted metadata
    /// *before* calling this, so the rebuild path reads the identical value and
    /// reproduces the exact recency ordering). A row whose persisted metadata
    /// genuinely lacks `created_at` (a legacy row written before the default was
    /// materialized) simply has no recency-index entry — the same as before —
    /// but is otherwise indexed identically by both paths.
    pub fn index(&self, id_u32: u32, metadata: &HashMap<String, String>) {
        // Bitmap indexes.
        if let Some(val) = metadata.get("category") {
            self.category.insert(id_u32, val);
        }
        if let Some(val) = metadata.get("format") {
            self.format.insert(id_u32, val);
        }
        if let Some(val) = metadata.get("creator_id") {
            self.creator.insert(id_u32, val);
            // Populate creator-items for the `following` profile / `unblocked`
            // predicate. This was missing from the rebuild path before the
            // shared indexer, so creator-following queries returned nothing
            // after a restart until each item was rewritten.
            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.insert(id_u32, tag);
                }
            }
        }

        // Range indexes.
        if let Some(val) = metadata.get("duration") {
            match val.parse::<u32>() {
                Ok(dur) => self.duration.insert(id_u32, dur),
                Err(e) => tracing::warn!(
                    entity_id = u64::from(id_u32),
                    value = %val,
                    error = %e,
                    "failed to parse 'duration' metadata; item will not be indexed by duration"
                ),
            }
        }
        if let Some(val) = metadata.get("created_at") {
            match val.parse::<u64>() {
                Ok(ts) => self.created_at.insert(id_u32, ts),
                Err(e) => tracing::warn!(
                    entity_id = u64::from(id_u32),
                    value = %val,
                    error = %e,
                    "failed to parse 'created_at' metadata; item will not be indexed by recency"
                ),
            }
        }
    }
}

/// Rebuild item indexes (universe bitmap + bitmap/range indexes) from durable storage.
///
/// Scans the items keyspace for `Tag::Meta` keys and populates the in-memory
/// universe bitmap and all item indexes (category, format, creator, tags,
/// duration, `created_at`, plus the creator-items bitmap) from the persisted
/// metadata via the shared [`ItemIndexes::index`]. This ensures `/health`
/// reports the correct item count and queries work immediately after a restart
/// without rewriting items.
///
/// Because the write path now persists a defaulted `created_at` into metadata,
/// the rebuild reproduces the exact recency ordering the live path produced.
///
/// For ephemeral mode the engine is empty, so this is a no-op.
#[allow(clippy::cast_possible_truncation)]
pub(super) fn rebuild_item_indexes(
    storage: &StorageBox,
    universe: &mut RoaringBitmap,
    indexes: &ItemIndexes<'_>,
) -> crate::Result<()> {
    let mut count = 0u64;
    let scan_start = std::time::Instant::now();

    for entry in storage.items_engine().scan_prefix(&[]) {
        let (key, value) = entry.map_err(TidalError::from)?;

        if let Some(entity_id) = item_meta_entity_id(&key) {
            // The write path (db/items.rs) REJECTS any item id > u32::MAX before
            // it ever lands in storage, so the only way one reaches here is a
            // corrupt or hostile durable store. Mirror the write path's refusal:
            // skip the row entirely rather than truncate it into a colliding u32
            // slot, which would alias a real lower id and manufacture phantom
            // query hits. Failing safe on corrupt input beats silently corrupting
            // the in-memory universe/indexes.
            if entity_id.as_u64() > u64::from(u32::MAX) {
                tracing::warn!(
                    entity_id = entity_id.as_u64(),
                    "entity ID exceeds u32::MAX during rebuild; skipping \
                     (write path never persists such ids)"
                );
                continue;
            }
            // In range by the guard above, so the narrowing is exact.
            let id_u32 = entity_id.as_u64() as u32;
            let meta = deserialize_metadata(&value);

            // Universe bitmap.
            universe.insert(id_u32);

            // All other indexes (shared with the write path).
            indexes.index(id_u32, &meta);

            count += 1;
            if count.is_multiple_of(10_000) {
                tracing::info!(rebuilt = count, "item index rebuild in progress");
            }
        }
    }

    if count > 0 {
        tracing::info!(
            items = count,
            elapsed_ms = scan_start.elapsed().as_millis(),
            "item indexes rebuilt from durable storage"
        );
    }

    Ok(())
}

/// Background thread body: checkpoint signal state to storage every 30 seconds.
///
/// Checkpoints the global signal ledger, the cohort signal ledger, the
/// per-contributor community ledger, the co-engagement index, and the per-user
/// preference vectors (each writes its own `WriteBatch`). Every one of these is
/// derived state with no WAL backing for its per-user/per-contributor identity,
/// so a periodic checkpoint is what bounds their post-crash loss window to the
/// checkpoint interval instead of "everything since the last clean shutdown."
///
/// After each successful checkpoint, compacts WAL segments that are fully
/// covered by the checkpoint. Compaction failure is non-fatal: a warning is
/// logged and the next checkpoint cycle will retry.
///
/// Polls the shutdown flag every 500ms so the thread exits promptly when
/// `shutdown_inner()` is called. Only runs in persistent mode (ephemeral opens
/// never spawn this thread).
///
/// The `Arc` arguments are intentionally passed by value: the thread must own
/// them for its entire lifetime (references cannot satisfy the `'static` bound
/// required by `std::thread::spawn`).
#[allow(
    clippy::needless_pass_by_value,
    clippy::too_many_arguments,
    clippy::too_many_lines
)]
pub(super) fn run_checkpoint_thread(
    shutdown: Arc<AtomicBool>,
    ledger: Arc<SignalLedger>,
    cohort_ledger: Arc<CohortSignalLedger>,
    community_ledger: Arc<crate::governance::CommunityLedger>,
    co_engagement: Arc<crate::entities::CoEngagementIndex>,
    preference_vectors: Arc<crate::entities::PreferenceVectors>,
    replication_state: Arc<ReplicationState>,
    storage: Box<dyn StorageEngine + Send + Sync>,
    last_wal_seq: Arc<AtomicU64>,
    wal_dir: Option<PathBuf>,
    metrics: Arc<MetricsState>,
    index_handles: IndexMetricsHandles,
) {
    const CHECKPOINT_INTERVAL: Duration = Duration::from_secs(30);
    /// Index health metrics (Tantivy, `USearch`, bitmap) refresh every 10s -- 3x more
    /// frequent than checkpoints so operators get near-real-time index visibility.
    const INDEX_METRICS_INTERVAL: Duration = Duration::from_secs(10);
    const POLL_INTERVAL: Duration = Duration::from_millis(500);

    let mut elapsed = Duration::ZERO;
    let mut index_metrics_elapsed = Duration::ZERO;
    loop {
        std::thread::sleep(POLL_INTERVAL);
        if shutdown.load(Ordering::Acquire) {
            break;
        }
        elapsed += POLL_INTERVAL;
        index_metrics_elapsed += POLL_INTERVAL;

        // Every 10s (3x faster than checkpoint): poll text-index liveness — a
        // correctness signal — and, when the `metrics` feature is on, also
        // refresh the index-size gauges. The text-health poll runs regardless of
        // feature so search liveness is always observed.
        if index_metrics_elapsed >= INDEX_METRICS_INTERVAL {
            index_metrics_elapsed = Duration::ZERO;
            poll_text_index_health(&index_handles, storage.as_ref());
            #[cfg(feature = "metrics")]
            refresh_index_metrics(&index_handles, &metrics);
        }

        if elapsed >= CHECKPOINT_INTERVAL {
            elapsed = Duration::ZERO;

            // Update signal hot entries gauge.
            #[cfg(feature = "metrics")]
            {
                metrics
                    .signal_hot_entries
                    .store(ledger.entries().len() as u64, Ordering::Relaxed);
            }

            // (index health metrics refreshed every 10s in the block above)

            // Trim signal ledger if over the memory budget (5M entries ~5.4 GB).
            let entry_count = ledger.entries().len();
            if entry_count > DEFAULT_MAX_SIGNAL_ENTRIES {
                tracing::info!(
                    entry_count,
                    max_entries = DEFAULT_MAX_SIGNAL_ENTRIES,
                    "signal ledger exceeds memory budget — trimming cold entries"
                );
                let evicted = trim_cold_entries(ledger.entries(), DEFAULT_MAX_SIGNAL_ENTRIES);
                tracing::info!(
                    evicted,
                    remaining = ledger.entries().len(),
                    "signal ledger trim complete"
                );
            }

            let seq = last_wal_seq.load(Ordering::Relaxed);
            let meta = crate::signals::checkpoint::CheckpointMeta {
                checkpoint_time_ns: Timestamp::now().as_nanos(),
                wal_sequence: seq,
                payload_hash: [0u8; 32], // computed by checkpoint()
            };
            if let Err(e) = ledger.checkpoint(storage.as_ref(), meta) {
                tracing::error!(error = %e, "periodic signal checkpoint failed");
                metrics
                    .checkpoint_failures_total
                    .fetch_add(1, Ordering::Relaxed);
            } else {
                tracing::debug!("periodic signal checkpoint written");

                // Update checkpoint age metric.
                #[cfg(feature = "metrics")]
                {
                    let now_ns = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .unwrap_or_default()
                        .as_nanos() as u64;
                    // Release pairs with the Acquire load in
                    // `MetricsState::is_degraded` (and `health_check`): a reader
                    // that observes this fresh timestamp must also observe that
                    // the checkpoint it timestamps actually completed, so the
                    // staleness derivation never trips on a half-published value.
                    metrics.last_checkpoint_ns.store(now_ns, Ordering::Release);
                }

                // Compact WAL segments covered by the checkpoint.
                // This runs AFTER the checkpoint is durable, so deleted
                // segments are guaranteed to be redundant. The WAL writer thread
                // is still live here, so we MUST use the online variant, which
                // never deletes the segment the writer is appending to (deleting
                // it would unlink the inode out from under the open FD and lose
                // post-checkpoint writes on Linux).
                //
                // NOTE: the WAL checkpoint *marker* is deliberately NOT advanced
                // here — only on clean shutdown (lifecycle.rs) and via the
                // test-only force_replication_checkpoint. Advancing it on the racy
                // periodic snapshot would trade the existing safe over-count
                // behavior (a crash replays a few already-materialized events) for
                // a potential lost-write (the marker would suppress replay of an
                // event the concurrent snapshot missed). For eventually-consistent
                // signal state, over-count self-heals via decay; an acked lost
                // write does not. The follower's replicated aggregate is therefore
                // fully recoverable from checkpoint + WAL replay, and its persisted
                // high-water-mark (checkpointed below) is the leader-seqno
                // idempotency boundary that re-shipped segments are gated against.
                if let Some(ref dir) = wal_dir
                    && seq > 0
                {
                    match crate::wal::compaction::compact_wal_online(dir, seq) {
                        Ok(result) => {
                            #[cfg(feature = "metrics")]
                            {
                                metrics
                                    .wal_compacted_segments_total
                                    .fetch_add(result.segments_deleted as u64, Ordering::Relaxed);
                            }
                            let _ = result; // suppress unused warning when metrics disabled
                        }
                        Err(e) => {
                            tracing::warn!(error = %e, "WAL compaction after checkpoint failed");
                        }
                    }

                    // Update WAL lag bytes: sum only segments NOT yet compacted
                    // by the checkpoint, which is exactly what the gauge's doc
                    // ("Bytes of WAL segments not yet compacted") promises. `seq`
                    // is the checkpoint sequence we just compacted at.
                    #[cfg(feature = "metrics")]
                    {
                        let lag = compute_wal_lag_bytes(dir, seq);
                        metrics.wal_lag_bytes.store(lag, Ordering::Relaxed);
                    }
                }
            }
            // Checkpoint cohort signal state with the same meta.
            if cohort_ledger.entry_count() > 0
                && let Err(e) = cohort_ledger.checkpoint(storage.as_ref(), meta)
            {
                tracing::error!(error = %e, "periodic cohort checkpoint failed");
            }

            // Checkpoint the derived per-user / per-contributor ledgers that have
            // no WAL backstop. A periodic checkpoint bounds their post-crash loss
            // window to the interval instead of "everything since the last clean
            // shutdown" (the community ledger is the SOLE durable copy of
            // per-contributor identity, so this is its only periodic durability).
            //
            // The cohort ledger is already checkpointed above with `meta`, so the
            // shared helper is called WITHOUT a cohort argument here to avoid a
            // double cohort write in the same cycle.
            let _ = checkpoint_secondary_ledgers(
                storage.as_ref(),
                None,
                community_ledger.as_ref(),
                co_engagement.as_ref(),
                preference_vectors.as_ref(),
            );

            // Persist the follower replication high-water-mark (BLOCKER 6): a
            // restarted follower must restore its idempotency boundary, not
            // reset to 0 and double-count every re-shipped segment. Non-fatal —
            // a missed checkpoint restores an older (still-correct) boundary.
            if let Err(e) =
                checkpoint_replication_state(storage.as_ref(), replication_state.as_ref())
            {
                tracing::error!(error = %e, "periodic replication-state checkpoint failed");
            }
        }
    }

    // `metrics` is only read inside `#[cfg(feature = "metrics")]` blocks above;
    // `index_handles` is always read by `poll_text_index_health`.
    let _ = &metrics;
}

/// Checkpoint the *secondary derived* ledgers — cohort (optional), community,
/// co-engagement, and preference vectors — into `storage`, with ONE consistent
/// set of guards and error handling.
///
/// This is the single shared implementation behind every secondary-checkpoint
/// site: the periodic checkpoint thread (above), shutdown
/// ([`TidalDb::shutdown_inner`](crate::db::TidalDb)), and backup
/// ([`TidalDb::create_backup`](crate::db::TidalDb)). Previously each site
/// open-coded the same four-checkpoint block with subtly different guards
/// (`entry_count() > 0` / `edge_count() > 0` / `!is_empty()` / none) and
/// divergent error handling; a new derived ledger or a changed guard had to be
/// edited in four places or one path silently skipped a checkpoint. Centralizing
/// it makes that drift impossible.
///
/// `cohort` is `Some((ledger, meta))` only where the cohort write is wanted in
/// this pass (shutdown / backup). The periodic thread passes `None` because it
/// already checkpoints the cohort ledger with its own `meta` earlier in the same
/// cycle — passing `Some` here would write it twice.
///
/// None of these ledgers has a WAL backstop for its per-user/per-contributor
/// identity, so this checkpoint is what bounds their post-crash loss window. Each
/// is independent: a failure is logged and the remaining checkpoints still run.
/// The FIRST error is returned so callers that must surface it (shutdown folds it
/// into its `first_err`) can, while callers that only need best-effort (backup,
/// periodic) discard it.
pub(super) fn checkpoint_secondary_ledgers(
    storage: &dyn StorageEngine,
    cohort: Option<(
        &CohortSignalLedger,
        crate::signals::checkpoint::CheckpointMeta,
    )>,
    community_ledger: &crate::governance::CommunityLedger,
    co_engagement: &crate::entities::CoEngagementIndex,
    preference_vectors: &crate::entities::PreferenceVectors,
) -> Option<TidalError> {
    let mut first_err: Option<TidalError> = None;
    if let Some((cohort_ledger, meta)) = cohort
        && cohort_ledger.entry_count() > 0
        && let Err(e) = cohort_ledger.checkpoint(storage, meta)
    {
        tracing::error!(error = %e, "cohort checkpoint failed");
        first_err.get_or_insert(e);
    }
    // The community ledger is the SOLE durable copy of per-contributor identity,
    // so it is always checkpointed (no emptiness guard): an empty checkpoint is a
    // cheap no-op, but skipping it on a transiently-empty in-memory map could
    // leave a stale durable snapshot.
    if let Err(e) = community_ledger.checkpoint(storage) {
        tracing::error!(error = %e, "community checkpoint failed");
        first_err.get_or_insert(e);
    }
    if co_engagement.edge_count() > 0
        && let Err(e) = co_engagement.checkpoint(storage)
    {
        tracing::error!(error = %e, "co-engagement checkpoint failed");
        first_err.get_or_insert(e);
    }
    if !preference_vectors.is_empty()
        && let Err(e) = preference_vectors.checkpoint(storage)
    {
        tracing::error!(error = %e, "preference-vector checkpoint failed");
        first_err.get_or_insert(e);
    }
    first_err
}

/// Poll text-index liveness and schedule a resync rebuild when unhealthy.
///
/// Runs every 10s on the checkpoint thread regardless of the `metrics` feature
/// (text-search liveness is a correctness signal). Reads
/// [`TextIndex::is_healthy`](crate::text::TextIndex::is_healthy) for both the
/// item and creator syncers. When either is unhealthy it latches
/// `handles.text_unhealthy` (so `health_check` flips the DB to degraded) and
/// attempts to rebuild the ITEM index from the durable item store the thread
/// already owns — the only store handle available to this thread. The creator
/// index resync is left to the foreground `flush_creator_text_index`/health
/// path; the latch still surfaces its unhealth.
///
/// A rebuild that itself fails leaves the latch set (the next reopen or a manual
/// resync recovers); a rebuild that succeeds clears the latch for the item side,
/// and the syncer's own `is_healthy()` flag governs from then on.
fn poll_text_index_health(handles: &IndexMetricsHandles, items_engine: &dyn StorageEngine) {
    let item_ok = handles
        .text_index
        .as_ref()
        .is_none_or(|idx| idx.is_healthy());
    let creator_ok = handles
        .creator_text_index
        .as_ref()
        .is_none_or(|idx| idx.is_healthy());

    if item_ok && creator_ok {
        return; // healthy -> nothing to do (do not clear: only a rebuild clears)
    }

    // Latch so health_check / the HTTP surface report degraded.
    handles.text_unhealthy.store(true, Ordering::Release);
    tracing::warn!(
        item_ok,
        creator_ok,
        "text index syncer unhealthy; scheduling rebuild from durable storage"
    );

    // Resync the item index from the durable store this thread owns.
    if !item_ok && let Some(idx) = handles.text_index.as_ref() {
        match rebuild_text_index_from_engine(idx, items_engine) {
            Ok(indexed) => {
                tracing::info!(indexed, "item text index rebuilt; clearing unhealthy latch");
                // Clear only if the creator side is also healthy; otherwise leave
                // the latch set to reflect the still-unhealthy creator syncer.
                if creator_ok {
                    handles.text_unhealthy.store(false, Ordering::Release);
                }
            }
            Err(e) => {
                tracing::error!(error = %e, "item text index rebuild failed; remains degraded");
            }
        }
    }
}

/// Rebuild a Tantivy text index from a durable items storage engine.
///
/// Scans `Tag::Meta` keys (skipping `EMB:` embedding rows, whose values are
/// vectors not metadata), deserializes each item's metadata, and feeds them to
/// [`TextIndex::rebuild_from`](crate::text::TextIndex::rebuild_from) which clears
/// and re-indexes atomically. Returns the number of items re-indexed.
fn rebuild_text_index_from_engine(
    idx: &crate::text::TextIndex,
    items_engine: &dyn StorageEngine,
) -> crate::Result<usize> {
    let mut items: Vec<(crate::schema::EntityId, HashMap<String, String>)> = Vec::new();
    for entry in items_engine.scan_prefix(&[]) {
        let (key, value) = entry.map_err(TidalError::from)?;
        if let Some(entity_id) = item_meta_entity_id(&key) {
            items.push((entity_id, deserialize_metadata(&value)));
        }
    }
    let count = items.len();
    idx.rebuild_from(items.into_iter())?;
    idx.reload_reader()?;
    Ok(count)
}

/// Unconditionally rebuild the item and creator Tantivy text indexes from the
/// durable entity stores at open, and fold the suggestion-index rebuild into the
/// same item scan.
///
/// # Why this exists (BLOCKER 7)
///
/// The text index is DERIVED state fed by a best-effort async syncer. Items are
/// persisted durably to the entity store and *then* enqueued to the syncer; a
/// hard crash within the syncer's batch window (default 2s) leaves those items
/// in the durable store but never committed to Tantivy. The old design relied on
/// a Tantivy commit-payload sequence number for recovery, but that machinery was
/// never wired (every write enqueued `seq: 0`) and never read at open, so those
/// items vanished from BM25 search permanently — silently, with health GREEN.
///
/// The fix mirrors the vector index, which is unconditionally rebuilt from the
/// durable embeddings at open: here we unconditionally rebuild the text indexes
/// from the durable entity stores (the source of truth) on every reopen. Any
/// item/creator present in the store but missing from Tantivy is re-indexed.
///
/// # Efficiency
///
/// The item side performs a SINGLE scan of the items engine and drives BOTH the
/// suggestion index and the item text index from it (the suggestion rebuild was
/// previously its own separate scan). The creator side is a separate scan of the
/// creators engine because creators live in their own store and are absent from
/// every item-keyed pass. `EMB:` embedding rows (items) are skipped, matching
/// the live write path's text exclusions.
///
/// Failures are non-fatal and logged: a failed text rebuild leaves search
/// degraded for that index, but the durable store is intact and the
/// health-driven resync (`poll_text_index_health`) remains a backstop.
pub(super) fn rebuild_text_indexes_at_open(
    storage: &StorageBox,
    item_text_index: Option<&crate::text::TextIndex>,
    creator_text_index: Option<&crate::text::TextIndex>,
    suggestion_index: &SuggestionIndex,
) {
    rebuild_item_text_and_suggestions_at_open(storage, item_text_index, suggestion_index);
    rebuild_creator_text_at_open(storage, creator_text_index);
}

/// Single item scan that rebuilds the suggestion index and (if present) the item
/// text index. See [`rebuild_text_indexes_at_open`] for rationale.
fn rebuild_item_text_and_suggestions_at_open(
    storage: &StorageBox,
    item_text_index: Option<&crate::text::TextIndex>,
    suggestion_index: &SuggestionIndex,
) {
    // One pass over every persisted item, feeding both derived indexes.
    let mut text_items: Vec<(EntityId, HashMap<String, String>)> = Vec::new();
    let mut suggestions = 0u64;
    for entry in storage.items_engine().scan_prefix(&[]) {
        let Ok((key, value)) = entry else { continue };
        let Some(entity_id) = item_meta_entity_id(&key) else {
            continue;
        };
        let meta = deserialize_metadata(&value);
        if let Some(title) = meta.get("title") {
            suggestion_index.index_title(title);
            suggestions += 1;
        }
        if item_text_index.is_some() {
            text_items.push((entity_id, meta));
        }
    }

    if suggestions > 0 {
        tracing::info!(
            items = suggestions,
            "suggestion index rebuilt from durable storage"
        );
    }

    if let Some(idx) = item_text_index {
        let count = text_items.len();
        // The health flag is freshly `true` on a just-opened index, so a
        // successful rebuild needs no explicit clear — search is in sync.
        match idx
            .rebuild_from(text_items.into_iter())
            .and_then(|()| idx.reload_reader())
        {
            Ok(()) => {
                if count > 0 {
                    tracing::info!(
                        items = count,
                        "item text index rebuilt from durable storage at open"
                    );
                }
            }
            Err(e) => tracing::error!(
                error = %e,
                "item text index rebuild at open failed; BM25 search degraded \
                 until the next health-driven resync"
            ),
        }
    }
}

/// Scan the creators engine and rebuild the creator text index from it.
///
/// Creators are stored via `serialize_entity(None, metadata)` (like users), so
/// each value is deserialized with [`deserialize_entity`](crate::entities::deserialize_entity)
/// rather than the raw-metadata codec used for items.
fn rebuild_creator_text_at_open(
    storage: &StorageBox,
    creator_text_index: Option<&crate::text::TextIndex>,
) {
    use crate::storage::keys::parse_key;

    let Some(idx) = creator_text_index else {
        return; // no creator text fields declared
    };

    let mut creators: Vec<(EntityId, HashMap<String, String>)> = Vec::new();
    for entry in storage.creators_engine().scan_prefix(&[]) {
        let Ok((key, value)) = entry else { continue };
        if let Some((entity_id, Tag::Meta, _suffix)) = parse_key(&key) {
            let (_emb, meta) = crate::entities::deserialize_entity(&value);
            creators.push((entity_id, meta));
        }
    }

    let count = creators.len();
    match idx
        .rebuild_from(creators.into_iter())
        .and_then(|()| idx.reload_reader())
    {
        Ok(()) => {
            if count > 0 {
                tracing::info!(
                    creators = count,
                    "creator text index rebuilt from durable storage at open"
                );
            }
        }
        Err(e) => tracing::error!(
            error = %e,
            "creator text index rebuild at open failed; creator BM25 search degraded"
        ),
    }
}

/// Refresh index health metrics from the live index handles.
///
/// Called once per checkpoint cycle (~30s). Reads current stats from the
/// Tantivy text index, `USearch` embedding registry, and bitmap indexes, then
/// stores them into the corresponding `MetricsState` atomic gauges.
///
/// All stores use `Relaxed` ordering because these are monitoring gauges --
/// a slightly stale value is acceptable, and no other thread depends on the
/// freshness of any individual gauge.
#[cfg(feature = "metrics")]
fn refresh_index_metrics(handles: &IndexMetricsHandles, metrics: &MetricsState) {
    // Tantivy text index.
    if let Some(ref text) = handles.text_index {
        let (segments, docs) = text.index_stats();
        metrics
            .tantivy_segment_count
            .store(segments as u64, Ordering::Relaxed);
        metrics.tantivy_indexed_docs.store(docs, Ordering::Relaxed);
    }

    // USearch embedding registry.
    if let Ok(registry) = handles.embedding_registry.read() {
        let (vectors, bytes) = registry.index_stats();
        metrics
            .usearch_vector_count
            .store(vectors, Ordering::Relaxed);
        metrics
            .usearch_index_size_bytes
            .store(bytes, Ordering::Relaxed);
    }

    // Bitmap indexes: sum cardinality across all four index types.
    let cardinality = handles.bitmap_category.total_cardinality()
        + handles.bitmap_format.total_cardinality()
        + handles.bitmap_creator.total_cardinality()
        + handles.bitmap_tag.total_cardinality();
    metrics
        .bitmap_index_cardinality
        .store(cardinality, Ordering::Relaxed);
}

/// Sum the file sizes of WAL segments NOT yet compacted by the checkpoint at
/// `checkpoint_seq` — i.e. the true "lag behind the checkpoint."
///
/// # Why not "all segments"
///
/// This previously summed EVERY segment, including ones already covered by the
/// checkpoint (deletable on the next compaction) and the active segment, so the
/// number it stored into `metrics.wal_lag_bytes` did not match that gauge's
/// documentation ("Bytes of WAL segments not yet compacted"). A segment is
/// "not yet compacted" exactly when [`compact_wal_online`](crate::wal::compaction::compact_wal_online)
/// would retain it: that compactor deletes segments whose `first_seq < floor`,
/// where `floor = min(checkpoint_seq, active_first_seq)` (the active segment is
/// always protected because it still holds uncheckpointed tail events). We
/// mirror that floor here and sum only the segments with `first_seq >= floor`,
/// which makes the gauge faithful to its own HELP string.
///
/// Run immediately AFTER `compact_wal_online`, the covered segments are already
/// gone, so in steady state this equals the on-disk retained bytes — but the
/// floor computation keeps the gauge honest even if compaction was skipped (e.g.
/// `seq == 0`) or fell behind.
///
/// Returns 0 if the directory cannot be read or contains no segments.
/// Errors on individual file metadata reads are treated as 0 bytes
/// (non-fatal: this is a best-effort monitoring metric).
#[cfg(feature = "metrics")]
fn compute_wal_lag_bytes(wal_dir: &std::path::Path, checkpoint_seq: u64) -> u64 {
    let Ok(segments) = crate::wal::segment::list_segments(wal_dir) else {
        return 0;
    };

    // The active segment (max `first_seq`) is never compacted away while the
    // writer is live; clamp the floor so it is always counted as lag. This is
    // the identical floor `compact_wal_online` uses, so "lag" == "what survived
    // compaction" by construction.
    let floor = match segments.iter().map(|(first_seq, _)| *first_seq).max() {
        Some(active_first_seq) => checkpoint_seq.min(active_first_seq),
        None => checkpoint_seq,
    };

    segments
        .iter()
        .filter(|(first_seq, _)| *first_seq >= floor)
        .map(|(_, path)| std::fs::metadata(path).map(|m| m.len()).unwrap_or(0))
        .sum()
}

#[cfg(all(test, feature = "metrics"))]
#[allow(clippy::unwrap_used)]
mod wal_lag_tests {
    use super::compute_wal_lag_bytes;
    use crate::{replication::ShardId, wal::segment::segment_filename};

    /// Write a segment file of `len` bytes whose name encodes `first_seq`, and
    /// return its byte length so callers can build expected sums.
    fn write_segment(dir: &std::path::Path, first_seq: u64, len: usize) -> u64 {
        let path = dir.join(segment_filename(ShardId::SINGLE, first_seq));
        std::fs::write(&path, vec![0u8; len]).unwrap();
        len as u64
    }

    /// The lag gauge must EXCLUDE segments fully covered by the checkpoint and
    /// INCLUDE both the straddling/active segment and any post-checkpoint
    /// segment — matching the gauge's "not yet compacted" documentation. The old
    /// sum-everything implementation over-counted the covered segments.
    #[test]
    fn lag_excludes_covered_segments_and_counts_the_active_tail() {
        let dir = tempfile::tempdir().unwrap();

        // Three segments. Checkpoint at seq=200 means:
        //   seg first_seq=1   -> covered (1 < floor)        -> EXCLUDED
        //   seg first_seq=200 -> active/straddling tail      -> INCLUDED
        //   (200 is the max first_seq, so floor = min(200,200) = 200)
        let _covered = write_segment(dir.path(), 1, 4096);
        let active = write_segment(dir.path(), 200, 1024);

        let lag = compute_wal_lag_bytes(dir.path(), 200);
        assert_eq!(
            lag, active,
            "lag must count only the uncompacted active segment, not the covered one"
        );
    }

    /// A second post-checkpoint segment is also lag; only the truly-covered
    /// segment is dropped from the sum.
    #[test]
    fn lag_counts_all_uncompacted_segments() {
        let dir = tempfile::tempdir().unwrap();

        // floor = min(checkpoint_seq=50, active_first_seq=300) = 50.
        //   first_seq=1   -> 1 < 50  -> EXCLUDED (covered)
        //   first_seq=100 -> >= 50   -> INCLUDED
        //   first_seq=300 -> active  -> INCLUDED
        let _covered = write_segment(dir.path(), 1, 8192);
        let mid = write_segment(dir.path(), 100, 2048);
        let active = write_segment(dir.path(), 300, 512);

        let lag = compute_wal_lag_bytes(dir.path(), 50);
        assert_eq!(lag, mid + active, "every uncompacted segment counts as lag");
    }

    /// With no checkpoint yet (`checkpoint_seq == 0`) nothing is covered, so the
    /// whole on-disk WAL is lag — there is nothing the checkpoint has caught up to.
    #[test]
    fn lag_with_zero_checkpoint_counts_everything() {
        let dir = tempfile::tempdir().unwrap();
        let a = write_segment(dir.path(), 1, 1000);
        let b = write_segment(dir.path(), 50, 2000);
        let lag = compute_wal_lag_bytes(dir.path(), 0);
        assert_eq!(lag, a + b, "no checkpoint => all WAL bytes are lag");
    }
}

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

    use roaring::RoaringBitmap;

    use super::{ItemIndexes, rebuild_entity_state, rebuild_item_indexes};
    use crate::{
        db::{metadata::serialize_metadata, storage_box::StorageBox},
        entities::{
            CreatorItemsBitmap, InteractionLedger, UserStateIndex,
            relationship::{
                RelationshipType, encode_relationship_key, serialize_relationship_value,
            },
        },
        schema::{EntityId, Timestamp},
        storage::{
            InMemoryBackend, Tag, encode_key,
            indexes::{bitmap::BitmapIndex, range::RangeIndex},
        },
    };

    fn empty_memory_storage() -> StorageBox {
        StorageBox::Memory {
            items: InMemoryBackend::new(),
            users: InMemoryBackend::new(),
            creators: InMemoryBackend::new(),
        }
    }

    /// Write one item-metadata row exactly as the live write path does
    /// (`encode_key(id, Tag::Meta, b"") -> serialize_metadata(map)`).
    fn put_item_meta(storage: &StorageBox, id: EntityId, meta: &HashMap<String, String>) {
        let key = encode_key(id, Tag::Meta, b"");
        storage
            .items_engine()
            .put(&key, &serialize_metadata(meta))
            .unwrap();
    }

    fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
        pairs
            .iter()
            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
            .collect()
    }

    /// Direct contract test for `rebuild_entity_state`: durable relationship
    /// edges (block / hide / follow / interaction) must reconstruct the identical
    /// in-memory `UserStateIndex` sets and interaction ledger after a restart.
    #[test]
    fn rebuild_entity_state_reconstructs_relationship_state() {
        let storage = empty_memory_storage();
        let ts = Timestamp::now();
        let (user, creator, item) = (EntityId::new(1), EntityId::new(100), EntityId::new(7));

        // Persist edges into the users keyspace exactly as the write path does.
        let edges = [
            (user, RelationshipType::Blocks, creator),
            (user, RelationshipType::Hide, item),
            (user, RelationshipType::Follows, creator),
            (user, RelationshipType::InteractionWeight, creator),
        ];
        for (from, rel, to) in edges {
            let key = encode_relationship_key(from, rel, to);
            let value = serialize_relationship_value(2.5, ts);
            storage.users_engine().put(&key, &value).unwrap();
        }

        let user_state = UserStateIndex::new();
        let interaction = InteractionLedger::new();
        rebuild_entity_state(&storage, &user_state, &interaction).unwrap();

        // Blocks -> blocked_creators.
        assert!(
            user_state.blocked_creators(1).contains(&100),
            "block edge must rebuild into blocked_creators"
        );
        // Hide -> hidden_items.
        assert!(
            user_state.hidden_items(1).contains(7),
            "hide edge must rebuild into hidden_items"
        );
        // Follows -> followed_creators (and reverse follower set).
        assert!(
            user_state.followed_creators(1).contains(&100),
            "follow edge must rebuild into followed_creators"
        );
        // InteractionWeight -> interaction ledger carries the persisted weight.
        assert!(
            interaction.score(1, 100, ts.as_nanos()) > 0.0,
            "interaction edge must rebuild a non-zero decayed weight"
        );
    }

    /// Direct contract test for `rebuild_item_indexes`: a persisted item's
    /// metadata must reconstruct the identical universe bitmap, every metadata
    /// index, the `creator_items` bitmap, and the `created_at` recency entry that the
    /// shared `ItemIndexes::index` write path would produce.
    #[test]
    fn rebuild_item_indexes_reconstructs_every_index() {
        let storage = empty_memory_storage();

        // One fully-tagged item, written via the canonical metadata codec.
        put_item_meta(
            &storage,
            EntityId::new(42),
            &meta(&[
                ("category", "jazz"),
                ("format", "audio"),
                ("creator_id", "100"),
                ("tags", "smooth,live"),
                ("duration", "180"),
                ("created_at", "1000"),
            ]),
        );

        let mut universe = RoaringBitmap::new();
        let category = BitmapIndex::new("category");
        let format = BitmapIndex::new("format");
        let creator = BitmapIndex::new("creator_id");
        let tag = BitmapIndex::new("tags");
        let duration = RangeIndex::<u32>::new("duration");
        let created_at = RangeIndex::<u64>::new("created_at");
        let creator_items = CreatorItemsBitmap::new();
        let indexes = ItemIndexes {
            category: &category,
            format: &format,
            creator: &creator,
            tag: &tag,
            duration: &duration,
            created_at: &created_at,
            creator_items: &creator_items,
        };

        rebuild_item_indexes(&storage, &mut universe, &indexes).unwrap();

        assert!(universe.contains(42), "item must enter the universe bitmap");
        assert!(
            category.get("jazz").is_some_and(|bm| bm.contains(42)),
            "category index must be rebuilt"
        );
        assert!(
            format.get("audio").is_some_and(|bm| bm.contains(42)),
            "format index must be rebuilt"
        );
        assert!(
            creator.get("100").is_some_and(|bm| bm.contains(42)),
            "creator index must be rebuilt"
        );
        assert!(
            tag.get("smooth").is_some_and(|bm| bm.contains(42))
                && tag.get("live").is_some_and(|bm| bm.contains(42)),
            "each comma-separated tag must be rebuilt"
        );
        assert!(
            creator_items.get(100).is_some_and(|bm| bm.contains(42)),
            "creator_items bitmap must be rebuilt for the `following` profile"
        );
        // Recency: the created_at value must land in the range index.
        let recent = std::ops::Bound::Included(&1000u64);
        let recent = created_at.range(recent, std::ops::Bound::Included(&1000u64));
        assert!(
            recent.contains(42),
            "created_at recency entry must be rebuilt at the persisted value"
        );
    }

    /// Corrupt/hostile input regression (WARNING fix): the write path rejects any
    /// item id > `u32::MAX` before it ever persists, so such a row can only reach
    /// the rebuild from a corrupt store. The rebuild must SKIP it — never truncate
    /// it into a colliding u32 slot that aliases a real lower id and manufactures
    /// phantom query hits.
    #[test]
    fn rebuild_item_indexes_skips_over_u32_ids_instead_of_colliding() {
        let storage = empty_memory_storage();

        // A legitimate low item id, and a hostile id that truncates onto it
        // (`(1<<32) + 5` -> 5). If the rebuild truncated, both would land on
        // slot 5 with last-write-wins metadata corruption.
        let low = EntityId::new(5);
        let over = EntityId::new((1u64 << 32) + 5);
        put_item_meta(&storage, low, &meta(&[("category", "real")]));
        put_item_meta(&storage, over, &meta(&[("category", "phantom")]));

        let mut universe = RoaringBitmap::new();
        let category = BitmapIndex::new("category");
        let format = BitmapIndex::new("format");
        let creator = BitmapIndex::new("creator_id");
        let tag = BitmapIndex::new("tags");
        let duration = RangeIndex::<u32>::new("duration");
        let created_at = RangeIndex::<u64>::new("created_at");
        let creator_items = CreatorItemsBitmap::new();
        let indexes = ItemIndexes {
            category: &category,
            format: &format,
            creator: &creator,
            tag: &tag,
            duration: &duration,
            created_at: &created_at,
            creator_items: &creator_items,
        };

        rebuild_item_indexes(&storage, &mut universe, &indexes).unwrap();

        // The legitimate row is indexed; the over-range row is skipped, so slot 5
        // is NOT cross-contaminated with the "phantom" category.
        assert!(universe.contains(5), "the valid low id must be indexed");
        assert!(
            category.get("real").is_some_and(|bm| bm.contains(5)),
            "the valid low id keeps its true category"
        );
        assert!(
            category.get("phantom").is_none(),
            "the over-u32::MAX row must be skipped, not truncated into slot 5"
        );
    }
}
