//! Chunking strategies for the content store (Plan 272 T1.6, T2.1–T2.4).
//!
//! Two strategies:
//! - [`FixedSizeChunker`] — splits into non-overlapping fixed-size slices.
//!   Simple, fast, but a 1-byte insertion shifts every subsequent boundary,
//!   collapsing cross-blob dedup.
//! - [`FastCdcChunker`] — content-defined chunking (FastCDC, Xia et al. 2016).
//!   Cut points are derived from byte content via a rolling gear hash, so a
//!   local edit only changes the chunk containing the edit. Enables G1 (high
//!   dedup on similar large blobs) and G2 (≤5% incremental push).
//!
//! Both strategies return borrowed `&[u8]` views into the input — zero-alloc
//! on the read path.

use super::r#trait::ChunkingStrategy;

/// Default chunk size — 64 KiB (matches Plan 272 T1.6 + Research 262 §2.3).
///
/// Large enough that per-chunk overhead is amortized; small enough that
/// incremental change at byte N only rewrites the tail from N onwards
/// (for fixed-size chunking; CDC is better but Phase 2).
pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;

/// Fixed-size chunker — splits bytes into non-overlapping slices of
/// `chunk_size` bytes (final slice may be shorter).
///
/// Default `chunk_size = `[`DEFAULT_CHUNK_SIZE`] (64 KiB).
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct FixedSizeChunker {
    /// Bytes per chunk. Must be > 0. Asserted in [`Self::new`].
    pub chunk_size: usize,
}

impl FixedSizeChunker {
    /// Construct with a custom chunk size. Panics if `chunk_size == 0`
    /// (zero-size chunking is undefined — would loop forever).
    #[inline]
    #[must_use]
    pub fn new(chunk_size: usize) -> Self {
        assert!(chunk_size > 0, "FixedSizeChunker: chunk_size must be > 0");
        Self { chunk_size }
    }
}

impl Default for FixedSizeChunker {
    #[inline]
    fn default() -> Self {
        Self {
            chunk_size: DEFAULT_CHUNK_SIZE,
        }
    }
}

impl ChunkingStrategy for FixedSizeChunker {
    #[inline]
    fn chunk<'a>(&self, bytes: &'a [u8]) -> Vec<&'a [u8]> {
        // Empty input → zero chunks (matches Plan 272 T1.10 `test_empty_blob`:
        // a zero-length blob has 0 chunks and its BlobId is BLAKE3(empty)).
        if bytes.is_empty() {
            return Vec::new();
        }
        // Pre-size the output Vec to avoid reallocation.
        let n = bytes.len().div_ceil(self.chunk_size);
        let mut out = Vec::with_capacity(n);
        let mut start = 0usize;
        while start < bytes.len() {
            // early-return pattern over `if start + chunk_size > len` (AGENTS.md:
            // prefer match over if; here a while loop with a single saturating
            // split_at is cleanest).
            let end = (start + self.chunk_size).min(bytes.len());
            out.push(&bytes[start..end]);
            start = end;
        }
        out
    }
}

// ============================================================================
// FastCDC content-defined chunker (Plan 272 T2.1–T2.4)
// ============================================================================
//
// Implements FastCDC (Xia et al. 2016, "FastCDC: a Fast and Efficient
// Content-Defined Chunking Approach for Data Deduplication",
// https://www.usenix.org/system/files/conference/atc16/atc16-paper-xia.pdf).
//
// ## Why CDC?
//
// `FixedSizeChunker` splits at rigid byte offsets, so a 1-byte insertion at
// the start of a blob shifts every subsequent boundary — cross-blob dedup
// collapses to zero. Content-defined chunking places cut points at offsets
// derived from byte *content* (via a rolling gear hash), so a local edit only
// changes the chunk containing the edit; subsequent chunks re-sync and dedup
// against the original.
//
// ## Determinism (NON-NEGOTIABLE)
//
// Chunk boundaries MUST be bit-identical across nodes. Any non-determinism
// in the gear table breaks cross-node dedup (different boundaries → different
// chunk hashes → zero dedup). The gear table is therefore generated from a
// fixed seed via splitmix64 at compile time. We do **not** use `fastrand`,
// `rand`, system time, or any other RNG.
//
// ## Two-Level Mask
//
// FastCDC uses two cut-point phases separated by `MAX/2`:
// - Phase 1 (normal) `[MIN, MAX/2)`: the normal mask — cut probability matches
//   the target average chunk size.
// - Phase 2 (large) `[MAX/2, MAX)`: a looser mask (fewer bits) — higher cut
//   probability to force a cut before `MAX`, reducing chunk-size variance.
//
// ## Defaults
//
// Plan 272 specified `NORMAL_LEVEL=23, MAX_LEVEL=17`, but those values give
// expected cut spacings of 2^23 = 8 MiB and 2^17 = 128 KiB respectively, which
// defeats the purpose of CDC on ≤1 MiB blobs (almost every chunk hits the
// `MAX` force-cut and the dedup test cannot pass). The values here are the
// FastCDC paper's actual recommendations for ~8 KiB average chunks:
// - `MIN = 4 KiB`, `MAX = 64 KiB`
// - `NORMAL_LEVEL = 13` (expected ~8 KiB in Phase 1 → ~12 KiB overall avg)
// - `MAX_LEVEL = 8` (looser — `NORMAL - 5` per paper §5.2)
//
// See `test_cdc_min_max_size` and `test_cdc_dedup_with_variant` for empirical
// confirmation.

/// Default minimum chunk size — no cut points before this offset (paper §5.2).
pub const FASTCDC_MIN_CHUNK_SIZE: usize = 4 * 1024;

/// Default maximum chunk size — force a cut at this offset (paper §5.2).
pub const FASTCDC_MAX_CHUNK_SIZE: usize = 64 * 1024;

/// Normal-phase mask level. Expected cut spacing in Phase 1 = `2^LEVEL` bytes.
///
/// Set to 13 (expected ~8 KiB) per the FastCDC paper's recommendation for
/// 8 KiB target average. Plan 272 originally specified 23, which gives an
/// expected spacing of 2^23 = 8 MiB — far too large for the ≤1 MiB blobs in
/// the test suite. See module-level "Defaults" comment above.
pub const FASTCDC_NORMAL_LEVEL: u32 = 13;

/// Small-phase mask level. Reserved for the three-level FastCDC variant; the
/// standard two-level algorithm skips cuts entirely in `[0, MIN)`. Stored for
/// API completeness (the [`ChunkerConfig`] exposes it).
pub const FASTCDC_MIN_LEVEL: u32 = 13;

/// Large-phase mask level — looser than the normal level (fewer bits =
/// higher cut probability) so Phase 2 forces a cut before `MAX`. Set to
/// `NORMAL - 5` per paper §5.2.
pub const FASTCDC_MAX_LEVEL: u32 = 8;

/// Fixed seed for the splitmix64 generator that produces the gear table.
///
/// Any fixed seed works — what matters is that every node uses the *same*
/// seed, producing bit-identical boundaries. The value itself is a
/// "nothing-up-our-sleeve" constant (not derived from system state).
const GEAR_TABLE_SEED: u64 = 0x0123_4567_89ab_cdef;

/// Compute the FastCDC gear table via splitmix64 from a fixed seed.
///
/// **Determinism:** two calls always produce the same table. Two nodes running
/// this produce bit-identical tables, which is critical for cross-node dedup
/// (see module-level "Determinism" comment). `const fn` so the table is
/// computed at compile time and stored in `.rodata`.
const fn compute_gear_table() -> [u64; 256] {
    let mut table = [0u64; 256];
    let mut state = GEAR_TABLE_SEED;
    let mut i = 0usize;
    while i < 256 {
        // splitmix64 step (constants from Vigna's reference C implementation:
        // https://prng.di.unimi.it/splitmix64.c).
        state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = state;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        table[i] = z ^ (z >> 31);
        i += 1;
    }
    table
}

/// The deterministically-computed gear table (compile-time constant).
const GEAR_TABLE: [u64; 256] = compute_gear_table();

/// FastCDC content-defined chunker (Plan 272 T2.1).
///
/// Gear-hash rolling hash + two-level boundary mask. Deterministic across
/// nodes (gear table is a compile-time constant). See the [module docs](self)
/// for algorithm details and the determinism guarantee.
///
/// The 2 KB `GEAR_TABLE` const lives in `.rodata` and is referenced directly
/// from `find_cut_point`; we do **not** embed a per-instance copy. This keeps
/// the chunker struct at ~40 bytes (vs ~2080 bytes), improving cache density
/// when chunkers are stored in collections, and is bit-identical at the
/// table-access site (both lower to a fixed-address `.rodata` load).
#[derive(Clone, Debug)]
pub struct FastCdcChunker {
    /// Minimum chunk size — no cut points before this offset.
    pub min_size: usize,
    /// Maximum chunk size — force a cut at this offset.
    pub max_size: usize,
    /// Mask bits for normal-phase boundary detection.
    pub normal_mask: u64,
    /// Mask bits for small-phase (stricter) boundary detection.
    /// Reserved — the standard two-level FastCDC skips cuts in `[0, MIN)`.
    pub min_mask: u64,
    /// Mask bits for large-phase (looser) boundary detection.
    pub max_mask: u64,
}

impl FastCdcChunker {
    /// Construct with FastCDC paper defaults (see module-level "Defaults").
    #[must_use]
    pub fn new() -> Self {
        Self::with_config(ChunkerConfig::default())
    }

    /// Construct with a custom [`ChunkerConfig`].
    ///
    /// # Panics
    ///
    /// Panics if `min_size == 0`, `max_size <= min_size`, `normal_level >= 64`,
    /// or `max_level >= normal_level` (the large-phase mask must be looser).
    #[must_use]
    pub fn with_config(config: ChunkerConfig) -> Self {
        assert!(config.min_size > 0, "FastCdcChunker: min_size must be > 0");
        assert!(
            config.max_size > config.min_size,
            "FastCdcChunker: max_size must be > min_size"
        );
        assert!(
            config.normal_level < 64,
            "FastCdcChunker: normal_level must be < 64"
        );
        assert!(
            config.max_level < config.normal_level,
            "FastCdcChunker: max_level must be < normal_level (looser phase needs fewer bits)"
        );
        Self {
            min_size: config.min_size,
            max_size: config.max_size,
            normal_mask: (1u64 << config.normal_level).wrapping_sub(1),
            min_mask: (1u64 << config.min_level).wrapping_sub(1),
            max_mask: (1u64 << config.max_level).wrapping_sub(1),
        }
    }

    /// Find the next cut point at or after `start`.
    ///
    /// Returns the exclusive end offset of the next chunk, in
    /// `(start, bytes.len()]`. Algorithm:
    /// 1. If `≤ MIN` bytes remain, return the tail as one chunk.
    /// 2. Phase 0 (skip): `[start, start+MIN)` — prime the rolling hash, no cuts.
    /// 3. Phase 1 (normal): `[start+MIN, start+MAX/2)` — check `normal_mask`.
    /// 4. Phase 2 (large): `[start+MAX/2, start+MAX)` — check `max_mask` (looser).
    /// 5. No cut found: return `start+MAX` (force-cut).
    #[inline]
    fn find_cut_point(&self, bytes: &[u8], start: usize) -> usize {
        let bytes_remaining = bytes.len() - start;
        // Short tail — emit as one final chunk (paper: "if n <= MIN, return n").
        if bytes_remaining <= self.min_size {
            return bytes.len();
        }

        let max_end = (start + self.max_size).min(bytes.len());
        let mid_end = (start + self.max_size / 2).min(max_end);
        let skip_end = (start + self.min_size).min(max_end);

        let mut fp: u64 = 0;
        let mut i = start;

        // Phase 0: prime fp, no cuts allowed.
        while i < skip_end {
            fp = (fp << 1) ^ GEAR_TABLE[bytes[i] as usize];
            i += 1;
        }
        // Phase 1: normal mask (stricter → expected chunk ≈ 2^normal_level).
        while i < mid_end {
            fp = (fp << 1) ^ GEAR_TABLE[bytes[i] as usize];
            if (fp & self.normal_mask) == 0 {
                return i + 1;
            }
            i += 1;
        }
        // Phase 2: looser mask (force a cut before MAX).
        while i < max_end {
            fp = (fp << 1) ^ GEAR_TABLE[bytes[i] as usize];
            if (fp & self.max_mask) == 0 {
                return i + 1;
            }
            i += 1;
        }
        max_end
    }

    /// Chunk into owned `Vec<u8>` — convenience for callers that need owned
    /// bytes (e.g. write paths where borrowed slices can't outlive the input).
    /// The `InMemoryChunkedStore::put` path already works correctly via the
    /// borrowed `chunk()` interface; this method is a convenience, not a
    /// requirement.
    #[must_use]
    pub fn chunk_into_owned(&self, bytes: &[u8]) -> Vec<Vec<u8>> {
        let avg = (self.min_size + self.max_size) / 2;
        let cap = bytes.len() / avg.max(1) + 1;
        let mut out: Vec<Vec<u8>> = Vec::with_capacity(cap);
        let mut offset = 0usize;
        while offset < bytes.len() {
            let end = self.find_cut_point(bytes, offset);
            out.push(bytes[offset..end].to_vec());
            offset = end;
        }
        out
    }
}

impl Default for FastCdcChunker {
    #[inline]
    fn default() -> Self {
        Self::new()
    }
}

impl ChunkingStrategy for FastCdcChunker {
    fn chunk<'a>(&self, bytes: &'a [u8]) -> Vec<&'a [u8]> {
        if bytes.is_empty() {
            return Vec::new();
        }
        // Pre-size based on estimated chunk count (avg of MIN and MAX).
        let avg = (self.min_size + self.max_size) / 2;
        let cap = bytes.len() / avg.max(1) + 1;
        let mut out: Vec<&'a [u8]> = Vec::with_capacity(cap);
        let mut offset = 0usize;
        while offset < bytes.len() {
            let end = self.find_cut_point(bytes, offset);
            out.push(&bytes[offset..end]);
            offset = end;
        }
        out
    }
}

/// Runtime-tunable chunker configuration. Used by [`FastCdcChunker::with_config`].
///
/// Defaults match the FastCDC paper (Xia et al. 2016, §5.2) for ~8 KiB average
/// chunks.
#[repr(C)]
#[derive(Clone, Debug)]
pub struct ChunkerConfig {
    /// Minimum chunk size — no cut points before this offset.
    pub min_size: usize,
    /// Maximum chunk size — force a cut at this offset.
    pub max_size: usize,
    /// Mask bits for normal-phase boundary detection.
    pub normal_level: u32,
    /// Mask bits for small-phase (stricter) boundary detection.
    /// Reserved — the standard two-level FastCDC skips cuts in `[0, MIN)`.
    pub min_level: u32,
    /// Mask bits for large-phase (looser) boundary detection.
    pub max_level: u32,
}

impl Default for ChunkerConfig {
    #[inline]
    fn default() -> Self {
        Self {
            min_size: FASTCDC_MIN_CHUNK_SIZE,
            max_size: FASTCDC_MAX_CHUNK_SIZE,
            normal_level: FASTCDC_NORMAL_LEVEL,
            min_level: FASTCDC_MIN_LEVEL,
            max_level: FASTCDC_MAX_LEVEL,
        }
    }
}

impl ChunkerConfig {
    /// Construct a [`FastCdcChunker`] from this config.
    #[must_use]
    pub fn fast_cdc_chunker(&self) -> FastCdcChunker {
        FastCdcChunker::with_config(self.clone())
    }
}

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

    #[test]
    fn test_default_chunk_size() {
        assert_eq!(DEFAULT_CHUNK_SIZE, 64 * 1024);
        let c = FixedSizeChunker::default();
        assert_eq!(c.chunk_size, DEFAULT_CHUNK_SIZE);
    }

    #[test]
    fn test_empty_input_returns_zero_chunks() {
        let c = FixedSizeChunker::default();
        let chunks = c.chunk(b"");
        assert!(chunks.is_empty(), "empty input must yield zero chunks");
    }

    #[test]
    fn test_single_byte_input() {
        let c = FixedSizeChunker::new(1024);
        let chunks = c.chunk(&[42u8]);
        assert_eq!(chunks.len(), 1);
        assert_eq!(chunks[0], &[42u8]);
    }

    #[test]
    fn test_exact_multiple() {
        let c = FixedSizeChunker::new(4);
        let bytes = b"abcdefghijkl"; // 12 bytes, exactly 3 chunks of 4
        let chunks = c.chunk(bytes);
        assert_eq!(chunks.len(), 3);
        assert_eq!(chunks[0], b"abcd");
        assert_eq!(chunks[1], b"efgh");
        assert_eq!(chunks[2], b"ijkl");
        // Roundtrip: concatenation must equal input.
        let recon: Vec<u8> = chunks.concat();
        assert_eq!(recon.as_slice(), bytes);
    }

    #[test]
    fn test_partial_last_chunk() {
        let c = FixedSizeChunker::new(4);
        let bytes = b"abcdefghij"; // 10 bytes: 2 full + 1 partial of 2
        let chunks = c.chunk(bytes);
        assert_eq!(chunks.len(), 3);
        assert_eq!(chunks[0].len(), 4);
        assert_eq!(chunks[1].len(), 4);
        assert_eq!(chunks[2].len(), 2);
        assert_eq!(chunks[2], b"ij");
    }

    #[test]
    #[should_panic(expected = "chunk_size must be > 0")]
    fn test_zero_chunk_size_panics() {
        let _ = FixedSizeChunker::new(0);
    }

    #[test]
    fn test_with_capacity_no_reallocation() {
        // Indirect check: a chunker over 10 * chunk_size input should yield
        // exactly 10 chunks with no over-allocation in the Vec.
        let cs = 8;
        let c = FixedSizeChunker::new(cs);
        let bytes: Vec<u8> = (0..(10 * cs)).map(|i| i as u8).collect();
        let chunks = c.chunk(&bytes);
        assert_eq!(chunks.len(), 10);
        assert!(chunks.iter().all(|c| c.len() == cs));
        // capacity may be >= len but the pre-sizing should make it equal.
        assert_eq!(chunks.capacity(), 10);
    }
}

// ============================================================================
// CDC unit tests (Plan 272 T2.3)
// ============================================================================

#[cfg(test)]
mod cdc_tests {
    use super::*;
    use std::collections::HashSet;

    /// Deterministic LCG byte generator — NOT a runtime RNG.
    ///
    /// Produces the same byte sequence for the same `(n, seed)` across runs
    /// (critical for the stability / determinism assertions). Plan 272 T2.3
    /// explicitly prohibits `fastrand` for this reason.
    fn lcg_bytes(n: usize, seed: u64) -> Vec<u8> {
        let mut out = Vec::with_capacity(n);
        let mut state = seed;
        for _ in 0..n {
            state = state
                .wrapping_mul(6364136223846793005)
                .wrapping_add(1442695040888963407);
            out.push((state >> 32) as u8);
        }
        out
    }

    /// Collect chunk lengths for boundary comparison.
    fn boundaries(chunks: &[&[u8]]) -> Vec<usize> {
        chunks.iter().map(|c| c.len()).collect()
    }

    /// BLAKE3-hash a chunk for dedup comparison.
    fn hash_chunk(chunk: &[u8]) -> [u8; 32] {
        blake3::hash(chunk).into()
    }

    #[test]
    fn test_cdc_empty_input() {
        let c = FastCdcChunker::new();
        assert!(c.chunk(b"").is_empty());
    }

    #[test]
    fn test_cdc_short_input() {
        // Input shorter than MIN_SIZE → single chunk containing the whole input.
        let c = FastCdcChunker::new();
        let data = lcg_bytes(100, 42);
        let chunks = c.chunk(&data);
        assert_eq!(chunks.len(), 1);
        assert_eq!(chunks[0].len(), 100);
        assert_eq!(chunks[0], &data[..]);
    }

    #[test]
    fn test_cdc_stable_boundaries() {
        // Same input → same boundaries, twice (within one instance).
        let c = FastCdcChunker::new();
        let data = lcg_bytes(64 * 1024, 7);
        let b1 = boundaries(&c.chunk(&data));
        let b2 = boundaries(&c.chunk(&data));
        assert_eq!(b1, b2, "chunker must be deterministic for same input");
    }

    #[test]
    fn test_cdc_deterministic_across_instances() {
        // Two fresh instances → identical boundaries. Proves the gear table is
        // deterministic (no RNG, no system state, compile-time const).
        let data = lcg_bytes(64 * 1024, 99);
        let b1 = boundaries(&FastCdcChunker::new().chunk(&data));
        let b2 = boundaries(&FastCdcChunker::new().chunk(&data));
        assert_eq!(b1, b2, "two fresh chunkers must agree");
    }

    #[test]
    fn test_cdc_min_max_size() {
        // 1 MiB of pseudo-random data; every non-final chunk must be in [MIN, MAX].
        let c = FastCdcChunker::new();
        let data = lcg_bytes(1024 * 1024, 1234);
        let chunks = c.chunk(&data);
        assert!(chunks.len() > 1, "1 MiB should yield multiple chunks");
        let last = chunks.len() - 1;
        for (i, chunk) in chunks.iter().enumerate() {
            if i == last {
                assert!(
                    chunk.len() <= c.max_size,
                    "final chunk {} len {} > max {}",
                    i,
                    chunk.len(),
                    c.max_size
                );
            } else {
                assert!(
                    chunk.len() >= c.min_size,
                    "chunk {} len {} < min {}",
                    i,
                    chunk.len(),
                    c.min_size
                );
                assert!(
                    chunk.len() <= c.max_size,
                    "chunk {} len {} > max {}",
                    i,
                    chunk.len(),
                    c.max_size
                );
            }
        }
    }

    /// Insert 1 byte at offset 0 of a 256 KiB blob. First chunk must differ;
    /// ≥ 50% of the original's subsequent chunks must re-appear in the
    /// mutated stream (CDC robustness — prefix change doesn't cascade).
    ///
    /// Empirically observed match ratio with the corrected constants
    /// (`NORMAL_LEVEL=13`): typically ~85–95%.
    #[test]
    fn test_cdc_local_change() {
        let c = FastCdcChunker::new();
        let original = lcg_bytes(256 * 1024, 555);
        let mut mutated = Vec::with_capacity(original.len() + 1);
        mutated.push(0xff);
        mutated.extend_from_slice(&original);

        let b_orig = boundaries(&c.chunk(&original));
        let b_mut = boundaries(&c.chunk(&mutated));

        assert!(!b_orig.is_empty() && !b_mut.is_empty());
        assert_ne!(b_orig[0], b_mut[0], "first chunk should differ");

        // Greedy subsequence match: how many of b_orig[1..] re-appear in b_mut?
        let mut matched = 0usize;
        let mut total = 0usize;
        let mut j = 0usize;
        for &len in &b_orig[1..] {
            total += 1;
            while j < b_mut.len() && b_mut[j] < len {
                j += 1;
            }
            if j < b_mut.len() && b_mut[j] == len {
                matched += 1;
                j += 1;
            }
        }

        let ratio = matched as f32 / total.max(1) as f32;
        assert!(
            ratio >= 0.50,
            "CDC robustness: matched {}/{} = {:.0}% (need ≥ 50%)",
            matched,
            total,
            ratio * 100.0
        );
    }

    /// **G1 GOAT gate.** Two ~1 MiB blobs: blob B has 1 KiB of different data
    /// *inserted* at offset 512 KiB (mid-blob). CDC should produce
    /// nearly-identical chunk sets; fixed-size chunking should produce
    /// nearly-disjoint sets (the insertion shifts all subsequent boundaries).
    ///
    /// Metric: **incremental push ratio** = (chunks in B not present in A) /
    /// (total chunks in B). This is the G2 goal (≤5% incremental push).
    ///
    /// Plan 272 specified "unique_chunks / total_chunks" with a ≤5% bar, but
    /// that metric has a floor of ~50% for two same-size blobs (each unique
    /// hash contributes once even when shared). Push-ratio is the correct
    /// measure of CDC's dedup benefit and matches the stated G1/G2 goals.
    ///
    /// Bars: FastCDC push ratio ≤ 5%; FixedSize push ratio ≥ 50%.
    #[test]
    fn test_cdc_dedup_with_variant() {
        let base = lcg_bytes(1024 * 1024, 1);
        // Build variant: insert 1 KiB of different data at offset 512 KiB.
        let patch_offset = 512 * 1024;
        let patch = lcg_bytes(1024, 999);
        let mut variant = Vec::with_capacity(base.len() + patch.len());
        variant.extend_from_slice(&base[..patch_offset]);
        variant.extend_from_slice(&patch);
        variant.extend_from_slice(&base[patch_offset..]);
        assert_eq!(variant.len(), base.len() + 1024);

        // ---- FastCDC (positive) ----
        let cdc = FastCdcChunker::new();
        let cdc_a = cdc.chunk(&base);
        let cdc_b = cdc.chunk(&variant);
        let a_hashes: HashSet<[u8; 32]> = cdc_a.iter().map(|c| hash_chunk(c)).collect();
        let cdc_new = cdc_b
            .iter()
            .filter(|c| !a_hashes.contains(&hash_chunk(c)))
            .count();
        let cdc_push = cdc_new as f32 / cdc_b.len() as f32;
        assert!(
            cdc_push <= 0.05,
            "FastCDC push ratio = {:.2}% ({} new / {} total in B) — must be ≤ 5%",
            cdc_push * 100.0,
            cdc_new,
            cdc_b.len()
        );

        // ---- FixedSize (negative control) ----
        let fixed = FixedSizeChunker::default();
        let fxd_a = fixed.chunk(&base);
        let fxd_b = fixed.chunk(&variant);
        let a_hashes_fixed: HashSet<[u8; 32]> = fxd_a.iter().map(|c| hash_chunk(c)).collect();
        let fxd_new = fxd_b
            .iter()
            .filter(|c| !a_hashes_fixed.contains(&hash_chunk(c)))
            .count();
        let fxd_push = fxd_new as f32 / fxd_b.len() as f32;
        assert!(
            fxd_push >= 0.50,
            "FixedSize push ratio = {:.2}% ({} new / {} total in B) — must be ≥ 50%",
            fxd_push * 100.0,
            fxd_new,
            fxd_b.len()
        );

        // Sanity: FastCDC must beat FixedSize by a wide margin.
        assert!(
            cdc_push < fxd_push / 5.0,
            "FastCDC ({:.2}%) should be >5× better than FixedSize ({:.2}%)",
            cdc_push * 100.0,
            fxd_push * 100.0
        );
    }
}
