//! Warm-tier bucketed event counter for windowed signal aggregation.
//!
//! `BucketedCounter` maintains per-minute and per-hour bucketed event counts
//! for efficient windowed aggregation queries (1h, 24h, 7d). The design uses
//! circular buffers with trigger-based rotation — rotation is performed inline
//! on signal writes when enough time has elapsed, requiring no background thread.
//!
//! # Window query costs
//!
//! | Window | Operation                 | Atomics        |
//! |--------|---------------------------|----------------|
//! | 1h     | sum 60 minute buckets     | 60 relaxed loads  |
//! | 24h    | sum 24 hour buckets       | 24 relaxed loads  |
//! | 7d     | sum 168 hour buckets      | 168 relaxed loads |
//! | all    | single counter read       | 1 relaxed load    |
//!
//! # Ordering rationale
//!
//! Bucket reads/writes use `Ordering::Relaxed` because windowed counts are
//! inherently approximate — a query at time T may see data from T ± 60s due
//! to scheduling. The ranking system does not require exact counts.
//!
//! `current_minute` and `current_hour` use `Acquire`/`Release` so the pointer
//! update is visible before/after bucket data modifications.

use std::fmt;
use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};

use crate::schema::Window;

/// Number of per-minute bucket slots (covers 1 hour).
pub const MINUTE_BUCKETS: usize = 60;
/// Number of per-hour bucket slots (covers 7 days).
pub const HOUR_BUCKETS: usize = 168;

/// Nanoseconds per minute.
const NS_PER_MIN: u64 = 60_000_000_000;
/// Nanoseconds per hour.
const NS_PER_HOUR: u64 = 3_600_000_000_000;

/// Warm-tier bucketed event counter for a single signal type on a single entity.
///
/// Supports simultaneous windowed count queries across 1h, 24h, 7d, and
/// all-time windows by summing appropriate time-bucketed counters.
///
/// # Design
///
/// Per-minute buckets cover the last 60 minutes. Per-hour buckets cover the
/// last 168 hours (7 days). The all-time counter is unbounded.
///
/// Bucket rotation is trigger-based, checked on each `increment()` call.
/// No background thread is required.
pub struct BucketedCounter {
    /// Per-minute event count buckets. Circular buffer of 60 slots.
    minute_buckets: [AtomicU32; MINUTE_BUCKETS],
    /// Per-hour event count buckets. Circular buffer of 168 slots.
    hour_buckets: [AtomicU32; HOUR_BUCKETS],
    /// Index of the current minute bucket (0..59).
    current_minute: AtomicU8,
    /// Index of the current hour bucket (0..167).
    current_hour: AtomicU8,
    /// All-time total event count.
    all_time_count: AtomicU64,
    /// Nanosecond timestamp of the last minute rotation.
    last_minute_rotation_ns: AtomicU64,
    /// Nanosecond timestamp of the last hour rotation.
    last_hour_rotation_ns: AtomicU64,
}

impl BucketedCounter {
    /// Construct a new counter with all buckets zeroed and rotation timestamps at 0.
    #[must_use]
    pub fn new() -> Self {
        Self {
            minute_buckets: std::array::from_fn(|_| AtomicU32::new(0)),
            hour_buckets: std::array::from_fn(|_| AtomicU32::new(0)),
            current_minute: AtomicU8::new(0),
            current_hour: AtomicU8::new(0),
            all_time_count: AtomicU64::new(0),
            last_minute_rotation_ns: AtomicU64::new(0),
            last_hour_rotation_ns: AtomicU64::new(0),
        }
    }

    /// Construct with initial rotation timestamps set to `now_ns`.
    ///
    /// Use this when the start time is known — it prevents spurious rotations
    /// on the first increment.
    #[must_use]
    pub fn with_start_time(now_ns: u64) -> Self {
        let counter = Self::new();
        counter
            .last_minute_rotation_ns
            .store(now_ns, Ordering::Relaxed);
        counter
            .last_hour_rotation_ns
            .store(now_ns, Ordering::Relaxed);
        counter
    }

    /// Increment the current minute bucket and all-time counter by 1.
    ///
    /// Triggers minute and/or hour rotation if enough time has elapsed
    /// since the last rotation (trigger-based, no background thread).
    pub fn increment(&self, now_ns: u64) {
        self.maybe_rotate(now_ns);
        let idx = self.current_minute.load(Ordering::Acquire) as usize;
        self.minute_buckets[idx].fetch_add(1, Ordering::Relaxed);
        self.all_time_count.fetch_add(1, Ordering::Relaxed);
    }

    /// Increment by a count other than 1 (for batch replay).
    pub fn increment_by(&self, count: u32, now_ns: u64) {
        self.maybe_rotate(now_ns);
        let idx = self.current_minute.load(Ordering::Acquire) as usize;
        self.minute_buckets[idx].fetch_add(count, Ordering::Relaxed);
        self.all_time_count
            .fetch_add(u64::from(count), Ordering::Relaxed);
    }

    /// Query the windowed event count for a given window.
    ///
    /// | Window        | Source                    |
    /// |---------------|---------------------------|
    /// | OneHour       | sum last 60 minute buckets |
    /// | TwentyFourHours | sum last 24 hour buckets |
    /// | SevenDays     | sum last 168 hour buckets  |
    /// | ThirtyDays    | not supported in M1 → 0    |
    /// | AllTime       | single atomic read         |
    #[must_use]
    pub fn windowed_count(&self, window: Window) -> u64 {
        match window {
            Window::OneHour => self.sum_last_n_minutes(MINUTE_BUCKETS),
            Window::TwentyFourHours => self.sum_last_n_hours(24),
            Window::SevenDays => self.sum_last_n_hours(HOUR_BUCKETS),
            Window::ThirtyDays => {
                tracing::warn!("ThirtyDays window not supported in M1; returning 0");
                0
            }
            Window::AllTime => self.all_time_count.load(Ordering::Relaxed),
        }
    }

    /// Read the all-time total event count.
    #[must_use]
    pub fn all_time_count(&self) -> u64 {
        self.all_time_count.load(Ordering::Relaxed)
    }

    /// Read the count in the current minute bucket only.
    ///
    /// Used for fine-grained velocity computation within the current minute.
    #[must_use]
    pub fn current_minute_count(&self) -> u32 {
        let idx = self.current_minute.load(Ordering::Acquire) as usize;
        self.minute_buckets[idx].load(Ordering::Relaxed)
    }

    /// Rotate the minute pointer: zero the next slot and advance `current_minute`.
    ///
    /// Returns the count from the expired bucket (the slot that was zeroed).
    /// Used internally and by the checkpoint restore path.
    pub fn rotate_minute(&self) -> u32 {
        let current = self.current_minute.load(Ordering::Acquire) as usize;
        let next = (current + 1) % MINUTE_BUCKETS;
        // Atomically zero the next slot and retrieve its old value.
        let expired = self.minute_buckets[next].swap(0, Ordering::Relaxed);
        // next is (0..59) % 60, always fits in u8.
        #[allow(clippy::cast_possible_truncation)]
        self.current_minute.store(next as u8, Ordering::Release);
        expired
    }

    /// Rotate the hour pointer: store `minute_aggregate` in the next hour slot
    /// and advance `current_hour`.
    ///
    /// `minute_aggregate` is the sum of the last 60 minute buckets, provided
    /// by the caller. The next slot is overwritten (no need to zero first).
    pub fn rotate_hour(&self, minute_aggregate: u32) {
        let current = self.current_hour.load(Ordering::Acquire) as usize;
        let next = (current + 1) % HOUR_BUCKETS;
        self.hour_buckets[next].store(minute_aggregate, Ordering::Relaxed);
        // next is (0..167) % 168, always fits in u8.
        #[allow(clippy::cast_possible_truncation)]
        self.current_hour.store(next as u8, Ordering::Release);
    }

    /// Snapshot all state for checkpoint serialization.
    #[must_use]
    pub fn snapshot(&self) -> BucketedCounterSnapshot {
        BucketedCounterSnapshot {
            minute_buckets: std::array::from_fn(|i| self.minute_buckets[i].load(Ordering::Relaxed)),
            hour_buckets: std::array::from_fn(|i| self.hour_buckets[i].load(Ordering::Relaxed)),
            current_minute: self.current_minute.load(Ordering::Acquire),
            current_hour: self.current_hour.load(Ordering::Acquire),
            all_time_count: self.all_time_count.load(Ordering::Relaxed),
            last_minute_rotation_ns: self.last_minute_rotation_ns.load(Ordering::Relaxed),
            last_hour_rotation_ns: self.last_hour_rotation_ns.load(Ordering::Relaxed),
        }
    }

    /// Restore from a checkpoint snapshot.
    ///
    /// # Bounds clamping
    ///
    /// `current_minute` and `current_hour` are clamped to valid array bounds
    /// before being stored. A corrupted or maliciously crafted snapshot with
    /// out-of-range indices (≥ 60 or ≥ 168) would otherwise cause
    /// `rotate_minute`/`rotate_hour` to index out of bounds on the next
    /// rotation. Clamping makes restoration safe regardless of snapshot origin.
    pub fn restore(&self, snapshot: &BucketedCounterSnapshot) {
        for (i, &v) in snapshot.minute_buckets.iter().enumerate() {
            self.minute_buckets[i].store(v, Ordering::Relaxed);
        }
        for (i, &v) in snapshot.hour_buckets.iter().enumerate() {
            self.hour_buckets[i].store(v, Ordering::Relaxed);
        }
        // Clamp to valid bounds: out-of-range indices from a corrupt snapshot
        // would panic on the next rotate_minute/rotate_hour call.
        // MINUTE_BUCKETS - 1 = 59, HOUR_BUCKETS - 1 = 167; both fit in u8.
        #[allow(clippy::cast_possible_truncation)]
        let current_minute = snapshot.current_minute.min((MINUTE_BUCKETS - 1) as u8);
        #[allow(clippy::cast_possible_truncation)]
        let current_hour = snapshot.current_hour.min((HOUR_BUCKETS - 1) as u8);
        self.current_minute.store(current_minute, Ordering::Release);
        self.current_hour.store(current_hour, Ordering::Release);
        self.all_time_count
            .store(snapshot.all_time_count, Ordering::Relaxed);
        self.last_minute_rotation_ns
            .store(snapshot.last_minute_rotation_ns, Ordering::Relaxed);
        self.last_hour_rotation_ns
            .store(snapshot.last_hour_rotation_ns, Ordering::Relaxed);
    }

    // ── Internal helpers ────────────────────────────────────────────────────────

    /// Check whether minute (and hour) rotation is needed based on `now_ns`,
    /// and perform it inline if so.
    ///
    /// Uses CAS on `last_minute_rotation_ns` so exactly one concurrent caller
    /// performs the rotation.
    fn maybe_rotate(&self, now_ns: u64) {
        let last_min = self.last_minute_rotation_ns.load(Ordering::Relaxed);
        if now_ns < last_min.saturating_add(NS_PER_MIN) {
            return;
        }

        let elapsed = now_ns.saturating_sub(last_min);
        // Max value: u64::MAX / NS_PER_MIN ≈ 307M, fits in u32 — safe even on 32-bit.
        #[allow(clippy::cast_possible_truncation)]
        let minutes_elapsed = (elapsed / NS_PER_MIN) as usize;
        let new_last_min =
            last_min.saturating_add((minutes_elapsed as u64).saturating_mul(NS_PER_MIN));

        // CAS to claim the rotation; only one thread proceeds.
        if self
            .last_minute_rotation_ns
            .compare_exchange(last_min, new_last_min, Ordering::AcqRel, Ordering::Relaxed)
            .is_err()
        {
            return;
        }

        // Perform minute rotations, capped to avoid clearing the entire buffer.
        let steps = minutes_elapsed.min(MINUTE_BUCKETS);
        for _ in 0..steps {
            self.rotate_minute();
        }

        // Check for hour rotation.
        let last_hour = self.last_hour_rotation_ns.load(Ordering::Relaxed);
        if now_ns < last_hour.saturating_add(NS_PER_HOUR) {
            return;
        }

        let h_elapsed = now_ns.saturating_sub(last_hour);
        // Max value: u64::MAX / NS_PER_HOUR ≈ 5.1M, fits in u32 — safe even on 32-bit.
        #[allow(clippy::cast_possible_truncation)]
        let hours_elapsed = (h_elapsed / NS_PER_HOUR) as usize;
        let new_last_hour =
            last_hour.saturating_add((hours_elapsed as u64).saturating_mul(NS_PER_HOUR));

        if self
            .last_hour_rotation_ns
            .compare_exchange(
                last_hour,
                new_last_hour,
                Ordering::AcqRel,
                Ordering::Relaxed,
            )
            .is_err()
        {
            return;
        }

        // Compute the current hour aggregate from minute buckets.
        let agg: u32 = self
            .minute_buckets
            .iter()
            .map(|b| b.load(Ordering::Relaxed))
            .fold(0u32, u32::saturating_add);

        let h_steps = hours_elapsed.min(HOUR_BUCKETS);
        for i in 0..h_steps {
            // Only the first rotation carries real data; the rest are empty hours.
            let bucket_val = if i == 0 { agg } else { 0 };
            self.rotate_hour(bucket_val);
        }
    }

    fn sum_last_n_minutes(&self, n: usize) -> u64 {
        let current = self.current_minute.load(Ordering::Acquire) as usize;
        (0..n)
            .map(|i| {
                let idx = (current + MINUTE_BUCKETS - i) % MINUTE_BUCKETS;
                u64::from(self.minute_buckets[idx].load(Ordering::Relaxed))
            })
            .sum()
    }

    fn sum_last_n_hours(&self, n: usize) -> u64 {
        let current = self.current_hour.load(Ordering::Acquire) as usize;
        (0..n)
            .map(|i| {
                let idx = (current + HOUR_BUCKETS - i) % HOUR_BUCKETS;
                u64::from(self.hour_buckets[idx].load(Ordering::Relaxed))
            })
            .sum()
    }
}

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

impl fmt::Debug for BucketedCounter {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("BucketedCounter")
            .field("all_time_count", &self.all_time_count())
            .field(
                "current_minute",
                &self.current_minute.load(Ordering::Relaxed),
            )
            .field("current_hour", &self.current_hour.load(Ordering::Relaxed))
            .field("windowed_1h", &self.windowed_count(Window::OneHour))
            .finish_non_exhaustive()
    }
}

/// Serializable snapshot of a `BucketedCounter`.
///
/// Used for checkpoint/restore. All fields are plain integers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BucketedCounterSnapshot {
    pub minute_buckets: [u32; MINUTE_BUCKETS],
    pub hour_buckets: [u32; HOUR_BUCKETS],
    pub current_minute: u8,
    pub current_hour: u8,
    pub all_time_count: u64,
    pub last_minute_rotation_ns: u64,
    pub last_hour_rotation_ns: u64,
}

// ── Tests ────────────────────────────────────────────────────────────────────

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

    #[test]
    fn new_counter_is_zeroed() {
        let counter = BucketedCounter::new();
        assert_eq!(counter.all_time_count(), 0);
        assert_eq!(counter.windowed_count(Window::OneHour), 0);
        assert_eq!(counter.windowed_count(Window::TwentyFourHours), 0);
        assert_eq!(counter.windowed_count(Window::SevenDays), 0);
        assert_eq!(counter.windowed_count(Window::AllTime), 0);
    }

    #[test]
    fn single_increment() {
        let counter = BucketedCounter::with_start_time(0);
        counter.increment(1_000_000_000); // 1 second
        assert_eq!(counter.all_time_count(), 1);
        assert_eq!(counter.windowed_count(Window::OneHour), 1);
        assert_eq!(counter.windowed_count(Window::AllTime), 1);
    }

    #[test]
    fn multiple_increments_same_minute() {
        let counter = BucketedCounter::with_start_time(0);
        for i in 0..100 {
            counter.increment(i * 100_000_000); // every 100ms for 10 seconds
        }
        assert_eq!(counter.all_time_count(), 100);
        assert_eq!(counter.windowed_count(Window::OneHour), 100);
    }

    #[test]
    fn minute_rotation_zeros_next_bucket() {
        let counter = BucketedCounter::with_start_time(0);

        // Fill minute 0 with 10 events
        for i in 0..10 {
            counter.increment(i * 1_000_000_000);
        }
        assert_eq!(counter.windowed_count(Window::OneHour), 10);

        // Advance past minute boundary (61 seconds)
        counter.increment(61_000_000_000);
        assert_eq!(counter.all_time_count(), 11);

        // The 1h window should include both minutes
        let count_1h = counter.windowed_count(Window::OneHour);
        assert_eq!(count_1h, 11);
    }

    #[test]
    fn events_outside_1h_window_not_counted() {
        let counter = BucketedCounter::with_start_time(0);

        // Add an event at t=0 (will rotate out)
        counter.increment(0);

        // Advance time past 1 hour with many rotations
        for minute in 1..=70 {
            let t_ns = minute * 60_000_000_000_u64;
            counter.increment(t_ns);
        }

        // The 1h window should contain the last 60 events, not all 71
        let count_1h = counter.windowed_count(Window::OneHour);
        assert!(count_1h <= 61, "1h count was {count_1h}, expected <= 61");
        assert_eq!(counter.all_time_count(), 71);
    }

    #[test]
    fn hour_rotation_aggregates_minutes() {
        let counter = BucketedCounter::with_start_time(0);

        // Simulate 2 hours of events: 5 per minute
        for minute in 0..120_u64 {
            let base_ns = minute * 60_000_000_000;
            for j in 0..5_u64 {
                counter.increment(base_ns + j * 1_000_000_000);
            }
        }

        assert_eq!(counter.all_time_count(), 600);

        // 24h window should include events (only 2 hours elapsed)
        let count_24h = counter.windowed_count(Window::TwentyFourHours);
        assert!(count_24h > 0, "24h window should have events");
    }

    #[test]
    fn all_time_window_reads_atomic_counter() {
        let counter = BucketedCounter::with_start_time(0);
        for i in 0..1000_u64 {
            counter.increment(i * 1_000_000);
        }
        assert_eq!(counter.windowed_count(Window::AllTime), 1000);
    }

    #[test]
    fn thirty_day_window_returns_zero() {
        let counter = BucketedCounter::with_start_time(0);
        counter.increment(1_000_000_000);
        // ThirtyDays not supported in M1
        assert_eq!(counter.windowed_count(Window::ThirtyDays), 0);
    }

    #[test]
    fn snapshot_and_restore_roundtrip() {
        let counter = BucketedCounter::with_start_time(0);
        for i in 0..50_u64 {
            counter.increment(i * 2_000_000_000); // every 2 seconds
        }
        let snapshot = counter.snapshot();

        let restored = BucketedCounter::new();
        restored.restore(&snapshot);

        assert_eq!(restored.all_time_count(), counter.all_time_count());
        assert_eq!(
            restored.windowed_count(Window::OneHour),
            counter.windowed_count(Window::OneHour)
        );
        assert_eq!(
            restored.windowed_count(Window::AllTime),
            counter.windowed_count(Window::AllTime)
        );
    }

    #[test]
    fn increment_by_adds_multiple() {
        let counter = BucketedCounter::with_start_time(0);
        counter.increment_by(42, 1_000_000_000);
        assert_eq!(counter.all_time_count(), 42);
        assert_eq!(counter.windowed_count(Window::OneHour), 42);
    }
}

#[cfg(test)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    // P3: Windowed count equals event count in window (1h window).
    //
    // Events are constrained to a 3000s (50 minute) span to avoid the design
    // limitation where a gap of ≥ 3600s triggers a full 60-bucket rotation cycle
    // that clears all minute-bucket data. With a 3000s max span, at most 50
    // rotation cycles fire from the initial timestamp, so all event data survives.
    // All events thus fall within the 1h window and expected = total event count.
    proptest! {
        #[test]
        fn windowed_count_1h_matches_events(
            event_times_secs in prop::collection::vec(0u64..3000, 1..200),
        ) {
            // Sort events — trigger-based rotation requires time-ordered insertion.
            let mut sorted_times = event_times_secs;
            sorted_times.sort_unstable();

            let counter = BucketedCounter::with_start_time(0);

            for &t_secs in &sorted_times {
                counter.increment(t_secs * 1_000_000_000);
            }

            // All events are within 3000s (< 50 minute-bucket rotation cycles),
            // so all must appear in the 1h windowed count.
            let expected = sorted_times.len() as u64;
            let actual = counter.windowed_count(Window::OneHour);

            // Allow ±2 for bucket-boundary effects.
            prop_assert!(
                actual.abs_diff(expected) <= 2,
                "actual={actual}, expected={expected}"
            );
        }
    }

    // All-time count equals total event count.
    proptest! {
        #[test]
        fn all_time_count_matches_total(
            event_count in 0u64..10_000,
        ) {
            let counter = BucketedCounter::with_start_time(0);
            for i in 0..event_count {
                let t_ns = i * 1_000_000;
                counter.increment(t_ns);
            }
            prop_assert_eq!(counter.all_time_count(), event_count);
        }
    }

    // Circular buffer wrapping: all-time count survives full rotation.
    proptest! {
        #[test]
        fn minute_rotation_preserves_total(
            events_per_minute in prop::collection::vec(0u32..100, 60..120),
        ) {
            let counter = BucketedCounter::with_start_time(0);
            let mut total = 0u64;

            for (minute_idx, &count) in events_per_minute.iter().enumerate() {
                let base_ns = (minute_idx as u64) * 60_000_000_000;
                for j in 0..count {
                    let t_ns = base_ns + u64::from(j) * 1_000_000;
                    counter.increment(t_ns);
                    total += 1;
                }
            }

            prop_assert_eq!(counter.all_time_count(), total);
        }
    }
}
