use std::{
    collections::HashSet,
    time::{Duration, Instant},
};

use super::format::{EventRecord, event_content_hash};

/// Default per-window entry cap for the deduplication buffers.
///
/// The dedup window is bounded by *time* (rotation every `window` duration),
/// but under a sustained high-throughput burst the `current` buffer can grow
/// without bound *within* a single window — an unbounded memory liability on the
/// hot write path. This cap forces an early rotation once `current` reaches it,
/// so total memory is bounded at roughly `2 * MAX_WINDOW_ENTRIES` hashes
/// (16 bytes each → ~32 MiB at this default) regardless of arrival rate.
///
/// The trade-off: an early size rotation can evict an entry sooner than the time
/// window would, so a *true* duplicate older than the most recent
/// ~`MAX_WINDOW_ENTRIES` events may slip through. That is acceptable —
/// deduplication is best-effort suppression of accidental re-sends, never a
/// durability or ordering guarantee (sequence numbers stay monotonic either
/// way), and the most-recent window of events (where accidental retries
/// realistically cluster) is always covered.
pub const MAX_WINDOW_ENTRIES: usize = 1_000_000;

/// Double-buffered deduplication window.
///
/// Maintains two `HashSet<u128>` buffers that rotate every `window` duration
/// *or* once the active buffer reaches `max_entries` (whichever comes first).
/// An event is considered a duplicate if its truncated BLAKE3 hash (first 128 bits)
/// exists in either the current or previous buffer.
///
/// Zero false positives: unique events are never incorrectly classified as duplicates.
/// The 128-bit hash space gives a collision probability of ~2.7e-26 at 3M entries,
/// which is effectively zero.
///
/// Bounded memory: see [`MAX_WINDOW_ENTRIES`]. The size cap guarantees the dedup
/// state cannot grow without bound under a high write rate.
pub struct DedupWindow {
    current: HashSet<u128>,
    previous: HashSet<u128>,
    rotation_time: Instant,
    window: Duration,
    /// Per-buffer entry cap. Reaching it in `current` forces an early rotation.
    max_entries: usize,
}

impl DedupWindow {
    /// Create a new dedup window with the given rotation interval and the
    /// default per-window size cap ([`MAX_WINDOW_ENTRIES`]).
    #[must_use]
    pub fn new(window: Duration) -> Self {
        Self::with_max_entries(window, MAX_WINDOW_ENTRIES)
    }

    /// Create a new dedup window with an explicit per-window size cap.
    ///
    /// `max_entries` is clamped to a minimum of 1 so the buffer can always hold
    /// at least the event currently being inserted.
    #[must_use]
    pub fn with_max_entries(window: Duration, max_entries: usize) -> Self {
        Self {
            current: HashSet::new(),
            previous: HashSet::new(),
            rotation_time: Instant::now(),
            window,
            max_entries: max_entries.max(1),
        }
    }

    /// Check if the event is a duplicate. If not, insert it into the current window.
    ///
    /// Returns `true` if the event has been seen before (within the dedup window).
    pub fn is_duplicate(&mut self, event: &EventRecord) -> bool {
        self.maybe_rotate();
        let hash = event_content_hash(event);
        if self.current.contains(&hash) || self.previous.contains(&hash) {
            return true;
        }
        self.current.insert(hash);
        false
    }

    /// Check whether `event` is already in the dedup window WITHOUT recording it.
    ///
    /// Used by the WAL writer to split duplicate *detection* from dedup-state
    /// *insertion*. An event must only be marked as seen once its batch is
    /// durably persisted (via [`record`](Self::record)); otherwise a batch that
    /// fails to flush and is then retried would be silently suppressed as a
    /// phantom duplicate — a lost, acknowledged-intent write. Rotates the buffers
    /// by time/size exactly like [`is_duplicate`](Self::is_duplicate).
    pub fn contains(&mut self, event: &EventRecord) -> bool {
        self.maybe_rotate();
        let hash = event_content_hash(event);
        self.current.contains(&hash) || self.previous.contains(&hash)
    }

    /// Record `event` as seen.
    ///
    /// Call this ONLY after the batch containing `event` has been durably
    /// fsynced, so a transient flush failure leaves the event un-recorded and a
    /// retry is accepted rather than dropped. Honours the per-window size cap
    /// like the rest of the window (forces an early rotation at the cap).
    ///
    /// A size-driven rotation here uses [`rotate_buffers`](Self::rotate_buffers),
    /// which does NOT reset the time-based rotation deadline. `record()` is
    /// called post-fsync for every kept event and never itself drives the time
    /// window (only `contains`/`is_duplicate` call `maybe_rotate`). If a
    /// size-driven rotation reset the clock, a sustained burst of unique events
    /// would keep pushing the time deadline forward, so the effective time
    /// window would silently grow longer than configured under load. Leaving the
    /// deadline untouched keeps the time window honest regardless of write rate.
    pub fn record(&mut self, event: &EventRecord) {
        if self.current.len() >= self.max_entries {
            self.rotate_buffers();
        }
        let hash = event_content_hash(event);
        self.current.insert(hash);
    }

    /// Populate the dedup window from replayed events during recovery.
    ///
    /// This inserts all provided events into the current window so that
    /// duplicates arriving after recovery are correctly detected. The size cap
    /// is honoured here too: a recovery set larger than `max_entries` rotates
    /// rather than growing `current` without bound. Like [`record`](Self::record),
    /// a size-driven rotation here preserves the time-based rotation deadline.
    pub fn populate_from_events<I: IntoIterator<Item = EventRecord>>(&mut self, events: I) {
        for event in events {
            if self.current.len() >= self.max_entries {
                self.rotate_buffers();
            }
            let hash = event_content_hash(&event);
            self.current.insert(hash);
        }
    }

    /// Number of hashes currently retained across both buffers.
    ///
    /// Exposed for tests/observability; never exceeds `2 * max_entries`.
    #[must_use]
    pub fn len(&self) -> usize {
        self.current.len() + self.previous.len()
    }

    /// Whether both dedup buffers are empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.current.is_empty() && self.previous.is_empty()
    }

    /// Rotate buffers if the window duration has elapsed OR the active buffer has
    /// reached its size cap, whichever comes first.
    ///
    /// A **time**-driven rotation resets the rotation deadline (the window just
    /// elapsed, so the next window starts now). A **size**-driven rotation does
    /// NOT reset it: the configured time window should still expire on schedule
    /// even when a burst forced an early size rotation, so the effective window
    /// can never silently grow longer than configured under sustained load.
    fn maybe_rotate(&mut self) {
        if self.rotation_time.elapsed() >= self.window {
            self.force_rotate();
        } else if self.current.len() >= self.max_entries {
            self.rotate_buffers();
        }
    }

    /// Swap `current` into `previous` and clear the new `current`, WITHOUT
    /// touching the time-based rotation deadline. Old `previous` entries are
    /// dropped. Used by every size-driven rotation (`record`,
    /// `populate_from_events`, and `maybe_rotate`'s size branch) so a burst of
    /// writes cannot push the time deadline forward.
    fn rotate_buffers(&mut self) {
        std::mem::swap(&mut self.current, &mut self.previous);
        self.current.clear();
    }

    /// Rotate the buffers AND reset the rotation timer. Used only by the
    /// time-driven rotation path: the window just elapsed, so the next window
    /// begins now.
    fn force_rotate(&mut self) {
        self.rotate_buffers();
        self.rotation_time = Instant::now();
    }
}

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

    fn make_event(id: u64) -> EventRecord {
        EventRecord::signal(id, 1, 1.0, 1_000_000_000)
    }

    #[test]
    fn unique_event_not_duplicate() {
        let mut dedup = DedupWindow::new(Duration::from_secs(30));
        assert!(!dedup.is_duplicate(&make_event(1)));
    }

    #[test]
    fn duplicate_detected() {
        let mut dedup = DedupWindow::new(Duration::from_secs(30));
        let event = make_event(1);
        assert!(!dedup.is_duplicate(&event));
        assert!(dedup.is_duplicate(&event));
    }

    #[test]
    fn different_events_not_duplicate() {
        let mut dedup = DedupWindow::new(Duration::from_secs(30));
        assert!(!dedup.is_duplicate(&make_event(1)));
        assert!(!dedup.is_duplicate(&make_event(2)));
    }

    #[test]
    fn rotation_clears_old_window() {
        // Use a zero-duration window to force immediate rotation.
        let mut dedup = DedupWindow::new(Duration::ZERO);
        let event = make_event(1);

        assert!(!dedup.is_duplicate(&event));
        // After inserting, the event is in `current`. Sleeping is not needed
        // because Duration::ZERO means rotation triggers on the next call.
        // The event is now in `previous`, still detectable.
        assert!(dedup.is_duplicate(&event));
        // One more rotation moves previous->cleared, current->previous.
        // The event was in previous (from the first swap), now it goes away
        // after a second rotation. We need to trigger two rotations.
        // Force a second rotation by calling is_duplicate again --
        // but actually, with ZERO window, every call to maybe_rotate rotates.
        // After the first is_duplicate(event) call that returned true:
        //   - maybe_rotate was called, swapping current<->previous, clearing new current
        //   - event found in previous (where it was inserted), returned true
        // On the third call:
        //   - maybe_rotate swaps again: previous (which had our hash) goes to current,
        //     but then current is cleared. Actually no -- swap then clear current.
        //   Wait. Let me trace through:
        //
        // State after new(): current={}, previous={}, time=T0
        // Call 1: is_duplicate(event)
        //   maybe_rotate: elapsed >= 0 -> swap. current(now empty)=previous(was empty).
        //                 previous(was current)=current(was empty). clear current.
        //                 State: current={}, previous={}
        //   Not found. Insert into current. State: current={H}, previous={}
        //   Returns false.
        // Call 2: is_duplicate(event)
        //   maybe_rotate: elapsed >= 0 -> swap. current(was {H}) goes to previous.
        //                 previous(was {}) goes to current. clear current.
        //                 State: current={}, previous={H}
        //   Found in previous. Returns true.
        // Call 3: is_duplicate(event)
        //   maybe_rotate: swap. current(was {}) -> previous. previous(was {H}) -> current.
        //                 clear current. State: current={}, previous={}
        //   Not found. Insert into current. State: current={H}, previous={}
        //   Returns false.

        // So after two rotations, the event is gone from both buffers.
        assert!(!dedup.is_duplicate(&event));
    }

    #[test]
    fn populate_from_events_enables_dedup() {
        let mut dedup = DedupWindow::new(Duration::from_secs(30));
        let events = vec![make_event(10), make_event(20)];
        dedup.populate_from_events(events);

        assert!(dedup.is_duplicate(&make_event(10)));
        assert!(dedup.is_duplicate(&make_event(20)));
        assert!(!dedup.is_duplicate(&make_event(30)));
    }

    #[test]
    fn size_cap_bounds_total_entries() {
        // A long window (so time never triggers rotation) with a small size cap.
        // Inserting far more than the cap must keep total retained entries
        // bounded at 2 * max_entries, never growing without bound.
        let max = 8;
        let mut dedup = DedupWindow::with_max_entries(Duration::from_secs(3600), max);
        for i in 0..1000u64 {
            // Each event is unique (distinct entity id), so none are duplicates;
            // only the size cap can keep the buffers bounded.
            let _ = dedup.is_duplicate(&make_event(i));
            assert!(
                dedup.len() <= 2 * max,
                "dedup retained {} entries, exceeds 2*max={}",
                dedup.len(),
                2 * max
            );
        }
    }

    #[test]
    fn size_cap_keeps_recent_events_deduplicated() {
        // The most-recent window of events must still be deduplicated even after
        // many size-driven rotations.
        let mut dedup = DedupWindow::with_max_entries(Duration::from_secs(3600), 4);
        let recent = make_event(999);
        assert!(!dedup.is_duplicate(&recent));
        // Insert exactly enough fresh events to fill `current` to the cap-1 so
        // `recent` is still within the current-or-previous window.
        for i in 0..3u64 {
            assert!(!dedup.is_duplicate(&make_event(i)));
        }
        // `recent` was seen this window: still detected as a duplicate.
        assert!(dedup.is_duplicate(&recent));
    }

    #[test]
    fn size_driven_record_rotation_does_not_reset_time_deadline() {
        // wal-recovery SUG: a size-driven rotation inside record() must NOT push
        // the time-based rotation deadline forward, or a sustained burst would
        // silently stretch the effective time window. White-box: the rotation
        // timer is module-private, so this same-module test reads it directly.
        let max = 4;
        let mut dedup = DedupWindow::with_max_entries(Duration::from_secs(3600), max);
        let deadline_before = dedup.rotation_time;

        // record() more than the cap so it size-rotates at least once. record()
        // never drives the time window, so the deadline must be untouched.
        for i in 0..(max as u64 * 3) {
            dedup.record(&make_event(i));
        }

        assert_eq!(
            dedup.rotation_time, deadline_before,
            "a size-driven record() rotation must preserve the time deadline"
        );
        // And the size cap still bounds memory.
        assert!(dedup.len() <= 2 * max);
    }

    #[test]
    fn time_driven_rotation_resets_deadline() {
        // The complement: a TIME-driven rotation (window elapsed) DOES reset the
        // deadline, so the next window starts fresh. A zero-length window makes
        // every maybe_rotate() take the time branch without sleeping.
        let mut dedup = DedupWindow::new(Duration::ZERO);
        let before = dedup.rotation_time;
        // `is_duplicate` calls `maybe_rotate`; with a ZERO window the time branch
        // fires and `force_rotate` updates the deadline to "now".
        let _ = dedup.is_duplicate(&make_event(1));
        assert!(
            dedup.rotation_time >= before,
            "a time-driven rotation must advance the deadline to now"
        );
    }

    #[test]
    fn populate_from_events_honours_size_cap() {
        let max = 16;
        let mut dedup = DedupWindow::with_max_entries(Duration::from_secs(3600), max);
        let events: Vec<EventRecord> = (0..1000u64).map(make_event).collect();
        dedup.populate_from_events(events);
        assert!(
            dedup.len() <= 2 * max,
            "populate left {} entries, exceeds 2*max={}",
            dedup.len(),
            2 * max
        );
    }

    #[test]
    fn no_false_positives_many_unique_events() {
        let mut dedup = DedupWindow::new(Duration::from_secs(60));
        let count = 100_000;
        for i in 0..count {
            let event = EventRecord::signal(i, (i % 256) as u8, i as f32, i * 1_000_000);
            assert!(!dedup.is_duplicate(&event), "false positive at event {i}");
        }
    }

    mod proptests {
        use proptest::prelude::*;

        use super::*;

        fn arb_event() -> impl Strategy<Value = EventRecord> {
            (any::<u64>(), any::<u8>(), any::<f32>(), any::<u64>()).prop_map(
                |(entity_id, signal_type, weight, timestamp_nanos)| {
                    EventRecord::signal(entity_id, signal_type, weight, timestamp_nanos)
                },
            )
        }

        proptest! {
            #[test]
            fn unique_events_never_dropped(
                events in proptest::collection::vec(arb_event(), 1..=1000)
            ) {
                // Generate events with unique (entity_id, signal_type, timestamp_nanos, weight)
                // combinations by using index-derived values.
                let mut dedup = DedupWindow::new(Duration::from_secs(60));
                let unique_events: Vec<EventRecord> = events
                    .iter()
                    .enumerate()
                    .map(|(i, e)| {
                        EventRecord::signal(
                            i as u64,
                            e.signal_type,
                            e.weight,
                            i as u64 * 1_000_000 + u64::from(e.signal_type),
                        )
                    })
                    .collect();

                for (i, event) in unique_events.iter().enumerate() {
                    prop_assert!(
                        !dedup.is_duplicate(event),
                        "false positive at index {i}"
                    );
                }
            }
        }
    }
}
