//! Warm-tier bucketed event counter for windowed signal aggregation.
//!
//! `BucketedCounter` maintains per-minute, per-hour, and per-day bucketed event
//! counts for efficient windowed aggregation queries (1h, 24h, 7d, 30d). 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    | bounded in-progress hour + 23 hour buckets  | ≤ 83 relaxed loads  |
//! | 7d     | bounded in-progress hour + 167 hour buckets | ≤ 227 relaxed loads |
//! | 30d    | 7d expression + 23 older day buckets        | ≤ 250 relaxed loads |
//! | all    | single counter read                         | 1 relaxed load      |
//!
//! # Bucket-tier cascade
//!
//! Each coarser tier is fed by the one below it on rotation: minute buckets
//! aggregate into an hour bucket every hour, and hour buckets aggregate into a
//! day bucket every day. Rotation cascades minute → hour → day in a single
//! `maybe_rotate` call, so a long gap between writes settles every tier at once.
//!
//! # 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`, `current_hour`, and `current_day` use `Acquire`/`Release`
//! so the pointer update is visible before/after bucket data modifications.

use std::{
    fmt,
    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;
/// Number of per-day bucket slots (covers 31 days).
///
/// The extra slot beyond 30 gives the 30d window's older-than-7-days tail
/// (offsets 7..=29) a full 23 day buckets to read without wrapping onto the slot
/// the current day's rotation just wrote.
pub const DAY_BUCKETS: usize = 31;

/// 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;
/// Nanoseconds per day.
const NS_PER_DAY: u64 = 86_400_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). Per-day buckets cover the last 31 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],
    /// Per-day event count buckets. Circular buffer of 31 slots.
    day_buckets: [AtomicU32; DAY_BUCKETS],
    /// Index of the current minute bucket (0..59).
    current_minute: AtomicU8,
    /// Index of the current hour bucket (0..167).
    current_hour: AtomicU8,
    /// Index of the current day bucket (0..30).
    current_day: 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,
    /// Nanosecond timestamp of the last day rotation.
    last_day_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)),
            day_buckets: std::array::from_fn(|_| AtomicU32::new(0)),
            current_minute: AtomicU8::new(0),
            current_hour: AtomicU8::new(0),
            current_day: AtomicU8::new(0),
            all_time_count: AtomicU64::new(0),
            last_minute_rotation_ns: AtomicU64::new(0),
            last_hour_rotation_ns: AtomicU64::new(0),
            last_day_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
            .last_day_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, as of `now_ns`.
    ///
    /// | Window          | Source                                                       |
    /// |-----------------|--------------------------------------------------------------|
    /// | `OneHour`         | sum 60 minute buckets                                      |
    /// | `TwentyFourHours` | in-progress hour (bounded minutes) + 23 completed hour buckets |
    /// | `SevenDays`       | in-progress hour (bounded minutes) + 167 completed hour buckets |
    /// | `ThirtyDays`      | last 7 days (hour/minute tier) + day buckets older than 7 days |
    /// | `AllTime`         | single atomic read                                        |
    ///
    /// # Read-time rotation
    ///
    /// Rotation is normally trigger-based on the *write* path
    /// (`increment`/`increment_by`/`reconcile_to_count`). An entity that received
    /// events and then went quiet would, without a read-time rotation, keep
    /// reporting those events in its windowed count forever — the minute/hour/day
    /// buckets that should have aged out are never zeroed because no write fires
    /// the rotation. That silently corrupts trending/velocity ranking for exactly
    /// the bursty-then-quiet items those surfaces are meant to demote, and
    /// violates the "windowed aggregates equal the sum of events within the
    /// window" invariant at read time.
    ///
    /// To close that gap we call [`maybe_rotate`](Self::maybe_rotate) with the
    /// read's `now_ns` *before* summing, mirroring how the hot tier lazily decays
    /// its score forward to the read time. `maybe_rotate` early-returns when
    /// `now_ns < last_minute_rotation_ns + 1 minute` and is CAS-guarded on
    /// atomics taking `&self`, so under read-heavy shared access (e.g. a
    /// `DashMap` ref) it adds only a single relaxed load and a comparison except
    /// when a minute boundary has actually been crossed — keeping the hot read
    /// path allocation-free and lock-free.
    ///
    /// # Why the in-progress hour is folded in — and why it is BOUNDED
    ///
    /// The bucket cascade only rolls minute data up into an hour bucket *when an
    /// hour rotation fires* (and an hour bucket up into a day bucket on a day
    /// rotation). Between rotations, the events of the current in-progress hour
    /// live solely in the minute buckets. Summing only the coarse buckets would
    /// therefore omit the most recent (up to) one full bucket of data — making
    /// 24h/7d/30d counts systematically one bucket short. We fold the in-progress
    /// hour in explicitly so the windows are complete and never lag by a bucket.
    ///
    /// But the minute tier is a *rolling* 60-minute window (it must be, because
    /// `OneHour` reads it directly), aged only by the elapsed minutes on each
    /// rotation — never fully drained. So right after an hour rotation it still
    /// holds the previous hour's tail, which the rollup already copied into the
    /// newest completed hour bucket. Folding in *all 60* minute buckets and then
    /// adding the completed hour buckets would double-count that overlap for any
    /// continuously-active entity. The fold-in is therefore bounded to the minutes
    /// since the last hour rotation (see [`sum_current_hour`](Self::sum_current_hour)),
    /// disjoint from every completed hour bucket. The symmetric bound is applied on
    /// the *write* side (`maybe_rotate`'s `hour_agg`/`day_agg`) so the durable hour
    /// and day buckets never absorb the overlap in the first place.
    #[must_use]
    pub fn windowed_count(&self, window: Window, now_ns: u64) -> u64 {
        // Age out expired buckets as of the read time so a quiet entity's
        // windowed count decays correctly even with no intervening write.
        self.maybe_rotate(now_ns);
        match window {
            Window::OneHour => self.sum_last_n_minutes(MINUTE_BUCKETS),
            // 24h = the in-progress hour (minute buckets written since the last
            // hour rotation, bounded by `now_ns`) + the 23 most recent completed
            // hour buckets. The in-progress portion is disjoint from the completed
            // buckets, so no event is counted twice (see `sum_current_hour`).
            Window::TwentyFourHours => self.sum_current_hour(now_ns) + self.sum_last_n_hours(23),
            // 7d = in-progress hour + the 167 most recent completed hour buckets
            // (the full 168-slot ring covers exactly 7 days).
            Window::SevenDays => {
                self.sum_current_hour(now_ns) + self.sum_last_n_hours(HOUR_BUCKETS - 1)
            }
            // 30d = the last 7 days (hour/minute tier, the same expression as 7d) +
            // the day buckets strictly OLDER than the hour tier's 7-day reach. The
            // day tier is not re-read for the recent week, so there is no hour/day
            // overlap (see `sum_days_older_than_7`); the in-progress day is covered
            // entirely by the hour/minute tier.
            Window::ThirtyDays => {
                self.sum_current_hour(now_ns)
                    + self.sum_last_n_hours(HOUR_BUCKETS - 1)
                    + self.sum_days_older_than_7()
            }
            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)
    }

    /// Reconcile the warm tier to a merged total event count from a CRDT merge.
    ///
    /// Used by reconciliation (`SignalLedger::apply_crdt_state`) to keep the warm
    /// windowed counts in agreement with the hot decay score after a CRDT merge.
    /// Without this, reconciliation would force the hot score to the merged truth
    /// while the warm counts kept diverged local-only state — so `read_decay_score`
    /// and `read_windowed_count` would disagree about the same merged signal.
    ///
    /// Semantics: the merged total is authoritative. If it exceeds the locally
    /// observed all-time count, the **delta** is credited into the current minute
    /// bucket as of `now_ns` (mirroring how the hot tier force-sets its score as
    /// current at `now_ns`), so the freshly merged events fall inside every active
    /// window. The all-time counter is then set to exactly `merged_total`. If the
    /// merged total is *not* greater than the local count (the local node already
    /// observed at least as many events — e.g. an idempotent re-merge), the warm
    /// tier is left untouched: we never retroactively shrink a count, which would
    /// erase locally-durable events the CRDT snapshot simply had not yet seen.
    ///
    /// Rotation is applied first (`maybe_rotate`) so the delta lands in the
    /// correct current bucket for `now_ns`.
    pub fn reconcile_to_count(&self, merged_total: u64, now_ns: u64) {
        self.maybe_rotate(now_ns);
        let local_total = self.all_time_count.load(Ordering::Relaxed);
        let delta = merged_total.saturating_sub(local_total);
        if delta == 0 {
            // Merged total does not exceed what we already counted locally;
            // never shrink — that would drop locally-durable events.
            return;
        }
        // Credit the delta into the current minute bucket so it is visible in
        // every windowed query. u32 is the bucket width; saturate on the rare
        // pathological delta rather than wrap.
        let idx = self.current_minute.load(Ordering::Acquire) as usize;
        let bucket_delta = u32::try_from(delta).unwrap_or(u32::MAX);
        self.minute_buckets[idx].fetch_add(bucket_delta, Ordering::Relaxed);
        // Set the all-time counter to exactly the merged truth.
        self.all_time_count.store(merged_total, 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.
        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 completing hour's event count (the minutes since
    /// the last hour rotation, bounded by the caller — see `maybe_rotate`'s
    /// `hour_agg`), NOT the full rolling 60-minute window. 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.
        self.current_hour.store(next as u8, Ordering::Release);
    }

    /// Rotate the day pointer: store `hour_aggregate` in the next day slot
    /// and advance `current_day`.
    ///
    /// `hour_aggregate` is the completing day's event count (the in-progress hour
    /// plus only the completed hours inside the completing day, bounded by the
    /// caller — see `maybe_rotate`'s `day_agg`), NOT the rolling last 24 hour
    /// buckets. The next slot is overwritten (no need to zero first).
    pub fn rotate_day(&self, hour_aggregate: u32) {
        let current = self.current_day.load(Ordering::Acquire) as usize;
        let next = (current + 1) % DAY_BUCKETS;
        self.day_buckets[next].store(hour_aggregate, Ordering::Relaxed);
        // next is (0..30) % 31, always fits in u8.
        self.current_day.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)),
            day_buckets: std::array::from_fn(|i| self.day_buckets[i].load(Ordering::Relaxed)),
            current_minute: self.current_minute.load(Ordering::Acquire),
            current_hour: self.current_hour.load(Ordering::Acquire),
            current_day: self.current_day.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),
            last_day_rotation_ns: self.last_day_rotation_ns.load(Ordering::Relaxed),
        }
    }

    /// Restore from a checkpoint snapshot.
    ///
    /// # Bounds clamping
    ///
    /// `current_minute`, `current_hour`, and `current_day` are clamped to valid
    /// array bounds before being stored. A corrupted or maliciously crafted
    /// snapshot with out-of-range indices (≥ 60, ≥ 168, or ≥ 31) would otherwise
    /// cause `rotate_minute`/`rotate_hour`/`rotate_day` to index out of bounds on
    /// the next rotation. Clamping makes restoration safe regardless of snapshot
    /// origin.
    ///
    /// # Rotation-anchor ordering
    ///
    /// The three rotation anchors carry a monotone invariant set by `maybe_rotate`:
    /// `last_day_rotation_ns ≤ last_hour_rotation_ns ≤ last_minute_rotation_ns`
    /// (a coarser anchor is only ever advanced *inside* a finer rotation that has
    /// already advanced its own anchor). The read path relies on it: `sum_current_hour`
    /// folds `k = (now_ns − last_hour_rotation_ns)/60s` minute buckets, and the
    /// write-side `hour_agg`/`day_agg` bounds derive `(last_min − last_hour)` and
    /// `(last_hour − last_day)`. A corrupt snapshot with, e.g.,
    /// `last_hour_rotation_ns > last_minute_rotation_ns` would make those
    /// `saturating_sub`s clamp to 0 and silently under-count the in-progress
    /// window until the next real rotation re-anchors the timestamps. We therefore
    /// repair the ordering on restore (pulling each coarser anchor down to its
    /// finer neighbor when it is ahead), so a hostile or torn checkpoint cannot
    /// produce a wrong windowed count. Pulling *down* (never up) is conservative:
    /// it can only widen the in-progress fold by at most one tier, never invent
    /// events, and the next `maybe_rotate` re-anchors precisely.
    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);
        }
        for (i, &v) in snapshot.day_buckets.iter().enumerate() {
            self.day_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/rotate_day call.
        // MINUTE_BUCKETS - 1 = 59, HOUR_BUCKETS - 1 = 167, DAY_BUCKETS - 1 = 30;
        // all fit in u8.
        let current_minute = snapshot.current_minute.min((MINUTE_BUCKETS - 1) as u8);
        let current_hour = snapshot.current_hour.min((HOUR_BUCKETS - 1) as u8);
        let current_day = snapshot.current_day.min((DAY_BUCKETS - 1) as u8);
        self.current_minute.store(current_minute, Ordering::Release);
        self.current_hour.store(current_hour, Ordering::Release);
        self.current_day.store(current_day, Ordering::Release);
        self.all_time_count
            .store(snapshot.all_time_count, Ordering::Relaxed);

        // Repair the monotone anchor ordering (day ≤ hour ≤ minute) before
        // storing, so a corrupt/hostile snapshot cannot drive a coarser anchor
        // ahead of a finer one and silently under-count the in-progress window
        // (see "Rotation-anchor ordering" above). The minute anchor is the
        // authoritative high-water mark; pull each coarser anchor down to it
        // when it is ahead.
        let last_minute = snapshot.last_minute_rotation_ns;
        let last_hour = snapshot.last_hour_rotation_ns.min(last_minute);
        let last_day = snapshot.last_day_rotation_ns.min(last_hour);
        self.last_minute_rotation_ns
            .store(last_minute, Ordering::Relaxed);
        self.last_hour_rotation_ns
            .store(last_hour, Ordering::Relaxed);
        self.last_day_rotation_ns.store(last_day, 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.
        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;
        }

        // If this minute rotation also crosses an hour boundary, the minute
        // buckets must be rolled up into an hour bucket BEFORE the rotations
        // below clear them. Snapshot the hour aggregate now (covering the
        // last 60 minutes of data) so a sparse-event gap does not lose the
        // count — the rotations clear buckets, and reading them afterward
        // would sum zeros.
        let last_hour = self.last_hour_rotation_ns.load(Ordering::Relaxed);
        let hour_due = now_ns >= last_hour.saturating_add(NS_PER_HOUR);
        let hour_agg: u32 = if hour_due {
            // Roll up ONLY the in-progress hour [last_hour, last_hour+1h): the
            // minute buckets written since the last hour rotation — NOT all 60.
            // The minute tier is a rolling 60-minute window aged only by `steps`
            // per rotation (never fully drained, so `OneHour` stays exact), so
            // after a gap it still holds the PREVIOUS hour's tail that an earlier
            // hour bucket already captured. Summing all 60 would re-roll that
            // overlap into this hour bucket a second time, permanently inflating
            // the 24h/7d/30d windows for a continuously-active entity (a verified
            // ~1.3–2x over-count baked into the durable hour tier). The completing
            // hour's data lives in the buckets from `current_minute` (still at the
            // `last_min` anchor — the minute rotations below run after this) back to
            // the minute aligned with `last_hour`: that count is
            // (last_min - last_hour)/min + 1, capped at the ring. The invariant
            // last_min < last_hour + 1h holds (any write at/after the hour boundary
            // fires this rotation first), so k is in 1..=MINUTE_BUCKETS.
            let k = (last_min.saturating_sub(last_hour) / NS_PER_MIN)
                .saturating_add(1)
                .min(MINUTE_BUCKETS as u64) as usize;
            let cur_min = self.current_minute.load(Ordering::Acquire) as usize;
            u32::try_from(sum_last_n_buckets(&self.minute_buckets, cur_min, k)).unwrap_or(u32::MAX)
        } else {
            0
        };

        // 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.
        if !hour_due {
            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.
        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;
        }

        // If this hour rotation also crosses a day boundary, snapshot the day
        // aggregate BEFORE the hour rotations below advance `current_hour`.
        let last_day = self.last_day_rotation_ns.load(Ordering::Relaxed);
        let day_due = now_ns >= last_day.saturating_add(NS_PER_DAY);
        let day_agg: u32 = if day_due {
            // Roll up ONLY the completing day [last_day, last_day+24h): the hour
            // aggregate we are about to write (the in-progress hour, already bounded
            // above) plus ONLY the completed hour buckets that fall inside the
            // completing day. The hour tier is itself a rolling window, so after a
            // gap "the 23 most recent hour buckets" would reach into the PREVIOUS
            // day that an earlier day bucket already captured — the same overlap as
            // the minute->hour case, one tier up. The completing day's completed
            // hours number (last_hour - last_day)/hour (the 24th hour is `hour_agg`),
            // capped at 23; the invariant last_hour < last_day + 24h keeps it <= 23.
            // `current_hour` still points at the most-recent completed hour (the
            // rotate_hour loop below runs after this), so walking back that many
            // buckets is exactly [last_day, last_hour).
            let j = (last_hour.saturating_sub(last_day) / NS_PER_HOUR).min(23) as usize;
            let cur_hour = self.current_hour.load(Ordering::Acquire) as usize;
            let completed = sum_last_n_buckets(&self.hour_buckets, cur_hour, j);
            u32::try_from(u64::from(hour_agg).saturating_add(completed)).unwrap_or(u32::MAX)
        } else {
            0
        };

        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 { hour_agg } else { 0 };
            self.rotate_hour(bucket_val);
        }

        if day_due {
            self.rotate_days(now_ns, last_day, day_agg);
        }
    }

    /// Perform day-bucket rotation, rolling `day_agg` (the pre-snapshotted last
    /// 24 hours of data) into the first new day bucket. Called from
    /// `maybe_rotate` only when a day boundary has been crossed.
    ///
    /// `last_day` is the value read from `last_day_rotation_ns` before the hour
    /// rotations ran; `day_agg` was captured before those rotations cleared the
    /// hour buckets. Uses CAS on `last_day_rotation_ns` so exactly one
    /// concurrent caller performs the rotation.
    fn rotate_days(&self, now_ns: u64, last_day: u64, day_agg: u32) {
        let d_elapsed = now_ns.saturating_sub(last_day);
        // Max value: u64::MAX / NS_PER_DAY ≈ 213K, fits in u32 — safe even on 32-bit.
        let days_elapsed = (d_elapsed / NS_PER_DAY) as usize;
        let new_last_day =
            last_day.saturating_add((days_elapsed as u64).saturating_mul(NS_PER_DAY));

        if self
            .last_day_rotation_ns
            .compare_exchange(last_day, new_last_day, Ordering::AcqRel, Ordering::Relaxed)
            .is_err()
        {
            return;
        }

        let d_steps = days_elapsed.min(DAY_BUCKETS);
        for i in 0..d_steps {
            // Only the first rotation carries real data; the rest are empty days.
            let bucket_val = if i == 0 { day_agg } else { 0 };
            self.rotate_day(bucket_val);
        }
    }

    /// Sum the events of the current in-progress hour — the minute buckets written
    /// *since the last hour rotation*, NOT the full rolling 60-minute window.
    ///
    /// The minute tier is a rolling 60-minute window (it must be, to answer
    /// `OneHour` exactly), so right after an hour rotation it still holds up to ~60
    /// minutes of the *previous* hour — the data the rollup already copied into the
    /// most-recent completed hour bucket. Folding all 60 minute buckets into 24h/7d
    /// and then adding the completed hour buckets would double-count that overlap
    /// for a continuously-active entity. Instead we sum only the last `k` minute
    /// buckets, `k = (now_ns - last_hour_rotation_ns)/60s + 1` (capped at the ring):
    /// exactly the minutes elapsed since the last hour rotation, disjoint from every
    /// completed hour bucket. `windowed_count` calls `maybe_rotate(now_ns)` first,
    /// so `now_ns >= last_hour_rotation_ns` and `k` lands in `1..=MINUTE_BUCKETS`.
    /// This mirrors the write-side bound in `maybe_rotate`'s `hour_agg`, which uses
    /// the same shape anchored at the rollup-time `last_min`.
    fn sum_current_hour(&self, now_ns: u64) -> u64 {
        let last_hour = self.last_hour_rotation_ns.load(Ordering::Relaxed);
        let k = (now_ns.saturating_sub(last_hour) / NS_PER_MIN)
            .saturating_add(1)
            .min(MINUTE_BUCKETS as u64) as usize;
        self.sum_last_n_minutes(k)
    }

    /// Sum only the day buckets *older* than the hour tier's 7-day reach, for the
    /// 30d window's tail beyond what the hour/minute tier already covers.
    ///
    /// The hour tier (168 buckets = 7 days) is the authoritative source for the
    /// most recent week, so the 7 most recent completed-day buckets (offsets 0..=6
    /// from `current_day`) duplicate data the hour tier already holds; summing them
    /// on top of the 7-day expression would double-count. We therefore skip those 7
    /// offsets and sum offsets 7..=29 (≈ days 8–30), which lie outside the hour
    /// tier's window. Day and hour ring boundaries advance in whole units from a
    /// common start, so the only imprecision is a bounded ≤1-day slack at the 7-day
    /// seam — far from the recent data that drives ranking, and the correct
    /// trade-off versus the ~2x over-count of summing the overlapping tiers.
    fn sum_days_older_than_7(&self) -> u64 {
        let current = self.current_day.load(Ordering::Acquire) as usize;
        let len = self.day_buckets.len();
        // Offsets 7..=29: the 23 day buckets beyond the hour tier's 7-day reach.
        (7..len.saturating_sub(1))
            .map(|i| {
                let idx = (current + len - i) % len;
                u64::from(self.day_buckets[idx].load(Ordering::Relaxed))
            })
            .sum()
    }

    fn sum_last_n_minutes(&self, n: usize) -> u64 {
        let current = self.current_minute.load(Ordering::Acquire) as usize;
        sum_last_n_buckets(&self.minute_buckets, current, n)
    }

    fn sum_last_n_hours(&self, n: usize) -> u64 {
        let current = self.current_hour.load(Ordering::Acquire) as usize;
        sum_last_n_buckets(&self.hour_buckets, current, n)
    }
}

/// Sum the `n` most recent slots of a circular `u32` bucket ring, walking
/// backward from `current` with wraparound (`current`, `current - 1`, …).
///
/// This is the one reverse-sum idiom shared by the minute, hour, and day tiers:
/// each summed only its own ring with the identical `(current + LEN - i) % LEN`
/// modular index. Taking the ring slice as a parameter (its `len()` supplies
/// `LEN`) collapses the three copies into a single read-path helper, so a change
/// to the wraparound or ordering is made once. Bucket reads use `Relaxed` for
/// the same reason as everywhere else in this module — windowed counts are
/// inherently approximate.
fn sum_last_n_buckets(buckets: &[AtomicU32], current: usize, n: usize) -> u64 {
    let len = buckets.len();
    (0..n)
        .map(|i| {
            let idx = (current + len - i) % len;
            u64::from(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",
                // Debug is a pure diagnostic with no clock; read as of the last
                // rotation so `maybe_rotate` is a no-op (it early-returns when
                // now_ns < last_minute_rotation_ns + 1 minute) and the formatter
                // never mutates bucket state as a side effect of printing.
                &self.windowed_count(
                    Window::OneHour,
                    self.last_minute_rotation_ns.load(Ordering::Relaxed),
                ),
            )
            .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 day_buckets: [u32; DAY_BUCKETS],
    pub current_minute: u8,
    pub current_hour: u8,
    pub current_day: u8,
    pub all_time_count: u64,
    pub last_minute_rotation_ns: u64,
    pub last_hour_rotation_ns: u64,
    pub last_day_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), 0);
        assert_eq!(counter.windowed_count(Window::TwentyFourHours, 0), 0);
        assert_eq!(counter.windowed_count(Window::SevenDays, 0), 0);
        assert_eq!(counter.windowed_count(Window::AllTime, 0), 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_000_000_000), 1);
        assert_eq!(counter.windowed_count(Window::AllTime, 1_000_000_000), 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);
        // Read as of the last event (~9.9s in); still well inside the 1h window.
        assert_eq!(
            counter.windowed_count(Window::OneHour, 99 * 100_000_000),
            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, 9 * 1_000_000_000),
            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, 61_000_000_000);
        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, 70 * 60_000_000_000_u64);
        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). Read as of the
        // last event (~2h in).
        let count_24h = counter.windowed_count(
            Window::TwentyFourHours,
            119 * 60_000_000_000 + 4_000_000_000,
        );
        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, 999 * 1_000_000),
            1000
        );
    }

    #[test]
    fn thirty_day_window_counts_rolled_up_events() {
        let counter = BucketedCounter::with_start_time(0);
        // Record an event in day 0. It lives in the minute/hour tiers but has
        // not yet cascaded into a day bucket — day rotation has not fired.
        counter.increment(1_000_000_000);
        // Cross the first day boundary; the day-0 events roll up into a day
        // bucket, so the 30d window now sees the day-0 event.
        counter.increment(NS_PER_DAY + 1_000_000_000);
        assert!(
            counter.windowed_count(Window::ThirtyDays, NS_PER_DAY + 1_000_000_000) >= 1,
            "30d window should include rolled-up events"
        );
    }

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

        // Record one event per day for 10 days. Each event must cross the next
        // day's boundary to roll up into a day bucket, so step by a full day.
        for day in 0..10u64 {
            counter.increment(day * NS_PER_DAY + 1_000_000_000);
        }
        // The increment at day=10 triggers the day-9 rollup. Trigger a final
        // rotation so the last day's event aggregates into a day bucket.
        counter.increment(10 * NS_PER_DAY + 1_000_000_000);

        assert_eq!(counter.all_time_count(), 11);
        // All 11 events fall within the last 30 days, so the 30d window should
        // count all events that have rolled up into day buckets (events still in
        // the current day's minute/hour buckets have not yet rolled up).
        let count_30d = counter.windowed_count(Window::ThirtyDays, 10 * NS_PER_DAY + 1_000_000_000);
        assert!(
            count_30d >= 9,
            "30d window should aggregate rolled-up days, got {count_30d}"
        );
    }

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

        // Add an event at t=0, then advance well past the 31-day day-bucket
        // capacity so the original event rotates out of the day ring.
        counter.increment(1_000_000_000);
        for day in 1..=40u64 {
            counter.increment(day * NS_PER_DAY + 1_000_000_000);
        }

        // The 30d window must not include the day-0 event (rotated out), and the
        // window can hold at most 30 days of rolled-up data.
        let count_30d = counter.windowed_count(Window::ThirtyDays, 40 * NS_PER_DAY + 1_000_000_000);
        assert!(
            count_30d <= 31,
            "30d count was {count_30d}, expected <= 31 (one per day for the window)"
        );
        assert_eq!(counter.all_time_count(), 41);
    }

    #[test]
    fn twenty_four_hour_window_includes_in_progress_hour() {
        // Regression: the 24h window summed only completed hour buckets and
        // omitted the current in-progress hour (still in the minute tier),
        // making the count systematically one bucket short.
        let counter = BucketedCounter::with_start_time(0);

        // 5 events in the very first (in-progress) hour. No hour rotation has
        // fired yet, so these live only in the minute buckets.
        for i in 0..5u64 {
            counter.increment(i * 1_000_000_000);
        }

        // The 24h window must see all 5 in-progress-hour events.
        assert_eq!(
            counter.windowed_count(Window::TwentyFourHours, 4 * 1_000_000_000),
            5,
            "24h window must include the in-progress hour, not lag it by a bucket"
        );
    }

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

        // 3 events in hour 0.
        for i in 0..3u64 {
            counter.increment(i * 1_000_000_000);
        }
        // Cross into hour 1 (rolls hour-0 minutes into an hour bucket), add 2.
        counter.increment(NS_PER_HOUR);
        counter.increment(NS_PER_HOUR + 1_000_000_000);

        // All 5 events fall in the last 24h, counted exactly once: 3 completed
        // (hour-0 bucket) + 2 in-progress (minute buckets). No double-count.
        assert_eq!(
            counter.windowed_count(Window::TwentyFourHours, NS_PER_HOUR + 1_000_000_000),
            5
        );
        assert_eq!(counter.all_time_count(), 5);
    }

    #[test]
    fn seven_day_window_includes_in_progress_hour() {
        let counter = BucketedCounter::with_start_time(0);
        for i in 0..4u64 {
            counter.increment(i * 1_000_000_000);
        }
        assert_eq!(
            counter.windowed_count(Window::SevenDays, 3 * 1_000_000_000),
            4,
            "7d window must include the in-progress hour"
        );
    }

    #[test]
    fn thirty_day_window_includes_in_progress_day() {
        // Regression: the 30d window summed only completed day buckets and
        // omitted the current in-progress day (still in the hour/minute tiers).
        let counter = BucketedCounter::with_start_time(0);
        for i in 0..7u64 {
            counter.increment(i * 1_000_000_000);
        }
        assert_eq!(
            counter.windowed_count(Window::ThirtyDays, 6 * 1_000_000_000),
            7,
            "30d window must include the in-progress day, not lag it by a bucket"
        );
    }

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

        // Read both as of the last event (98s in) so neither side rotates.
        let read_ns = 49 * 2_000_000_000;
        assert_eq!(restored.all_time_count(), counter.all_time_count());
        assert_eq!(
            restored.windowed_count(Window::OneHour, read_ns),
            counter.windowed_count(Window::OneHour, read_ns)
        );
        assert_eq!(
            restored.windowed_count(Window::AllTime, read_ns),
            counter.windowed_count(Window::AllTime, read_ns)
        );
    }

    #[test]
    fn restore_repairs_anchors_with_coarser_ahead_of_finer() {
        // Review pass2 (signals): restore() must repair the monotone anchor
        // ordering (last_day ≤ last_hour ≤ last_minute). A corrupt snapshot with
        // the hour anchor AHEAD of the minute anchor would make sum_current_hour's
        // `now_ns.saturating_sub(last_hour)` clamp to 0 and silently under-count
        // the in-progress window. We feed exactly that hostile shape and assert
        // the stored anchors come out monotone (day ≤ hour ≤ minute), and that a
        // windowed read does not panic and is internally consistent.
        let hostile = BucketedCounterSnapshot {
            minute_buckets: [0; MINUTE_BUCKETS],
            hour_buckets: [0; HOUR_BUCKETS],
            day_buckets: [0; DAY_BUCKETS],
            current_minute: 0,
            current_hour: 0,
            current_day: 0,
            all_time_count: 0,
            // Deliberately inverted: hour and day anchors are AHEAD of the minute
            // anchor, violating the day ≤ hour ≤ minute invariant maybe_rotate sets.
            last_minute_rotation_ns: 10 * NS_PER_MIN,
            last_hour_rotation_ns: 100 * NS_PER_HOUR,
            last_day_rotation_ns: 50 * NS_PER_DAY,
        };

        let counter = BucketedCounter::new();
        counter.restore(&hostile);

        let restored_minute = counter.last_minute_rotation_ns.load(Ordering::Relaxed);
        let restored_hour = counter.last_hour_rotation_ns.load(Ordering::Relaxed);
        let restored_day = counter.last_day_rotation_ns.load(Ordering::Relaxed);

        // Minute is the authoritative high-water mark — preserved verbatim.
        assert_eq!(restored_minute, 10 * NS_PER_MIN);
        // Coarser anchors are pulled DOWN to satisfy day ≤ hour ≤ minute.
        assert!(
            restored_day <= restored_hour && restored_hour <= restored_minute,
            "anchors must be monotone after restore: day={restored_day} \
             hour={restored_hour} minute={restored_minute}"
        );

        // A windowed read at a clock just past the (repaired) minute anchor must
        // not under-flow or panic; with empty buckets it is simply 0.
        let read_ns = 11 * NS_PER_MIN;
        assert_eq!(counter.windowed_count(Window::OneHour, read_ns), 0);
        assert_eq!(counter.windowed_count(Window::TwentyFourHours, read_ns), 0);
    }

    #[test]
    fn restore_preserves_already_monotone_anchors() {
        // A well-formed snapshot (day ≤ hour ≤ minute) must round-trip unchanged —
        // the repair only ever pulls a coarser anchor DOWN, never perturbs a valid
        // ordering.
        let mut snapshot = BucketedCounter::with_start_time(0).snapshot();
        snapshot.last_day_rotation_ns = 2 * NS_PER_DAY;
        snapshot.last_hour_rotation_ns = 50 * NS_PER_HOUR;
        snapshot.last_minute_rotation_ns = 3001 * NS_PER_MIN; // > 50h, > 2d

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

        assert_eq!(
            counter.last_minute_rotation_ns.load(Ordering::Relaxed),
            3001 * NS_PER_MIN
        );
        assert_eq!(
            counter.last_hour_rotation_ns.load(Ordering::Relaxed),
            50 * NS_PER_HOUR
        );
        assert_eq!(
            counter.last_day_rotation_ns.load(Ordering::Relaxed),
            2 * NS_PER_DAY
        );
    }

    #[test]
    fn quiet_entity_windowed_count_decays_to_zero_on_read() {
        // CRITICAL #6 regression: a read with an advanced clock and NO intervening
        // write must age expired buckets out. Previously rotation only fired on the
        // write path, so a bursty-then-quiet entity reported its peak count forever.
        let counter = BucketedCounter::with_start_time(0);

        // 50 events in the first minute.
        for i in 0..50u64 {
            counter.increment(i * 1_000_000); // every 1ms
        }

        // A read still inside the 1h window (1 minute in) sees all 50 events.
        let within = counter.windowed_count(Window::OneHour, NS_PER_MIN);
        assert_eq!(within, 50, "within-window read must still count the events");

        // Advance the clock past the full 1h window with NO further writes. The
        // read itself must rotate every minute bucket out — the count drops to 0.
        let past_window = 2 * NS_PER_HOUR;
        let after = counter.windowed_count(Window::OneHour, past_window);
        assert_eq!(
            after, 0,
            "a quiet entity's 1h window must decay to 0 once the clock passes the window, \
             even with no intervening write"
        );

        // all_time is unbounded and must be untouched by read-time rotation.
        assert_eq!(counter.all_time_count(), 50);
    }

    #[test]
    fn quiet_entity_velocity_inputs_decay_via_read() {
        // The 24h window (which drives velocity ranking) must likewise age out via
        // a pure read once the clock advances a full 24h past the last event.
        let counter = BucketedCounter::with_start_time(0);
        for i in 0..10u64 {
            counter.increment(i * 1_000_000_000); // 10 events in the first 10s
        }
        // Inside 24h: all 10 counted.
        assert_eq!(
            counter.windowed_count(Window::TwentyFourHours, 9_000_000_000),
            10
        );
        // 25h later with no writes: the 24h window must read 0 on the read path.
        let past_24h = 25 * NS_PER_HOUR;
        assert_eq!(counter.windowed_count(Window::TwentyFourHours, past_24h), 0);
    }

    #[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, 1_000_000_000), 42);
    }

    /// Drive a counter with an event every `step_ns` over `[0, end_ns]` inclusive,
    /// returning the total number of events written. Dense enough (sub-minute step)
    /// that the minute tier never fully drains on an hour rotation, so the rolling
    /// window keeps the previous hour's tail — the minute/hour tier-overlap
    /// condition the double-count bugs need.
    fn drive_continuous(counter: &BucketedCounter, step_ns: u64, end_ns: u64) -> u64 {
        let mut total = 0u64;
        let mut t = 0u64;
        while t <= end_ns {
            counter.increment(t);
            total += 1;
            t += step_ns;
        }
        total
    }

    #[test]
    fn twenty_four_hour_no_double_count_continuous_read_at_end() {
        // Read-side bound: a continuously-active entity crossing an hour boundary,
        // read at the last write. The whole 75-min history fits in the 24h window,
        // so the count must equal the all-time total (was 477 before the read-side
        // bound; the real total is 301).
        let counter = BucketedCounter::with_start_time(0);
        let total = drive_continuous(&counter, 15 * 1_000_000_000, 4500 * 1_000_000_000);
        assert_eq!(counter.all_time_count(), total);
        assert_eq!(
            counter.windowed_count(Window::TwentyFourHours, 4500 * 1_000_000_000),
            total,
            "24h at the last write must count each event once across an hour boundary"
        );
    }

    #[test]
    fn twenty_four_hour_no_double_count_continuous_read_after_gap() {
        // WRITE-side bound (the half-fix's blind spot): the same dense history, but
        // read 30 min AFTER the last write so the read-time rotation rolls the
        // in-progress hour into a DURABLE hour bucket. If that rollup summed all 60
        // minute buckets it would re-roll the previous hour's tail (verified 477 for
        // 361 real events). With the bounded hour_agg every event is counted once.
        let counter = BucketedCounter::with_start_time(0);
        let total = drive_continuous(&counter, 15 * 1_000_000_000, 90 * NS_PER_MIN);
        assert_eq!(counter.all_time_count(), total);
        let read_ns = 120 * NS_PER_MIN; // 30 min past the last write, into a 3rd hour
        let c24 = counter.windowed_count(Window::TwentyFourHours, read_ns);
        assert_eq!(
            c24, total,
            "24h read after a gap must not double-count the rolled-up hour bucket \
             (got {c24} for {total} real events)"
        );
    }

    #[test]
    fn twenty_four_hour_no_double_count_pure_write_path() {
        // The overlap is committed to the durable hour tier on the WRITE path, with
        // no read-time rotation involved: dense activity in two bursts straddling an
        // hour boundary, then one more write a full hour later that fires the rollup.
        // Reading AT that write (so maybe_rotate ran only on the write path) must
        // still equal the all-time total (was ~441 for ~343 real events).
        let counter = BucketedCounter::with_start_time(0);
        let mut total = 0u64;
        let mut t = 0u64;
        while t <= 45 * NS_PER_MIN {
            counter.increment(t);
            total += 1;
            t += 15 * 1_000_000_000;
        }
        t = 50 * NS_PER_MIN;
        while t <= 90 * NS_PER_MIN {
            counter.increment(t);
            total += 1;
            t += 15 * 1_000_000_000;
        }
        // One event a full hour after the last burst fires the second-hour rollup
        // on the write path itself.
        let read_ns = 120 * NS_PER_MIN;
        counter.increment(read_ns);
        total += 1;
        assert_eq!(counter.all_time_count(), total);
        let c24 = counter.windowed_count(Window::TwentyFourHours, read_ns);
        assert_eq!(
            c24, total,
            "24h must not double-count on the pure write path (got {c24} for {total})"
        );
    }

    #[test]
    fn seven_day_no_double_count_continuous_read_after_gap() {
        // 7d folds in the in-progress hour the same way; the write-side overlap
        // inflates it identically. Dense history under 7 days, read after a gap.
        let counter = BucketedCounter::with_start_time(0);
        let total = drive_continuous(&counter, 30 * 1_000_000_000, 90 * NS_PER_MIN);
        assert_eq!(counter.all_time_count(), total);
        let c7 = counter.windowed_count(Window::SevenDays, 120 * NS_PER_MIN);
        assert_eq!(c7, total, "7d after a gap must count each event once");
    }

    #[test]
    fn thirty_day_no_double_count_continuous_read_after_gap() {
        // 30d's recent-7-day prefix is the same hour-tier expression, so the
        // write-side overlap inflates it too. Whole history under 7 days (no day
        // tier involved), read after a gap.
        let counter = BucketedCounter::with_start_time(0);
        let total = drive_continuous(&counter, 30 * 1_000_000_000, 90 * NS_PER_MIN);
        assert_eq!(counter.all_time_count(), total);
        let c30 = counter.windowed_count(Window::ThirtyDays, 120 * NS_PER_MIN);
        assert_eq!(c30, total, "30d after a gap must count each event once");
    }

    #[test]
    fn thirty_day_no_double_count_day_tier_overlap_after_gap() {
        // WRITE-side DAY rollup (day_agg): the hour tier is itself a rolling window,
        // so a day rotation firing after a gap must not fold the PREVIOUS day's tail
        // (already in an earlier day bucket) into the new day bucket. Day 0 and day 1
        // are both active; day 1 goes quiet well before the day-2 boundary, so when
        // the rollup fires the naive "23 most recent hour buckets" reaches back into
        // day 0. We then age day 1's bucket into the 30d window's day-tier tail.
        //
        // The 30d window is a subset of all-time, so its count must NEVER exceed the
        // all-time total — a day_agg overlap violates exactly that invariant
        // (verified: ~+150 inflation with the unbounded rollup).
        let counter = BucketedCounter::with_start_time(0);
        let mut total = 0u64;

        // Day 0: dense every 5 min for the full 24h.
        let mut t = 0u64;
        while t < NS_PER_DAY {
            counter.increment(t);
            total += 1;
            t += 5 * NS_PER_MIN;
        }
        // Day 1: dense every 5 min for only the first 10 hours, then quiet.
        t = NS_PER_DAY;
        while t < NS_PER_DAY + 10 * NS_PER_HOUR {
            counter.increment(t);
            total += 1;
            t += 5 * NS_PER_MIN;
        }
        // One event just past the day-2 boundary fires day 1's rollup after the gap.
        counter.increment(2 * NS_PER_DAY + NS_PER_MIN);
        total += 1;
        // One event per day for days 3..=12 ages day 1's bucket into the 30d tail.
        for day in 3..=12u64 {
            counter.increment(day * NS_PER_DAY + NS_PER_MIN);
            total += 1;
        }

        let read_ns = 12 * NS_PER_DAY + 2 * NS_PER_MIN;
        assert_eq!(counter.all_time_count(), total);
        let c30 = counter.windowed_count(Window::ThirtyDays, read_ns);
        assert!(
            c30 <= total,
            "30d must never exceed all-time; a day-tier rollup overlap inflates it \
             (got {c30} for {total} real events, all within 30 days)"
        );
        // Sanity floor: the bulk of the (in-window) events are still counted; a clean
        // run lands at total minus at most a day's worth of 7-day-seam slack.
        assert!(
            c30 + 50 >= total,
            "30d under-counted far more than the bounded 7-day seam slack: {c30} vs {total}"
        );
    }
}

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

    use super::*;

    // 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. Read as of the last
            // event so the read-time rotation does not age any of them out.
            let read_ns = sorted_times.last().copied().unwrap_or(0) * 1_000_000_000;
            let expected = sorted_times.len() as u64;
            let actual = counter.windowed_count(Window::OneHour, read_ns);

            // 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);
        }
    }
}
