//! Signal ledger LRU trimmer.
//!
//! At 1M entities × 10 signal types per entity, the `SignalLedger`'s in-memory
//! `DashMap` would grow to ~10M entries. The per-entry memory cost breaks down as
//! (sizes verified by `signal_state_size_documentation` below):
//!
//! | Component                    | Size    |
//! |------------------------------|---------|
//! | `HotSignalState`             |   64 B  |
//! | `BucketedCounter`            | 1072 B  |
//! | `EntitySignalEntry`          | 1152 B  |
//! | `DashMap` per-entry overhead |  ~80 B  |
//! | **Total per entry**          | **~1232 B** |
//!
//! `BucketedCounter` is 1072 B, not the 944 B an earlier draft of this table
//! claimed: 60×4 (minute buckets) + 168×4 (hour) + 31×4 (day) + 3×1
//! (`current_minute/hour/day`) + 8 (`all_time_count`) + 3×8 (rotation timestamps)
//! = 1071, padded to 1072. `EntitySignalEntry` rounds the 64 + 1072 sum up to
//! its alignment (≈1152 B).
//!
//! Projection: 10M entries × 1232 B ≈ **12.3 GB** — over the 10 GB budget, which
//! is precisely why this module implements time-ordered eviction to bound
//! memory. The default threshold below keeps the working set well under budget.
//!
//! # Policy
//!
//! Entries are evicted by their `last_update_ns` timestamp (least-recently-used).
//! Oldest entries are removed first, preferring to evict signal state for
//! entities that have not been signalled recently. This is conservative:
//! re-signalling an evicted entity simply creates a fresh entry.
//!
//! The default threshold is **5 million entries** (~6.2 GB at ~1232 B/entry),
//! keeping a comfortable margin below the 10 GB limit.
//!
//! # When is trimming triggered?
//!
//! The checkpoint background thread calls `trim_if_needed()` on every checkpoint
//! cycle. This amortises the O(N log N) sort cost across many signal writes.

use dashmap::DashMap;

use crate::{
    schema::EntityId,
    signals::{SignalTypeId, ledger::EntitySignalEntry},
};

/// Default maximum number of `(entity, signal_type)` entries to keep in the hot path.
///
/// At ~1232 bytes per entry: 5M × 1232 ≈ 6.16 GB — comfortably under the 10 GB budget.
pub const DEFAULT_MAX_SIGNAL_ENTRIES: usize = 5_000_000;

/// Evict the oldest `(entity, signal_type)` entries from `entries` until
/// `entries.len() <= max_entries`.
///
/// Entries are sorted by `last_update_ns` ascending (oldest first) and the
/// bottom `over` entries are removed.
///
/// # Complexity
///
/// O(N log N) in the number of entries when eviction fires, with N being the
/// current number of entries. At 10M entries this is ~10M × 23 ≈ 230M comparisons,
/// which takes ~2–3 seconds on modern hardware. Amortised over the checkpoint
/// interval (typically minutes), this is acceptable.
///
/// # Thread safety
///
/// Uses `DashMap`'s snapshot iteration (`iter()` holds shard read-locks one-at-a-time).
/// Removals use `remove()` which briefly holds the shard write-lock. Concurrent
/// signal writes during trim may re-insert an evicted entry immediately — this is
/// acceptable: the entry will be re-populated and will not be stale.
/// Returns the number of entries evicted.
#[must_use]
pub fn trim_cold_entries(
    entries: &DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
    max_entries: usize,
) -> usize {
    let len = entries.len();
    if len <= max_entries {
        return 0;
    }

    let over = len - max_entries;

    // Collect (last_update_ns, key) pairs for sorting.
    // We allocate a full snapshot because DashMap does not support sorted iteration.
    let mut snapshot: Vec<(u64, (EntityId, SignalTypeId))> = entries
        .iter()
        .map(|r| (r.value().hot.last_update_ns(), *r.key()))
        .collect();

    // Sort ascending: oldest (lowest timestamp) first.
    snapshot.sort_unstable_by_key(|(ts, _)| *ts);

    // Remove the `over` oldest entries.
    let mut evicted = 0;
    for (_, key) in snapshot.into_iter().take(over) {
        entries.remove(&key);
        evicted += 1;
    }

    evicted
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::time::Duration;

    use super::*;
    use crate::{
        schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Window},
        signals::{NoopWalWriter, SignalLedger},
    };

    fn make_ledger() -> SignalLedger {
        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::TwentyFourHours])
            .velocity(false)
            .add();
        let schema = builder.build().unwrap();
        SignalLedger::new(schema, Box::new(NoopWalWriter))
    }

    /// After trim, `len() <= max_entries` invariant holds.
    #[test]
    fn trim_reduces_to_max_entries() {
        let ledger = make_ledger();

        // Insert 200 entries across 200 entities.
        for i in 1u64..=200 {
            ledger
                .record_signal(
                    "view",
                    EntityId::new(i),
                    1.0,
                    crate::schema::Timestamp::from_nanos(i * 1_000_000_000),
                )
                .unwrap();
        }
        assert_eq!(ledger.entries().len(), 200);

        // Trim to 100.
        let _ = trim_cold_entries(ledger.entries(), 100);
        assert!(
            ledger.entries().len() <= 100,
            "expected <= 100 entries, got {}",
            ledger.entries().len()
        );
    }

    /// After trim, evicted entries have older timestamps than retained entries.
    #[test]
    fn trim_evicts_oldest_entries() {
        let ledger = make_ledger();

        // Insert 10 entries with timestamps 1s..10s.
        for i in 1u64..=10 {
            ledger
                .record_signal(
                    "view",
                    EntityId::new(i),
                    1.0,
                    crate::schema::Timestamp::from_nanos(i * 1_000_000_000),
                )
                .unwrap();
        }

        // Trim to 5: should keep the 5 most recently updated (entities 6..=10).
        let _ = trim_cold_entries(ledger.entries(), 5);
        assert_eq!(ledger.entries().len(), 5);

        // Entities 1..=5 (oldest) should be evicted; 6..=10 should remain.
        let type_id = ledger.resolve_signal_type("view").unwrap();
        for i in 1u64..=5 {
            assert!(
                ledger.entries().get(&(EntityId::new(i), type_id)).is_none(),
                "entity {i} should have been evicted"
            );
        }
        for i in 6u64..=10 {
            assert!(
                ledger.entries().get(&(EntityId::new(i), type_id)).is_some(),
                "entity {i} should have been retained"
            );
        }
    }

    /// No-op when entries already at or below max.
    #[test]
    fn trim_noop_when_under_limit() {
        let ledger = make_ledger();
        for i in 1u64..=10 {
            ledger
                .record_signal(
                    "view",
                    EntityId::new(i),
                    1.0,
                    crate::schema::Timestamp::from_nanos(i * 1_000_000_000),
                )
                .unwrap();
        }
        let before = ledger.entries().len();
        let evicted = trim_cold_entries(ledger.entries(), 100);
        assert_eq!(evicted, 0, "no eviction should occur when under limit");
        assert_eq!(
            ledger.entries().len(),
            before,
            "no eviction should occur when under limit"
        );
    }

    /// Memory size documentation: compile-time assertion that struct sizes are as expected.
    #[test]
    fn signal_state_size_documentation() {
        use crate::signals::{BucketedCounter, HotSignalState, ledger::EntitySignalEntry};

        // HotSignalState = exactly 64 bytes (cache-line aligned, verified by const assert).
        assert_eq!(std::mem::size_of::<HotSignalState>(), 64);

        // BucketedCounter = 60×4 (minute) + 168×4 (hour) + 31×4 (day)
        //   + 3×1 (current_minute/hour/day) + 8 (all_time_count)
        //   + 3×8 (minute/hour/day rotation timestamps)
        //   = 240+672+124+3+8+24 = 1071, padded to 1072.
        let warm_size = std::mem::size_of::<BucketedCounter>();
        // Allow ±8 bytes for platform-specific padding.
        assert!(
            (1066..=1080).contains(&warm_size),
            "BucketedCounter size={warm_size}, expected ~1072"
        );

        // EntitySignalEntry = HotSignalState (64) + BucketedCounter (~1072),
        // rounded up to the struct's alignment ≈ 1152.
        let entry_size = std::mem::size_of::<EntitySignalEntry>();
        assert!(
            (1136..=1160).contains(&entry_size),
            "EntitySignalEntry size={entry_size}, expected ~1152"
        );
    }

    mod proptests {
        use std::time::Duration;

        use proptest::prelude::*;

        use super::*;
        use crate::{
            schema::{DecaySpec, EntityKind, SchemaBuilder, Window},
            signals::{NoopWalWriter, SignalLedger},
        };

        proptest! {
            /// After trimming, the entry count never exceeds `max_entries`,
            /// regardless of how many entries existed before.
            #[test]
            fn trim_never_exceeds_max(
                entry_count in 1usize..=500,
                max_entries in 1usize..=499,
            ) {
                let mut builder = SchemaBuilder::new();
                let _ = builder
                    .signal(
                        "view",
                        EntityKind::Item,
                        DecaySpec::Exponential {
                            half_life: Duration::from_secs(7 * 24 * 3600),
                        },
                    )
                    .windows(&[Window::TwentyFourHours])
                    .velocity(false)
                    .add();
                let schema = builder.build().unwrap();
                let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));

                for i in 1..=(entry_count as u64) {
                    ledger
                        .record_signal(
                            "view",
                            crate::schema::EntityId::new(i),
                            1.0,
                            crate::schema::Timestamp::from_nanos(i * 1_000_000_000),
                        )
                        .unwrap();
                }

                let evicted = trim_cold_entries(ledger.entries(), max_entries);

                let remaining = ledger.entries().len();
                prop_assert!(
                    remaining <= max_entries,
                    "after trim, {remaining} entries remain but max_entries={max_entries}",
                );
                // Evicted + remaining must conserve the original population.
                prop_assert_eq!(evicted + remaining, entry_count);
            }
        }
    }
}
