//! Per-user, per-creator daily notification delivery tracking.
//!
//! `NotificationTracker` records how many notifications have been delivered
//! to each `(user, creator)` pair per calendar day (days since epoch).
//! The executor consults this state when `NotificationCaps` are set on
//! a RETRIEVE query.

use dashmap::DashMap;

/// Tracks notification deliveries per `(user, creator, day)` and per `(user, day)`.
///
/// Thread-safe via `DashMap`. Each `(user_id, creator_id, day)` triple
/// maps to the number of notifications delivered on that day. A separate
/// `(user_id, day)` map tracks the cross-request total for `max_total_per_day`
/// enforcement across multiple RETRIEVE calls.
///
/// # Memory Management
///
/// Entries accumulate over time; call [`evict_old_days`](Self::evict_old_days)
/// periodically (e.g., on startup or daily) to remove stale entries. Without
/// eviction the maps grow by O(users × creators) per day of operation.
pub struct NotificationTracker {
    /// `(user_id, creator_id, days_since_epoch)` -> count delivered today.
    delivered: DashMap<(u64, u64, u32), u32>,
    /// `(user_id, days_since_epoch)` -> total notifications delivered today.
    ///
    /// Tracks cross-request totals for `max_total_per_day` enforcement.
    user_total: DashMap<(u64, u32), u32>,
}

impl NotificationTracker {
    /// Create a new empty tracker.
    #[must_use]
    pub fn new() -> Self {
        Self {
            delivered: DashMap::new(),
            user_total: DashMap::new(),
        }
    }

    /// Record a delivery for `(user, creator)` on `day`.
    ///
    /// Increments both the per-creator count and the per-user total.
    pub fn record(&self, user_id: u64, creator_id: u64, day: u32) {
        *self
            .delivered
            .entry((user_id, creator_id, day))
            .or_insert(0) += 1;
        *self.user_total.entry((user_id, day)).or_insert(0) += 1;
    }

    /// Get today's delivery count for a user-creator pair.
    #[must_use]
    pub fn count_today(&self, user_id: u64, creator_id: u64, day: u32) -> u32 {
        self.delivered
            .get(&(user_id, creator_id, day))
            .map_or(0, |c| *c)
    }

    /// Get the total number of notifications delivered to `user_id` on `day`,
    /// across all creators. Used for cross-request `max_total_per_day` enforcement.
    #[must_use]
    pub fn count_total_today(&self, user_id: u64, day: u32) -> u32 {
        self.user_total.get(&(user_id, day)).map_or(0, |c| *c)
    }

    /// Check whether delivering one more notification to `(user, creator)` on `day`
    /// would exceed the per-creator cap.
    #[must_use]
    pub fn would_exceed_per_creator(
        &self,
        user_id: u64,
        creator_id: u64,
        day: u32,
        max_per_creator: u32,
    ) -> bool {
        self.count_today(user_id, creator_id, day) >= max_per_creator
    }

    /// Check whether the user has already reached the daily total cap (cross-request).
    #[must_use]
    pub fn would_exceed_total(&self, user_id: u64, day: u32, max_total: u32) -> bool {
        self.count_total_today(user_id, day) >= max_total
    }

    /// Evict entries for days older than `current_day - keep_days`.
    ///
    /// Call this periodically (e.g., on startup or daily) to prevent unbounded
    /// memory growth. Entries with `day < current_day - keep_days` are removed;
    /// entries for `day >= current_day - keep_days` (i.e. the last `keep_days + 1`
    /// days including today) are retained.
    ///
    /// # Example
    ///
    /// ```ignore
    /// // Keep only today and yesterday; evict everything older.
    /// tracker.evict_old_days(today, 1);
    /// ```
    pub fn evict_old_days(&self, current_day: u32, keep_days: u32) {
        let oldest_to_keep = current_day.saturating_sub(keep_days);
        self.delivered.retain(|k, _| k.2 >= oldest_to_keep);
        self.user_total.retain(|k, _| k.1 >= oldest_to_keep);
    }
}

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

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

    #[test]
    fn record_and_count() {
        let tracker = NotificationTracker::new();
        assert_eq!(tracker.count_today(1, 100, 19_700), 0);
        tracker.record(1, 100, 19_700);
        assert_eq!(tracker.count_today(1, 100, 19_700), 1);
        tracker.record(1, 100, 19_700);
        assert_eq!(tracker.count_today(1, 100, 19_700), 2);
        // Different day is independent.
        assert_eq!(tracker.count_today(1, 100, 19_701), 0);
    }

    #[test]
    fn would_exceed() {
        let tracker = NotificationTracker::new();
        assert!(!tracker.would_exceed_per_creator(1, 100, 19_700, 2));
        tracker.record(1, 100, 19_700);
        assert!(!tracker.would_exceed_per_creator(1, 100, 19_700, 2));
        tracker.record(1, 100, 19_700);
        assert!(tracker.would_exceed_per_creator(1, 100, 19_700, 2));
    }

    #[test]
    fn cross_request_total_tracking() {
        let tracker = NotificationTracker::new();
        let day = 19_700u32;

        // Initial total is zero.
        assert_eq!(tracker.count_total_today(1, day), 0);
        assert!(!tracker.would_exceed_total(1, day, 3));

        // Record from two different creators.
        tracker.record(1, 100, day);
        tracker.record(1, 200, day);
        assert_eq!(tracker.count_total_today(1, day), 2);
        assert!(!tracker.would_exceed_total(1, day, 3));

        tracker.record(1, 300, day);
        assert_eq!(tracker.count_total_today(1, day), 3);
        assert!(tracker.would_exceed_total(1, day, 3));
    }

    #[test]
    fn total_is_independent_across_days() {
        let tracker = NotificationTracker::new();
        tracker.record(1, 100, 19_700);
        tracker.record(1, 200, 19_700);
        // Day 19_701 starts fresh.
        assert_eq!(tracker.count_total_today(1, 19_701), 0);
    }

    #[test]
    fn evict_old_days_removes_stale_entries() {
        let tracker = NotificationTracker::new();
        let current_day = 100u32;

        // Record on old days and current day.
        tracker.record(1, 100, 97); // 3 days ago
        tracker.record(1, 100, 98); // 2 days ago
        tracker.record(1, 100, 99); // yesterday
        tracker.record(1, 100, 100); // today

        // Evict entries older than 1 day (keep today and yesterday).
        tracker.evict_old_days(current_day, 1);

        assert_eq!(
            tracker.count_today(1, 100, 97),
            0,
            "3 days ago should be evicted"
        );
        assert_eq!(
            tracker.count_today(1, 100, 98),
            0,
            "2 days ago should be evicted"
        );
        assert_eq!(
            tracker.count_today(1, 100, 99),
            1,
            "yesterday should be retained"
        );
        assert_eq!(
            tracker.count_today(1, 100, 100),
            1,
            "today should be retained"
        );

        // Total counts should also be evicted.
        assert_eq!(tracker.count_total_today(1, 97), 0);
        assert_eq!(tracker.count_total_today(1, 99), 1);
    }

    mod proptests {
        use super::*;
        use proptest::prelude::*;

        proptest! {
            /// Cross-user isolation: recording for user A does not affect user B.
            #[test]
            fn cross_user_isolation(
                user_a in 1u64..100,
                creator in 1u64..50,
                day in 1u32..100,
                recs in 1u32..10,
            ) {
                let user_b = user_a + 1000; // guaranteed different
                let tracker = NotificationTracker::new();
                for _ in 0..recs {
                    tracker.record(user_a, creator, day);
                }
                prop_assert_eq!(tracker.count_today(user_b, creator, day), 0);
                prop_assert_eq!(tracker.count_total_today(user_b, day), 0);
            }

            /// Cross-day isolation: recording on day D does not affect day D+1.
            #[test]
            fn cross_day_isolation(
                user in 1u64..100,
                creator in 1u64..50,
                day in 1u32..100,
                recs in 1u32..10,
            ) {
                let tracker = NotificationTracker::new();
                for _ in 0..recs {
                    tracker.record(user, creator, day);
                }
                prop_assert_eq!(tracker.count_today(user, creator, day + 1), 0);
                prop_assert_eq!(tracker.count_total_today(user, day + 1), 0);
            }

            /// Monotonic growth: count_today and count_total_today only increase.
            #[test]
            fn monotonic_growth(
                user in 1u64..100,
                creator in 1u64..50,
                day in 1u32..100,
                recs in 1u32..20,
            ) {
                let tracker = NotificationTracker::new();
                for i in 1..=recs {
                    tracker.record(user, creator, day);
                    prop_assert_eq!(tracker.count_today(user, creator, day), i);
                    prop_assert_eq!(tracker.count_total_today(user, day), i);
                }
            }

            /// Per-creator counts and totals stay consistent.
            #[test]
            fn per_creator_and_total_consistency(
                user in 1u64..10,
                creator_a in 1u64..50,
                creator_b in 51u64..100,
                day in 1u32..100,
                recs_a in 1u32..5,
                recs_b in 1u32..5,
            ) {
                let tracker = NotificationTracker::new();
                for _ in 0..recs_a {
                    tracker.record(user, creator_a, day);
                }
                for _ in 0..recs_b {
                    tracker.record(user, creator_b, day);
                }
                prop_assert_eq!(tracker.count_today(user, creator_a, day), recs_a);
                prop_assert_eq!(tracker.count_today(user, creator_b, day), recs_b);
                prop_assert_eq!(
                    tracker.count_total_today(user, day),
                    recs_a + recs_b,
                    "total must equal sum of per-creator counts"
                );
            }
        }
    }
}
