//! Per-user state bitmap indexes.
//!
//! Tracks seen items, blocked creators/items, saved items, liked items,
//! and completion progress.
//!
//! # Durability
//!
//! `seen`, `blocked`, `follows`, and `creator_followers` are rebuilt on open
//! from durable relationship edges (`rebuild_entity_state`). The
//! `completion` / `saved` / `liked` / `save_timestamps` maps have no
//! relationship edge — they are driven by `completion` / `save` / `like` signals
//! — so they are persisted write-through as durable `Tag::UserState` rows by the
//! signal path and restored on open via [`UserStateIndex::restore`]. Without
//! that, the `InProgress` filter and `DateSaved` sort silently reset to empty on
//! every restart.

use std::collections::HashSet;

use dashmap::DashMap;
use roaring::{RoaringBitmap, RoaringTreemap};

use super::CreatorItemsBitmap;
use crate::{
    schema::EntityId,
    storage::{StorageEngine, Tag, encode_key, entity_tag_prefix, parse_key},
};

/// Narrow a `u64` item-side entity ID into the `u32` slot used by every
/// per-user item bitmap (seen / hidden / saved / liked), emitting a
/// `tracing::warn!` if the ID exceeds `u32::MAX`.
///
/// The item universe is bounded to `u32::MAX` (~4 billion) because the
/// in-memory candidate-generation indexes key on a `u32` slot. The primary
/// write path (`db/items.rs::write_item_with_metadata`) *rejects* an
/// over-range item ID before it ever lands in storage, so in a well-formed
/// database this narrowing never overflows. The relationship-driven write
/// paths (the `Hide` edge in `db/relationships.rs` and its replay in
/// `db/state_rebuild.rs`) reach this index with a `to` id that was *not*
/// funneled through that guard, so they must route their `u64 -> u32`
/// narrowing through this helper to keep every truncation observable and
/// consistent with the warning `db/items.rs` already emits. Truncation is
/// preserved (the documented universe limit is unchanged); only the silent
/// part is fixed.
///
/// Out-of-scope note: `db/relationships.rs` (the `Hide` relationship write /
/// delete) and `db/state_rebuild.rs` (the `Hide` replay) currently perform a
/// bare `to.as_u64() as u32`; they should call this helper instead. Those
/// files are not edited here.
#[must_use]
pub(crate) fn narrow_item_slot(item_id: u64) -> u32 {
    if item_id > u64::from(u32::MAX) {
        // SLOT-NARROW: mirrors the warn db/items.rs emits when rejecting an
        // over-range item ID, so a Hide on such an ID is equally observable.
        tracing::warn!(
            item_id,
            limit = u32::MAX,
            "item id exceeds u32 item-slot universe; narrowing aliases a lower id (silent cross-id collision in per-user bitmaps)"
        );
    }
    // Documented, intentional truncation to the u32 item slot.
    #[allow(clippy::cast_possible_truncation)]
    {
        item_id as u32
    }
}

/// Blocked state: both individual items and all items from blocked creators.
#[derive(Debug, Default)]
pub struct BlockedState {
    pub creators: HashSet<u64>,
    pub items: RoaringBitmap,
}

/// Per-user state bitmaps for filtering during ranking.
///
/// Thread-safe via `DashMap` -- no mutex on the hot path. Each user's
/// state is independent, so concurrent updates to different users never
/// contend.
pub struct UserStateIndex {
    seen: DashMap<u64, RoaringBitmap>,
    blocked: DashMap<u64, BlockedState>,
    saved: DashMap<u64, RoaringBitmap>,
    liked: DashMap<u64, RoaringBitmap>,
    /// `user_id` -> (`item_id` -> completion ratio).
    ///
    /// Keyed per-user rather than by a flat `(user_id, item_id)` so that
    /// [`in_progress_items`](Self::in_progress_items) scans only one user's
    /// completion entries instead of the global map. The threshold is applied
    /// at query time, so the inner map keeps the raw ratio (it cannot be
    /// pre-bucketed by an "in-progress" flag that depends on a per-call
    /// threshold).
    completion: DashMap<u64, std::collections::HashMap<u64, f64>>,
    /// `user_id` -> set of followed creator IDs.
    /// Populated from `RelationshipType::Follows` edges on startup and
    /// maintained in real-time by `write_relationship` / `delete_relationship`.
    follows: DashMap<u64, HashSet<u64>>,
    /// Reverse index: `creator_id` -> set of follower user IDs.
    ///
    /// Populated from `RelationshipType::Follows` edges. Updated on every
    /// `write_relationship` / `delete_relationship`. Rebuilt from durable
    /// relationship storage on open -- no separate persistence needed.
    ///
    /// Backed by a `RoaringTreemap` (64-bit) so the **full** `u64` user ID is
    /// preserved. The item-side bitmaps elsewhere in this index legitimately use
    /// `u32` because item IDs are dense and bounded, but user IDs are not -- a
    /// `u32` here silently aliased high user IDs onto each other, corrupting the
    /// follower graph for any deployment with > 4B users or sparse ID schemes.
    creator_followers: DashMap<u64, RoaringTreemap>,
    /// M6p3: save timestamps for `DateSaved` sort.
    /// Maps `(user_id, item_id_u64)` -> `saved_at_ns`.
    save_timestamps: DashMap<(u64, u64), u64>,
}

impl UserStateIndex {
    #[must_use]
    pub fn new() -> Self {
        Self {
            seen: DashMap::new(),
            blocked: DashMap::new(),
            saved: DashMap::new(),
            liked: DashMap::new(),
            completion: DashMap::new(),
            follows: DashMap::new(),
            creator_followers: DashMap::new(),
            save_timestamps: DashMap::new(),
        }
    }

    // ── Seen ────────────────────────────────────────────────────────────

    pub fn mark_seen(&self, user_id: u64, item_id: u32) {
        self.seen.entry(user_id).or_default().insert(item_id);
    }

    #[must_use]
    pub fn is_seen(&self, user_id: u64, item_id: u32) -> bool {
        self.seen
            .get(&user_id)
            .is_some_and(|bm| bm.contains(item_id))
    }

    /// Get the seen bitmap for a user (cloned).
    #[must_use]
    pub fn seen_bitmap(&self, user_id: u64) -> RoaringBitmap {
        self.seen
            .get(&user_id)
            .map(|r| r.clone())
            .unwrap_or_default()
    }

    // ── Blocked ─────────────────────────────────────────────────────────

    /// Hide a specific item for a user (item already narrowed to a `u32` slot).
    pub fn add_hide(&self, user_id: u64, item_id: u32) {
        self.blocked
            .entry(user_id)
            .or_default()
            .items
            .insert(item_id);
    }

    /// Hide a specific item for a user, narrowing the `u64` item ID to its
    /// `u32` slot through [`narrow_item_slot`] so an over-range ID logs a
    /// warning instead of silently aliasing a lower ID.
    ///
    /// This is the observable entry point the `Hide` relationship write path
    /// (`db/relationships.rs`) and its rebuild replay (`db/state_rebuild.rs`)
    /// should call instead of `add_hide(user_id, to.as_u64() as u32)`.
    pub fn add_hide_item(&self, user_id: u64, item_id: u64) {
        self.add_hide(user_id, narrow_item_slot(item_id));
    }

    /// Un-hide a specific item for a user, narrowing the `u64` item ID to its
    /// `u32` slot through [`narrow_item_slot`]. Companion to
    /// [`add_hide_item`](Self::add_hide_item) for the `Hide` delete path.
    pub fn remove_hide_item(&self, user_id: u64, item_id: u64) {
        self.remove_hide(user_id, narrow_item_slot(item_id));
    }

    /// Block an entire creator for a user.
    pub fn add_block_creator(&self, user_id: u64, creator_id: u64) {
        self.blocked
            .entry(user_id)
            .or_default()
            .creators
            .insert(creator_id);
    }

    /// Get hidden items bitmap for a user.
    #[must_use]
    pub fn hidden_items(&self, user_id: u64) -> RoaringBitmap {
        self.blocked
            .get(&user_id)
            .map(|state| state.items.clone())
            .unwrap_or_default()
    }

    /// Get blocked creator IDs for a user.
    #[must_use]
    pub fn blocked_creators(&self, user_id: u64) -> HashSet<u64> {
        self.blocked
            .get(&user_id)
            .map(|state| state.creators.clone())
            .unwrap_or_default()
    }

    /// Returns a predicate that checks whether an item ID is unseen for a user.
    #[must_use]
    pub fn unseen_predicate(&self, user_id: u64) -> Box<dyn Fn(u32) -> bool + Send + Sync> {
        let seen = self.seen_bitmap(user_id);
        let hidden = self.hidden_items(user_id);
        Box::new(move |item_id: u32| !seen.contains(item_id) && !hidden.contains(item_id))
    }

    /// Returns a predicate that checks whether an item is not from a blocked creator.
    ///
    /// Requires the `CreatorItemsBitmap` to expand creator blocks into item blocks.
    #[must_use]
    pub fn unblocked_predicate(
        &self,
        user_id: u64,
        creator_items: &CreatorItemsBitmap,
    ) -> Box<dyn Fn(u32) -> bool + Send + Sync> {
        let blocked_creators = self.blocked_creators(user_id);
        let hidden = self.hidden_items(user_id);

        // Build a bitmap of all items from blocked creators.
        let mut blocked_items = hidden;
        for &cid in &blocked_creators {
            if let Some(bm) = creator_items.get(cid) {
                blocked_items |= &bm;
            }
        }

        Box::new(move |item_id: u32| !blocked_items.contains(item_id))
    }

    /// Build a bitmap of all items from the given followed creators.
    ///
    /// The result is purely a function of `followed_creator_ids` and the
    /// creator->items map; it does not depend on any per-user state held by this
    /// index, so it takes no `user_id`. Callers resolve a user's followed
    /// creators via [`followed_creators_vec`](Self::followed_creators_vec) and
    /// pass them in.
    #[must_use]
    pub fn follows_bitmap(
        &self,
        followed_creator_ids: &[u64],
        creator_items: &CreatorItemsBitmap,
    ) -> RoaringBitmap {
        creator_items.union_for(followed_creator_ids)
    }

    /// Remove a creator block for a user.
    pub fn remove_block_creator(&self, user_id: u64, creator_id: u64) {
        if let Some(mut state) = self.blocked.get_mut(&user_id) {
            state.creators.remove(&creator_id);
        }
    }

    /// Remove a hidden item for a user (un-hide).
    ///
    /// Clears *only* the hidden flag. The seen bitmap is intentionally left
    /// untouched: hiding an item the user has already seen, then un-hiding it,
    /// must not resurface that item as UNSEEN. Seen-state and hidden-state are
    /// orthogonal -- `seen` records "the user encountered this", `blocked.items`
    /// records "the user asked to suppress this". Un-suppressing does not erase
    /// the fact that they saw it, so `unseen_predicate` keeps excluding it.
    pub fn remove_hide(&self, user_id: u64, item_id: u32) {
        if let Some(mut state) = self.blocked.get_mut(&user_id) {
            state.items.remove(item_id);
        }
    }

    // ── Follows ─────────────────────────────────────────────────────────

    /// Record that a user follows a creator.
    pub fn add_follow(&self, user_id: u64, creator_id: u64) {
        self.follows.entry(user_id).or_default().insert(creator_id);
    }

    /// Remove a follow relationship.
    pub fn remove_follow(&self, user_id: u64, creator_id: u64) {
        if let Some(mut set) = self.follows.get_mut(&user_id) {
            set.remove(&creator_id);
        }
    }

    /// Get the set of creator IDs this user follows.
    #[must_use]
    pub fn followed_creators(&self, user_id: u64) -> HashSet<u64> {
        self.follows
            .get(&user_id)
            .map(|r| r.clone())
            .unwrap_or_default()
    }

    /// Get the followed creator IDs as a Vec (for convenience).
    #[must_use]
    pub fn followed_creators_vec(&self, user_id: u64) -> Vec<u64> {
        self.follows
            .get(&user_id)
            .map(|r| r.iter().copied().collect())
            .unwrap_or_default()
    }

    // ── Creator Followers (reverse index) ────────────────────────────────

    /// Add a follower to a creator's follower set.
    ///
    /// `creator_id` is the entity being followed; `follower_user_id` is the
    /// user doing the following. Stored at full `u64` precision in a
    /// `RoaringTreemap`.
    pub fn add_creator_follower(&self, creator_id: u64, follower_user_id: u64) {
        self.creator_followers
            .entry(creator_id)
            .or_default()
            .insert(follower_user_id);
    }

    /// Remove a follower from a creator's follower set.
    pub fn remove_creator_follower(&self, creator_id: u64, follower_user_id: u64) {
        if let Some(mut bm) = self.creator_followers.get_mut(&creator_id) {
            bm.remove(follower_user_id);
        }
    }

    /// Get the set of follower user IDs for a creator (as a 64-bit treemap, cloned).
    #[must_use]
    pub fn followers_bitmap(&self, creator_id: u64) -> RoaringTreemap {
        self.creator_followers
            .get(&creator_id)
            .map(|r| r.clone())
            .unwrap_or_default()
    }

    /// Get follower user IDs for a creator as a `Vec<u64>`.
    #[must_use]
    pub fn follower_ids(&self, creator_id: u64) -> Vec<u64> {
        self.creator_followers
            .get(&creator_id)
            .map(|bm| bm.iter().collect())
            .unwrap_or_default()
    }

    // ── Saved / Liked ───────────────────────────────────────────────────

    pub fn add_save(&self, user_id: u64, item_id: u32) {
        self.saved.entry(user_id).or_default().insert(item_id);
    }

    /// Save an item with a timestamp (for `DateSaved` sort).
    ///
    /// Also adds the item to the saved bitmap for inclusion filters.
    pub fn add_save_timestamped(&self, user_id: u64, item_id: u32, timestamp_ns: u64) {
        self.saved.entry(user_id).or_default().insert(item_id);
        self.save_timestamps
            .insert((user_id, u64::from(item_id)), timestamp_ns);
    }

    /// Get the save timestamp for a (user, item) pair.
    ///
    /// Returns `None` if the item was not saved via `add_save_timestamped` or
    /// was saved via the older `add_save` (which does not record timestamps).
    #[must_use]
    pub fn get_save_timestamp(&self, user_id: u64, item_id: u64) -> Option<u64> {
        self.save_timestamps.get(&(user_id, item_id)).map(|r| *r)
    }

    #[must_use]
    pub fn is_saved(&self, user_id: u64, item_id: u32) -> bool {
        self.saved
            .get(&user_id)
            .is_some_and(|bm| bm.contains(item_id))
    }

    /// Get the saved-items bitmap for a user (cloned).
    #[must_use]
    pub fn saved_bitmap(&self, user_id: u64) -> RoaringBitmap {
        self.saved
            .get(&user_id)
            .map(|r| r.clone())
            .unwrap_or_default()
    }

    pub fn add_like(&self, user_id: u64, item_id: u32) {
        self.liked.entry(user_id).or_default().insert(item_id);
    }

    #[must_use]
    pub fn is_liked(&self, user_id: u64, item_id: u32) -> bool {
        self.liked
            .get(&user_id)
            .is_some_and(|bm| bm.contains(item_id))
    }

    /// Get the liked-items bitmap for a user (cloned).
    #[must_use]
    pub fn liked_bitmap(&self, user_id: u64) -> RoaringBitmap {
        self.liked
            .get(&user_id)
            .map(|r| r.clone())
            .unwrap_or_default()
    }

    // ── Completion ──────────────────────────────────────────────────────

    pub fn record_completion(&self, user_id: u64, item_id: u64, ratio: f64) {
        self.completion
            .entry(user_id)
            .or_default()
            .insert(item_id, ratio);
    }

    #[must_use]
    pub fn get_completion(&self, user_id: u64, item_id: u64) -> Option<f64> {
        self.completion
            .get(&user_id)
            .and_then(|items| items.get(&item_id).copied())
    }

    /// Check if an item is "in progress" (0.0 < completion < threshold).
    #[must_use]
    pub fn is_in_progress(&self, user_id: u64, item_id: u64, threshold: f64) -> bool {
        self.completion
            .get(&user_id)
            .and_then(|items| items.get(&item_id).copied())
            .is_some_and(|r| r > 0.0 && r < threshold)
    }

    /// List all item IDs with partial completion for a user (0 < v < threshold).
    ///
    /// Returns a `RoaringBitmap` of item IDs (narrowed to a u32 slot via
    /// [`narrow_item_slot`]) that satisfy the in-progress predicate. Used by
    /// Stage 2.5 of the executor to build the inclusion set for
    /// `FilterExpr::InProgress`.
    ///
    /// Scans only this user's completion entries — the map is keyed per-user,
    /// so the cost is `O(items completed by this user)`, not `O(global
    /// completion rows)` as it was when the map was keyed by a flat
    /// `(user_id, item_id)` tuple.
    #[must_use]
    pub fn in_progress_items(&self, user_id: u64, threshold: f64) -> RoaringBitmap {
        let mut bm = RoaringBitmap::new();
        if let Some(items) = self.completion.get(&user_id) {
            for (&iid, &ratio) in items.iter() {
                if ratio > 0.0 && ratio < threshold {
                    bm.insert(narrow_item_slot(iid));
                }
            }
        }
        bm
    }
}

// ── Durable rows (completion / saved-timestamp / liked) ──────────────────────

/// Discriminator byte for a `Tag::UserState` durable row, distinguishing the
/// three signal-driven maps that share the sentinel `(0, Tag::UserState)` prefix.
#[repr(u8)]
#[derive(Clone, Copy, PartialEq, Eq)]
enum UserStateRowKind {
    /// `(user_be8, item_be8)` -> completion ratio (8B LE f64 value).
    Completion = 0x01,
    /// `(user_be8, item_be8)` -> save timestamp ns (8B LE u64 value).
    Save = 0x02,
    /// `(user_be8, item_be8)` -> liked (empty value).
    Like = 0x03,
}

/// Encode a `Tag::UserState` key: `[kind: 1B][user: 8B BE][item: 8B BE]`.
fn user_state_key(kind: UserStateRowKind, user_id: u64, item_id: u64) -> Vec<u8> {
    let mut suffix = [0u8; 17];
    suffix[0] = kind as u8;
    suffix[1..9].copy_from_slice(&user_id.to_be_bytes());
    suffix[9..].copy_from_slice(&item_id.to_be_bytes());
    encode_key(EntityId::new(0), Tag::UserState, &suffix)
}

/// Decode a `Tag::UserState` suffix into `(kind_byte, user_id, item_id)`.
fn decode_user_state_suffix(suffix: &[u8]) -> Option<(u8, u64, u64)> {
    if suffix.len() < 17 {
        return None;
    }
    let user_id = u64::from_be_bytes(suffix[1..9].try_into().ok()?);
    let item_id = u64::from_be_bytes(suffix[9..17].try_into().ok()?);
    Some((suffix[0], user_id, item_id))
}

impl UserStateIndex {
    /// Persist a completion ratio write-through as a durable `Tag::UserState` row,
    /// then update the in-memory map. Mirrors [`record_completion`](Self::record_completion).
    ///
    /// # Errors
    ///
    /// Returns storage errors from the underlying engine.
    pub fn record_completion_durable(
        &self,
        storage: &dyn StorageEngine,
        user_id: u64,
        item_id: u64,
        ratio: f64,
    ) -> crate::Result<()> {
        let key = user_state_key(UserStateRowKind::Completion, user_id, item_id);
        storage
            .put(&key, &ratio.to_le_bytes())
            .map_err(crate::schema::TidalError::from)?;
        self.record_completion(user_id, item_id, ratio);
        Ok(())
    }

    /// Persist a timestamped save write-through, then update the in-memory map.
    /// Mirrors [`add_save_timestamped`](Self::add_save_timestamped).
    ///
    /// # Errors
    ///
    /// Returns storage errors from the underlying engine.
    pub fn add_save_durable(
        &self,
        storage: &dyn StorageEngine,
        user_id: u64,
        item_id: u32,
        timestamp_ns: u64,
    ) -> crate::Result<()> {
        let key = user_state_key(UserStateRowKind::Save, user_id, u64::from(item_id));
        storage
            .put(&key, &timestamp_ns.to_le_bytes())
            .map_err(crate::schema::TidalError::from)?;
        self.add_save_timestamped(user_id, item_id, timestamp_ns);
        Ok(())
    }

    /// Persist a like write-through, then update the in-memory map.
    /// Mirrors [`add_like`](Self::add_like).
    ///
    /// # Errors
    ///
    /// Returns storage errors from the underlying engine.
    pub fn add_like_durable(
        &self,
        storage: &dyn StorageEngine,
        user_id: u64,
        item_id: u32,
    ) -> crate::Result<()> {
        let key = user_state_key(UserStateRowKind::Like, user_id, u64::from(item_id));
        storage
            .put(&key, &[])
            .map_err(crate::schema::TidalError::from)?;
        self.add_like(user_id, item_id);
        Ok(())
    }

    /// Restore the completion / saved-timestamp / liked maps from durable
    /// `Tag::UserState` rows on open. Targeted prefix scan over the sentinel
    /// entity (`0`), so restore is `O(user_state_rows)`.
    ///
    /// # Errors
    ///
    /// Returns storage errors from the underlying engine.
    #[allow(clippy::cast_possible_truncation)]
    pub fn restore(&self, storage: &dyn StorageEngine) -> crate::Result<()> {
        let prefix = entity_tag_prefix(EntityId::new(0), Tag::UserState);
        let mut count = 0u64;
        for entry in storage.scan_prefix(&prefix) {
            let (key, value) = entry.map_err(crate::schema::TidalError::from)?;
            let Some((_, Tag::UserState, suffix)) = parse_key(&key) else {
                continue;
            };
            let Some((kind, user_id, item_id)) = decode_user_state_suffix(suffix) else {
                continue;
            };
            match kind {
                k if k == UserStateRowKind::Completion as u8 => {
                    if value.len() >= 8 {
                        let ratio = f64::from_le_bytes(value[..8].try_into().unwrap_or([0u8; 8]));
                        self.record_completion(user_id, item_id, ratio);
                        count += 1;
                    }
                }
                k if k == UserStateRowKind::Save as u8 => {
                    if value.len() >= 8 {
                        let ts = u64::from_le_bytes(value[..8].try_into().unwrap_or([0u8; 8]));
                        self.add_save_timestamped(user_id, item_id as u32, ts);
                        count += 1;
                    }
                }
                k if k == UserStateRowKind::Like as u8 => {
                    self.add_like(user_id, item_id as u32);
                    count += 1;
                }
                _ => {}
            }
        }
        if count > 0 {
            tracing::info!(rows = count, "user-state index restored from durable rows");
        }
        Ok(())
    }
}

impl Default for UserStateIndex {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn seen_mark_and_check() {
        let idx = UserStateIndex::new();
        assert!(!idx.is_seen(1, 10));
        idx.mark_seen(1, 10);
        assert!(idx.is_seen(1, 10));
        assert!(!idx.is_seen(1, 20));
        assert!(!idx.is_seen(2, 10)); // different user
    }

    #[test]
    fn hide_item() {
        let idx = UserStateIndex::new();
        idx.add_hide(1, 100);
        let hidden = idx.hidden_items(1);
        assert!(hidden.contains(100));
        assert!(!hidden.contains(200));
    }

    #[test]
    fn remove_hide_clears_only_hidden_not_seen() {
        // Regression: un-hiding an item the user has already seen must NOT
        // mark it unseen. Seen-state and hidden-state are orthogonal.
        let idx = UserStateIndex::new();
        idx.mark_seen(1, 100);
        idx.add_hide(1, 100);

        // While hidden + seen, the item is excluded for both reasons.
        assert!(idx.is_seen(1, 100));
        assert!(idx.hidden_items(1).contains(100));
        assert!(!idx.unseen_predicate(1)(100));

        // Un-hide: the hidden flag clears, but seen-state must survive.
        idx.remove_hide(1, 100);
        assert!(!idx.hidden_items(1).contains(100), "hidden flag must clear");
        assert!(idx.is_seen(1, 100), "un-hide must not reset seen-state");
        assert!(
            !idx.unseen_predicate(1)(100),
            "still-seen item must not resurface as unseen after un-hide"
        );
    }

    #[test]
    fn narrow_item_slot_in_range_is_identity() {
        // Any id within the u32 universe narrows to itself with no warning.
        assert_eq!(narrow_item_slot(0), 0);
        assert_eq!(narrow_item_slot(123), 123);
        assert_eq!(narrow_item_slot(u64::from(u32::MAX)), u32::MAX);
    }

    #[test]
    fn narrow_item_slot_over_range_truncates() {
        // Over-range ids are truncated to the low 32 bits (the documented,
        // intentional universe limit); the warning makes the truncation
        // observable but the value contract is unchanged.
        assert_eq!(narrow_item_slot(u64::from(u32::MAX) + 1), 0);
        assert_eq!(narrow_item_slot((1u64 << 32) | 7), 7);
    }

    #[test]
    fn add_hide_item_routes_through_narrowing() {
        let idx = UserStateIndex::new();
        // In-range id hides exactly that slot.
        idx.add_hide_item(1, 100);
        assert!(idx.hidden_items(1).contains(100));

        // Over-range id narrows to its low-32 slot, matching narrow_item_slot.
        // (Low 32 bits = 42; the high bit is dropped by the narrowing.)
        idx.add_hide_item(1, (1u64 << 32) + 42);
        assert!(idx.hidden_items(1).contains(42));

        // remove_hide_item un-hides through the same narrowing.
        idx.remove_hide_item(1, (1u64 << 32) + 42);
        assert!(!idx.hidden_items(1).contains(42));
        // The unrelated in-range hide is untouched.
        assert!(idx.hidden_items(1).contains(100));
    }

    #[test]
    fn block_creator() {
        let idx = UserStateIndex::new();
        idx.add_block_creator(1, 77);
        let blocked = idx.blocked_creators(1);
        assert!(blocked.contains(&77));
    }

    #[test]
    fn unseen_predicate_excludes_seen_and_hidden() {
        let idx = UserStateIndex::new();
        idx.mark_seen(1, 10);
        idx.add_hide(1, 20);

        let pred = idx.unseen_predicate(1);
        assert!(!pred(10)); // seen
        assert!(!pred(20)); // hidden
        assert!(pred(30)); // neither
    }

    #[test]
    fn unblocked_predicate_excludes_blocked_creator_items() {
        let idx = UserStateIndex::new();
        let creator_items = CreatorItemsBitmap::new();
        creator_items.add_item(77, 100);
        creator_items.add_item(77, 101);

        idx.add_block_creator(1, 77);
        idx.add_hide(1, 200);

        let pred = idx.unblocked_predicate(1, &creator_items);
        assert!(!pred(100)); // from blocked creator
        assert!(!pred(101)); // from blocked creator
        assert!(!pred(200)); // hidden
        assert!(pred(300)); // ok
    }

    #[test]
    fn save_and_like() {
        let idx = UserStateIndex::new();
        idx.add_save(1, 10);
        idx.add_like(1, 20);

        assert!(idx.is_saved(1, 10));
        assert!(!idx.is_saved(1, 20));
        assert!(idx.is_liked(1, 20));
        assert!(!idx.is_liked(1, 10));
    }

    #[test]
    fn saved_bitmap_returns_all_saved_items() {
        let idx = UserStateIndex::new();
        idx.add_save(1, 10);
        idx.add_save(1, 20);
        idx.add_save(1, 30);
        idx.add_save(2, 99); // different user

        let bm = idx.saved_bitmap(1);
        assert_eq!(bm.len(), 3);
        assert!(bm.contains(10));
        assert!(bm.contains(20));
        assert!(bm.contains(30));
        assert!(!bm.contains(99)); // belongs to user 2

        // Unknown user returns empty bitmap.
        assert!(idx.saved_bitmap(999).is_empty());
    }

    #[test]
    fn liked_bitmap_returns_all_liked_items() {
        let idx = UserStateIndex::new();
        idx.add_like(1, 5);
        idx.add_like(1, 15);
        idx.add_like(2, 25); // different user

        let bm = idx.liked_bitmap(1);
        assert_eq!(bm.len(), 2);
        assert!(bm.contains(5));
        assert!(bm.contains(15));
        assert!(!bm.contains(25));

        // Unknown user returns empty bitmap.
        assert!(idx.liked_bitmap(999).is_empty());
    }

    // ── Creator Followers Tests ──────────────────────────────────────────

    #[test]
    fn add_creator_follower_and_query() {
        let idx = UserStateIndex::new();
        idx.add_creator_follower(100, 1); // creator 100, follower user 1
        idx.add_creator_follower(100, 2);
        idx.add_creator_follower(200, 3);

        let bm = idx.followers_bitmap(100);
        assert_eq!(bm.len(), 2);
        assert!(bm.contains(1));
        assert!(bm.contains(2));

        let ids = idx.follower_ids(100);
        assert_eq!(ids.len(), 2);
        assert!(ids.contains(&1));
        assert!(ids.contains(&2));

        // Creator 200 has only user 3.
        assert_eq!(idx.follower_ids(200), vec![3]);

        // Unknown creator returns empty.
        assert!(idx.followers_bitmap(999).is_empty());
        assert!(idx.follower_ids(999).is_empty());
    }

    #[test]
    fn remove_creator_follower() {
        let idx = UserStateIndex::new();
        idx.add_creator_follower(100, 1);
        idx.add_creator_follower(100, 2);

        idx.remove_creator_follower(100, 1);
        let bm = idx.followers_bitmap(100);
        assert_eq!(bm.len(), 1);
        assert!(!bm.contains(1));
        assert!(bm.contains(2));

        // Removing from unknown creator is a no-op.
        idx.remove_creator_follower(999, 1);
    }

    #[test]
    fn creator_followers_preserve_full_u64() {
        // Regression: the reverse index previously stored follower IDs as u32,
        // which aliased high user IDs onto each other (e.g. `1` and
        // `1 + 2^32` collide once truncated). Full-u64 storage keeps them
        // distinct.
        let idx = UserStateIndex::new();
        let low = 1u64;
        let high = 1u64 + (1u64 << 32); // shares low 32 bits with `low`
        let huge = u64::MAX - 7; // far outside u32 range

        idx.add_creator_follower(100, low);
        idx.add_creator_follower(100, high);
        idx.add_creator_follower(100, huge);

        let bm = idx.followers_bitmap(100);
        assert_eq!(bm.len(), 3, "u32 truncation would have collapsed low/high");
        assert!(bm.contains(low));
        assert!(bm.contains(high));
        assert!(bm.contains(huge));

        let ids = idx.follower_ids(100);
        assert_eq!(ids.len(), 3);
        assert!(ids.contains(&low));
        assert!(ids.contains(&high));
        assert!(ids.contains(&huge));

        // Removing the truncation-colliding `high` must not affect `low`.
        idx.remove_creator_follower(100, high);
        let bm = idx.followers_bitmap(100);
        assert!(bm.contains(low));
        assert!(!bm.contains(high));
        assert!(bm.contains(huge));
    }

    #[test]
    fn completion_tracking() {
        let idx = UserStateIndex::new();
        idx.record_completion(1, 10, 0.5);
        assert_eq!(idx.get_completion(1, 10), Some(0.5));
        assert!(idx.is_in_progress(1, 10, 0.8));
        assert!(!idx.is_in_progress(1, 10, 0.4)); // 0.5 >= 0.4

        idx.record_completion(1, 10, 0.9);
        assert!(!idx.is_in_progress(1, 10, 0.8)); // 0.9 >= 0.8
    }

    #[test]
    fn in_progress_items_bitmap() {
        let idx = UserStateIndex::new();
        idx.record_completion(1, 10, 0.3); // in progress
        idx.record_completion(1, 20, 0.9); // complete
        idx.record_completion(1, 30, 0.0); // not started
        idx.record_completion(1, 40, 0.5); // in progress
        idx.record_completion(2, 50, 0.4); // different user

        let bm = idx.in_progress_items(1, 0.8);
        assert_eq!(bm.len(), 2);
        assert!(bm.contains(10));
        assert!(bm.contains(40));
        assert!(!bm.contains(20)); // >= threshold
        assert!(!bm.contains(30)); // == 0.0
        assert!(!bm.contains(50)); // different user
    }

    #[test]
    fn in_progress_items_isolates_users_under_shared_item_ids() {
        // Regression for the per-user completion keying: many users completing
        // the *same* item IDs must not bleed into each other's in-progress set.
        // Previously the global (user_id, item_id) map was scanned in full per
        // call; the per-user map makes each query touch only its own user.
        let idx = UserStateIndex::new();
        for uid in 0..100u64 {
            // Every user has item 7 in progress and item 8 complete.
            idx.record_completion(uid, 7, 0.5);
            idx.record_completion(uid, 8, 0.95);
        }
        // User 42 additionally has item 9 in progress.
        idx.record_completion(42, 9, 0.25);

        let bm = idx.in_progress_items(42, 0.8);
        assert_eq!(bm.len(), 2, "only user 42's in-progress items");
        assert!(bm.contains(7));
        assert!(bm.contains(9));
        assert!(!bm.contains(8)); // >= threshold

        // A user without the extra item sees only the shared in-progress item.
        let other = idx.in_progress_items(7, 0.8);
        assert_eq!(other.len(), 1);
        assert!(other.contains(7));
        assert!(!other.contains(9)); // belongs to user 42 only

        // get_completion / is_in_progress agree with the per-user map.
        assert_eq!(idx.get_completion(42, 9), Some(0.25));
        assert_eq!(idx.get_completion(7, 9), None);
        assert!(idx.is_in_progress(42, 9, 0.8));
        assert!(!idx.is_in_progress(7, 9, 0.8));
    }
}
