//! Interaction weight ledger: per-(user, creator) interaction strength.
//!
//! Tracks how strongly a user interacts with each creator. The weight
//! is updated with lazy exponential decay -- only applied on read, not
//! continuously. This gives O(1) writes and O(1) reads.
//!
//! Used by the `for_you` profile to boost items from creators the user
//! engages with, and by the `following` profile to order followed creators
//! by interaction strength.

use dashmap::DashMap;

/// Per-(user, creator) interaction weight with lazy decay.
#[derive(Debug, Clone)]
struct InteractionEntry {
    /// Running score: sum of weighted interactions, decayed lazily.
    score: f64,
    /// Timestamp (nanoseconds) of the last update.
    last_update_ns: u64,
}

/// Signal weights for different interaction types.
///
/// Used to convert raw signals into interaction weight increments.
/// Higher weights mean the interaction is a stronger signal of interest.
pub struct InteractionWeights {
    /// Weight for a "view" signal.
    pub view: f64,
    /// Weight for a "like" signal.
    pub like: f64,
    /// Weight for a "share" signal.
    pub share: f64,
    /// Weight for a "completion" signal.
    pub completion: f64,
    /// Weight for a "save" signal.
    pub save: f64,
}

impl Default for InteractionWeights {
    fn default() -> Self {
        Self {
            view: 1.0,
            like: 3.0,
            share: 5.0,
            completion: 2.0,
            save: 4.0,
        }
    }
}

/// Per-(user, creator) interaction weight ledger.
///
/// Structured as a nested `DashMap`: outer key is `user_id`, inner key is
/// `creator_id`. This makes `top_creators(user_id)` an O(M) scan over
/// that user's creators rather than O(N*M) over all user-creator pairs.
///
/// Thread-safe via `DashMap` -- concurrent updates to different users
/// never contend.
pub struct InteractionLedger {
    /// `user_id` -> (`creator_id` -> entry)
    inner: DashMap<u64, DashMap<u64, InteractionEntry>>,
    /// Decay half-life in seconds. Default: 7 days.
    #[allow(dead_code)]
    half_life_secs: f64,
    /// Precomputed: `ln(2) / half_life_secs`.
    lambda: f64,
}

impl InteractionLedger {
    /// Create a new interaction ledger with a 7-day half-life.
    #[must_use]
    pub fn new() -> Self {
        Self::with_half_life(7.0 * 24.0 * 3600.0)
    }

    /// Create with a custom half-life (in seconds).
    #[must_use]
    pub fn with_half_life(half_life_secs: f64) -> Self {
        let lambda = std::f64::consts::LN_2 / half_life_secs;
        Self {
            inner: DashMap::new(),
            half_life_secs,
            lambda,
        }
    }

    /// Record an interaction between a user and a creator.
    ///
    /// Applies lazy decay since the last update, then adds the new weight.
    /// This is the O(1) running score trick: `S(t) = S(prev) * e^(-lambda * dt) + w`.
    ///
    /// # At-Most-Once Semantics
    /// This method is not idempotent. Callers must guarantee that `record()` is called
    /// at most once per signal event. Do not call from WAL replay paths.
    pub fn record(&self, user_id: u64, creator_id: u64, weight: f64, timestamp_ns: u64) {
        let user_map = self.inner.entry(user_id).or_default();
        let mut entry = user_map.entry(creator_id).or_insert(InteractionEntry {
            score: 0.0,
            last_update_ns: timestamp_ns,
        });

        #[allow(clippy::cast_precision_loss)]
        let dt_secs = if timestamp_ns > entry.last_update_ns {
            (timestamp_ns - entry.last_update_ns) as f64 / 1_000_000_000.0
        } else {
            0.0
        };

        // Lazy decay + accumulate.
        entry.score = entry.score.mul_add((-self.lambda * dt_secs).exp(), weight);
        entry.last_update_ns = timestamp_ns;
    }

    /// Read the current interaction weight for a (user, creator) pair.
    ///
    /// Applies lazy decay to the current time. Returns 0.0 if no interaction
    /// has been recorded.
    #[must_use]
    pub fn score(&self, user_id: u64, creator_id: u64, now_ns: u64) -> f64 {
        let Some(user_map) = self.inner.get(&user_id) else {
            return 0.0;
        };
        let Some(entry) = user_map.get(&creator_id) else {
            return 0.0;
        };

        #[allow(clippy::cast_precision_loss)]
        let dt_secs = if now_ns > entry.last_update_ns {
            (now_ns - entry.last_update_ns) as f64 / 1_000_000_000.0
        } else {
            0.0
        };

        entry.score * (-self.lambda * dt_secs).exp()
    }

    /// Get the top-N creators for a user, sorted by decayed interaction weight.
    ///
    /// O(M) where M is the number of creators this user has interacted with,
    /// instead of O(N*M) over all user-creator pairs.
    ///
    /// Returns `(creator_id, score)` pairs in descending score order.
    #[must_use]
    pub fn top_creators(&self, user_id: u64, n: usize, now_ns: u64) -> Vec<(u64, f64)> {
        let Some(user_map) = self.inner.get(&user_id) else {
            return Vec::new();
        };

        let mut scored: Vec<(u64, f64)> = user_map
            .iter()
            .map(|entry| {
                let creator_id = *entry.key();
                #[allow(clippy::cast_precision_loss)]
                let score = {
                    let dt_secs = if now_ns > entry.last_update_ns {
                        (now_ns - entry.last_update_ns) as f64 / 1_000_000_000.0
                    } else {
                        0.0
                    };
                    entry.score * (-self.lambda * dt_secs).exp()
                };
                (creator_id, score)
            })
            .filter(|(_, s)| *s > f64::EPSILON)
            .collect();

        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
        scored.truncate(n);
        scored
    }

    /// Number of unique users tracked.
    #[must_use]
    pub fn user_count(&self) -> usize {
        self.inner.len()
    }

    /// Total number of (user, creator) pairs tracked across all users.
    #[must_use]
    pub fn len(&self) -> usize {
        self.inner.iter().map(|r| r.value().len()).sum()
    }

    /// Whether the ledger is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.inner.is_empty()
    }
}

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

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

    #[test]
    fn record_and_score() {
        let ledger = InteractionLedger::new();
        let base_ns = 1_000_000_000_000u64;
        ledger.record(1, 100, 5.0, base_ns);
        // Score immediately after (no decay).
        let s = ledger.score(1, 100, base_ns);
        assert!((s - 5.0).abs() < 1e-6);
    }

    #[test]
    fn score_decays_over_time() {
        let ledger = InteractionLedger::with_half_life(10.0); // 10-second half-life
        let base_ns = 1_000_000_000_000u64;
        ledger.record(1, 100, 10.0, base_ns);

        // After 10 seconds (one half-life), score should be ~5.0.
        let s = ledger.score(1, 100, base_ns + 10_000_000_000);
        assert!((s - 5.0).abs() < 0.1);

        // After 20 seconds (two half-lives), score should be ~2.5.
        let s = ledger.score(1, 100, base_ns + 20_000_000_000);
        assert!((s - 2.5).abs() < 0.1);
    }

    #[test]
    fn score_unknown_pair() {
        let ledger = InteractionLedger::new();
        assert_eq!(ledger.score(1, 100, 1_000_000_000_000), 0.0);
    }

    #[test]
    fn accumulate_signals() {
        let ledger = InteractionLedger::new();
        let base_ns = 1_000_000_000_000u64;
        ledger.record(1, 100, 3.0, base_ns);
        ledger.record(1, 100, 2.0, base_ns);
        let s = ledger.score(1, 100, base_ns);
        assert!((s - 5.0).abs() < 1e-6);
    }

    #[test]
    fn top_creators_sorted() {
        let ledger = InteractionLedger::new();
        let now = 1_000_000_000_000u64;
        ledger.record(1, 100, 5.0, now);
        ledger.record(1, 200, 10.0, now);
        ledger.record(1, 300, 1.0, now);

        let top = ledger.top_creators(1, 2, now);
        assert_eq!(top.len(), 2);
        assert_eq!(top[0].0, 200); // highest score
        assert_eq!(top[1].0, 100);
    }

    #[test]
    fn top_creators_different_user() {
        let ledger = InteractionLedger::new();
        let now = 1_000_000_000_000u64;
        ledger.record(1, 100, 5.0, now);
        ledger.record(2, 200, 10.0, now);

        let top = ledger.top_creators(1, 10, now);
        assert_eq!(top.len(), 1);
        assert_eq!(top[0].0, 100);
    }

    #[test]
    fn len_and_is_empty() {
        let ledger = InteractionLedger::new();
        assert!(ledger.is_empty());
        ledger.record(1, 100, 1.0, 1_000_000_000_000);
        assert_eq!(ledger.len(), 1);
        assert!(!ledger.is_empty());
    }

    #[test]
    fn default_weights() {
        let w = InteractionWeights::default();
        assert!(w.share > w.like);
        assert!(w.like > w.view);
    }

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

        proptest! {
            /// Decayed score is always non-negative for non-negative weights.
            #[test]
            fn decay_non_negative(
                weight in 0.0f64..100.0,
                half_life in 1.0f64..10000.0,
                dt_secs in 0.0f64..100000.0,
            ) {
                let ledger = InteractionLedger::with_half_life(half_life);
                let base_ns = 1_000_000_000_000u64;
                ledger.record(1, 1, weight, base_ns);
                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
                let now_ns = base_ns + (dt_secs * 1_000_000_000.0) as u64;
                let s = ledger.score(1, 1, now_ns);
                prop_assert!(s >= 0.0, "score was negative: {s}");
            }

            /// Score at exactly one half-life is approximately half the initial weight.
            #[test]
            fn score_at_half_life(
                weight in 0.1f64..100.0,
                half_life in 1.0f64..10000.0,
            ) {
                let ledger = InteractionLedger::with_half_life(half_life);
                let base_ns = 1_000_000_000_000u64;
                ledger.record(1, 1, weight, base_ns);
                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
                let now_ns = base_ns + (half_life * 1_000_000_000.0) as u64;
                let s = ledger.score(1, 1, now_ns);
                let expected = weight / 2.0;
                let tolerance = expected * 0.01; // 1% tolerance
                prop_assert!(
                    (s - expected).abs() < tolerance,
                    "score {s} not ~{expected} (tolerance {tolerance})"
                );
            }
        }
    }
}
