//! Idempotency key derivation and bounded LRU store.
//!
//! Duplicate session writes arriving via replication are detected in O(1)
//! time and silently discarded.

use std::num::NonZeroUsize;
use std::sync::Mutex;

use blake3::Hasher;
use lru::LruCache;

use crate::wal::format::session::SessionSeqNo;

/// Per-operation idempotency key derived from session context.
///
/// Derived as: `BLAKE3(session_id_bytes || seqno_bytes || operation_bytes)`.
/// Stored as `u128` (first 16 bytes of the 32-byte BLAKE3 output).
/// The 128-bit space gives 2^64 expected first collision at 2^64 operations --
/// astronomically safe for our use case.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct IdempotencyKey(pub u128);

impl IdempotencyKey {
    /// Derive an idempotency key for a session write operation.
    ///
    /// - `session_id_raw`: the session's `u64` id (from `SessionId::as_u64()`)
    /// - `seqno`: monotonic sequence number for this write
    /// - `operation_bytes`: serialized operation payload
    #[must_use]
    pub fn derive(session_id_raw: u64, seqno: SessionSeqNo, operation_bytes: &[u8]) -> Self {
        let mut hasher = Hasher::new();
        hasher.update(&session_id_raw.to_le_bytes());
        hasher.update(&seqno.0.to_le_bytes());
        hasher.update(operation_bytes);
        let hash = hasher.finalize();
        let out = hash.as_bytes();
        // BLAKE3 output is always 32 bytes; we take the first 16 for u128.
        let bytes = [
            out[0], out[1], out[2], out[3], out[4], out[5], out[6], out[7], out[8], out[9],
            out[10], out[11], out[12], out[13], out[14], out[15],
        ];
        Self(u128::from_le_bytes(bytes))
    }
}

/// Bounded LRU store for idempotency keys.
///
/// Capacity: configurable (default 100K entries ~ 1.6 MB).
/// When capacity is reached, the least-recently-seen key is evicted.
/// Evicted keys return `true` on re-check (look new); the `SessionSeqNo`
/// high-water-mark provides correctness for these older operations.
///
/// Thread-safe via `Mutex<LruCache>`.
pub struct IdempotencyStore {
    cache: Mutex<LruCache<IdempotencyKey, ()>>,
    capacity: usize,
}

impl IdempotencyStore {
    /// Create a store with the given capacity (must be > 0).
    ///
    /// # Panics
    /// Panics if `capacity == 0`.
    #[must_use]
    pub fn new(capacity: usize) -> Self {
        Self {
            cache: Mutex::new(LruCache::new(
                NonZeroUsize::new(capacity).expect("capacity must be > 0"),
            )),
            capacity,
        }
    }

    /// Create a store with the default 100K capacity.
    #[must_use]
    pub fn default_capacity() -> Self {
        Self::new(100_000)
    }

    /// Check if a key has been seen and record it if not.
    ///
    /// Returns `true` if the key is new (apply the operation).
    /// Returns `false` if the key was already seen (skip -- duplicate).
    ///
    /// # Panics
    /// Panics if the internal mutex is poisoned.
    #[must_use]
    pub fn check_and_record(&self, key: IdempotencyKey) -> bool {
        let mut cache = self.cache.lock().expect("idempotency store lock poisoned");
        if cache.contains(&key) {
            false
        } else {
            cache.put(key, ());
            true
        }
    }

    /// Current number of tracked keys.
    ///
    /// # Panics
    /// Panics if the internal mutex is poisoned.
    #[must_use]
    pub fn len(&self) -> usize {
        self.cache
            .lock()
            .expect("idempotency store lock poisoned")
            .len()
    }

    /// Returns `true` if the store is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Configured maximum capacity.
    #[must_use]
    pub const fn capacity(&self) -> usize {
        self.capacity
    }
}

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

    fn make_key(sid: u64, seq: u64, op: &[u8]) -> IdempotencyKey {
        IdempotencyKey::derive(sid, SessionSeqNo(seq), op)
    }

    #[test]
    fn derive_is_deterministic() {
        let k1 = make_key(1, 1, b"op");
        let k2 = make_key(1, 1, b"op");
        assert_eq!(k1, k2);
    }

    #[test]
    fn different_inputs_produce_different_keys() {
        let k1 = make_key(1, 1, b"op_a");
        let k2 = make_key(1, 1, b"op_b");
        let k3 = make_key(1, 2, b"op_a");
        let k4 = make_key(2, 1, b"op_a");
        assert_ne!(k1, k2);
        assert_ne!(k1, k3);
        assert_ne!(k1, k4);
    }

    #[test]
    fn store_first_call_returns_true() {
        let store = IdempotencyStore::new(10);
        let key = make_key(1, 1, b"op");
        assert!(store.check_and_record(key));
    }

    #[test]
    fn store_second_call_returns_false() {
        let store = IdempotencyStore::new(10);
        let key = make_key(1, 1, b"op");
        let _ = store.check_and_record(key);
        assert!(!store.check_and_record(key));
    }

    #[test]
    fn store_len_grows_up_to_capacity() {
        let store = IdempotencyStore::new(5);
        for i in 0..5u64 {
            assert!(store.check_and_record(make_key(1, i, b"op")));
        }
        assert_eq!(store.len(), 5);
    }

    #[test]
    fn store_lru_eviction_at_capacity() {
        let store = IdempotencyStore::new(3);
        let k1 = make_key(1, 1, b"op");
        let k2 = make_key(1, 2, b"op");
        let k3 = make_key(1, 3, b"op");
        let k4 = make_key(1, 4, b"op");
        let _ = store.check_and_record(k1);
        let _ = store.check_and_record(k2);
        let _ = store.check_and_record(k3);
        // Capacity hit: inserting k4 evicts k1 (LRU). Order: k2, k3, k4.
        let _ = store.check_and_record(k4);
        // k1 was evicted -> looks new again.
        assert!(store.check_and_record(k1), "evicted key should return true");
        // Re-inserting k1 evicted k2 (LRU). Order: k3, k4, k1.
        // k2 is now evicted, so it also looks new.
        assert!(store.check_and_record(k2), "k2 was evicted by k1 re-insert");
        // k3 should still be present (though now LRU after k2 re-insert evicted it... no):
        // Actually inserting k2 evicts k3 (LRU). Order: k4, k1, k2.
        // k4 is still present.
        assert!(!store.check_and_record(k4));
    }

    #[test]
    fn memory_bound_100k_entries() {
        // u128 = 16 bytes per key; at 100K entries ~ 1.6 MB.
        // Verify the type size is correct.
        let key_size = std::mem::size_of::<IdempotencyKey>();
        assert_eq!(key_size, 16, "IdempotencyKey must be 16 bytes");
        // 16 bytes * 100_000 = 1.6 MB, well within the 10 MB limit.
        assert!(key_size * 100_000 < 10 * 1024 * 1024);
    }

    #[test]
    fn is_empty_initially() {
        let store = IdempotencyStore::new(10);
        assert!(store.is_empty());
        assert_eq!(store.len(), 0);
    }
}
