use std::mem::size_of;
use std::ops::Bound;
use std::sync::Arc;

use crate::Key;

use crate::batch::{Applied, BatchWrite};
use crate::byte_view::ByteView;
use crate::cache::{BlockCache, BlockKey};
use crate::compaction::{CompactionIndex, compact_shard};
use crate::config::Config;
use crate::disk_loc::DiskLoc;
use crate::engine::Engine;
use crate::error::{DbError, DbResult};
use crate::hook::{NoHook, WriteHook};
use crate::io::aligned_buf::AlignedBuf;
use crate::recovery::recover_var_tree;
use crate::shard::ShardInner;
use crate::skiplist::node::{SkipNode, VarNode, random_height};
use crate::skiplist::{InsertResult, SkipList};
use crate::sync::MutexGuard;
use crate::value_cache::{ValueCache, ValueKey};

const MAX_STALE_RETRIES: usize = 3;

/// Outcome of a locked read-modify-write step (no lock acquisition, no hook).
enum UpdateOutcome {
    /// Key absent — closure not invoked.
    Missing,
    /// Closure returned `None` (abort) — current value left unchanged.
    Unchanged(ByteView),
    /// Value replaced.
    Updated { old: ByteView, new: ByteView },
}

/// A tree with fixed-size keys and variable-length values.
/// Values are stored as `ByteView` (inline ≤20 bytes, heap with ref counting for larger).
/// Disk reads are cached at 4096-byte block granularity via `BlockCache`.
///
/// Each `VarTree` owns its storage engine — one tree = one database directory.
///
/// # Usage
///
/// ```ignore
/// let tree = VarTree::<16>::open("data/messages", Config::large_values().build())?;
/// tree.put(&key, b"hello world")?;
/// tree.close()?;
/// ```
///
/// # Iteration
///
/// `iter()`, `range()`, and `prefix_iter()` all return [`VarIter`] which
/// implements `Iterator + DoubleEndedIterator` with `Item = (K, ByteView)`.
/// Each `next()` / `next_back()` may perform disk I/O on a block-cache miss.
///
/// ```ignore
/// let latest = tree.prefix_iter(&group_id).take(50).collect::<Vec<_>>();
/// let oldest = tree.prefix_iter(&group_id).rev().take(10);  // DoubleEndedIterator
/// ```
///
/// # Write hooks
///
/// Uses [`WriteHook<K>`]. `on_write` fires on `put`/`insert`/`delete`/`cas`/`compare_delete`/`update`/`fetch_update`
/// and inside `atomic()` (buffered during the closure, replayed after the shard lock is released).
/// When `NEEDS_OLD_VALUE = false`, old value is omitted from `on_write` to skip disk I/O.
///
/// `on_init` fires once per live entry during `migrate()` or `replay_init()`
/// (enable via `NEEDS_INIT = true`).
///
/// # Batch operations
///
/// [`get_many`](Self::get_many) does one sorted finger-walk over the index for a
/// list of keys; [`update_many`](Self::update_many) applies a per-key closure
/// grouped by shard (per-shard isolation, no global atomicity). See
/// `docs/superpowers/specs/26-06-24-armdb-batch-tree-ops.md`.
pub struct VarTree<K: Key, H: WriteHook<K> = NoHook> {
    index: SkipList<VarNode<K>>,
    engine: Engine,
    block_cache: BlockCache,
    value_cache: ValueCache,
    compaction_threshold: f64,
    shard_prefix_bits: usize,
    reversed: bool,
    hook: H,
}

impl<K: Key> VarTree<K> {
    /// Open or create a `VarTree` at the given path.
    /// Recovers the index from existing data files on disk.
    pub fn open(path: impl AsRef<std::path::Path>, config: Config) -> DbResult<Self> {
        Self::open_hooked(path, config, NoHook)
    }
}

impl<K: Key, H: WriteHook<K>> VarTree<K, H> {
    /// Open or create a `VarTree` with a write hook for secondary index maintenance.
    pub fn open_hooked(
        path: impl AsRef<std::path::Path>,
        config: Config,
        hook: H,
    ) -> DbResult<Self> {
        let config = config.with_resolved_hints(true);
        if config.var_cache_warn_needed() {
            tracing::warn!(
                "Var collection opened with no cache (block_cache=0 and value_cache=0): every get hits disk"
            );
        }
        if config.app_cache_without_direct_io() {
            tracing::warn!(
                "Var collection has an app cache enabled (block_cache/value_cache) but direct_io is off: \
                 values are cached twice (app cache + OS page cache). Enable direct_io to avoid redundant \
                 RAM — exception: keeping the app cache for plaintext caching under encryption."
            );
        }
        Self::open_inner(path, config, hook)
    }

    fn open_inner(path: impl AsRef<std::path::Path>, config: Config, hook: H) -> DbResult<Self> {
        let compaction_threshold = config.compaction_threshold;
        let shard_prefix_bits = config.shard_prefix_bits;
        let reversed = config.reversed;
        let block_cache = BlockCache::new(&config.block_cache);
        let value_cache = ValueCache::new(&config.value_cache);
        let engine = Engine::open(path, config)?;

        let tree = Self {
            index: SkipList::new(reversed),
            engine,
            block_cache,
            value_cache,
            compaction_threshold,
            shard_prefix_bits,
            reversed,
            hook,
        };

        // Recover index from disk
        let shard_dirs = tree.engine.shard_dirs();
        let shard_dir_refs = Engine::shard_dir_refs(&shard_dirs);
        let shard_ids = tree.engine.shard_ids();

        let hints = tree.engine.hints();
        let outcome = recover_var_tree::<K>(
            &shard_dir_refs,
            &shard_ids,
            tree.index(),
            hints,
            #[cfg(feature = "encryption")]
            tree.engine.cipher(),
        )?;
        for tail in &outcome.active_tails {
            tree.engine.shards()[tail.shard_idx].apply_recovery_tail(tail)?;
        }
        for (shard_idx, dead) in outcome.shard_dead_bytes {
            tree.engine.shards()[shard_idx].install_dead_bytes(dead);
        }
        let max_gsn = outcome.max_gsn;

        tree.engine
            .gsn()
            .fetch_max(max_gsn + 1, std::sync::atomic::Ordering::Relaxed);
        if hints {
            for shard in tree.engine.shards().iter() {
                shard.set_key_len(size_of::<K>());
            }
        }
        tracing::info!(
            key_size = size_of::<K>(),
            entries = tree.len(),
            "var_tree recovered"
        );

        Ok(tree)
    }

    /// Graceful shutdown: write hint files (if enabled), flush write buffers + fsync.
    pub fn close(self) -> DbResult<()> {
        if self.engine.hints() {
            self.sync_hints()?;
        }
        self.engine.flush()
    }

    /// Flush all shard write buffers to disk (without fsync).
    pub fn flush_buffers(&self) -> DbResult<()> {
        self.engine.flush_buffers()
    }

    /// Get the database configuration.
    pub fn config(&self) -> &Config {
        self.engine.config()
    }
}

impl<K: Key, H: WriteHook<K>> CompactionIndex<K> for VarTree<K, H> {
    fn update_if_match(&self, key: &K, old_loc: DiskLoc, new_loc: DiskLoc) -> bool {
        let guard = self.index.collector().enter();
        if let Some(node) = self.index.get(key.as_bytes(), &guard)
            && node.read_loc() == old_loc
        {
            node.write_loc(new_loc);
            return true;
        }
        false
    }

    fn invalidate_blocks(&self, shard_id: u8, file_id: u32, total_bytes: u64) {
        self.block_cache
            .invalidate_file(shard_id, file_id, total_bytes);
    }

    fn contains_key(&self, key: &K) -> bool {
        self.contains(key)
    }

    fn is_live(&self, _shard_id: u8, key: &K, loc: DiskLoc) -> bool {
        let guard = self.index.collector().enter();
        self.index
            .get(key.as_bytes(), &guard)
            .is_some_and(|node| node.read_loc() == loc)
    }
}

impl<K: Key, H: WriteHook<K>> VarTree<K, H> {
    /// Trigger a background compaction pass across all shards.
    pub fn compact(&self) -> DbResult<usize> {
        let mut total_compacted = 0;
        for shard in self.engine.shards().iter() {
            total_compacted += compact_shard(shard, self, self.compaction_threshold)?;
        }
        Ok(total_compacted)
    }

    /// Get a value by key. Checks block cache first, then reads from disk.
    ///
    /// **O(log n)** — lock-free SkipList lookup; on a block-cache miss a brief shard lock
    /// fetches the file handles, then the `pread` runs outside the lock.
    pub fn get(&self, key: &K) -> Option<ByteView> {
        metrics::counter!("armdb.ops", "op" => "get", "tree" => "var_tree").increment(1);
        #[cfg(feature = "hot-path-tracing")]
        tracing::trace!("var_tree.get");
        let guard = self.index.collector().enter();
        let node = match self.index.get(key.as_bytes(), &guard) {
            Some(n) => n,
            None => {
                #[cfg(feature = "hot-path-tracing")]
                tracing::error!(
                    "VarTree get error: index.get returned None for key {:?}",
                    key.as_bytes()
                );
                return None;
            }
        };
        self.read_value_cached(node, &guard)
    }

    /// Get a value by key, returning `Err(KeyNotFound)` if absent or the real
    /// error (`Io`/`EncryptionError`/`StaleDiskLoc`) if present but unreadable.
    pub fn get_or_err(&self, key: &K) -> DbResult<ByteView> {
        let guard = self.index.collector().enter();
        let node = self
            .index
            .get(key.as_bytes(), &guard)
            .ok_or(DbError::KeyNotFound)?;
        self.read_value_cached_result(node, &guard)
    }

    /// Strict read: `Ok(None)` only when the key is genuinely absent; `Err` when the
    /// key exists but its value cannot be read.
    pub fn try_get(&self, key: &K) -> DbResult<Option<ByteView>> {
        let guard = self.index.collector().enter();
        match self.index.get(key.as_bytes(), &guard) {
            Some(node) => self.read_value_cached_result(node, &guard).map(Some),
            None => Ok(None),
        }
    }

    /// Strict first entry: `Ok(None)` only when there is no live entry; `Err` when
    /// the boundary live entry exists but its value cannot be read.
    pub fn try_first(&self) -> DbResult<Option<(K, ByteView)>> {
        let guard = self.index.collector().enter();
        let mut ptr = crate::skiplist::strip_mark(unsafe {
            (*self.index.head_ptr())
                .tower(0)
                .load(std::sync::atomic::Ordering::Acquire)
        });
        while !ptr.is_null() {
            let node = unsafe { &*ptr };
            if !node.is_marked() {
                return self
                    .read_value_cached_result(node, &guard)
                    .map(|v| Some((node.key, v)));
            }
            ptr = crate::skiplist::strip_mark(
                node.tower(0).load(std::sync::atomic::Ordering::Acquire),
            );
        }
        Ok(None)
    }

    /// Strict last entry (symmetric to `try_first`).
    pub fn try_last(&self) -> DbResult<Option<(K, ByteView)>> {
        let guard = self.index.collector().enter();
        let mut ptr = self.index.find_last(&guard);
        while !ptr.is_null() {
            let node = unsafe { &*ptr };
            if !node.is_marked() {
                return self
                    .read_value_cached_result(node, &guard)
                    .map(|v| Some((node.key, v)));
            }
            ptr = self.index.find_last_lt(node.key_bytes(), &guard);
        }
        Ok(None)
    }

    /// Insert or update a key-value pair.
    ///
    /// Returns `true` if a previous value for `key` existed (i.e. this was an
    /// overwrite), `false` for a fresh insert. The flag is read directly from
    /// the index update and adds no extra I/O or value decoding.
    pub fn put(&self, key: &K, value: &[u8]) -> DbResult<bool> {
        metrics::counter!("armdb.ops", "op" => "put", "tree" => "var_tree").increment(1);
        #[cfg(feature = "hot-path-tracing")]
        tracing::trace!("var_tree.put");
        let shard_id = self.shard_for(key);
        let mut inner = self.engine.shards()[shard_id].lock();
        let guard = self.index.collector().enter();
        let old_value = if H::NEEDS_OLD_VALUE {
            if let Some(node) = self.index.get(key.as_bytes(), &guard) {
                let disk = node.read_loc();
                Some(self.read_value_locked_result(&disk, shard_id as u8, &inner)?)
            } else {
                None
            }
        } else {
            None
        };
        let existed = self.put_locked(shard_id, &mut inner, &guard, key, value)?;
        drop(inner);
        self.hook.on_write(key, old_value.as_deref(), Some(value));
        Ok(existed)
    }

    /// Insert a key-value pair only if the key does not exist.
    /// Returns `Err(KeyExists)` if the key is already present.
    pub fn insert(&self, key: &K, value: &[u8]) -> DbResult<()> {
        metrics::counter!("armdb.ops", "op" => "insert", "tree" => "var_tree").increment(1);
        #[cfg(feature = "hot-path-tracing")]
        tracing::trace!("var_tree.insert");
        let shard_id = self.shard_for(key);
        let mut inner = self.engine.shards()[shard_id].lock();
        let guard = self.index.collector().enter();
        self.insert_locked(shard_id, &mut inner, &guard, key, value)?;
        drop(inner);
        self.hook.on_write(key, None, Some(value));
        Ok(())
    }

    /// Delete a key. Returns `true` if the key existed.
    pub fn delete(&self, key: &K) -> DbResult<bool> {
        metrics::counter!("armdb.ops", "op" => "delete", "tree" => "var_tree").increment(1);
        #[cfg(feature = "hot-path-tracing")]
        tracing::trace!("var_tree.delete");
        let shard_id = self.shard_for(key);
        let mut inner = self.engine.shards()[shard_id].lock();
        let guard = self.index.collector().enter();
        let old_value = if H::NEEDS_OLD_VALUE {
            if let Some(node) = self.index.get(key.as_bytes(), &guard) {
                let disk = node.read_loc();
                Some(self.read_value_locked_result(&disk, shard_id as u8, &inner)?)
            } else {
                None
            }
        } else {
            None
        };
        let existed = self.delete_locked(shard_id, &mut inner, &guard, key)?;
        drop(inner);
        if existed {
            self.hook.on_write(key, old_value.as_deref(), None);
        }
        Ok(existed)
    }

    /// Atomically execute multiple operations on a single shard.
    /// All keys must route to the same shard as `shard_key`.
    /// The closure must be short — shard lock is held for its duration.
    pub fn atomic<R>(
        &self,
        shard_key: &K,
        f: impl FnOnce(&mut VarShard<'_, K, H>) -> DbResult<R>,
    ) -> DbResult<R> {
        let shard_id = self.shard_for(shard_key);
        let inner = self.engine.shards()[shard_id].lock();
        let guard = self.index.collector().enter();
        let mut shard = VarShard {
            tree: self,
            inner,
            shard_id,
            guard,
            events: Vec::new(),
        };
        let result = f(&mut shard);
        let VarShard {
            inner,
            guard,
            events,
            ..
        } = shard;
        drop(inner);
        if H::NEEDS_WRITE {
            for (k, old, new) in &events {
                self.hook.on_write(k, old.as_deref(), new.as_deref());
            }
        }
        drop(guard);
        result
    }

    /// Returns `true` if `key` already had a value (overwrite), `false` if newly inserted.
    fn put_locked(
        &self,
        shard_id: usize,
        inner: &mut ShardInner,
        guard: &seize::LocalGuard<'_>,
        key: &K,
        value: &[u8],
    ) -> DbResult<bool> {
        let (disk_loc, _gsn) = inner.append_entry(shard_id as u8, key.as_bytes(), value, false)?;

        // Fast path: key exists — atomic swap, no write_lock, no node allocation
        if let Some(existing) = self.index.get(key.as_bytes(), guard) {
            let old_disk = existing.read_loc();
            inner.add_dead_bytes(
                old_disk.file_id,
                crate::entry::entry_size(size_of::<K>(), old_disk.len),
            );
            existing.write_loc(disk_loc);
            return Ok(true);
        }

        // Slow path: new key — allocate node + take write_lock via insert
        let height = random_height();
        let node_ptr = VarNode::alloc(*key, disk_loc, height);

        match self.index.insert(node_ptr, guard) {
            InsertResult::Inserted => Ok(false),
            InsertResult::Exists(existing) => {
                // Race: another shard inserted same key between get and insert
                let old_disk = existing.read_loc();
                inner.add_dead_bytes(
                    old_disk.file_id,
                    crate::entry::entry_size(size_of::<K>(), old_disk.len),
                );
                existing.write_loc(disk_loc);
                unsafe {
                    VarNode::<K>::dealloc_node(node_ptr);
                }
                Ok(true)
            }
        }
    }

    fn insert_locked(
        &self,
        shard_id: usize,
        inner: &mut ShardInner,
        guard: &seize::LocalGuard<'_>,
        key: &K,
        value: &[u8],
    ) -> DbResult<()> {
        if self.index.get(key.as_bytes(), guard).is_some() {
            return Err(DbError::KeyExists);
        }

        let (disk_loc, _gsn) = inner.append_entry(shard_id as u8, key.as_bytes(), value, false)?;
        let height = random_height();
        let node_ptr = VarNode::alloc(*key, disk_loc, height);

        match self.index.insert(node_ptr, guard) {
            InsertResult::Inserted => Ok(()),
            InsertResult::Exists(_existing) => {
                // Race: another path inserted this key after our `get` check and
                // before `index.insert`. Account for the entry we already appended
                // as dead bytes, free the unpublished node, and report KeyExists.
                inner.add_dead_bytes(
                    disk_loc.file_id,
                    crate::entry::entry_size(size_of::<K>(), disk_loc.len),
                );
                // SAFETY: node_ptr was just allocated by VarNode::alloc and has
                // not been published into the SkipList. It uniquely owns its
                // freshly-allocated DiskLoc box.
                unsafe { VarNode::<K>::dealloc_node(node_ptr) };
                Err(DbError::KeyExists)
            }
        }
    }

    fn delete_locked(
        &self,
        shard_id: usize,
        inner: &mut ShardInner,
        guard: &seize::LocalGuard<'_>,
        key: &K,
    ) -> DbResult<bool> {
        if self.index.get(key.as_bytes(), guard).is_none() {
            return Ok(false);
        }

        inner.append_entry(shard_id as u8, key.as_bytes(), &[], true)?;

        let removed = self.index.remove(key.as_bytes(), guard);

        if let Some(node_ptr) = removed {
            let disk = unsafe { &*node_ptr }.read_loc();
            inner.add_dead_bytes(
                disk.file_id,
                crate::entry::entry_size(size_of::<K>(), disk.len),
            );
        }

        Ok(removed.is_some())
    }

    /// Check if a key exists.
    pub fn contains(&self, key: &K) -> bool {
        let guard = self.index.collector().enter();
        self.index.get(key.as_bytes(), &guard).is_some()
    }

    /// Encoded value byte length for `key`, or `None` if absent.
    /// Reads only the in-memory index entry (`DiskLoc::len`); no disk I/O.
    pub fn entry_len(&self, key: &K) -> Option<u32> {
        let guard = self.index.collector().enter();
        self.index
            .get(key.as_bytes(), &guard)
            .map(|node| node.read_loc().len)
    }

    /// Return the first entry in index order, or `None` if empty.
    /// With `reversed=true` (default): the entry with the largest key.
    /// O(1) index lookup. May perform disk I/O on block-cache miss.
    pub fn first(&self) -> Option<(K, ByteView)> {
        let guard = self.index.collector().enter();
        let mut ptr = crate::skiplist::strip_mark(unsafe {
            (*self.index.head_ptr())
                .tower(0)
                .load(std::sync::atomic::Ordering::Acquire)
        });
        while !ptr.is_null() {
            let node = unsafe { &*ptr };
            if !node.is_marked()
                && let Some(v) = self.read_value_cached(node, &guard)
            {
                return Some((node.key, v));
            }
            ptr = crate::skiplist::strip_mark(
                node.tower(0).load(std::sync::atomic::Ordering::Acquire),
            );
        }
        None
    }

    /// Return the last entry in index order, or `None` if empty.
    /// With `reversed=true` (default): the entry with the smallest key.
    /// May perform disk I/O on block-cache miss.
    pub fn last(&self) -> Option<(K, ByteView)> {
        self.iter().next_back()
    }

    // -- Range helpers (front/back pointer positioning) -------------------------

    fn resolve_front(&self, bound: &Bound<&K>, guard: &seize::LocalGuard<'_>) -> *mut VarNode<K> {
        match bound {
            Bound::Included(k) => self.index.find_first_ge(k.as_bytes(), guard),
            Bound::Excluded(k) => {
                let ge = self.index.find_first_ge(k.as_bytes(), guard);
                if !ge.is_null()
                    && !unsafe { &*ge }.is_marked()
                    && unsafe { &*ge }.key_bytes() == k.as_bytes()
                {
                    crate::skiplist::strip_mark(unsafe {
                        (*ge).tower(0).load(std::sync::atomic::Ordering::Acquire)
                    })
                } else {
                    ge
                }
            }
            Bound::Unbounded => crate::skiplist::strip_mark(unsafe {
                (*self.index.head_ptr())
                    .tower(0)
                    .load(std::sync::atomic::Ordering::Acquire)
            }),
        }
    }

    fn prefix_bounds(&self, prefix: &[u8]) -> (K, Bound<K>) {
        if self.reversed {
            let mut search = K::zeroed();
            search.as_bytes_mut().fill(0xFF);
            search.as_bytes_mut()[..prefix.len()].copy_from_slice(prefix);
            let mut end_key = K::zeroed();
            end_key.as_bytes_mut()[..prefix.len()].copy_from_slice(prefix);
            (search, Bound::Included(end_key))
        } else {
            let mut search = K::zeroed();
            search.as_bytes_mut()[..prefix.len()].copy_from_slice(prefix);
            let end = prefix_to_end_bound::<K>(prefix);
            (search, end)
        }
    }

    /// Iterate entries whose keys start with `prefix`.
    ///
    /// `reversed=true` (default): yields matching keys in DESC order.
    /// `next()` is O(1), `next_back()` is O(log n). Both may perform disk I/O on cache miss.
    pub fn prefix_iter(&self, prefix: &[u8]) -> VarIter<'_, K, H> {
        let guard = self.index.collector().enter();
        let (search_key, end) = self.prefix_bounds(prefix);
        let front = self.index.find_first_ge(search_key.as_bytes(), &guard);
        VarIter {
            tree: self,
            front,
            back: None,
            end,
            start: Bound::Included(search_key),
            reversed: self.reversed,
            done: false,
            _guard: guard,
        }
    }

    /// Iterate all entries in index order.
    ///
    /// `reversed=true` (default): DESC. `reversed=false`: ASC.
    /// `next()` is O(1), `next_back()` is O(log n). Both may perform disk I/O on cache miss.
    pub fn iter(&self) -> VarIter<'_, K, H> {
        let guard = self.index.collector().enter();
        let front = crate::skiplist::strip_mark(unsafe {
            (*self.index.head_ptr())
                .tower(0)
                .load(std::sync::atomic::Ordering::Acquire)
        });
        VarIter {
            tree: self,
            front,
            back: None,
            end: Bound::Unbounded,
            start: Bound::Unbounded,
            reversed: self.reversed,
            done: false,
            _guard: guard,
        }
    }

    /// Iterate entries in `[start, end)` — start inclusive, end exclusive.
    ///
    /// `reversed=true` (default): DESC within range. `reversed=false`: ASC.
    /// `next()` is O(1), `next_back()` is O(log n). Both may perform disk I/O on cache miss.
    pub fn range(&self, start: &K, end: &K) -> VarIter<'_, K, H> {
        self.range_bounds(Bound::Included(start), Bound::Excluded(end))
    }

    /// Iterate entries in range defined by `start` and `end` bounds.
    ///
    /// Unlike [`range()`](Self::range), allows `Included`, `Excluded`, or `Unbounded`
    /// for each bound independently.
    ///
    /// `reversed=true` (default): DESC within range. `reversed=false`: ASC.
    /// `next()` is O(1), `next_back()` is O(log n). Both may perform disk I/O on cache miss.
    pub fn range_bounds(&self, start: Bound<&K>, end: Bound<&K>) -> VarIter<'_, K, H> {
        let guard = self.index.collector().enter();
        if self.reversed {
            let front = self.resolve_front(&end, &guard);
            VarIter {
                tree: self,
                front,
                back: None,
                end: bound_owned(&start),
                start: bound_owned(&end),
                reversed: true,
                done: false,
                _guard: guard,
            }
        } else {
            let front = self.resolve_front(&start, &guard);
            VarIter {
                tree: self,
                front,
                back: None,
                end: bound_owned(&end),
                start: bound_owned(&start),
                reversed: false,
                done: false,
                _guard: guard,
            }
        }
    }

    pub fn len(&self) -> usize {
        self.index.len()
    }

    pub fn is_empty(&self) -> bool {
        self.index.is_empty()
    }

    /// Write hint files for all active shard files. Call during graceful shutdown.
    pub fn sync_hints(&self) -> DbResult<()> {
        for shard in self.engine.shards().iter() {
            shard.write_active_hint(size_of::<K>())?;
        }
        Ok(())
    }

    /// Pre-populate the block cache with blocks containing live values.
    ///
    /// Walks the index to collect unique block offsets, sorts them for sequential I/O,
    /// then reads each block into the cache. Only blocks with live data are loaded —
    /// dead entries and garbage are skipped.
    pub fn warmup(&self) -> DbResult<()> {
        use std::collections::BTreeSet;

        let guard = self.index.collector().enter();

        // Collect unique (shard_id, file_id, block_offset) from live index entries
        let mut blocks: BTreeSet<(u8, u32, u64)> = BTreeSet::new();
        let mut current = crate::skiplist::strip_mark(unsafe {
            (*self.index.head_ptr())
                .tower(0)
                .load(std::sync::atomic::Ordering::Acquire)
        });
        while !current.is_null() {
            let node = unsafe { &*current };
            current = crate::skiplist::strip_mark(
                node.tower(0).load(std::sync::atomic::Ordering::Acquire),
            );
            if node.is_marked() {
                continue;
            }
            let disk = node.read_loc();
            // Values > 2 blocks are served by the value cache, never the block
            // cache — caching their first block here only evicts useful blocks.
            if disk.is_value_cache_routed() {
                continue;
            }
            let shard_id = self.shard_for(&node.key) as u8;
            let block_offset = disk.offset as u64 & !4095;
            blocks.insert((shard_id, disk.file_id, block_offset));
        }
        drop(guard);

        // Read blocks in sorted order (sequential I/O per shard/file)
        for (shard_id, file_id, block_offset) in &blocks {
            let key = BlockKey {
                shard_id: *shard_id,
                file_id: *file_id,
                block_offset: *block_offset,
            };
            if self.block_cache.get(&key).is_some() {
                continue;
            }
            let shard = &self.engine.shards()[*shard_id as usize];
            let (buf, is_full_block) = shard.read_block(*file_id, *block_offset)?;
            if is_full_block {
                self.block_cache.insert(key, Arc::new(buf));
            }
        }

        Ok(())
    }

    pub(crate) fn index(&self) -> &SkipList<VarNode<K>> {
        &self.index
    }

    /// Iterate all entries and optionally mutate them. Call once at startup.
    ///
    /// The callback receives each (key, value_bytes) and returns `MigrateAction`:
    /// - `Keep` — no change (fires `on_init` if `H::NEEDS_INIT`); not counted
    /// - `Update(new_value)` — replace value (hook-free write, fires `on_init`)
    /// - `Delete` — remove entry (hook-free tombstone, no hooks)
    ///
    /// `on_write` is NEVER fired during migrate — see `docs/hooks.md` in the armdb crate.
    /// Returns the number of mutated entries.
    pub fn migrate(
        &self,
        f: impl Fn(&K, &[u8]) -> crate::MigrateAction<ByteView>,
    ) -> DbResult<usize> {
        use crate::MigrateAction;

        let guard = self.index.collector().enter();
        let mut current = crate::skiplist::strip_mark(unsafe {
            (*self.index.head_ptr())
                .tower(0)
                .load(std::sync::atomic::Ordering::Acquire)
        });
        let mut count = 0;
        while !current.is_null() {
            let node = unsafe { &*current };
            current = crate::skiplist::strip_mark(
                node.tower(0).load(std::sync::atomic::Ordering::Acquire),
            );
            if node.is_marked() {
                continue;
            }
            let value = match self.read_value_cached(node, &guard) {
                Some(v) => v,
                None => {
                    tracing::warn!(
                        key = ?node.key.as_bytes(),
                        "var_tree migrate: skipping entry — value read failed"
                    );
                    continue;
                }
            };
            match f(&node.key, &value) {
                MigrateAction::Keep => {
                    if H::NEEDS_INIT {
                        self.hook.on_init(&node.key, &value);
                    }
                }
                MigrateAction::Update(new_value) => {
                    let shard_id = self.shard_for(&node.key);
                    {
                        let mut inner = self.engine.shards()[shard_id].lock();
                        self.put_locked(shard_id, &mut inner, &guard, &node.key, &new_value)?;
                    }
                    if H::NEEDS_INIT {
                        self.hook.on_init(&node.key, &new_value);
                    }
                    count += 1;
                }
                MigrateAction::Delete => {
                    let shard_id = self.shard_for(&node.key);
                    let mut inner = self.engine.shards()[shard_id].lock();
                    self.delete_locked(shard_id, &mut inner, &guard, &node.key)?;
                    count += 1;
                }
            }
        }

        tracing::info!(mutations = count, "var_tree migration complete");
        Ok(count)
    }

    /// Replay `on_init` for every live entry. Used when no migration runs
    /// (Db calls this after `run_migration` returns `false`). Public users
    /// should invoke `migrate(|_, _| MigrateAction::Keep)` instead.
    pub(crate) fn replay_init(&self) {
        if !H::NEEDS_INIT {
            return;
        }
        let guard = self.index.collector().enter();
        let mut current = crate::skiplist::strip_mark(unsafe {
            (*self.index.head_ptr())
                .tower(0)
                .load(std::sync::atomic::Ordering::Acquire)
        });
        let mut count = 0usize;
        while !current.is_null() {
            let node = unsafe { &*current };
            current = crate::skiplist::strip_mark(
                node.tower(0).load(std::sync::atomic::Ordering::Acquire),
            );
            if node.is_marked() {
                continue;
            }
            let value = match self.read_value_cached(node, &guard) {
                Some(v) => v,
                None => {
                    tracing::warn!(
                        key = ?node.key.as_bytes(),
                        "var_tree replay_init: skipping entry — value read failed"
                    );
                    continue;
                }
            };
            self.hook.on_init(&node.key, &value);
            count += 1;
        }
        tracing::debug!(replayed = count, "var_tree replay_init complete");
    }

    /// Lock-free read body. Returns `Ok(v)` on success, `Err(StaleDiskLoc)` if
    /// compaction removed the file referenced by `disk` between the caller's
    /// snapshot and the read. Other `DbError`s indicate I/O or decryption
    /// failure.
    fn read_value_cached_inner(&self, disk: &DiskLoc, shard_id: u8) -> DbResult<ByteView> {
        let len = disk.len as usize;
        let start = (disk.offset & 4095) as usize;

        // Large values (> 2 blocks): write buffer first, then a lock-free disk
        // read. The value cache lives in `read_value_cached_result` (it has the
        // node for verify-on-hit); this node-less helper keeps its direct
        // StaleDiskLoc contract.
        if start + len > 8192 {
            {
                let shard = &self.engine.shards()[shard_id as usize];
                let inner = shard.lock();
                if inner.active.file_id == disk.file_id
                    && let Some(bytes) = inner.write_buf.read(disk.offset as u64, len)
                {
                    return Ok(ByteView::new(bytes));
                }
            }
            let shard = &self.engine.shards()[shard_id as usize];
            return Ok(ByteView::from_vec(shard.read_value(
                disk.file_id,
                disk.offset,
                len,
            )?));
        }

        let block_offset = disk.offset as u64 & !4095;
        let cache_key = BlockKey {
            shard_id,
            file_id: disk.file_id,
            block_offset,
        };

        // 1. Block Cache (lock-free)
        if let Some(block) = self.block_cache.get(&cache_key) {
            metrics::counter!("armdb.block_cache.hit").increment(1);
            return Self::extract_from_block(&block, start, len, || {
                self.get_or_read_block(shard_id, disk.file_id, block_offset + 4096)
            });
        }

        // 2. Write buffer — for unflushed data in active file (brief shard lock)
        {
            let shard = &self.engine.shards()[shard_id as usize];
            let inner = shard.lock();
            if inner.active.file_id == disk.file_id
                && let Some(bytes) = inner.write_buf.read(disk.offset as u64, len)
            {
                return Ok(ByteView::new(bytes));
            }
        }

        // 3. Disk read + cache
        metrics::counter!("armdb.block_cache.miss").increment(1);
        let block = self.get_or_read_block(shard_id, disk.file_id, block_offset)?;
        Self::extract_from_block(&block, start, len, || {
            self.get_or_read_block(shard_id, disk.file_id, block_offset + 4096)
        })
    }

    /// Lock-free cached read, propagating `DbError`. Retries on `StaleDiskLoc`
    /// (fresh DiskLoc per attempt) up to `MAX_STALE_RETRIES`, then returns
    /// `Err(StaleDiskLoc)`. Used by `get_or_err` / `try_get` / `try_first` /
    /// `try_last`. The caller holds the `seize::LocalGuard` so `node` cannot be
    /// reclaimed between retries.
    fn read_value_cached_result(
        &self,
        node: &VarNode<K>,
        _guard: &seize::LocalGuard<'_>,
    ) -> DbResult<ByteView> {
        let shard_id = self.shard_for(&node.key) as u8;
        for _ in 0..MAX_STALE_RETRIES {
            let disk = node.read_loc();
            let is_large = disk.is_value_cache_routed();
            if is_large {
                let vkey = ValueKey {
                    shard_id,
                    file_id: disk.file_id,
                    offset: disk.offset,
                };
                if let Some(v) = self.value_cache.get(&vkey) {
                    // verify-on-hit: node still points here ⇒ file live ⇒ current.
                    let now = node.read_loc();
                    // (file_id, offset) uniquely identifies the bytes under append-only
                    // allocation, so len need not be compared — a matching loc proves the
                    // node still points here and the cached bytes are current.
                    if now.file_id == disk.file_id && now.offset == disk.offset {
                        metrics::counter!("armdb.value_cache.hit").increment(1);
                        return Ok(v);
                    }
                    continue; // node moved (edit/compaction) → retry with fresh loc
                }
                metrics::counter!("armdb.value_cache.miss").increment(1);
                match self.read_value_cached_inner(&disk, shard_id) {
                    Ok(v) => {
                        self.value_cache.insert(vkey, v.clone());
                        return Ok(v);
                    }
                    Err(DbError::StaleDiskLoc) => {
                        metrics::counter!("armdb.read.stale_retry", "tree" => "var_tree")
                            .increment(1);
                        continue;
                    }
                    Err(e) => return Err(e),
                }
            }
            match self.read_value_cached_inner(&disk, shard_id) {
                Ok(v) => return Ok(v),
                Err(DbError::StaleDiskLoc) => {
                    metrics::counter!("armdb.read.stale_retry", "tree" => "var_tree").increment(1);
                    continue;
                }
                Err(e) => return Err(e),
            }
        }
        Err(DbError::StaleDiskLoc)
    }

    /// Lossy wrapper: `None` on absence *or* unreadable value. Keeps the existing
    /// hot-path `get` contract.
    fn read_value_cached(
        &self,
        node: &VarNode<K>,
        guard: &seize::LocalGuard<'_>,
    ) -> Option<ByteView> {
        let result = self.read_value_cached_result(node, guard);
        #[cfg(feature = "hot-path-tracing")]
        if let Err(ref _e) = result {
            tracing::error!("VarTree read_value_cached error: {:?}", _e);
        }
        result.ok()
    }

    fn extract_from_block(
        block: &AlignedBuf,
        start: usize,
        len: usize,
        next_block: impl FnOnce() -> DbResult<Arc<AlignedBuf>>,
    ) -> DbResult<ByteView> {
        debug_assert!(
            start + len <= 8192,
            "extract_from_block supports at most 2 blocks (8192 bytes)"
        );
        if start + len <= 4096 {
            Ok(ByteView::new(&block[start..start + len]))
        } else {
            let next = next_block()?;
            let first_part = &block[start..];
            let second_len = len - first_part.len();
            let mut combined = Vec::with_capacity(len);
            combined.extend_from_slice(first_part);
            combined.extend_from_slice(&next[..second_len]);
            Ok(ByteView::from_vec(combined))
        }
    }

    /// Get a block from cache, or read it from disk and cache it.
    fn get_or_read_block(
        &self,
        shard_id: u8,
        file_id: u32,
        block_offset: u64,
    ) -> DbResult<Arc<AlignedBuf>> {
        let key = BlockKey {
            shard_id,
            file_id,
            block_offset,
        };
        if let Some(cached) = self.block_cache.get(&key) {
            return Ok(cached);
        }
        let shard = &self.engine.shards()[shard_id as usize];
        let (buf, is_full_block) = shard.read_block(file_id, block_offset)?;
        let arc = Arc::new(buf);
        // Only cache full blocks (entirely within the file's data region).
        // Partial blocks at the end of a file have zero-padded tails that
        // would become stale after file rotation or further writes.
        if is_full_block {
            self.block_cache.insert(key, arc.clone());
        }
        Ok(arc)
    }

    /// Compare-and-swap: if current value == expected, replace with new_value.
    /// Returns `Ok(())` on success, `Err(CasMismatch)` if current != expected,
    /// `Err(KeyNotFound)` if key doesn't exist.
    ///
    /// **Caveat:** Holds the shard lock while reading the current value. On a
    /// block-cache miss this performs disk I/O under the lock, blocking all
    /// writes to the same shard. Pre-warm the cache to avoid latency spikes.
    pub fn cas(&self, key: &K, expected: &[u8], new_value: &[u8]) -> DbResult<()> {
        metrics::counter!("armdb.ops", "op" => "cas", "tree" => "var_tree").increment(1);
        #[cfg(feature = "hot-path-tracing")]
        tracing::trace!("var_tree.cas");
        let shard_id = self.shard_for(key);
        let shard = &self.engine.shards()[shard_id];
        let mut inner = shard.lock();

        let guard = self.index.collector().enter();
        let existing = self
            .index
            .get(key.as_bytes(), &guard)
            .ok_or(DbError::KeyNotFound)?;

        let disk = existing.read_loc();
        let current = self.read_value_locked_result(&disk, shard_id as u8, &inner)?;
        if current.as_ref() != expected {
            return Err(DbError::CasMismatch);
        }

        let (new_disk_loc, _gsn) =
            inner.append_entry(shard_id as u8, key.as_bytes(), new_value, false)?;

        let old_disk = existing.read_loc();
        inner.add_dead_bytes(
            old_disk.file_id,
            crate::entry::entry_size(size_of::<K>(), old_disk.len),
        );
        existing.write_loc(new_disk_loc);

        drop(inner);
        self.hook.on_write(
            key,
            if H::NEEDS_OLD_VALUE {
                Some(&*current)
            } else {
                None
            },
            Some(new_value),
        );
        Ok(())
    }

    /// Compare-and-delete: if the current value == `expected`, delete the key.
    /// Returns `Ok(())` on success, `Err(CasMismatch)` if current != expected,
    /// `Err(KeyNotFound)` if the key doesn't exist.
    ///
    /// **Caveat:** Holds the shard lock while reading the current value. On a
    /// block-cache miss this performs disk I/O under the lock, blocking all
    /// writes to the same shard. Pre-warm the cache to avoid latency spikes.
    pub fn compare_delete(&self, key: &K, expected: &[u8]) -> DbResult<()> {
        metrics::counter!("armdb.ops", "op" => "compare_delete", "tree" => "var_tree").increment(1);
        #[cfg(feature = "hot-path-tracing")]
        tracing::trace!("var_tree.compare_delete");
        let shard_id = self.shard_for(key);
        let shard = &self.engine.shards()[shard_id];
        let mut inner = shard.lock();

        let guard = self.index.collector().enter();
        let existing = self
            .index
            .get(key.as_bytes(), &guard)
            .ok_or(DbError::KeyNotFound)?;

        let disk = existing.read_loc();
        let current = self.read_value_locked_result(&disk, shard_id as u8, &inner)?;
        if current.as_ref() != expected {
            return Err(DbError::CasMismatch);
        }

        // value matches → reuse the delete path (tombstone + index removal + dead bytes)
        let removed = self.delete_locked(shard_id, &mut inner, &guard, key)?;
        debug_assert!(
            removed,
            "compare_delete: key must exist after a successful match"
        );

        drop(inner);
        self.hook.on_write(
            key,
            if H::NEEDS_OLD_VALUE {
                Some(&*current)
            } else {
                None
            },
            None,
        );
        Ok(())
    }

    /// Atomically read-modify-write. Returns `Some(new_value)` if key existed, `None` otherwise.
    /// The closure receives the current value and returns a new ByteView.
    /// The closure must not be heavy (shard lock is held).
    ///
    /// **Caveat:** Holds the shard lock while reading the current value. On a
    /// block-cache miss this performs disk I/O under the lock, blocking all
    /// writes to the same shard. Pre-warm the cache to avoid latency spikes.
    pub fn update(&self, key: &K, f: impl FnOnce(&[u8]) -> ByteView) -> DbResult<Option<ByteView>> {
        self.update_inner(key, f, false)
    }

    /// Like [`update()`](Self::update), but returns `Some(old_value)` instead of the new one.
    pub fn fetch_update(
        &self,
        key: &K,
        f: impl FnOnce(&[u8]) -> ByteView,
    ) -> DbResult<Option<ByteView>> {
        self.update_inner(key, f, true)
    }

    /// Core read-modify-write logic: **no** lock acquisition, **no** hook, **no** drop.
    /// Reads the current value (may hit disk on cache miss), runs the closure, and on
    /// a real update appends the new entry + accounts for dead bytes.
    fn try_update_locked(
        &self,
        shard_id: usize,
        inner: &mut ShardInner,
        guard: &seize::LocalGuard<'_>,
        key: &K,
        f: impl FnOnce(&[u8]) -> DbResult<Option<ByteView>>,
    ) -> DbResult<UpdateOutcome> {
        let existing = match self.index.get(key.as_bytes(), guard) {
            Some(n) => n,
            None => return Ok(UpdateOutcome::Missing),
        };
        let disk = existing.read_loc();
        let current = self.read_value_locked_result(&disk, shard_id as u8, inner)?;

        let new_value = match f(&current)? {
            Some(v) => v,
            None => return Ok(UpdateOutcome::Unchanged(current)),
        };

        let (new_disk_loc, _gsn) =
            inner.append_entry(shard_id as u8, key.as_bytes(), &new_value, false)?;
        let old_disk = existing.read_loc();
        inner.add_dead_bytes(
            old_disk.file_id,
            crate::entry::entry_size(size_of::<K>(), old_disk.len),
        );
        existing.write_loc(new_disk_loc);

        Ok(UpdateOutcome::Updated {
            old: current,
            new: new_value,
        })
    }

    pub(crate) fn try_update_inner(
        &self,
        key: &K,
        f: impl FnOnce(&[u8]) -> DbResult<Option<ByteView>>,
        return_old: bool,
    ) -> DbResult<Option<ByteView>> {
        metrics::counter!("armdb.ops", "op" => "update", "tree" => "var_tree").increment(1);
        #[cfg(feature = "hot-path-tracing")]
        tracing::trace!("var_tree.update");
        let shard_id = self.shard_for(key);
        let shard = &self.engine.shards()[shard_id];
        let mut inner = shard.lock();
        let guard = self.index.collector().enter();

        match self.try_update_locked(shard_id, &mut inner, &guard, key, f)? {
            UpdateOutcome::Missing => Ok(None),
            UpdateOutcome::Unchanged(current) => Ok(Some(current)),
            UpdateOutcome::Updated { old, new } => {
                drop(inner);
                self.hook.on_write(
                    key,
                    if H::NEEDS_OLD_VALUE {
                        Some(&*old)
                    } else {
                        None
                    },
                    Some(&*new),
                );
                Ok(Some(if return_old { old } else { new }))
            }
        }
    }

    fn update_inner(
        &self,
        key: &K,
        f: impl FnOnce(&[u8]) -> ByteView,
        return_old: bool,
    ) -> DbResult<Option<ByteView>> {
        self.try_update_inner(key, |bytes| Ok(Some(f(bytes))), return_old)
    }

    /// Read value when shard lock is already held, propagating `DbError`.
    /// Used by both `read_value_locked` (Option wrapper) and the large-value
    /// fallback in `read_value_cached_inner`.
    fn read_value_locked_result(
        &self,
        disk: &DiskLoc,
        shard_id: u8,
        inner: &ShardInner,
    ) -> DbResult<ByteView> {
        let len = disk.len as usize;

        // 1. Write buffer (for unflushed data)
        if inner.active.file_id == disk.file_id
            && let Some(bytes) = inner.write_buf.read(disk.offset as u64, len)
        {
            return Ok(ByteView::new(bytes));
        }

        // 2. Block cache (lock-free, single-block fast path)
        let block_offset = disk.offset as u64 & !4095;
        let start = (disk.offset & 4095) as usize;
        if start + len <= 4096 {
            let cache_key = BlockKey {
                shard_id,
                file_id: disk.file_id,
                block_offset,
            };
            if let Some(block) = self.block_cache.get(&cache_key) {
                return Ok(ByteView::new(&block[start..start + len]));
            }

            // 2b. Cache miss — read block under held lock, populate cache
            let (buf, is_full_block) = inner.read_block_locked(disk.file_id, block_offset)?;
            let arc = Arc::new(buf);
            if is_full_block {
                self.block_cache.insert(cache_key, arc.clone());
            }
            return Ok(ByteView::new(&arc[start..start + len]));
        }

        // 3. Multi-block: disk read via encryption-aware helper (no cache).
        let bytes = inner.read_value_from_disk_locked(disk)?;
        Ok(ByteView::new(&bytes))
    }

    /// Read value when shard lock is already held. Used by CAS/update.
    ///
    /// Thin `Option` wrapper around `read_value_locked_result` that preserves
    /// the existing call-site contract: `StaleDiskLoc` is logged at error
    /// level and collapsed to `None`; other errors collapse to `None` silently.
    fn read_value_locked(
        &self,
        disk: &DiskLoc,
        shard_id: u8,
        inner: &ShardInner,
    ) -> Option<ByteView> {
        match self.read_value_locked_result(disk, shard_id, inner) {
            Ok(v) => Some(v),
            Err(DbError::StaleDiskLoc) => {
                tracing::error!(
                    file_id = disk.file_id,
                    shard_id,
                    "stale DiskLoc under shard lock - programming bug",
                );
                None
            }
            Err(_) => None,
        }
    }

    pub fn shard_for(&self, key: &K) -> usize {
        crate::shard_for_key(key, self.shard_prefix_bits, self.engine.shards().len())
    }

    /// Batch point-read (tier-3 finger-walk). **Lossy**, mirroring single-key
    /// [`get`](Self::get): decode/disk/`StaleDiskLoc` errors become `None`. Values
    /// are read in list order under one guard through the existing cache path, so
    /// stale-retry runs against each node's current `DiskLoc`. Result aligned to `keys`.
    pub fn get_many(&self, keys: &[K]) -> Vec<Option<ByteView>> {
        let n = keys.len();
        let mut out: Vec<Option<ByteView>> = (0..n).map(|_| None).collect();
        if n == 0 {
            return out;
        }
        let mut order: Vec<usize> = (0..n).collect();
        order.sort_by(|&a, &b| {
            let ord = keys[a].as_bytes().cmp(keys[b].as_bytes());
            if self.reversed { ord.reverse() } else { ord }
        });
        let guard = self.index.collector().enter();
        let mut finger = self.index.finger();
        for &i in &order {
            let kb = keys[i].as_bytes();
            if let Some(node) = self.index.finger_seek(&mut finger, kb, &guard)
                && node.key_bytes() == kb
            {
                out[i] = self.read_value_cached(node, &guard);
            }
        }
        out
    }

    /// Batch read-modify-write (tier-2, shard-grouped). See [`crate::ConstTree::update_many`]
    /// for the contract. The old value is read from disk under the shard lock via the
    /// existing locked read path. VarTree is Bitcask-engine only — no per-op sync.
    pub fn update_many<P>(
        &self,
        items: Vec<(K, P)>,
        f: impl Fn(&K, Option<&[u8]>, &P) -> BatchWrite<ByteView>,
    ) -> DbResult<Vec<(K, Applied<ByteView>)>> {
        let n = items.len();
        let mut by_shard: std::collections::BTreeMap<usize, Vec<usize>> =
            std::collections::BTreeMap::new();
        for (i, (k, _)) in items.iter().enumerate() {
            by_shard.entry(self.shard_for(k)).or_default().push(i);
        }

        let mut out: Vec<Option<(K, Applied<ByteView>)>> = (0..n).map(|_| None).collect();
        let mut events: Vec<(K, Option<ByteView>, Option<ByteView>)> = Vec::new();
        let mut err: Option<DbError> = None;

        'shards: for (shard_id, idxs) in by_shard {
            let mut inner = self.engine.shards()[shard_id].lock();
            let guard = self.index.collector().enter();
            for &i in &idxs {
                let (k, p) = &items[i];
                // Read the old value unconditionally (no NEEDS_OLD_VALUE gate, unlike single-key
                // put/delete): the closure receives it and it is part of the returned
                // Applied<ByteView> envelope, so it is always needed regardless of the hook.
                let cur: Option<ByteView> = match self.index.get(k.as_bytes(), &guard) {
                    Some(node) => {
                        let disk = node.read_loc();
                        match self.read_value_locked_result(&disk, shard_id as u8, &inner) {
                            Ok(v) => Some(v),
                            Err(e) => {
                                err = Some(e);
                                drop(inner);
                                drop(guard);
                                break 'shards;
                            }
                        }
                    }
                    None => None,
                };
                let applied = match f(k, cur.as_deref(), p) {
                    BatchWrite::Set(v) => {
                        match self.put_locked(shard_id, &mut inner, &guard, k, &v) {
                            Ok(_existed) => {
                                if H::NEEDS_WRITE {
                                    events.push((*k, cur.clone(), Some(v.clone())));
                                }
                                Applied::Written { old: cur, new: v }
                            }
                            Err(e) => {
                                err = Some(e);
                                drop(inner);
                                drop(guard);
                                break 'shards;
                            }
                        }
                    }
                    BatchWrite::Keep => Applied::Kept,
                    BatchWrite::Delete => {
                        match self.delete_locked(shard_id, &mut inner, &guard, k) {
                            Ok(true) => {
                                // delete_locked returned Ok(true) under the shard lock, so the key existed and
                                // `cur` (read above under the same lock) is provably Some — not a "shouldn't happen".
                                let old = cur.expect("existing key has an old value");
                                if H::NEEDS_WRITE {
                                    events.push((*k, Some(old.clone()), None));
                                }
                                Applied::Deleted(old)
                            }
                            Ok(false) => Applied::Kept, // delete-miss
                            Err(e) => {
                                err = Some(e);
                                drop(inner);
                                drop(guard);
                                break 'shards;
                            }
                        }
                    }
                };
                out[i] = Some((*k, applied));
            }
            drop(inner);
            drop(guard);
        }

        if H::NEEDS_WRITE {
            for (k, old, new) in &events {
                self.hook.on_write(k, old.as_deref(), new.as_deref());
            }
        }

        if let Some(e) = err {
            return Err(e);
        }
        Ok(out
            .into_iter()
            .map(|slot| slot.expect("every index assigned on the success path"))
            .collect())
    }
}

#[cfg(feature = "replication")]
impl<K: Key, H: WriteHook<K>> crate::replication::ReplicationTarget for VarTree<K, H> {
    fn apply_entry(
        &self,
        _shard_inner: &mut crate::shard::ShardInner,
        _shard_id: u8,
        file_id: u32,
        entry_offset: u64,
        header: &crate::entry::EntryHeader,
        key: &[u8],
        _value: &[u8],
    ) -> DbResult<crate::replication::ApplyOutcome> {
        use crate::replication::ApplyOutcome;

        let key: K = K::from_bytes(key);

        let value_offset =
            entry_offset + size_of::<crate::entry::EntryHeader>() as u64 + size_of::<K>() as u64;
        let disk = DiskLoc::new(file_id, value_offset as u32, header.value_len);

        if header.is_tombstone() {
            let guard = self.index.collector().enter();
            let removed = self.index.remove(key.as_bytes(), &guard);
            match removed {
                Some(node_ptr) => {
                    let old_disk = unsafe { &*node_ptr }.read_loc();
                    Ok(ApplyOutcome::TombstoneRemoved(old_disk))
                }
                None => Ok(ApplyOutcome::Inserted), // no-op tombstone — no dead bytes
            }
        } else {
            let guard = self.index.collector().enter();
            let height = random_height();
            let node_ptr = VarNode::alloc(key, disk, height);
            match self.index.insert(node_ptr, &guard) {
                InsertResult::Inserted => Ok(ApplyOutcome::Inserted),
                InsertResult::Exists(existing) => {
                    let old_disk = existing.read_loc();
                    existing.write_loc(disk);
                    unsafe {
                        VarNode::<K>::dealloc_node(node_ptr);
                    }
                    Ok(ApplyOutcome::Replaced(old_disk))
                }
            }
        }
    }

    fn try_apply_entry(
        &self,
        shard_inner: &mut crate::shard::ShardInner,
        shard_id: u8,
        file_id: u32,
        entry_offset: u64,
        header: &crate::entry::EntryHeader,
        raw_after_header: &[u8],
    ) -> DbResult<crate::replication::ApplyOutcome> {
        use crate::replication::ApplyOutcome;

        if raw_after_header.len() < size_of::<K>() + header.value_len as usize {
            return Ok(ApplyOutcome::NotMatched);
        }
        let key = &raw_after_header[..size_of::<K>()];
        let value = &raw_after_header[size_of::<K>()..size_of::<K>() + header.value_len as usize];
        let crc = crate::entry::compute_crc32(header.gsn, header.value_len, key, value);
        if crc != header.crc32 {
            return Ok(ApplyOutcome::NotMatched);
        }
        self.apply_entry(
            shard_inner,
            shard_id,
            file_id,
            entry_offset,
            header,
            key,
            value,
        )
    }

    fn key_len(&self) -> usize {
        size_of::<K>()
    }
}

#[cfg(feature = "replication")]
impl<K: Key, H: WriteHook<K>> VarTree<K, H> {
    /// Install SPSC replication producers into every shard and start a
    /// `ReplicationServer` bound to `bind_addr`.
    ///
    /// # Single-call contract
    ///
    /// Each call installs fresh SPSC producers, replacing any previously
    /// installed ones. Call this at most once per `VarTree` instance — a
    /// second call will orphan the in-flight producer of any active streaming
    /// connection on the first server, which will then observe an empty ring
    /// buffer and silently stop forwarding entries.
    pub fn start_replication_server(
        &self,
        bind_addr: std::net::SocketAddr,
        signal: crate::shutdown::ShutdownSignal,
    ) -> crate::error::DbResult<crate::replication::ReplicationServer> {
        let consumers = self.install_replication_producers()?;
        crate::replication::ReplicationServer::start(
            bind_addr,
            self.engine.shards().clone(),
            consumers,
            self.engine.config().max_file_size,
            signal,
        )
    }

    pub fn start_replication_server_with_options(
        &self,
        bind_addr: std::net::SocketAddr,
        signal: crate::shutdown::ShutdownSignal,
        options: crate::replication::ReplicationServerOptions,
    ) -> crate::error::DbResult<crate::replication::ReplicationServer> {
        let consumers = self.install_replication_producers()?;
        crate::replication::ReplicationServer::start_with_options(
            bind_addr,
            self.engine.shards().clone(),
            consumers,
            self.engine.config().max_file_size,
            signal,
            options,
        )
    }

    fn install_replication_producers(
        &self,
    ) -> crate::error::DbResult<Vec<rtrb::Consumer<crate::replication::ReplicationEntry>>> {
        const SPSC_CAPACITY: usize = 4096;
        let shards = self.engine.shards();
        let mut consumers = Vec::with_capacity(shards.len());
        for shard in shards.iter() {
            let (p, c) = rtrb::RingBuffer::new(SPSC_CAPACITY);
            shard.set_replication_producer(p);
            consumers.push(c);
        }
        Ok(consumers)
    }

    /// Start a `ReplicationClient` that streams entries from `leader_addr`
    /// into `registry`. Symmetric to [`Self::start_replication_server`].
    ///
    /// `key_len` is derived from `size_of::<K>()`.
    pub fn start_replication_client(
        &self,
        leader_addr: std::net::SocketAddr,
        registry: std::sync::Arc<crate::replication::ReplicationRegistry>,
        signal: crate::shutdown::ShutdownSignal,
    ) -> crate::error::DbResult<crate::replication::ReplicationClient> {
        crate::replication::ReplicationClient::start(
            leader_addr,
            self.engine.shards().clone(),
            registry,
            size_of::<K>() as u16,
            signal,
        )
    }

    pub fn start_replication_client_with_options(
        &self,
        leader_addr: std::net::SocketAddr,
        registry: std::sync::Arc<crate::replication::ReplicationRegistry>,
        signal: crate::shutdown::ShutdownSignal,
        options: crate::replication::ReplicationClientOptions,
    ) -> crate::error::DbResult<crate::replication::ReplicationClient> {
        crate::replication::ReplicationClient::start_with_options(
            leader_addr,
            self.engine.shards().clone(),
            registry,
            size_of::<K>() as u16,
            signal,
            options,
        )
    }
}

#[cfg(feature = "replication")]
impl<K, H> VarTree<K, H>
where
    K: Key + Send + Sync + 'static,
    H: WriteHook<K> + Send + Sync + 'static,
{
    /// Wrap a shared handle to this tree as a `Box<dyn ReplicationTarget>`.
    ///
    /// The returned box holds an `Arc` clone — the caller retains full read
    /// access to the original tree through the `Arc` while the registry owns
    /// the box. This is the intended pattern for follower-side wiring:
    ///
    /// ```ignore
    /// let follower = Arc::new(VarTree::<[u8; 8]>::open(path, cfg)?);
    /// let registry = ReplicationRegistry::new(follower.as_replication_target());
    /// // `follower` remains usable for .get() etc.
    /// ```
    pub fn as_replication_target(
        self: &std::sync::Arc<Self>,
    ) -> Box<dyn crate::replication::ReplicationTarget> {
        Box::new(std::sync::Arc::clone(self))
    }
}

/// Handle for atomic multi-key operations within a single shard.
/// Obtained via [`VarTree::atomic`]. The shard lock is held for the
/// lifetime of this struct — keep the closure short.
pub struct VarShard<'a, K: Key, H: WriteHook<K> = NoHook> {
    tree: &'a VarTree<K, H>,
    inner: MutexGuard<'a, ShardInner>,
    shard_id: usize,
    guard: seize::LocalGuard<'a>,
    events: Vec<(K, Option<ByteView>, Option<ByteView>)>,
}

impl<K: Key, H: WriteHook<K>> VarShard<'_, K, H> {
    /// Insert or overwrite `key`. Returns `true` if the key already existed (overwrite),
    /// `false` for a fresh insert.
    pub fn put(&mut self, key: &K, value: &[u8]) -> DbResult<bool> {
        self.check_shard(key)?;
        let old = if H::NEEDS_OLD_VALUE && H::NEEDS_WRITE {
            if let Some(node) = self.tree.index.get(key.as_bytes(), &self.guard) {
                let disk = node.read_loc();
                Some(
                    self.tree
                        .read_value_locked_result(&disk, self.shard_id as u8, &self.inner)?,
                )
            } else {
                None
            }
        } else {
            None
        };
        let existed =
            self.tree
                .put_locked(self.shard_id, &mut self.inner, &self.guard, key, value)?;
        if H::NEEDS_WRITE {
            self.events.push((*key, old, Some(ByteView::from(value))));
        }
        Ok(existed)
    }

    pub fn insert(&mut self, key: &K, value: &[u8]) -> DbResult<()> {
        self.check_shard(key)?;
        self.tree
            .insert_locked(self.shard_id, &mut self.inner, &self.guard, key, value)?;
        if H::NEEDS_WRITE {
            self.events.push((*key, None, Some(ByteView::from(value))));
        }
        Ok(())
    }

    pub fn delete(&mut self, key: &K) -> DbResult<bool> {
        self.check_shard(key)?;
        let old = if H::NEEDS_OLD_VALUE && H::NEEDS_WRITE {
            if let Some(node) = self.tree.index.get(key.as_bytes(), &self.guard) {
                let disk = node.read_loc();
                Some(
                    self.tree
                        .read_value_locked_result(&disk, self.shard_id as u8, &self.inner)?,
                )
            } else {
                None
            }
        } else {
            None
        };
        let existed = self
            .tree
            .delete_locked(self.shard_id, &mut self.inner, &self.guard, key)?;
        if existed && H::NEEDS_WRITE {
            self.events.push((*key, old, None));
        }
        Ok(existed)
    }

    pub fn get(&self, key: &K) -> Option<ByteView> {
        let node = self.tree.index.get(key.as_bytes(), &self.guard)?;
        let disk = node.read_loc();
        self.tree
            .read_value_locked(&disk, self.shard_id as u8, &self.inner)
    }

    pub fn get_or_err(&self, key: &K) -> DbResult<ByteView> {
        let node = self
            .tree
            .index
            .get(key.as_bytes(), &self.guard)
            .ok_or(DbError::KeyNotFound)?;
        let disk = node.read_loc();
        self.tree
            .read_value_locked_result(&disk, self.shard_id as u8, &self.inner)
    }

    pub fn contains(&self, key: &K) -> bool {
        self.tree.index.get(key.as_bytes(), &self.guard).is_some()
    }

    /// Atomically read-modify-write within the shard. Returns `Some(new_value)` if key
    /// existed, `None` if absent. No hook is fired directly — the event is buffered and
    /// replayed by `VarTree::atomic` after the shard lock is released.
    pub fn update(
        &mut self,
        key: &K,
        f: impl FnOnce(&[u8]) -> ByteView,
    ) -> DbResult<Option<ByteView>> {
        self.update_inner(key, |b| Ok(Some(f(b))), false)
    }

    /// Like [`update`](Self::update), but returns `Some(old_value)` instead of the new one.
    pub fn fetch_update(
        &mut self,
        key: &K,
        f: impl FnOnce(&[u8]) -> ByteView,
    ) -> DbResult<Option<ByteView>> {
        self.update_inner(key, |b| Ok(Some(f(b))), true)
    }

    /// Abort-capable update used by `VarTypedShard`. The closure returns
    /// `Ok(None)` to abort (leave value unchanged), `Ok(Some(v))` to replace.
    pub(crate) fn update_inner(
        &mut self,
        key: &K,
        f: impl FnOnce(&[u8]) -> DbResult<Option<ByteView>>,
        return_old: bool,
    ) -> DbResult<Option<ByteView>> {
        self.check_shard(key)?;
        match self
            .tree
            .try_update_locked(self.shard_id, &mut self.inner, &self.guard, key, f)?
        {
            UpdateOutcome::Missing => Ok(None),
            UpdateOutcome::Unchanged(current) => Ok(Some(current)),
            UpdateOutcome::Updated { old, new } => {
                if H::NEEDS_WRITE {
                    let event_old = if H::NEEDS_OLD_VALUE {
                        Some(old.clone())
                    } else {
                        None
                    };
                    self.events.push((*key, event_old, Some(new.clone())));
                }
                Ok(Some(if return_old { old } else { new }))
            }
        }
    }

    fn check_shard(&self, key: &K) -> DbResult<()> {
        if self.tree.shard_for(key) != self.shard_id {
            return Err(DbError::ShardMismatch);
        }
        Ok(())
    }
}

fn bound_owned<K: Copy>(b: &Bound<&K>) -> Bound<K> {
    match b {
        Bound::Included(k) => Bound::Included(**k),
        Bound::Excluded(k) => Bound::Excluded(**k),
        Bound::Unbounded => Bound::Unbounded,
    }
}

fn prefix_to_end_bound<K: Key>(prefix: &[u8]) -> Bound<K> {
    let mut incremented = prefix.to_vec();
    let mut carry = true;
    for byte in incremented.iter_mut().rev() {
        if carry {
            if *byte == 0xFF {
                *byte = 0x00;
            } else {
                *byte += 1;
                carry = false;
                break;
            }
        }
    }
    if carry {
        Bound::Unbounded
    } else {
        let mut end = K::zeroed();
        end.as_bytes_mut()[..incremented.len()].copy_from_slice(&incremented);
        Bound::Excluded(end)
    }
}

/// Iterator over entries in a `VarTree`. Returned by `iter()`, `range()`, and `prefix_iter()`.
///
/// Weakly-consistent: concurrent inserts/updates may be visible during iteration.
/// Deleted entries are skipped. The `seize` guard prevents use-after-free.
/// Each `next()` may perform disk I/O on a block-cache miss.
pub struct VarIter<'a, K: Key, H: WriteHook<K> = NoHook> {
    tree: &'a VarTree<K, H>,
    front: *mut VarNode<K>,
    /// `None` = not yet resolved (lazy). Computed on first `next_back()` call.
    back: Option<*mut VarNode<K>>,
    end: Bound<K>,
    start: Bound<K>,
    reversed: bool,
    done: bool,
    _guard: seize::LocalGuard<'a>,
}

impl<K: Key, H: WriteHook<K>> Iterator for VarIter<'_, K, H> {
    type Item = (K, ByteView);

    fn next(&mut self) -> Option<Self::Item> {
        loop {
            if self.done || self.front.is_null() {
                return None;
            }
            let node = unsafe { &*self.front };
            let converged = self.back.is_some_and(|back| std::ptr::eq(self.front, back));
            self.front = crate::skiplist::strip_mark(
                node.tower(0).load(std::sync::atomic::Ordering::Acquire),
            );
            if converged {
                self.done = true;
            }
            if node.is_marked() {
                if converged {
                    return None;
                }
                continue;
            }
            if !self.check_end(&node.key) {
                self.done = true;
                return None;
            }
            match self.tree.read_value_cached(node, &self._guard) {
                Some(value) => return Some((node.key, value)),
                None => {
                    if converged {
                        return None;
                    }
                    continue;
                }
            }
        }
    }
}

impl<K: Key, H: WriteHook<K>> DoubleEndedIterator for VarIter<'_, K, H> {
    fn next_back(&mut self) -> Option<Self::Item> {
        if self.back.is_none() {
            self.back = Some(self.resolve_back());
            if self.front.is_null() {
                self.done = true;
            }
        }
        loop {
            let back = self.back.unwrap_or(std::ptr::null_mut());
            if self.done || back.is_null() {
                return None;
            }
            let node = unsafe { &*back };
            let key = node.key;
            let converged = std::ptr::eq(self.front, back);
            self.back = Some(self.tree.index().find_last_lt(key.as_bytes(), &self._guard));
            if converged {
                self.done = true;
            }
            if node.is_marked() {
                if converged {
                    return None;
                }
                continue;
            }
            if !self.check_start(&key) {
                self.done = true;
                return None;
            }
            match self.tree.read_value_cached(node, &self._guard) {
                Some(value) => return Some((key, value)),
                None => {
                    if converged {
                        return None;
                    }
                    continue;
                }
            }
        }
    }
}

impl<K: Key, H: WriteHook<K>> VarIter<'_, K, H> {
    /// Lazily resolve the back pointer for DoubleEndedIterator.
    fn resolve_back(&self) -> *mut VarNode<K> {
        let index = self.tree.index();
        match &self.end {
            Bound::Unbounded => index.find_last(&self._guard),
            Bound::Excluded(k) => index.find_last_lt(k.as_bytes(), &self._guard),
            Bound::Included(k) => {
                let ge = index.find_first_ge(k.as_bytes(), &self._guard);
                if !ge.is_null()
                    && !unsafe { &*ge }.is_marked()
                    && unsafe { &*ge }.key_bytes() == k.as_bytes()
                {
                    ge
                } else {
                    index.find_last_lt(k.as_bytes(), &self._guard)
                }
            }
        }
    }

    #[inline(always)]
    fn check_end(&self, key: &K) -> bool {
        match &self.end {
            Bound::Unbounded => true,
            Bound::Excluded(end) => {
                if self.reversed {
                    key.as_bytes() > end.as_bytes()
                } else {
                    key.as_bytes() < end.as_bytes()
                }
            }
            Bound::Included(end) => {
                if self.reversed {
                    key.as_bytes() >= end.as_bytes()
                } else {
                    key.as_bytes() <= end.as_bytes()
                }
            }
        }
    }

    #[inline(always)]
    fn check_start(&self, key: &K) -> bool {
        match &self.start {
            Bound::Unbounded => true,
            Bound::Excluded(s) => {
                if self.reversed {
                    key.as_bytes() < s.as_bytes()
                } else {
                    key.as_bytes() > s.as_bytes()
                }
            }
            Bound::Included(s) => {
                if self.reversed {
                    key.as_bytes() <= s.as_bytes()
                } else {
                    key.as_bytes() >= s.as_bytes()
                }
            }
        }
    }
    /// Collect only the keys. Convenience for backward compatibility.
    pub fn collect_keys(&mut self) -> Vec<K> {
        self.map(|(k, _)| k).collect()
    }

    /// Collect all remaining entries. Convenience for backward compatibility.
    pub fn collect_entries(&mut self) -> Vec<(K, ByteView)> {
        self.collect()
    }
}

// ==========================================================================
// MultiTx — cross-collection transaction support (feature `armour`)
//
// Engine-direct variable family: locks `engine.shards()[id]` per shard, holds one
// seize guard, reads decode through the shard's `inner` (write buffer/disk), and
// has NO durability sync seam. Hooks replay from one collection-wide log.
// ==========================================================================

/// Multi-shard transaction handle for [`VarTree`] inside `Db::atomicN`.
#[cfg(feature = "armour")]
pub struct VarTx<'a, K: Key, H: WriteHook<K> = NoHook> {
    tree: &'a VarTree<K, H>,
    inners: Vec<(usize, MutexGuard<'a, ShardInner>)>,
    seize: seize::LocalGuard<'a>,
    log: Vec<(K, Option<ByteView>, Option<ByteView>)>,
}

#[cfg(feature = "armour")]
impl<K: Key, H: WriteHook<K>> VarTx<'_, K, H> {
    fn position(&self, key: &K) -> DbResult<usize> {
        let sid = self.tree.shard_for(key);
        self.inners
            .iter()
            .position(|(s, _)| *s == sid)
            .ok_or(DbError::ShardMismatch)
    }

    pub fn try_get(&self, key: &K) -> DbResult<Option<ByteView>> {
        let i = self.position(key)?;
        let (sid, inner) = &self.inners[i];
        let Some(node) = self.tree.index.get(key.as_bytes(), &self.seize) else {
            return Ok(None);
        };
        let disk = node.read_loc();
        Ok(Some(
            self.tree
                .read_value_locked_result(&disk, *sid as u8, inner)?,
        ))
    }

    pub fn try_contains(&self, key: &K) -> DbResult<bool> {
        self.position(key)?;
        Ok(self.tree.index.get(key.as_bytes(), &self.seize).is_some())
    }

    pub fn get_or_err(&self, key: &K) -> DbResult<ByteView> {
        self.try_get(key)?.ok_or(DbError::KeyNotFound)
    }

    pub fn put(&mut self, key: &K, value: &[u8]) -> DbResult<()> {
        let i = self.position(key)?;
        let old = if H::NEEDS_OLD_VALUE && H::NEEDS_WRITE {
            let (sid, inner) = &self.inners[i];
            if let Some(node) = self.tree.index.get(key.as_bytes(), &self.seize) {
                let disk = node.read_loc();
                Some(
                    self.tree
                        .read_value_locked_result(&disk, *sid as u8, inner)?,
                )
            } else {
                None
            }
        } else {
            None
        };
        let (sid, inner) = &mut self.inners[i];
        self.tree.put_locked(*sid, inner, &self.seize, key, value)?;
        if H::NEEDS_WRITE {
            self.log.push((*key, old, Some(ByteView::from(value))));
        }
        Ok(())
    }

    pub fn insert(&mut self, key: &K, value: &[u8]) -> DbResult<()> {
        let i = self.position(key)?;
        let (sid, inner) = &mut self.inners[i];
        self.tree
            .insert_locked(*sid, inner, &self.seize, key, value)?;
        if H::NEEDS_WRITE {
            self.log.push((*key, None, Some(ByteView::from(value))));
        }
        Ok(())
    }

    pub fn delete(&mut self, key: &K) -> DbResult<bool> {
        let i = self.position(key)?;
        let old = if H::NEEDS_OLD_VALUE && H::NEEDS_WRITE {
            let (sid, inner) = &self.inners[i];
            if let Some(node) = self.tree.index.get(key.as_bytes(), &self.seize) {
                let disk = node.read_loc();
                Some(
                    self.tree
                        .read_value_locked_result(&disk, *sid as u8, inner)?,
                )
            } else {
                None
            }
        } else {
            None
        };
        let (sid, inner) = &mut self.inners[i];
        let existed = self.tree.delete_locked(*sid, inner, &self.seize, key)?;
        if existed && H::NEEDS_WRITE {
            self.log.push((*key, old, None));
        }
        Ok(existed)
    }
}

#[cfg(feature = "armour")]
impl<K: Key, H: WriteHook<K>> crate::armour::multi_tx::MultiTx for VarTree<K, H> {
    type Key = K;
    type Tx<'a>
        = VarTx<'a, K, H>
    where
        Self: 'a;

    fn shard_for_key(&self, key: &K) -> usize {
        self.shard_for(key)
    }

    fn begin_tx(&self) -> VarTx<'_, K, H> {
        VarTx {
            tree: self,
            inners: Vec::new(),
            seize: self.index.collector().enter(),
            log: Vec::new(),
        }
    }

    fn lock_shard_into<'a>(&'a self, shard_id: usize, tx: &mut VarTx<'a, K, H>) {
        tx.inners
            .push((shard_id, self.engine.shards()[shard_id].lock()));
    }

    fn release_locks(&self, tx: &mut VarTx<'_, K, H>) -> crate::armour::multi_tx::SyncNeeds {
        tx.inners.clear(); // drops engine shard locks; no per-write sync seam
        crate::armour::multi_tx::SyncNeeds::none()
    }

    fn run_sync(&self, _needs: crate::armour::multi_tx::SyncNeeds) -> DbResult<()> {
        Ok(())
    }

    fn replay_hooks(&self, tx: VarTx<'_, K, H>) {
        if H::NEEDS_WRITE {
            for (k, old, new) in &tx.log {
                self.hook.on_write(k, old.as_deref(), new.as_deref());
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Config;
    use crate::compaction::compact_shard;
    use tempfile::tempdir;

    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};

    /// Test hook with counters for on_write / on_init calls and the last observed new value for
    /// on_write. NEEDS_INIT and NEEDS_OLD_VALUE are parameterized by const generics.
    #[derive(Default)]
    struct CountingHook<const NEEDS_INIT: bool, const NEEDS_OLD: bool> {
        writes: AtomicUsize,
        writes_with_old: AtomicUsize,
        inits: AtomicUsize,
        last_write_new: crate::sync::Mutex<Option<Vec<u8>>>,
        last_init_value: crate::sync::Mutex<Option<Vec<u8>>>,
    }

    impl<const NEEDS_INIT: bool, const NEEDS_OLD: bool> WriteHook<[u8; 8]>
        for CountingHook<NEEDS_INIT, NEEDS_OLD>
    {
        const NEEDS_OLD_VALUE: bool = NEEDS_OLD;
        const NEEDS_INIT: bool = NEEDS_INIT;

        fn on_write(&self, _key: &[u8; 8], old: Option<&[u8]>, new: Option<&[u8]>) {
            self.writes.fetch_add(1, AtomicOrdering::Relaxed);
            if old.is_some() {
                self.writes_with_old.fetch_add(1, AtomicOrdering::Relaxed);
            }
            *crate::sync::lock(&self.last_write_new) = new.map(<[u8]>::to_vec);
        }

        fn on_init(&self, _key: &[u8; 8], value: &[u8]) {
            self.inits.fetch_add(1, AtomicOrdering::Relaxed);
            *crate::sync::lock(&self.last_init_value) = Some(value.to_vec());
        }
    }

    fn open_test_tree(dir: &std::path::Path) -> VarTree<[u8; 8]> {
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 8192;
        cfg.write_buffer_size = 8192;
        cfg.compaction_threshold = 0.0;
        VarTree::open(dir, cfg).expect("open test tree")
    }

    fn open_test_tree_hooked<const NEEDS_INIT: bool, const NEEDS_OLD: bool>(
        dir: &std::path::Path,
        hook: CountingHook<NEEDS_INIT, NEEDS_OLD>,
    ) -> VarTree<[u8; 8], CountingHook<NEEDS_INIT, NEEDS_OLD>> {
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 8192;
        cfg.write_buffer_size = 8192;
        cfg.compaction_threshold = 0.0;
        VarTree::open_hooked(dir, cfg, hook).expect("open hooked test tree")
    }

    /// Regression (bug 26-06-18, codex coverage note): the reclaim fix must also
    /// hold for `VarTree`, where hints are on by default and values are read back
    /// from disk (not inlined). Under pure overwrite churn, on-disk size must
    /// collapse toward the live set and reads must still return the latest value.
    #[test]
    fn var_compaction_reclaims_dead_bytes_under_overwrite_churn() {
        let dir = tempdir().unwrap();
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 64 * 1024;
        cfg.write_buffer_size = 16 * 1024;
        cfg.compaction_threshold = 0.30;
        let max_file_size = cfg.max_file_size;
        let tree = VarTree::<[u8; 8]>::open(dir.path(), cfg).unwrap();

        const N: u64 = 500;
        const M: u64 = 40;
        let entry_sz: u64 = 16 + 8 + 64;
        let live_set = N * entry_sz;
        let total_written = N * (M + 1) * entry_sz;

        for k in 0..N {
            tree.put(&k.to_be_bytes(), &[0u8; 64]).unwrap();
        }
        for round in 1..=M {
            let mut v = [0u8; 64];
            v[0] = round as u8;
            for k in 0..N {
                tree.put(&k.to_be_bytes(), &v).unwrap();
            }
        }
        tree.flush_buffers().unwrap();

        let shard = &tree.engine.shards()[0];
        let disk = || {
            let inner = shard.lock();
            inner.active.write_offset + inner.immutable.iter().map(|f| f.total_bytes).sum::<u64>()
        };
        assert!(disk() > total_written / 2, "expected dead-byte buildup");

        for _ in 0..200 {
            let before = disk();
            tree.compact().unwrap();
            if disk() == before {
                break;
            }
        }
        assert!(
            disk() <= live_set + max_file_size,
            "VarTree compaction failed to reclaim: disk={} live_set={live_set}",
            disk()
        );

        // Disk-backed reads must still return the latest value after rewrites.
        let mut v = [0u8; 64];
        v[0] = M as u8;
        assert_eq!(tree.get(&0u64.to_be_bytes()).as_deref(), Some(&v[..]));
        assert_eq!(tree.get(&(N - 1).to_be_bytes()).as_deref(), Some(&v[..]));
        assert_eq!(tree.len(), N as usize);
    }

    fn put_until_compactable(tree: &VarTree<[u8; 8]>, key: [u8; 8]) -> DiskLoc {
        // Capture the DiskLoc after the first put — this points to an early
        // file that will be erased by compaction (all later puts overwrite
        // the index, leaving this entry dead in its file).
        tree.put(&key, &[0u8; 256]).expect("first put");
        let snap = {
            let guard = tree.index.collector().enter();
            let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
            node.read_loc()
        };
        // Each put is 280 bytes (16 header + 8 key + 256 value, 8-byte aligned).
        // With max_file_size=8192, ~30 puts cross the first rotation boundary.
        // Use 65 puts so the snap file is sealed as immutable AND fully dead.
        for i in 1..65u8 {
            tree.put(&key, &[i; 256]).expect("put");
        }
        tree.put(&key, b"final-value-payload-XX")
            .expect("final put");
        snap
    }

    #[test]
    fn put_reports_whether_key_existed() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());
        let key = 42u64.to_be_bytes();

        // Fresh insert -> false.
        assert!(!tree.put(&key, b"v1").expect("put"));
        // Overwrite of the same key -> true.
        assert!(tree.put(&key, b"v2").expect("put"));
        assert!(tree.put(&key, b"v3").expect("put"));
        // Survives a flush (overwrite still detected after entries hit disk).
        tree.flush_buffers().expect("flush");
        assert!(tree.put(&key, b"v4").expect("put"));
        // A different key is a fresh insert again.
        assert!(!tree.put(&7u64.to_be_bytes(), b"x").expect("put"));
    }

    #[test]
    fn warmup_threads_correct_shard_across_shards() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());
        // 256 keys spread across both shards
        for i in 0..256u64 {
            tree.put(&i.to_be_bytes(), &[i as u8; 64]).expect("put");
        }
        tree.flush_buffers().expect("flush");
        // warmup threads shard from node.key across both shards
        tree.warmup().expect("warmup");
        // values read correctly after warmup
        for i in 0..256u64 {
            assert_eq!(
                tree.get(&i.to_be_bytes()).as_deref(),
                Some(&[i as u8; 64][..])
            );
        }
    }

    #[test]
    fn read_value_cached_inner_returns_stale_after_compaction() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 7u64.to_be_bytes();
        let snap = put_until_compactable(&tree, key);
        let shard_id = tree.shard_for(&key) as u8;

        let shard = &tree.engine.shards()[shard_id as usize];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        match tree.read_value_cached_inner(&snap, shard_id) {
            Err(DbError::StaleDiskLoc) => {}
            Ok(v) => panic!("expected StaleDiskLoc, got Ok({:?})", v.as_bytes()),
            Err(e) => panic!("expected StaleDiskLoc, got Err({e})"),
        }
    }

    #[test]
    fn read_value_cached_returns_some_after_compaction() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 11u64.to_be_bytes();
        let _snap = put_until_compactable(&tree, key);
        let shard = &tree.engine.shards()[0];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let v = tree
            .read_value_cached(node, &guard)
            .expect("post-compaction value must be readable");
        assert_eq!(v.as_bytes(), b"final-value-payload-XX");
    }

    #[test]
    fn get_during_compaction_returns_some() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 13u64.to_be_bytes();
        let _snap = put_until_compactable(&tree, key);
        let shard = &tree.engine.shards()[0];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        let v = tree.get(&key).expect("post-compaction get");
        assert_eq!(v.as_bytes(), b"final-value-payload-XX");
    }

    #[test]
    fn iter_during_compaction_yields_all_live_keys() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        for k in 1u64..=3 {
            put_until_compactable(&tree, k.to_be_bytes());
        }
        let shard = &tree.engine.shards()[0];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        let collected: std::collections::BTreeMap<[u8; 8], Vec<u8>> = tree
            .iter()
            .map(|(k, v)| (k, v.as_bytes().to_vec()))
            .collect();
        assert_eq!(collected.len(), 3);
        for k in 1u64..=3 {
            let bytes = collected
                .get(&k.to_be_bytes())
                .expect("every original key must remain");
            assert_eq!(bytes.as_slice(), b"final-value-payload-XX");
        }
    }

    #[test]
    fn get_or_read_block_returns_stale_for_unknown_file_id() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        match tree.get_or_read_block(0, 9999, 0) {
            Err(DbError::StaleDiskLoc) => {}
            Ok(_) => panic!("expected StaleDiskLoc, got Ok"),
            Err(e) => panic!("expected StaleDiskLoc, got Err({e})"),
        }
    }

    #[test]
    fn extract_from_block_propagates_next_block_error() {
        let block = AlignedBuf::zeroed(4096);
        let start = 4090;
        let len = 32;
        let result: DbResult<ByteView> =
            VarTree::<[u8; 8]>::extract_from_block(&block, start, len, || {
                Err(DbError::StaleDiskLoc)
            });
        match result {
            Err(DbError::StaleDiskLoc) => {}
            Ok(_) => panic!("expected StaleDiskLoc, got Ok"),
            Err(e) => panic!("expected StaleDiskLoc, got Err({e})"),
        }
    }

    #[test]
    fn extract_from_block_multi_block_first_cached_second_stale() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 21u64.to_be_bytes();
        let value = vec![0xCDu8; 4073];
        tree.put(&key, &value).expect("initial put");

        // Capture the multi-block DiskLoc BEFORE overwriting.
        let snap = {
            let guard = tree.index.collector().enter();
            let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
            node.read_loc()
        };

        // Make the initial entry dead by overwriting many times so compaction
        // erases the file containing `snap` (need enough writes to force file
        // rotation past max_file_size=8192 and seal the file as immutable).
        // 65 overwrites ensures the snap file is fully dead after two rotations.
        for i in 0..65u8 {
            tree.put(&key, &[i; 256]).expect("overwrite");
        }
        tree.put(&key, b"final").expect("final");

        let shard = &tree.engine.shards()[0];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        let block_offset = snap.offset as u64 & !4095;
        let shard_id = tree.shard_for(&key) as u8;
        let first_block = AlignedBuf::zeroed(4096);
        let cache_key = BlockKey {
            shard_id,
            file_id: snap.file_id,
            block_offset,
        };
        tree.block_cache.insert(cache_key, Arc::new(first_block));

        match tree.read_value_cached_inner(&snap, shard_id) {
            Err(DbError::StaleDiskLoc) => {}
            Ok(_) => panic!("expected StaleDiskLoc from second block, got Ok"),
            Err(e) => panic!("expected StaleDiskLoc, got Err({e})"),
        }
    }

    #[test]
    fn retry_limit_returns_none_on_persistent_stale() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 99u64.to_be_bytes();
        tree.put(&key, b"payload").expect("put");

        // Force every read_value_cached_inner call to see Stale by manually
        // clearing inner.immutable AND moving the active.file_id out of the
        // way. node.read_loc() still points at the original (now-unreachable)
        // file_id, so each retry hits Shard::read_block, which returns
        // StaleDiskLoc.
        let guard = tree.index.collector().enter();
        assert!(tree.index.get(key.as_bytes(), &guard).is_some());
        drop(guard);

        {
            let shard_id = tree.shard_for(&key) as u8;
            let shard = &tree.engine.shards()[shard_id as usize];
            let mut inner = shard.lock();
            inner.immutable = Vec::new();
            // Reassign active.file_id to a sentinel value that does not match
            // snap.file_id, so the active-file branch in Shard::read_block
            // never matches either.
            inner.active.file_id = u32::MAX;
        }

        let guard = tree.index.collector().enter();
        let node = tree
            .index
            .get(key.as_bytes(), &guard)
            .expect("still indexed");
        assert!(
            tree.read_value_cached(node, &guard).is_none(),
            "MAX_STALE_RETRIES must terminate the loop and return None"
        );
    }

    #[test]
    fn var_tree_replay_init_fires_on_init_per_live_key_raw() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, false>(dir.path(), CountingHook::default());

        for i in 0u64..5 {
            tree.put(&i.to_be_bytes(), &[i as u8; 16]).expect("put");
        }
        // Delete one — replay_init must not see it.
        tree.delete(&3u64.to_be_bytes()).expect("delete");

        // Reset counters so we measure only the effect of replay_init.
        tree.hook.writes.store(0, AtomicOrdering::Relaxed);
        tree.hook.inits.store(0, AtomicOrdering::Relaxed);

        tree.replay_init();

        assert_eq!(
            tree.hook.inits.load(AtomicOrdering::Relaxed),
            4,
            "4 live keys"
        );
        assert_eq!(
            tree.hook.writes.load(AtomicOrdering::Relaxed),
            0,
            "no on_write"
        );
    }

    #[test]
    fn var_tree_replay_init_no_hook_is_noop() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        for i in 0u64..3 {
            tree.put(&i.to_be_bytes(), &[i as u8; 8]).expect("put");
        }
        // Must finish instantly with no effects.
        tree.replay_init();
        // sanity: tree is intact.
        assert!(tree.get(&0u64.to_be_bytes()).is_some());
    }

    #[test]
    fn var_tree_migrate_keep_fires_on_init_not_on_write_raw() {
        use crate::MigrateAction;
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, false>(dir.path(), CountingHook::default());

        for i in 0u64..4 {
            tree.put(&i.to_be_bytes(), &[i as u8; 16]).expect("put");
        }
        tree.hook.writes.store(0, AtomicOrdering::Relaxed);
        tree.hook.inits.store(0, AtomicOrdering::Relaxed);

        let mutated = tree.migrate(|_, _| MigrateAction::Keep).expect("migrate");

        assert_eq!(mutated, 0);
        assert_eq!(
            tree.hook.inits.load(AtomicOrdering::Relaxed),
            4,
            "4 keeps -> 4 on_init"
        );
        assert_eq!(
            tree.hook.writes.load(AtomicOrdering::Relaxed),
            0,
            "Keep must not fire on_write"
        );
    }

    #[test]
    fn var_tree_migrate_update_fires_on_init_with_new_value_raw() {
        use crate::MigrateAction;
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, false>(dir.path(), CountingHook::default());

        let key = 42u64.to_be_bytes();
        tree.put(&key, b"old-value").expect("put");
        tree.hook.writes.store(0, AtomicOrdering::Relaxed);
        tree.hook.inits.store(0, AtomicOrdering::Relaxed);

        let new = ByteView::new(b"new-value");
        let mutated = tree
            .migrate(move |_, _| MigrateAction::Update(new.clone()))
            .expect("migrate");

        assert_eq!(mutated, 1);
        assert_eq!(tree.hook.inits.load(AtomicOrdering::Relaxed), 1);
        assert_eq!(
            crate::sync::lock(&tree.hook.last_init_value).as_deref(),
            Some(b"new-value".as_ref()),
            "on_init must receive the NEW value"
        );
        assert_eq!(
            tree.hook.writes.load(AtomicOrdering::Relaxed),
            0,
            "Update must NOT fire on_write (was double-firing through self.put)"
        );
        assert_eq!(tree.get(&key).unwrap().as_bytes(), b"new-value");
    }

    #[test]
    fn var_tree_migrate_delete_fires_no_hooks_raw() {
        use crate::MigrateAction;
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, false>(dir.path(), CountingHook::default());

        let key = 7u64.to_be_bytes();
        tree.put(&key, b"x").expect("put");
        tree.hook.writes.store(0, AtomicOrdering::Relaxed);
        tree.hook.inits.store(0, AtomicOrdering::Relaxed);

        let mutated = tree.migrate(|_, _| MigrateAction::Delete).expect("migrate");

        assert_eq!(mutated, 1);
        assert_eq!(tree.hook.inits.load(AtomicOrdering::Relaxed), 0);
        assert_eq!(tree.hook.writes.load(AtomicOrdering::Relaxed), 0);
        assert!(tree.get(&key).is_none());
    }

    #[test]
    fn var_tree_migrate_no_init_hook_is_silent_for_keep_and_update() {
        use crate::MigrateAction;
        let dir = tempdir().unwrap();
        // NEEDS_INIT = false: on_init must never be called regardless of action.
        let tree = open_test_tree_hooked::<false, false>(dir.path(), CountingHook::default());

        for i in 0u64..3 {
            tree.put(&i.to_be_bytes(), &[i as u8; 16]).expect("put");
        }
        tree.hook.writes.store(0, AtomicOrdering::Relaxed);
        tree.hook.inits.store(0, AtomicOrdering::Relaxed);

        // Keep
        tree.migrate(|_, _| MigrateAction::Keep)
            .expect("migrate keep");
        assert_eq!(
            tree.hook.inits.load(AtomicOrdering::Relaxed),
            0,
            "Keep with NEEDS_INIT=false"
        );
        assert_eq!(tree.hook.writes.load(AtomicOrdering::Relaxed), 0);

        // Update
        let new = ByteView::new(b"new");
        tree.migrate(move |_, _| MigrateAction::Update(new.clone()))
            .expect("migrate update");
        assert_eq!(
            tree.hook.inits.load(AtomicOrdering::Relaxed),
            0,
            "Update with NEEDS_INIT=false"
        );
        assert_eq!(
            tree.hook.writes.load(AtomicOrdering::Relaxed),
            0,
            "Update must not fire on_write either"
        );
    }

    #[test]
    fn var_tree_public_put_still_fires_on_write_once() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, false>(dir.path(), CountingHook::default());

        tree.put(&1u64.to_be_bytes(), b"v").expect("put");
        assert_eq!(tree.hook.writes.load(AtomicOrdering::Relaxed), 1);
    }

    #[test]
    fn var_tree_atomic_fires_hooks() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, true>(dir.path(), CountingHook::default());
        let key = 1u64.to_be_bytes();
        tree.atomic(&key, |shard| {
            shard.put(&key, b"a")?; // new key
            shard.delete(&key)?; // delete, old=b"a"
            Ok(())
        })
        .expect("atomic");
        assert_eq!(tree.hook.writes.load(AtomicOrdering::Relaxed), 2);
        assert_eq!(tree.hook.writes_with_old.load(AtomicOrdering::Relaxed), 1); // delete saw old
    }

    #[test]
    fn var_tree_atomic_fires_for_applied_on_err() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, true>(dir.path(), CountingHook::default());
        let key = 1u64.to_be_bytes();
        let r: DbResult<()> = tree.atomic(&key, |shard| {
            shard.put(&key, b"x")?;
            Err(DbError::KeyNotFound)
        });
        assert!(r.is_err());
        assert_eq!(tree.hook.writes.load(AtomicOrdering::Relaxed), 1);
    }

    #[test]
    fn var_tree_atomic_same_key_old_from_disk() {
        // NEEDS_OLD_VALUE=true: the old value of the 2nd put must be the 1st put's value,
        // read back through the write buffer under the lock.
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<false, true>(dir.path(), CountingHook::default());
        let key = 1u64.to_be_bytes();
        tree.atomic(&key, |shard| {
            shard.put(&key, b"first")?;
            shard.put(&key, b"second")?;
            Ok(())
        })
        .expect("atomic");
        assert_eq!(tree.hook.writes.load(AtomicOrdering::Relaxed), 2);
        assert_eq!(tree.hook.writes_with_old.load(AtomicOrdering::Relaxed), 1); // 2nd put saw old
        assert_eq!(
            crate::sync::lock(&tree.hook.last_write_new).clone(),
            Some(b"second".to_vec())
        );
    }

    #[test]
    fn var_tree_shard_put_returns_existed() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, true>(dir.path(), CountingHook::default());
        let key = 1u64.to_be_bytes();
        let (fresh, overwrite) = tree
            .atomic(&key, |shard| {
                let fresh = shard.put(&key, b"a")?; // fresh insert -> false
                let overwrite = shard.put(&key, b"b")?; // overwrite -> true
                Ok((fresh, overwrite))
            })
            .expect("atomic");
        assert!(!fresh, "fresh insert must return false");
        assert!(overwrite, "overwrite must return true");
    }

    #[test]
    fn compare_delete_match_mismatch_absent() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let k = [1u8; 8];
        tree.put(&k, b"hello").unwrap();

        assert!(matches!(
            tree.compare_delete(&k, b"WRONG"),
            Err(DbError::CasMismatch)
        ));
        assert_eq!(tree.get(&k).as_deref(), Some(&b"hello"[..]));

        assert!(tree.compare_delete(&k, b"hello").is_ok());
        assert!(tree.get(&k).is_none());

        assert!(matches!(
            tree.compare_delete(&k, b"hello"),
            Err(DbError::KeyNotFound)
        ));
    }

    #[test]
    fn compare_delete_hook_needs_old_true_gets_old_value() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<false, true>(dir.path(), CountingHook::default());

        let k = [2u8; 8];
        tree.put(&k, b"payload").unwrap();
        let with_old_before = tree.hook.writes_with_old.load(AtomicOrdering::Relaxed);

        tree.compare_delete(&k, b"payload").unwrap();

        // NEEDS_OLD_VALUE = true → on_write received Some(old)
        assert_eq!(
            tree.hook.writes_with_old.load(AtomicOrdering::Relaxed) - with_old_before,
            1
        );
        assert_eq!(*crate::sync::lock(&tree.hook.last_write_new), None);
    }

    #[test]
    fn compare_delete_hook_needs_old_false_gets_none() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<false, false>(dir.path(), CountingHook::default());

        let k = [3u8; 8];
        tree.put(&k, b"payload").unwrap();
        let writes_before = tree.hook.writes.load(AtomicOrdering::Relaxed);
        let with_old_before = tree.hook.writes_with_old.load(AtomicOrdering::Relaxed);

        tree.compare_delete(&k, b"payload").unwrap();

        // exactly one on_write, but old = None (NEEDS_OLD_VALUE = false)
        assert_eq!(
            tree.hook.writes.load(AtomicOrdering::Relaxed) - writes_before,
            1
        );
        assert_eq!(
            tree.hook.writes_with_old.load(AtomicOrdering::Relaxed) - with_old_before,
            0
        );
    }

    /// `_result` returns Ok when the value sits in the active write buffer.
    #[test]
    fn read_value_locked_result_ok_from_write_buf() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 1u64.to_be_bytes();
        let payload = b"in-write-buffer-value";
        tree.put(&key, payload).expect("put");

        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let disk = node.read_loc();
        drop(guard);

        let shard_id = tree.shard_for(&key) as u8;
        let shard = &tree.engine.shards()[shard_id as usize];
        let inner = shard.lock();
        let v = tree
            .read_value_locked_result(&disk, shard_id, &inner)
            .expect("write-buf read must succeed");
        assert_eq!(v.as_bytes(), payload);
    }

    /// `_result` returns Ok when the value is on an immutable file and the
    /// entry sits within a single 4 KiB block.
    #[test]
    fn read_value_locked_result_ok_from_disk_immutable() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 2u64.to_be_bytes();
        let payload = b"single-block-immutable";
        tree.put(&key, payload).expect("put");
        // Force rotation so the entry moves out of the active write buffer.
        // Each put is 280 bytes; with max_file_size=8192, need >30 puts to
        // cross the rotation boundary. Use keys 100..135 to avoid overwriting key 2.
        for i in 100u64..135 {
            tree.put(&i.to_be_bytes(), &[i as u8; 256])
                .expect("rotator");
        }

        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let disk = node.read_loc();
        drop(guard);

        // `open_test_tree` uses the default CacheConfig (max_size = 0), so the
        // cache is disabled and step 2 (single-block cache lookup) returns
        // None unconditionally — the read naturally exercises step 3 (disk).

        let shard_id = tree.shard_for(&key) as u8;
        let shard = &tree.engine.shards()[shard_id as usize];
        let inner = shard.lock();
        // Sanity: the entry must NOT be in the write buffer anymore — the rotator
        // loop above is sized to force rotation past max_file_size=8192.
        assert_ne!(
            disk.file_id, inner.active.file_id,
            "test setup failed: key=2 entry is still in the active file's write buffer",
        );
        let v = tree
            .read_value_locked_result(&disk, shard_id, &inner)
            .expect("disk read must succeed");
        assert_eq!(v.as_bytes(), payload);
    }

    /// `_result` propagates `StaleDiskLoc` from the disk read.
    #[test]
    fn read_value_locked_result_propagates_stale_disk_loc() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 3u64.to_be_bytes();
        let snap = put_until_compactable(&tree, key);

        let shard_id = tree.shard_for(&key) as u8;
        let shard = &tree.engine.shards()[shard_id as usize];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        let inner = shard.lock();
        match tree.read_value_locked_result(&snap, shard_id, &inner) {
            Err(DbError::StaleDiskLoc) => {}
            Ok(v) => panic!("expected StaleDiskLoc, got Ok({:?})", v.as_bytes()),
            Err(e) => panic!("expected StaleDiskLoc, got Err({e})"),
        }
    }

    /// Backward-compat: the `Option` wrapper still returns `None` on
    /// `StaleDiskLoc`, so callers like `cas`/`update` keep their contract.
    /// We intentionally do NOT assert on the tracing output (no capture
    /// helper exists in this crate); the `None` is the load-bearing
    /// invariant.
    #[test]
    fn read_value_locked_returns_none_on_stale() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree(dir.path());

        let key = 4u64.to_be_bytes();
        let snap = put_until_compactable(&tree, key);

        let shard_id = tree.shard_for(&key) as u8;
        let shard = &tree.engine.shards()[shard_id as usize];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        let inner = shard.lock();
        assert!(tree.read_value_locked(&snap, shard_id, &inner).is_none());
    }

    /// Pins the "size check sits before cache lookup" invariant.
    ///
    /// Manually inject the first 4 KiB block of a large value into the
    /// cache. Without the early `start + len > 8192` check, the cached path
    /// would call `extract_from_block`, which only supports two blocks and
    /// would panic on `next[..second_len]` for `second_len > 4096`.
    ///
    /// With the early check, the call routes to the locked path and returns
    /// the correct bytes.
    #[test]
    fn large_value_with_first_block_cached_uses_fallback() {
        let dir = tempdir().unwrap();
        // Custom geometry: write_buffer_size big enough for 20 KiB entry,
        // max_file_size == write_buffer_size (required by Config::validate);
        // rotation is forced explicitly via rotate_active_for_test.
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        // Enable the block cache so `cache.insert` actually takes effect.
        // Default `CacheConfig::default` has `max_size = 0` which makes the
        // cache a no-op; we need real inserts to set up the warm-cache scenario.
        cfg.block_cache.max_size = 1 << 20;
        let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open");

        let key = 42u64.to_be_bytes();
        // Build a value whose body spans ~5 blocks: > 8192 bytes.
        let payload: Vec<u8> = (0..20_000u32).map(|i| i as u8).collect();
        tree.put(&key, &payload).expect("put large");
        // Force rotation so the large entry moves to an immutable file.
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");

        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let disk = node.read_loc();
        drop(guard);

        // Sanity: this is a multi-block large value.
        let start = (disk.offset & 4095) as usize;
        assert!(
            start + disk.len as usize > 8192,
            "test precondition: large value must span >2 blocks",
        );

        // Inject the first block (zeroes) into the cache to ensure the cache
        // lookup would succeed for the first block — exactly the warm-cache
        // scenario that would have triggered the old panic.
        let block_offset = disk.offset as u64 & !4095;
        let shard_id = tree.shard_for(&key) as u8;
        let cache_key = BlockKey {
            shard_id,
            file_id: disk.file_id,
            block_offset,
        };
        tree.block_cache
            .insert(cache_key, Arc::new(AlignedBuf::zeroed(4096)));
        // Sanity: insert took effect.
        assert!(
            tree.block_cache.get(&cache_key).is_some(),
            "cache must be enabled for warm-cache scenario",
        );

        let v = tree
            .read_value_cached_inner(&disk, shard_id)
            .expect("large value must read via locked fallback");
        assert_eq!(v.as_bytes(), payload.as_slice());
    }

    /// Value entirely in one block: `start + len <= 4096`.
    #[test]
    fn extract_from_block_single_block() {
        let mut block = AlignedBuf::zeroed(4096);
        for (i, byte) in block.iter_mut().enumerate() {
            *byte = i as u8;
        }
        let v = VarTree::<[u8; 8]>::extract_from_block(&block, 100, 50, || {
            panic!("next_block must not be called for single-block reads")
        })
        .expect("ok");
        let expected: Vec<u8> = (100u8..150u8).collect();
        assert_eq!(v.as_bytes(), expected.as_slice());
    }

    /// Value ends exactly at the second block's end: `start + len == 8192`.
    #[test]
    fn extract_from_block_two_blocks_exact() {
        let mut first = AlignedBuf::zeroed(4096);
        for byte in first.iter_mut() {
            *byte = 0xAA;
        }
        let mut second = AlignedBuf::zeroed(4096);
        for byte in second.iter_mut() {
            *byte = 0xBB;
        }
        // start = 4096 - 1 (last byte of first block); len = 4097.
        // start + len = 8192 — the boundary that must remain supported.
        let v = VarTree::<[u8; 8]>::extract_from_block(&first, 4095, 4097, || Ok(Arc::new(second)))
            .expect("ok");
        let bytes = v.as_bytes();
        assert_eq!(bytes.len(), 4097);
        assert_eq!(bytes[0], 0xAA);
        assert_eq!(bytes[1], 0xBB);
        assert_eq!(bytes[4096], 0xBB);
    }

    /// Value spans two blocks partially: `4096 < start + len < 8192`.
    #[test]
    fn extract_from_block_two_blocks_partial() {
        let mut first = AlignedBuf::zeroed(4096);
        for byte in first.iter_mut() {
            *byte = 0x11;
        }
        let mut second = AlignedBuf::zeroed(4096);
        for byte in second.iter_mut() {
            *byte = 0x22;
        }
        // start = 4000, len = 200 -> first 96 bytes from first, next 104 from second.
        let v = VarTree::<[u8; 8]>::extract_from_block(&first, 4000, 200, || Ok(Arc::new(second)))
            .expect("ok");
        let bytes = v.as_bytes();
        assert_eq!(bytes.len(), 200);
        assert!(bytes[..96].iter().all(|b| *b == 0x11));
        assert!(bytes[96..].iter().all(|b| *b == 0x22));
    }

    /// Fixed geometry that forces large entries onto an immutable file:
    /// `write_buffer_size` fits the 50 KiB entry; rotation is done explicitly
    /// via `rotate_active_for_test` so max_file_size == write_buffer_size
    /// satisfies Config::validate.
    fn open_large_value_tree(dir: &std::path::Path) -> VarTree<[u8; 8]> {
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        VarTree::open(dir, cfg).expect("open large-value test tree")
    }

    fn build_large_payload(seed: u8) -> Vec<u8> {
        (0..50_000u32)
            .map(|i| (i as u8).wrapping_add(seed))
            .collect()
    }

    fn open_value_cache_tree(dir: &std::path::Path) -> VarTree<[u8; 8]> {
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.value_cache.max_size = 4 << 20; // enable the value cache
        VarTree::open(dir, cfg).expect("open value-cache test tree")
    }

    #[test]
    fn large_value_miss_then_cached() {
        let dir = tempdir().unwrap();
        let tree = open_value_cache_tree(dir.path());
        let key = 1u64.to_be_bytes();
        let payload = build_large_payload(0x33);
        tree.put(&key, &payload).unwrap();

        // First read is a miss that populates the cache.
        assert_eq!(tree.get(&key).unwrap().as_bytes(), &payload[..]);

        // The value is now cached at its current loc.
        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).unwrap();
        let disk = node.read_loc();
        let vkey = crate::value_cache::ValueKey {
            shard_id: 0,
            file_id: disk.file_id,
            offset: disk.offset,
        };
        assert!(
            tree.value_cache.get(&vkey).is_some(),
            "value cached after read"
        );
        drop(guard);

        // Second read still correct.
        assert_eq!(tree.get(&key).unwrap().as_bytes(), &payload[..]);
    }

    #[test]
    fn large_value_read_then_edit_returns_new() {
        let dir = tempdir().unwrap();
        let tree = open_value_cache_tree(dir.path());
        let key = 7u64.to_be_bytes();
        let v1 = build_large_payload(0x11);
        let v2 = build_large_payload(0x22);
        tree.put(&key, &v1).unwrap();
        assert_eq!(tree.get(&key).unwrap().as_bytes(), &v1[..]); // caches v1 at loc1
        tree.put(&key, &v2).unwrap(); // node -> loc2
        // verify-on-hit: fresh loc misses the v1 entry, reads v2.
        assert_eq!(tree.get(&key).unwrap().as_bytes(), &v2[..]);
    }

    #[test]
    fn large_value_read_then_compact_returns_live() {
        let dir = tempdir().unwrap();
        let tree = open_value_cache_tree(dir.path());
        let key = 102u64.to_be_bytes();
        let payload = build_large_payload(0x42);
        tree.put(&key, &payload).unwrap();
        assert_eq!(tree.get(&key).unwrap().as_bytes(), &payload[..]); // cache the large value

        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");
        for i in 1..20u8 {
            tree.put(&key, &[i; 256]).unwrap();
        }
        tree.put(&key, b"live-after-compaction").unwrap();
        let shard = &tree.engine.shards()[0];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        // Must return the live value, never the stale cached large payload.
        assert_eq!(tree.get(&key).unwrap().as_bytes(), b"live-after-compaction");
    }

    #[test]
    fn large_value_disabled_cache_reads_correctly() {
        let dir = tempdir().unwrap();
        let tree = open_large_value_tree(dir.path()); // value_cache disabled (Config::test)
        let key = 3u64.to_be_bytes();
        let payload = build_large_payload(0x55);
        tree.put(&key, &payload).unwrap();
        assert_eq!(tree.get(&key).unwrap().as_bytes(), &payload[..]);
        assert_eq!(tree.get(&key).unwrap().as_bytes(), &payload[..]);
    }

    /// End-to-end: 50 KiB value, force rotation to immutable, then `get`
    /// must return correct bytes via read_value_from_disk_locked.
    ///
    /// `open_large_value_tree` uses the default CacheConfig (max_size = 0),
    /// so the block cache is disabled and the read goes through the
    /// large-value locked fallback → disk read on every call.
    #[test]
    fn large_value_read_via_locked_fallback() {
        let dir = tempdir().unwrap();
        let tree = open_large_value_tree(dir.path());

        let key = 100u64.to_be_bytes();
        let payload = build_large_payload(0);
        tree.put(&key, &payload).expect("put large");
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");

        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let v = tree
            .read_value_cached(node, &guard)
            .expect("read must succeed via locked fallback");
        assert_eq!(v.as_bytes(), payload.as_slice());
    }

    #[cfg(feature = "encryption")]
    #[test]
    fn get_or_err_and_try_get_surface_read_fault() {
        use crate::test_faults::{big_value, corrupt_tags};
        let dir = tempfile::tempdir().unwrap();
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.encryption_key = Some([7u8; 32]);
        let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open enc");

        let key = 1u64.to_be_bytes();
        tree.put(&key, &big_value(0xAB)).expect("put");
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");
        corrupt_tags(&tree.engine.shard_dirs()[0]);

        // lossy get still swallows the error
        assert!(tree.get(&key).is_none());
        // strict reads surface it — NOT KeyNotFound
        assert!(matches!(
            tree.get_or_err(&key),
            Err(DbError::EncryptionError(_))
        ));
        assert!(matches!(
            tree.try_get(&key),
            Err(DbError::EncryptionError(_))
        ));
        // genuinely-absent key is still KeyNotFound / Ok(None)
        let absent = 999u64.to_be_bytes();
        assert!(matches!(
            tree.get_or_err(&absent),
            Err(DbError::KeyNotFound)
        ));
        assert!(matches!(tree.try_get(&absent), Ok(None)));
    }

    #[cfg(feature = "encryption")]
    #[test]
    fn mutators_surface_read_fault_not_keynotfound() {
        use crate::test_faults::{big_value, corrupt_tags};
        let dir = tempfile::tempdir().unwrap();
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.encryption_key = Some([7u8; 32]);
        let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open enc");

        let key = 1u64.to_be_bytes();
        tree.put(&key, &big_value(0xAB)).unwrap();
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");
        corrupt_tags(&tree.engine.shard_dirs()[0]);

        assert!(matches!(
            tree.cas(&key, b"x", b"y"),
            Err(DbError::EncryptionError(_))
        ));
        assert!(matches!(
            tree.compare_delete(&key, b"x"),
            Err(DbError::EncryptionError(_))
        ));
        assert!(matches!(
            tree.update(&key, |_| ByteView::new(b"z")),
            Err(DbError::EncryptionError(_))
        ));
        assert!(matches!(
            tree.fetch_update(&key, |_| ByteView::new(b"z")),
            Err(DbError::EncryptionError(_))
        ));
    }

    #[cfg(feature = "encryption")]
    #[test]
    fn first_skips_unreadable_boundary_try_first_surfaces_it() {
        use crate::test_faults::{big_value, corrupt_tags};
        let dir = tempfile::tempdir().unwrap();
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.encryption_key = Some([7u8; 32]);
        // Default reversed=true: first() == largest key. Make key 2 the boundary.
        let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open enc");

        let k1 = 1u64.to_be_bytes();
        let k2 = 2u64.to_be_bytes();
        tree.put(&k1, &big_value(0x11)).unwrap();
        tree.put(&k2, &big_value(0x22)).unwrap();
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");
        corrupt_tags(&tree.engine.shard_dirs()[0]); // both entries now unreadable

        // Both unreadable -> lossy first()/last() skip everything -> None, and match iter.
        assert!(tree.first().is_none());
        assert_eq!(
            tree.first().map(|(k, _)| k),
            tree.iter().next().map(|(k, _)| k)
        );
        // Strict try_first surfaces the boundary read error, not None.
        assert!(matches!(tree.try_first(), Err(DbError::EncryptionError(_))));
        assert!(matches!(tree.try_last(), Err(DbError::EncryptionError(_))));
    }

    #[cfg(feature = "encryption")]
    #[test]
    fn large_value_read_encrypted() {
        let dir = tempdir().unwrap();
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.encryption_key = Some([7u8; 32]);
        // Cache stays at default (max_size = 0, disabled). The locked
        // fallback always goes to step 3 -> read_value_from_disk_locked,
        // which routes to pread_value_encrypted under this feature gate.
        let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open enc");

        let key = 101u64.to_be_bytes();
        let payload = build_large_payload(0xAB);
        tree.put(&key, &payload).expect("put encrypted large");
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");

        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let v = tree
            .read_value_cached(node, &guard)
            .expect("encrypted large read must succeed via pread_value_encrypted");
        assert_eq!(v.as_bytes(), payload.as_slice());
    }

    /// Deterministic stale-DiskLoc test for large values: build a large
    /// entry, capture its DiskLoc, overwrite enough to make the file fully
    /// dead, compact (removes the file), then the captured DiskLoc must
    /// surface as StaleDiskLoc via the locked fallback. The public
    /// retry path then returns the live value.
    #[test]
    fn large_value_stale_disk_loc_deterministic() {
        let dir = tempdir().unwrap();
        let tree = open_large_value_tree(dir.path());

        let key = 102u64.to_be_bytes();
        let payload = build_large_payload(0x42);
        tree.put(&key, &payload).expect("first large put");

        // Snapshot the DiskLoc of the first put — points at the file we
        // will erase via compaction.
        let snap = {
            let guard = tree.index.collector().enter();
            let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
            node.read_loc()
        };

        // Rotate the active file so the large entry moves to an immutable file.
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate after large put");

        // Overwrite many times with small payloads to move the live pointer off
        // the original file (making it 100% dead so compaction erases it).
        for i in 1..20u8 {
            tree.put(&key, &[i; 256]).expect("overwrite");
        }
        tree.put(&key, b"live-after-compaction").expect("final put");

        let shard_id = tree.shard_for(&key) as u8;
        let shard = &tree.engine.shards()[shard_id as usize];
        let _ = compact_shard(shard, &tree, 0.0).expect("compaction");

        // Direct call on the stale snapshot — must propagate StaleDiskLoc
        // through the large-value locked fallback.
        match tree.read_value_cached_inner(&snap, shard_id) {
            Err(DbError::StaleDiskLoc) => {}
            Ok(v) => panic!("expected StaleDiskLoc, got Ok({:?})", v.as_bytes()),
            Err(e) => panic!("expected StaleDiskLoc, got Err({e})"),
        }

        // Public retry path picks up the live value.
        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let v = tree
            .read_value_cached(node, &guard)
            .expect("public path must retry and return live value");
        assert_eq!(v.as_bytes(), b"live-after-compaction");
    }

    /// `_result` step 2: single-block cache fast path.
    /// Writes a value, snapshots its DiskLoc, manually inserts the matching
    /// 4 KiB block into the cache, then calls `_result` and verifies the
    /// cache-hit path returns the correct bytes.
    #[test]
    fn read_value_locked_result_ok_from_cache_single_block() {
        let dir = tempdir().unwrap();
        // Enable the cache so `cache.insert` actually takes effect.
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 8192;
        cfg.write_buffer_size = 8192;
        cfg.compaction_threshold = 0.0;
        cfg.block_cache.max_size = 1 << 20;
        let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open");

        let key = 9u64.to_be_bytes();
        let payload = b"small-single-block-value";
        tree.put(&key, payload).expect("put");
        // Force rotation so the entry leaves the active write buffer (otherwise
        // step 1 short-circuits and step 2 never runs). Need enough writes to
        // push write_offset past max_file_size=8192.
        for i in 100u64..135 {
            tree.put(&i.to_be_bytes(), &[i as u8; 256])
                .expect("rotator");
        }

        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
        let disk = node.read_loc();
        drop(guard);

        // Sanity: this value fits in a single block (step 2 only runs when
        // start + len <= 4096).
        let start = (disk.offset & 4095) as usize;
        let len = disk.len as usize;
        assert!(
            start + len <= 4096,
            "test precondition: value must fit in a single block",
        );

        // Pre-warm the cache BEFORE acquiring the shard lock.
        // get_or_read_block → shard.read_block → sync::lock(&inner), so it
        // must not be called while the shard lock is already held.
        let block_offset = disk.offset as u64 & !4095;
        let shard_id = tree.shard_for(&key) as u8;
        let cache_key = BlockKey {
            shard_id,
            file_id: disk.file_id,
            block_offset,
        };
        let block = tree
            .get_or_read_block(shard_id, disk.file_id, block_offset)
            .expect("read block");
        tree.block_cache.insert(cache_key, block);
        assert!(
            tree.block_cache.get(&cache_key).is_some(),
            "cache must contain the block for step 2 to fire",
        );

        let shard = &tree.engine.shards()[shard_id as usize];
        let inner = shard.lock();
        assert_ne!(
            disk.file_id, inner.active.file_id,
            "test setup failed: key entry is still in the active write buffer",
        );

        let v = tree
            .read_value_locked_result(&disk, shard_id, &inner)
            .expect("cache-hit read must succeed");
        assert_eq!(v.as_bytes(), payload);
    }

    /// End-to-end roundtrip with file_id above u16::MAX.
    ///
    /// Uses write_buffer_size=128 KiB so a single 512-byte entry fits in one
    /// flush cycle; max_file_size=4096 forces per-entry rotation; cache
    /// enabled so the fast path is also exercised.  After bumping
    /// next_file_id to 70_000 and rotating, the active file gets an id that
    /// exceeds u16::MAX.  The entry is flushed + rotated to make it
    /// immutable, then `get` must return the correct bytes via the locked
    /// disk-read path.  Before the file_id u32 widening this would either
    /// panic or return wrong bytes because the high bits were truncated.
    #[test]
    fn var_tree_get_with_file_id_above_u16() {
        let dir = tempdir().unwrap();
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.block_cache.max_size = 1 << 20;
        let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open");

        let shard = &tree.engine.shards()[0];

        // Bump file id well past u16::MAX and rotate so the next active file
        // carries the new id.
        shard.set_next_file_id(70_000);
        shard.rotate_active_for_test(8).expect("first rotate");
        assert!(
            shard.active_file_id() >= 70_000,
            "active_file_id should be >= 70_000 after rotation"
        );

        let key = 42u64.to_be_bytes();
        let value = vec![0xC3u8; 512];
        tree.put(&key, &value).expect("put");

        // Confirm the DiskLoc has file_id > u16::MAX.
        {
            let guard = tree.index.collector().enter();
            let node = tree.index.get(key.as_bytes(), &guard).expect("indexed");
            let disk = node.read_loc();
            assert!(
                disk.file_id > u16::MAX as u32,
                "DiskLoc.file_id must be above u16::MAX, got {}",
                disk.file_id,
            );
        }

        // Flush the write buffer and rotate so the entry lands on an
        // immutable file — reads will go through Shard::read_block.
        shard.flush().expect("flush");
        shard.rotate_active_for_test(8).expect("second rotate");

        let got = tree.get(&key).expect("get must return Some");
        assert_eq!(got.as_bytes(), value.as_slice());
    }

    /// Recovery roundtrip with file_id above u16::MAX.
    ///
    /// Phase A: open, bump next_file_id past u16::MAX, rotate once (so the
    /// active file gets an id > 65535), write entries, close.  `close()` calls
    /// `sync_hints()` which flushes the write buffer and writes the hint file
    /// with the correct key length (8), then `engine.flush()` fsyncs.
    /// Phase B: reopen from the same tempdir, read every entry back, confirm
    /// at least one on-disk file_id still exceeds u16::MAX.
    ///
    /// Before the file_id u32 widening the high bits were truncated during
    /// hint-file serialization and recovery; this test pins that code path so
    /// a future regression cannot go undetected.
    #[test]
    fn recovery_handles_file_id_above_u16() {
        let dir = tempdir().unwrap();

        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.block_cache.max_size = 1 << 20;

        // --- Phase A: open, bump file_id past u16, write, close ---
        {
            let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg.clone()).expect("open A");
            let shard = &tree.engine.shards()[0];

            // Bump the id counter and rotate so the active file gets id 70_000.
            shard.set_next_file_id(70_000);
            shard
                .rotate_active_for_test(8)
                .expect("rotate to file_id 70_000");
            assert!(
                shard.active_file_id() >= 70_000,
                "active_file_id should be >= 70_000 after rotation"
            );

            for i in 0u64..4 {
                let key = i.to_be_bytes();
                let value = vec![i as u8; 200];
                tree.put(&key, &value).expect("put phase A");
            }

            tree.close().expect("close phase A");
        }

        // --- Phase B: reopen, read entries, assert wide file_id persists ---
        {
            let tree: VarTree<[u8; 8]> = VarTree::open(dir.path(), cfg).expect("open B");

            assert_eq!(tree.len(), 4, "all 4 entries must survive recovery");

            for i in 0u64..4 {
                let key = i.to_be_bytes();
                let expected = vec![i as u8; 200];
                let got = tree
                    .get(&key)
                    .unwrap_or_else(|| panic!("key {i} not found after recovery"));
                assert_eq!(
                    got.as_bytes(),
                    expected.as_slice(),
                    "value mismatch for key {i} after recovery"
                );
            }

            // Verify at least one on-disk file_id exceeded u16::MAX so that
            // the test actually exercises the wide-id path.
            let shard = &tree.engine.shards()[0];
            let max_fid = shard.file_ids().into_iter().max().expect("non-empty");
            assert!(
                max_fid > u16::MAX as u32,
                "max file_id should exceed u16::MAX after recovery (got {})",
                max_fid
            );
        }
    }

    #[test]
    fn warmup_skips_large_values_block_cache() {
        let dir = tempdir().unwrap();
        let mut cfg = Config::test();
        cfg.shard_count = 1;
        cfg.max_file_size = 128 * 1024;
        cfg.write_buffer_size = 128 * 1024;
        cfg.compaction_threshold = 0.0;
        cfg.block_cache.max_size = 1 << 20; // enable block cache so inserts are observable
        let tree = VarTree::<[u8; 8]>::open(dir.path(), cfg).unwrap();

        let key = 5u64.to_be_bytes();
        let payload = build_large_payload(0x42); // 50_000 bytes -> large
        tree.put(&key, &payload).unwrap();
        // Rotate to immutable so warmup can actually cache the block (active
        // files are never cached by warmup — is_full_block=false for them).
        tree.engine.shards()[0]
            .rotate_active_for_test(8)
            .expect("rotate");

        // Precondition: the value is large (spans > 2 blocks).
        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).unwrap();
        let disk = node.read_loc();
        assert!(
            disk.is_value_cache_routed(),
            "test precondition: value must be large"
        );
        drop(guard);

        tree.warmup().unwrap();

        // The large value's first block must NOT be in the block cache.
        let guard = tree.index.collector().enter();
        let node = tree.index.get(key.as_bytes(), &guard).unwrap();
        let disk = node.read_loc();
        let bkey = BlockKey {
            shard_id: 0,
            file_id: disk.file_id,
            block_offset: disk.offset as u64 & !4095,
        };
        assert!(
            tree.block_cache.get(&bkey).is_none(),
            "warmup must not cache a large value's first block"
        );
    }

    // ---------------------------------------------------------------------------
    // Tests — get_many / update_many (Task 5)
    // ---------------------------------------------------------------------------

    #[test]
    fn var_get_many_lossy_aligned() {
        let dir = tempdir().unwrap();
        let cfg = Config::test();
        let tree = VarTree::<[u8; 8]>::open(dir.path(), cfg).unwrap();
        tree.put(&1u64.to_be_bytes(), b"alpha").unwrap();
        tree.put(&2u64.to_be_bytes(), b"beta").unwrap();
        let keys: Vec<[u8; 8]> = vec![2u64.to_be_bytes(), 9u64.to_be_bytes(), 1u64.to_be_bytes()];
        let got = tree.get_many(&keys);
        assert_eq!(got.len(), 3);
        assert_eq!(got[0].as_deref(), Some(&b"beta"[..]));
        assert_eq!(got[1], None);
        assert_eq!(got[2].as_deref(), Some(&b"alpha"[..]));
    }

    #[test]
    fn var_get_many_large_value_cache_path() {
        let dir = tempdir().unwrap();
        let mut cfg = Config::test();
        // Enable the whole-value cache so this regression actually exercises the
        // cache-backed read path named by the test (Config::test() disables it).
        cfg.value_cache.max_size = 4 << 20;
        let tree = VarTree::<[u8; 8]>::open(dir.path(), cfg).unwrap();
        let big = vec![7u8; 16 * 1024]; // > 8 KiB -> value-cache route
        tree.put(&1u64.to_be_bytes(), &big).unwrap();
        // First read populates the value cache (miss), second read is a cache hit;
        // both must return the full value.
        let got1 = tree.get_many(&[1u64.to_be_bytes()]);
        assert_eq!(got1[0].as_deref(), Some(&big[..]));
        let got2 = tree.get_many(&[1u64.to_be_bytes()]);
        assert_eq!(got2[0].as_deref(), Some(&big[..]));
    }

    #[test]
    fn var_update_many_set_keep_delete() {
        use crate::{Applied, BatchWrite};
        let dir = tempdir().unwrap();
        let cfg = Config::test();
        let tree = VarTree::<[u8; 8]>::open(dir.path(), cfg).unwrap();
        tree.put(&1u64.to_be_bytes(), b"old").unwrap();
        tree.put(&2u64.to_be_bytes(), b"gone").unwrap();

        let items: Vec<([u8; 8], &[u8])> = vec![
            (1u64.to_be_bytes(), b"new"),
            (2u64.to_be_bytes(), b""),    // delete
            (3u64.to_be_bytes(), b"ins"), // insert
            (4u64.to_be_bytes(), b""),    // delete-miss
        ];
        let out = tree
            .update_many(items, |k, _cur, p| {
                let k2 = 2u64.to_be_bytes();
                let k4 = 4u64.to_be_bytes();
                if *k == k2 || *k == k4 {
                    BatchWrite::Delete
                } else {
                    BatchWrite::Set(ByteView::new(p))
                }
            })
            .unwrap();
        assert!(matches!(out[0].1, Applied::Written { .. }));
        assert!(matches!(out[1].1, Applied::Deleted(_)));
        assert!(matches!(out[2].1, Applied::Written { old: None, .. }));
        assert_eq!(out[3].1, Applied::Kept);

        assert_eq!(tree.get(&1u64.to_be_bytes()).as_deref(), Some(&b"new"[..]));
        assert!(tree.get(&2u64.to_be_bytes()).is_none());
        assert_eq!(tree.get(&3u64.to_be_bytes()).as_deref(), Some(&b"ins"[..]));
    }

    #[test]
    fn var_tree_shard_update_and_fetch_update() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, true>(dir.path(), CountingHook::default());
        let key = 1u64.to_be_bytes();
        tree.put(&key, b"a").unwrap();

        let (upd, fetched, missing) = tree
            .atomic(&key, |shard| {
                let upd = shard.update(&key, |old| {
                    let mut v = old.to_vec();
                    v.push(b'b');
                    ByteView::from(v.as_slice())
                })?;
                let fetched = shard.fetch_update(&key, |old| {
                    let mut v = old.to_vec();
                    v.push(b'c');
                    ByteView::from(v.as_slice())
                })?;
                let missing = shard.update(&2u64.to_be_bytes(), |old| ByteView::from(old))?;
                Ok((upd, fetched, missing))
            })
            .expect("atomic");

        assert_eq!(upd.as_deref(), Some(&b"ab"[..])); // new value
        assert_eq!(fetched.as_deref(), Some(&b"ab"[..])); // old value before +c
        assert!(missing.is_none());
        assert_eq!(tree.get(&key).as_deref(), Some(&b"abc"[..]));
    }

    #[test]
    fn var_tree_atomic_update_fires_hook() {
        let dir = tempdir().unwrap();
        let tree = open_test_tree_hooked::<true, true>(dir.path(), CountingHook::default());
        let key = 1u64.to_be_bytes();

        tree.atomic(&key, |shard| {
            shard.put(&key, b"init")?; // write 1: new insert, no old value
            shard.update(&key, |old| {
                let mut v = old.to_vec();
                v.push(b'!');
                ByteView::from(v.as_slice())
            })?; // write 2: update, old = b"init"
            Ok(())
        })
        .expect("atomic");

        // Hook fires for both put and update after the shard lock is released.
        assert_eq!(
            tree.hook.writes.load(AtomicOrdering::Relaxed),
            2,
            "hook must fire once for put and once for update"
        );
        // The update saw the old value (NEEDS_OLD_VALUE=true) → writes_with_old incremented.
        assert_eq!(
            tree.hook.writes_with_old.load(AtomicOrdering::Relaxed),
            1,
            "only the update event carries an old value"
        );
        // Final value is b"init!"
        assert_eq!(tree.get(&key).as_deref(), Some(&b"init!"[..]));
    }
}
