//! Per-user per-item signal index for social-graph-scoped trending.
//!
//! Tracks windowed signal counts per `(user_id, entity_id, signal_type_id)`.
//! Updated on every `signal_with_context()` call.
//!
//! Used by the social-graph trending path to aggregate velocity across
//! a resolved user subgraph instead of the global ledger.
//!
//! Intentionally NOT persisted: the ephemeral velocity data is acceptable
//! to lose on restart (trending will gradually re-stabilize from new signals).

use dashmap::DashMap;

use crate::schema::{EntityId, Window};
use crate::signals::SignalTypeId;
use crate::signals::warm::BucketedCounter;

/// Per-user per-item signal index for social-graph-scoped trending.
pub struct UserSignalIndex {
    /// `(user_id, entity_id, signal_type_id)` -> windowed counter.
    entries: DashMap<(u64, u64, u16), BucketedCounter>,
}

impl UserSignalIndex {
    /// Create a new user signal index.
    #[must_use]
    pub fn new() -> Self {
        Self {
            entries: DashMap::new(),
        }
    }

    /// Record a signal from a specific user for an entity.
    pub fn record(&self, user_id: u64, entity_id: EntityId, type_id: SignalTypeId, ts_ns: u64) {
        self.entries
            .entry((user_id, entity_id.as_u64(), type_id.as_u16()))
            .or_insert_with(|| BucketedCounter::with_start_time(ts_ns))
            .increment(ts_ns);
    }

    /// Get the windowed count for a `(user, entity, signal)` triplet.
    #[must_use]
    pub fn windowed_count(
        &self,
        user_id: u64,
        entity_id: EntityId,
        type_id: SignalTypeId,
        window: Window,
    ) -> u64 {
        self.entries
            .get(&(user_id, entity_id.as_u64(), type_id.as_u16()))
            .map_or(0, |counter| counter.windowed_count(window))
    }

    /// Get velocity for a `(user, entity, signal)` triplet.
    ///
    /// Velocity = `windowed_count / window_duration_seconds`.
    #[must_use]
    pub fn velocity(
        &self,
        user_id: u64,
        entity_id: EntityId,
        type_id: SignalTypeId,
        window: Window,
    ) -> f64 {
        let count = self.windowed_count(user_id, entity_id, type_id, window);
        let seconds = window_seconds(window);
        if seconds == 0.0 {
            return 0.0;
        }
        #[allow(clippy::cast_precision_loss)]
        let result = count as f64 / seconds;
        result
    }

    /// Compute aggregate velocity for an entity across a set of user IDs.
    ///
    /// Used for social-graph-scoped trending: velocity = sum of per-user velocities.
    #[must_use]
    pub fn aggregate_velocity(
        &self,
        entity_id: EntityId,
        user_ids: &[u64],
        type_id: SignalTypeId,
        window: Window,
    ) -> f64 {
        user_ids
            .iter()
            .map(|&uid| self.velocity(uid, entity_id, type_id, window))
            .sum()
    }

    /// Return the number of entries currently tracked.
    #[must_use]
    pub fn entry_count(&self) -> usize {
        self.entries.len()
    }
}

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

/// Window duration in seconds. Returns 0.0 for AllTime/ThirtyDays (velocity undefined).
const fn window_seconds(window: Window) -> f64 {
    match window {
        Window::OneHour => 3600.0,
        Window::TwentyFourHours => 86400.0,
        Window::SevenDays => 604_800.0,
        Window::ThirtyDays | Window::AllTime => 0.0,
    }
}

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

    #[test]
    fn record_and_read_windowed_count() {
        let index = UserSignalIndex::new();
        let type_id = SignalTypeId::new(0);
        let entity = EntityId::new(42);
        let ts = 1_000_000_000_000_000_000_u64; // 1e18 ns

        index.record(1, entity, type_id, ts);
        index.record(1, entity, type_id, ts + 1_000_000_000); // 1 second later

        let count = index.windowed_count(1, entity, type_id, Window::OneHour);
        assert_eq!(count, 2);

        // Different user has 0.
        assert_eq!(index.windowed_count(2, entity, type_id, Window::OneHour), 0);
    }

    #[test]
    fn velocity_computed_correctly() {
        let index = UserSignalIndex::new();
        let type_id = SignalTypeId::new(0);
        let entity = EntityId::new(42);
        let ts = 1_000_000_000_000_000_000u64;

        // Record 3600 events in 1 second interval -- they all land in the same minute bucket.
        for i in 0..3600 {
            index.record(1, entity, type_id, ts + i * 1_000_000_000);
        }

        let vel = index.velocity(1, entity, type_id, Window::OneHour);
        // 3600 events / 3600 seconds = 1.0 events/sec
        assert!((vel - 1.0).abs() < f64::EPSILON);
    }

    #[test]
    fn aggregate_velocity_sums_across_users() {
        let index = UserSignalIndex::new();
        let type_id = SignalTypeId::new(0);
        let entity = EntityId::new(42);
        let ts = 1_000_000_000_000_000_000u64;

        // User 1: 100 events.
        for i in 0..100 {
            index.record(1, entity, type_id, ts + i * 1_000_000_000);
        }
        // User 2: 200 events.
        for i in 0..200 {
            index.record(2, entity, type_id, ts + i * 1_000_000_000);
        }

        let agg_vel = index.aggregate_velocity(entity, &[1, 2], type_id, Window::OneHour);
        let vel1 = index.velocity(1, entity, type_id, Window::OneHour);
        let vel2 = index.velocity(2, entity, type_id, Window::OneHour);
        assert!((agg_vel - (vel1 + vel2)).abs() < f64::EPSILON);
    }

    #[test]
    fn alltime_velocity_returns_zero() {
        let index = UserSignalIndex::new();
        let type_id = SignalTypeId::new(0);
        let entity = EntityId::new(42);
        let ts = 1_000_000_000_000_000_000u64;

        index.record(1, entity, type_id, ts);
        assert_eq!(
            index.velocity(1, entity, type_id, Window::AllTime),
            0.0,
            "AllTime window has no defined velocity"
        );
    }
}
