//! Hard-negative tracking: per-user explicit rejections.
//!
//! Tracks items a user has explicitly rejected (skip, hide, dislike, block).
//! Hard negatives are used to exclude items from personalized ranking
//! and to downweight similar items in the preference vector.
//!
//! # Durability
//!
//! The in-memory index is a `DashMap<u64, RoaringBitmap>` for O(1) lookup, but
//! it is NOT the source of truth: each `(user, item)` rejection is also written
//! write-through as a durable `Tag::HardNeg` row by the signal path
//! ([`TidalDb::signal_with_context`](crate::TidalDb::signal_with_context)), with
//! the same durability class as the base signal (zero loss window). On open,
//! [`HardNegIndex::restore`] scans those rows and repopulates the bitmaps. The
//! WAL signal record carries no per-user identity, so these durable rows — not
//! WAL replay — are what makes a `skip`/`dislike` survive a hard crash.

use dashmap::DashMap;
use roaring::RoaringBitmap;

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

/// Signal types that constitute a hard negative.
pub const HARD_NEG_SIGNALS: &[&str] = &["skip", "hide", "dislike", "block"];

/// Per-user hard-negative bitmap index.
///
/// Each user has a `RoaringBitmap` of item IDs they have explicitly
/// rejected. The bitmap is repopulated on open by [`HardNegIndex::restore`]
/// from the durable `Tag::HardNeg` rows the signal path writes write-through,
/// and updated in real-time as new signals arrive (the in-memory `add` is paired
/// with a synchronous durable write at the call site).
///
/// Thread-safe via `DashMap` -- concurrent reads and writes to different
/// users never contend.
///
/// # Replication
///
/// Hard-negative ordering across nodes is resolved one layer up, in the
/// reconciliation engine: each `(user, item)` decision is an
/// `LWWRegister<HardNegAction>` carrying its own HLC timestamp, and
/// `ReconciliationEngine::plan` LWW-resolves the winner before
/// `ReconciliationEngine::apply` commits it via [`add`](Self::add) /
/// [`remove`](Self::remove). This index therefore stores only the resolved
/// membership set and does not carry per-item HLC bookkeeping of its own --
/// duplicating that resolution here would create two sources of truth for the
/// same ordering decision.
pub struct HardNegIndex {
    inner: DashMap<u64, RoaringBitmap>,
}

impl HardNegIndex {
    #[must_use]
    pub fn new() -> Self {
        Self {
            inner: DashMap::new(),
        }
    }

    /// Record a hard-negative event: user rejected item.
    pub fn add(&self, user_id: u64, item_id: u32) {
        self.inner.entry(user_id).or_default().insert(item_id);
    }

    /// Remove a hard negative for a user-item pair.
    ///
    /// No-op if the user has no bitmap or the item is not in it.
    pub fn remove(&self, user_id: u64, item_id: u32) {
        if let Some(mut bm) = self.inner.get_mut(&user_id) {
            bm.remove(item_id);
        }
    }

    /// Check if a user has rejected a specific item.
    #[must_use]
    pub fn is_negative(&self, user_id: u64, item_id: u32) -> bool {
        self.inner
            .get(&user_id)
            .is_some_and(|bm| bm.contains(item_id))
    }

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

    /// Returns a predicate that excludes hard-negative items for a user.
    #[must_use]
    pub fn exclusion_predicate(&self, user_id: u64) -> Box<dyn Fn(u32) -> bool + Send + Sync> {
        let bitmap = self.bitmap(user_id);
        Box::new(move |item_id: u32| !bitmap.contains(item_id))
    }

    /// Number of hard negatives recorded for a user.
    #[must_use]
    pub fn count(&self, user_id: u64) -> u64 {
        self.inner.get(&user_id).map_or(0, |bm| bm.len())
    }

    /// Check if a signal type constitutes a hard negative.
    #[must_use]
    pub fn is_hard_neg_signal(signal_type: &str) -> bool {
        HARD_NEG_SIGNALS.contains(&signal_type)
    }

    /// Collect every `(user_id, item_id)` hard-negative pair currently held in
    /// memory.
    ///
    /// Used by `TidalDb::take_crdt_snapshot` to materialize the local
    /// hard-negative membership into per-pair LWW registers for reconciliation.
    /// This index stores only resolved membership (no per-item HLC); the caller
    /// stamps each pair with a timestamp when building the snapshot. The returned
    /// vector is a point-in-time copy, so it does not hold any `DashMap` shard
    /// lock.
    #[must_use]
    pub(crate) fn all_pairs(&self) -> Vec<(u64, u32)> {
        let mut pairs = Vec::new();
        for entry in &self.inner {
            let user_id = *entry.key();
            for item_id in entry.value() {
                pairs.push((user_id, item_id));
            }
        }
        pairs
    }
}

// ── Durable rows ─────────────────────────────────────────────────────────────

/// The durable storage key for one `(user, item)` hard-negative row.
///
/// All hard-neg rows share the sentinel entity id (`0`) so [`HardNegIndex::restore`]
/// can target them with a single prefix scan rather than a full table scan.
///
/// Key format: `encode_key(EntityId(0), Tag::HardNeg, [user: 8B BE, item: 4B BE])`.
/// The value is empty — presence of the key is the fact. The item is stored as
/// `u32` because the in-memory `RoaringBitmap` is u32-keyed; this matches the
/// truncation the signal path already applies at `entity_id.as_u64() as u32`.
#[must_use]
pub fn hard_neg_key(user_id: u64, item_id: u32) -> Vec<u8> {
    let mut suffix = [0u8; 12];
    suffix[..8].copy_from_slice(&user_id.to_be_bytes());
    suffix[8..].copy_from_slice(&item_id.to_be_bytes());
    encode_key(EntityId::new(0), Tag::HardNeg, &suffix)
}

impl HardNegIndex {
    /// Persist a `(user, item)` hard-negative as a durable `Tag::HardNeg` row.
    ///
    /// Called write-through by the signal path so a `skip`/`dislike` survives a
    /// hard crash with zero loss window. The in-memory [`add`](Self::add) is the
    /// paired hot-path mutation; this is its durable backing.
    ///
    /// # Errors
    ///
    /// Returns storage errors from the underlying engine.
    pub fn persist(
        &self,
        storage: &dyn StorageEngine,
        user_id: u64,
        item_id: u32,
    ) -> crate::Result<()> {
        storage
            .put(&hard_neg_key(user_id, item_id), &[])
            .map_err(crate::schema::TidalError::from)
    }

    /// Restore the in-memory bitmaps from durable `Tag::HardNeg` rows on open.
    ///
    /// Uses a targeted prefix scan over the sentinel entity (`0`), so restore is
    /// `O(hard_neg_rows)` rather than `O(total_keys)`.
    ///
    /// # Errors
    ///
    /// Returns storage errors from the underlying engine.
    pub fn restore(&self, storage: &dyn StorageEngine) -> crate::Result<()> {
        let prefix = entity_tag_prefix(EntityId::new(0), Tag::HardNeg);
        let mut count = 0u64;
        for entry in storage.scan_prefix(&prefix) {
            let (key, _) = entry.map_err(crate::schema::TidalError::from)?;
            if let Some((_, Tag::HardNeg, suffix)) = parse_key(&key) {
                if suffix.len() < 12 {
                    continue;
                }
                let user_id = u64::from_be_bytes([
                    suffix[0], suffix[1], suffix[2], suffix[3], suffix[4], suffix[5], suffix[6],
                    suffix[7],
                ]);
                let item_id = u32::from_be_bytes([suffix[8], suffix[9], suffix[10], suffix[11]]);
                self.add(user_id, item_id);
                count += 1;
            }
        }
        if count > 0 {
            tracing::info!(
                rows = count,
                "hard-negative index restored from durable rows"
            );
        }
        Ok(())
    }
}

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

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

    #[test]
    fn add_and_check() {
        let idx = HardNegIndex::new();
        assert!(!idx.is_negative(1, 100));
        idx.add(1, 100);
        assert!(idx.is_negative(1, 100));
        assert!(!idx.is_negative(1, 200));
        assert!(!idx.is_negative(2, 100));
    }

    #[test]
    fn bitmap_returns_empty_for_unknown_user() {
        let idx = HardNegIndex::new();
        let bm = idx.bitmap(999);
        assert!(bm.is_empty());
    }

    #[test]
    fn exclusion_predicate_excludes_negatives() {
        let idx = HardNegIndex::new();
        idx.add(1, 10);
        idx.add(1, 20);
        let pred = idx.exclusion_predicate(1);
        assert!(!pred(10));
        assert!(!pred(20));
        assert!(pred(30));
    }

    #[test]
    fn count_tracks_negatives() {
        let idx = HardNegIndex::new();
        assert_eq!(idx.count(1), 0);
        idx.add(1, 10);
        idx.add(1, 20);
        assert_eq!(idx.count(1), 2);
    }

    #[test]
    fn is_hard_neg_signal_checks_known_types() {
        assert!(HardNegIndex::is_hard_neg_signal("skip"));
        assert!(HardNegIndex::is_hard_neg_signal("hide"));
        assert!(HardNegIndex::is_hard_neg_signal("dislike"));
        assert!(HardNegIndex::is_hard_neg_signal("block"));
        assert!(!HardNegIndex::is_hard_neg_signal("view"));
        assert!(!HardNegIndex::is_hard_neg_signal("like"));
    }

    #[test]
    fn add_then_remove_roundtrips() {
        // The bitmap API is the only path the reconciliation engine drives:
        // it LWW-resolves the winner per (user, item) and commits Hide via
        // add() / Unhide via remove(). Verify that round-trip.
        let idx = HardNegIndex::new();
        idx.add(10, 1);
        assert!(idx.is_negative(10, 1));
        idx.remove(10, 1);
        assert!(!idx.is_negative(10, 1));
        // Removing an item that was never added is a no-op.
        idx.remove(10, 2);
        assert!(!idx.is_negative(10, 2));
    }
}
