//! Hard-negative tracking: per-user explicit rejections.
//!
//! Tracks items a user has explicitly rejected (skip, hide, dislike).
//! Hard negatives are used to exclude items from personalized ranking
//! and to downweight similar items in the preference vector.
//!
//! The index is in-memory (rebuilt from signals on startup) and provides
//! O(1) lookup via `RoaringBitmap`.

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

use crate::replication::crdt::HlcTimestamp;

/// 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 populated from signal events during startup
/// and updated in real-time as new signals arrive.
///
/// Thread-safe via `DashMap` -- concurrent reads and writes to different
/// users never contend.
pub struct HardNegIndex {
    inner: DashMap<u64, RoaringBitmap>,
    /// Highest hide HLC timestamp seen per (`user_id`, `item_id`).
    /// Used for union semantics: unhide only clears if its ts > max hide ts.
    hide_ts: DashMap<(u64, u32), HlcTimestamp>,
}

impl HardNegIndex {
    #[must_use]
    pub fn new() -> Self {
        Self {
            inner: DashMap::new(),
            hide_ts: 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)
    }

    // -----------------------------------------------------------------
    // Replication-aware methods (HLC-timestamped, union semantics)
    // -----------------------------------------------------------------

    /// Apply a hide from replication (union semantics).
    ///
    /// Always adds to the bitmap (hide from any shard wins during convergence).
    /// Records the highest HLC timestamp seen for this (user, item) pair.
    ///
    /// This is the replication path -- local hides still use [`add`].
    pub fn apply_replication_hide(&self, user_id: u64, item_id: u32, ts: HlcTimestamp) {
        self.inner.entry(user_id).or_default().insert(item_id);
        let mut entry = self.hide_ts.entry((user_id, item_id)).or_insert(ts);
        if ts > *entry {
            *entry = ts;
        }
    }

    /// Apply an unhide from replication (union semantics).
    ///
    /// Only removes from the bitmap if `ts` strictly beats the highest recorded
    /// hide timestamp for this (user, item) pair.  This ensures that during
    /// convergence, a hide with a later HLC always wins over an earlier unhide.
    ///
    /// Returns `true` if the item was actually removed from the bitmap.
    #[must_use]
    pub fn apply_replication_unhide(&self, user_id: u64, item_id: u32, ts: HlcTimestamp) -> bool {
        let max_hide_ts = self.hide_ts.get(&(user_id, item_id)).map(|v| *v);
        match max_hide_ts {
            Some(hide_ts) if ts > hide_ts => {
                // Unhide wins: ts is strictly later than the latest hide.
                if let Some(mut bm) = self.inner.get_mut(&user_id) {
                    bm.remove(item_id);
                }
                // Remove the hide_ts entry (no longer hidden).
                self.hide_ts.remove(&(user_id, item_id));
                true
            }
            Some(_) => {
                // Hide wins: unhide's ts is not later than the hide's ts.
                false
            }
            None => {
                // No recorded hide -- safe to remove (legacy local hide or pre-HLC).
                if let Some(mut bm) = self.inner.get_mut(&user_id) {
                    bm.remove(item_id);
                }
                true
            }
        }
    }

    /// Highest recorded hide timestamp for (`user_id`, `item_id`), if any.
    ///
    /// Returns `None` if the item has not been hidden via the replication path.
    #[must_use]
    pub fn hide_timestamp(&self, user_id: u64, item_id: u32) -> Option<HlcTimestamp> {
        self.hide_ts.get(&(user_id, item_id)).map(|v| *v)
    }
}

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

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

    fn ts(wall_ns: u64, logical: u32, node_id: u16) -> HlcTimestamp {
        HlcTimestamp {
            wall_ns,
            logical,
            node_id,
        }
    }

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

    // -----------------------------------------------------------------
    // Replication-aware method tests
    // -----------------------------------------------------------------

    #[test]
    fn apply_replication_hide_adds_to_bitmap() {
        let idx = HardNegIndex::new();
        idx.apply_replication_hide(1, 100, ts(1000, 0, 0));
        assert!(idx.is_negative(1, 100));
        assert_eq!(idx.hide_timestamp(1, 100), Some(ts(1000, 0, 0)));
    }

    #[test]
    fn apply_replication_unhide_with_higher_ts_clears() {
        let idx = HardNegIndex::new();
        idx.apply_replication_hide(1, 200, ts(100, 0, 0));
        let cleared = idx.apply_replication_unhide(1, 200, ts(200, 0, 0));
        assert!(cleared, "unhide with higher ts should succeed");
        assert!(!idx.is_negative(1, 200));
        assert_eq!(idx.hide_timestamp(1, 200), None);
    }

    #[test]
    fn apply_replication_unhide_with_lower_ts_is_blocked() {
        let idx = HardNegIndex::new();
        // Hide at t=100
        idx.apply_replication_hide(1, 300, ts(100, 0, 0));
        // Unhide at t=50 (earlier -- should NOT clear the hide)
        let cleared = idx.apply_replication_unhide(1, 300, ts(50, 0, 1));
        assert!(
            !cleared,
            "unhide with lower ts should be blocked by union semantics"
        );
        assert!(idx.is_negative(1, 300), "item should still be hidden");
    }

    #[test]
    fn apply_replication_unhide_equal_ts_is_blocked() {
        let idx = HardNegIndex::new();
        idx.apply_replication_hide(1, 400, ts(100, 0, 0));
        // Same timestamp -- ts is NOT strictly greater, so blocked.
        let cleared = idx.apply_replication_unhide(1, 400, ts(100, 0, 0));
        assert!(!cleared);
        assert!(idx.is_negative(1, 400));
    }

    #[test]
    fn union_semantics_hide_from_any_shard_wins() {
        let idx = HardNegIndex::new();
        // Shard A hides at t=100
        idx.apply_replication_hide(2, 500, ts(100, 0, 0));
        // Shard B had an older unhide at t=50 (arrives after)
        let cleared = idx.apply_replication_unhide(2, 500, ts(50, 0, 1));
        assert!(!cleared);
        assert!(idx.is_negative(2, 500), "hide from shard A still wins");
    }

    #[test]
    fn multiple_hides_track_highest_ts() {
        let idx = HardNegIndex::new();
        idx.apply_replication_hide(3, 600, ts(50, 0, 0));
        idx.apply_replication_hide(3, 600, ts(200, 0, 1));
        idx.apply_replication_hide(3, 600, ts(100, 0, 2));
        // Highest hide ts is 200.
        assert_eq!(idx.hide_timestamp(3, 600), Some(ts(200, 0, 1)));
        // Unhide at ts=150 is blocked (150 < 200).
        assert!(!idx.apply_replication_unhide(3, 600, ts(150, 0, 0)));
        // Unhide at ts=300 succeeds (300 > 200).
        assert!(idx.apply_replication_unhide(3, 600, ts(300, 0, 0)));
        assert!(!idx.is_negative(3, 600));
    }

    #[test]
    fn existing_api_unchanged() {
        // Verify the bitmap API still works exactly as before.
        let idx = HardNegIndex::new();
        idx.add(10, 1);
        assert!(idx.is_negative(10, 1));
        idx.remove(10, 1);
        assert!(!idx.is_negative(10, 1));
        // apply_replication_unhide on locally-added item (no hide_ts) also removes it.
        idx.add(10, 2);
        let _ = idx.apply_replication_unhide(10, 2, ts(1, 0, 0));
        assert!(!idx.is_negative(10, 2));
    }
}
