use std::path::Path;

use super::{
    WriteBatch, batch::BatchOp, engine::StorageEngine, error::StorageError,
    iterator::PrefixIterator,
};
use crate::schema::EntityKind;

/// A storage backend wrapping a single fjall keyspace.
///
/// Implements `StorageEngine` by delegating to fjall's `insert`, `get`,
/// `remove`, and `prefix` operations.
///
/// Also holds a handle to the owning `fjall::Database` so that
/// [`write_batch`](FjallBackend::write_batch) can route through fjall's
/// atomic batch (a single journal record committed under one seqno) and
/// [`flush`](FjallBackend::flush) can `fsync` the journal to stable storage.
///
/// `Clone` is cheap: both `fjall::Keyspace` and `fjall::Database` are
/// `Arc`-backed and reference-counted internally.
#[derive(Clone)]
pub struct FjallBackend {
    keyspace: fjall::Keyspace,
    db: fjall::Database,
}

impl FjallBackend {
    /// Wrap an existing fjall keyspace together with its owning database.
    ///
    /// The `db` handle is required so per-keyspace batches commit atomically
    /// (one journal record) and so `flush` can persist with `fsync`.
    pub(crate) const fn new(keyspace: fjall::Keyspace, db: fjall::Database) -> Self {
        Self { keyspace, db }
    }

    /// Access the underlying fjall keyspace.
    ///
    /// Used by `FjallAtomicBatch` for cross-keyspace operations.
    #[must_use]
    pub const fn keyspace(&self) -> &fjall::Keyspace {
        &self.keyspace
    }

    /// Rotate this keyspace's active memtable to disk **without** an fsync.
    ///
    /// This is the rotation half of [`flush`](Self::flush). It moves the active
    /// memtable to a flushed segment but does *not* persist the shared journal,
    /// so it is **not** a durability barrier on its own. It exists so a
    /// multi-keyspace flush (see [`FjallStorage::flush_all`]) can rotate every
    /// keyspace and then issue a *single* `db.persist(SyncAll)` fsync covering
    /// all of them, rather than fsyncing the shared database once per keyspace.
    ///
    /// # Errors
    ///
    /// Returns `StorageError` if the rotation fails.
    pub(crate) fn rotate_only(&self) -> Result<(), StorageError> {
        self.keyspace
            .rotate_memtable_and_wait()
            .map_err(map_fjall_err)
    }
}

impl std::fmt::Debug for FjallBackend {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("FjallBackend").finish_non_exhaustive()
    }
}

/// Map a fjall error to our `StorageError`, preserving its operational class.
///
/// Taking the error **by value** lets a transport-level `io::Error` move into
/// [`StorageError::Io`] intact (with its `ErrorKind` and source chain) instead
/// of being stringified into a corruption message. The routing distinguishes
/// three classes so a 3am operator sees the right signal:
///
/// - **I/O** (`Io`, and the `lsm_tree::Error::Io` it can nest): a disk-full
///   `ENOSPC`, a permission error, a torn read at the syscall boundary. These
///   are transient or environmental, not on-disk corruption. → [`StorageError::Io`].
/// - **Transient / unavailable** (`Poisoned`, `Locked`, `KeyspaceDeleted`):
///   the keyspace cannot service the request right now — a poisoned writer, a
///   held database lock, or a deleted keyspace. The engine is effectively
///   closed to this request. `StorageError` has no dedicated transient variant,
///   so the closest existing semantic is [`StorageError::Closed`].
/// - **Corruption** (`Decompress`, `InvalidTrailer`, `InvalidTag`,
///   `InvalidVersion`, `Unrecoverable`, `JournalRecovery`, and any non-I/O
///   `Storage` failure): a checksum/trailer/tag/version decode failure — bytes
///   on disk that no longer parse. → [`StorageError::Corruption`].
///
/// `fjall::Error` is `#[non_exhaustive]`; the wildcard arm conservatively
/// classifies any future variant as corruption (the safest default: it halts
/// rather than masks a data-integrity problem as transient).
fn map_fjall_err(e: fjall::Error) -> StorageError {
    match e {
        // Genuine syscall-level I/O — move the io::Error so its kind/source survive.
        fjall::Error::Io(io) => StorageError::Io(io),
        // The LSM layer nests its own I/O; route that to Io too, otherwise the
        // LSM error is a decode/checksum failure -> corruption.
        fjall::Error::Storage(lsm) => match lsm {
            fjall::LsmError::Io(io) => StorageError::Io(io),
            other => StorageError::Corruption {
                message: format!("lsm-tree error: {other:?}"),
            },
        },
        // Transient / unavailable: poisoned writer, held lock, deleted keyspace.
        // No dedicated transient variant exists; Closed is the closest semantic.
        fjall::Error::Poisoned | fjall::Error::Locked | fjall::Error::KeyspaceDeleted => {
            StorageError::Closed
        }
        // Decode/trailer/tag/version family + unrecoverable + journal recovery:
        // bytes on disk no longer parse -> corruption. `fjall::Error` is
        // non_exhaustive, so this wildcard also classifies any unknown future
        // variant as corruption — the safest default: halt on integrity
        // ambiguity rather than mask a data problem as transient.
        other => StorageError::Corruption {
            message: other.to_string(),
        },
    }
}

impl StorageEngine for FjallBackend {
    // CODING_GUIDELINES §11.2 names these the non-negotiable storage stage
    // boundaries to instrument. The spans skip key/value bytes (only their
    // lengths are recorded) so a 3am operator can attribute latency to a
    // specific storage op — a hot `get`, a full-keyspace `scan_prefix`, the
    // atomic-commit barrier, or the fsync — without logging payloads.
    #[tracing::instrument(level = "trace", skip(self), fields(key_len = key.len()))]
    fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StorageError> {
        Ok(self
            .keyspace
            .get(key)
            .map_err(map_fjall_err)?
            .map(|value| value.to_vec()))
    }

    #[tracing::instrument(
        level = "trace",
        skip(self, value),
        fields(key_len = key.len(), value_len = value.len())
    )]
    fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StorageError> {
        self.keyspace.insert(key, value).map_err(map_fjall_err)
    }

    #[tracing::instrument(level = "trace", skip(self), fields(key_len = key.len()))]
    fn delete(&self, key: &[u8]) -> Result<(), StorageError> {
        self.keyspace.remove(key).map_err(map_fjall_err)
    }

    #[tracing::instrument(level = "debug", skip(self), fields(prefix_len = prefix.len()))]
    fn scan_prefix(&self, prefix: &[u8]) -> PrefixIterator<'_> {
        // Stream lazily — do NOT collect the keyspace into RAM. `fjall::Iter`
        // is `'static`: it owns the snapshot `SnapshotNonce` internally (which
        // keeps the GC watermark pinned so the data stays readable) and its
        // inner iterator is `Box<dyn ... + Send + 'static>`, so it borrows
        // nothing from `self.keyspace`. We can therefore hand back a boxed
        // adapter that maps each `Guard` to an owned `(Vec, Vec)` on demand.
        // This matters for `scan_prefix(&[])` (full-keyspace enumeration on
        // restart), which the old eager `.collect()` loaded entirely into RAM
        // at once. `Guard::into_inner` consumes the guard and returns owned key
        // and value bytes, so no fjall-internal buffer is held across `next()`.
        let iter = self.keyspace.prefix(prefix).map(|guard| {
            let (k, v) = guard.into_inner().map_err(map_fjall_err)?;
            Ok((k.to_vec(), v.to_vec()))
        });
        Box::new(iter)
    }

    #[tracing::instrument(level = "debug", skip(self, batch), fields(ops = batch.len()))]
    fn write_batch(&self, batch: WriteBatch) -> Result<(), StorageError> {
        // Atomicity: the `StorageEngine::write_batch` contract requires
        // all-or-nothing semantics. Looping over `keyspace.insert`/`remove`
        // would frame each op as its own journal record (each gets its own
        // seqno + checksum envelope in fjall 3.x), so a crash or mid-loop
        // I/O error durably recovers a *prefix* of the batch -- a torn write.
        // Checkpoint persistence (signals / cohort / community) builds one
        // batch of all entry rows PLUS the BLAKE3 meta-key and relies on this
        // guarantee; a partial commit yields a hash that no longer matches the
        // rows. Route every op through a single `OwnedWriteBatch`, which commits
        // all items under one batch seqno inside one checksum envelope, so
        // recovery sees the whole batch or none of it.
        let mut wb = self.db.batch();
        for op in &batch.ops {
            match op {
                BatchOp::Put { key, value } => {
                    wb.insert(&self.keyspace, key.as_slice(), value.as_slice());
                }
                BatchOp::Delete { key } => {
                    wb.remove(&self.keyspace, key.as_slice());
                }
            }
            // Fault-injection hook: a crash here (before `commit`) must leave
            // the durable state untouched, proving all-or-nothing on the fjall
            // backend. Compiled out entirely outside tests.
            #[cfg(test)]
            fault::check_write_batch_crash();
        }
        wb.commit().map_err(map_fjall_err)
    }

    #[tracing::instrument(level = "debug", skip(self))]
    fn flush(&self) -> Result<(), StorageError> {
        // `StorageEngine::flush` documents "force all buffered data to stable
        // storage", which means fsync -- not merely rotating the memtable.
        // `rotate_only` moves the active memtable to disk but does not fsync the
        // journal, so a power-loss event could still lose recently committed
        // writes. Pair it with `persist(SyncAll)`, which `fsync`s data +
        // metadata. This is the single-keyspace durability primitive; the
        // multi-keyspace `FjallStorage::flush_all` amortizes the fsync across
        // all three keyspaces instead of paying one per keyspace.
        self.rotate_only()?;
        self.db
            .persist(fjall::PersistMode::SyncAll)
            .map_err(map_fjall_err)
    }
}

/// Test-only fault injection for `write_batch` atomicity verification.
///
/// Mirrors the `crate::testing::crash_injector` thread-local pattern but is
/// scoped to this module so the crash boundary (mid-batch, before `commit`)
/// lives exactly where the durability invariant must hold. Compiled out in
/// non-test builds.
#[cfg(test)]
mod fault {
    use std::cell::Cell;

    thread_local! {
        /// When `> 0`, the next `check_write_batch_crash` after this many
        /// crossings panics, simulating a process crash mid-batch.
        static CRASH_AFTER: Cell<Option<u64>> = const { Cell::new(None) };
        static CROSSINGS: Cell<u64> = const { Cell::new(0) };
    }

    /// Arm a one-shot mid-batch crash on this thread after `after_n` crossings.
    pub fn arm_write_batch_crash(after_n: u64) {
        CROSSINGS.with(|c| c.set(0));
        CRASH_AFTER.with(|c| c.set(Some(after_n)));
    }

    /// Disarm any installed mid-batch crash on this thread.
    pub fn clear_write_batch_crash() {
        CRASH_AFTER.with(|c| c.set(None));
        CROSSINGS.with(|c| c.set(0));
    }

    /// Crash hook called once per batch op, before `commit`.
    pub fn check_write_batch_crash() {
        let Some(after_n) = CRASH_AFTER.with(Cell::get) else {
            return;
        };
        let count = CROSSINGS.with(|c| {
            let n = c.get();
            c.set(n + 1);
            n
        });
        if count >= after_n {
            // One-shot: disarm so the unwind does not re-fire.
            clear_write_batch_crash();
            panic!("simulated crash mid write_batch after {count} ops");
        }
    }
}

/// Owns a fjall `Database` and provides three `FjallBackend` instances
/// for the three entity kinds (items, users, creators).
pub struct FjallStorage {
    db: fjall::Database,
    items: FjallBackend,
    users: FjallBackend,
    creators: FjallBackend,
}

impl FjallStorage {
    /// Open (or create) a `FjallStorage` at the given path.
    ///
    /// Creates three keyspaces: "items", "users", "creators".
    ///
    /// # Errors
    ///
    /// Returns `StorageError` if the underlying fjall database cannot be opened.
    pub fn open(path: impl AsRef<Path>) -> Result<Self, StorageError> {
        // Route through `map_fjall_err` so an open failure keeps its operational
        // class: a permission-denied or disk-full (`Io`) or a held/poisoned lock
        // (`Closed`) is NOT stringified into a corruption message. Only a genuine
        // decode/checksum/trailer failure is classified as `Corruption` — the
        // exact mis-classification `map_fjall_err` exists to prevent. (The lost
        // path context is acceptable: the io::Error carries the OS error, and the
        // open span/caller already names which database failed.)
        let db = fjall::Database::builder(path)
            .open()
            .map_err(map_fjall_err)?;

        let items = Self::open_keyspace(&db, "items")?;
        let users = Self::open_keyspace(&db, "users")?;
        let creators = Self::open_keyspace(&db, "creators")?;

        Ok(Self {
            db,
            items,
            users,
            creators,
        })
    }

    /// Open (or create) one named keyspace and wrap it in a `FjallBackend`.
    ///
    /// Extracted from [`open`](Self::open) so the three entity keyspaces share a
    /// single open path with identical create-options, rather than three verbatim
    /// copies that could drift. A keyspace-open failure is routed through
    /// [`map_fjall_err`] so its operational class (I/O / closed / corruption) is
    /// preserved exactly as on the get/put path, not collapsed into a corruption
    /// message regardless of cause.
    fn open_keyspace(db: &fjall::Database, name: &str) -> Result<FjallBackend, StorageError> {
        let keyspace = db
            .keyspace(name, fjall::KeyspaceCreateOptions::default)
            .map_err(map_fjall_err)?;
        Ok(FjallBackend::new(keyspace, db.clone()))
    }

    /// Get the backend for a specific entity kind.
    #[must_use]
    pub const fn backend(&self, kind: EntityKind) -> &FjallBackend {
        match kind {
            EntityKind::Item => &self.items,
            EntityKind::User => &self.users,
            EntityKind::Creator => &self.creators,
        }
    }

    /// Flush all three keyspaces and persist the database to stable storage.
    ///
    /// The three `FjallBackend`s share one `fjall::Database` and therefore one
    /// journal. `db.persist(SyncAll)` is a full fsync of *that whole shared
    /// database*, so calling per-keyspace [`flush`](FjallBackend::flush) three
    /// times — each of which both rotates its memtable and issues its own
    /// `SyncAll` — would fsync the same database three times (and a fourth
    /// explicit persist made it four). Instead, rotate all three memtables with
    /// the non-fsyncing [`rotate_only`](FjallBackend::rotate_only) primitive,
    /// then issue a *single* `SyncAll` barrier covering all of them (3 rotations
    /// plus 1 fsync). Durability is identical — the lone fsync persists every
    /// rotated keyspace's journal records to stable storage.
    ///
    /// # Errors
    ///
    /// Returns `StorageError` if any rotation or the final persist fails.
    pub fn flush_all(&self) -> Result<(), StorageError> {
        self.items.rotate_only()?;
        self.users.rotate_only()?;
        self.creators.rotate_only()?;
        self.db
            .persist(fjall::PersistMode::SyncAll)
            .map_err(map_fjall_err)
    }

    /// Access the underlying fjall database.
    ///
    /// Used by `FjallAtomicBatch` for cross-keyspace atomic writes.
    #[must_use]
    pub const fn db(&self) -> &fjall::Database {
        &self.db
    }
}

impl std::fmt::Debug for FjallStorage {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("FjallStorage").finish_non_exhaustive()
    }
}

/// Cross-keyspace atomic batch for fjall.
///
/// Wraps `fjall::OwnedWriteBatch` to provide atomic writes across multiple
/// keyspaces (entity kinds). This is a fjall-specific API not
/// exposed through the `StorageEngine` trait.
///
/// # Status: capability, not yet on a hot path
///
/// As of M10 nothing in the engine writes across more than one entity keyspace
/// in a single atomic step — every checkpoint and entity write is scoped to one
/// `FjallBackend` and goes through [`StorageEngine::write_batch`]. This type
/// therefore has **no production caller**; its only consumers are the storage UAT
/// integration tests, which exercise the cross-keyspace path so the guarantee is
/// regression-covered for the day a writer needs it (e.g. an item write that must
/// land atomically with its creator-side index row). It is deliberately *not*
/// abstracted behind a backend-agnostic trait: cross-keyspace atomicity is a
/// fjall-specific affordance (`InMemoryBackend` has no notion of keyspaces), so a
/// trait would imply a portability that does not exist. Promote it to a trait only
/// once a second backend needs the same multi-keyspace barrier.
pub struct FjallAtomicBatch {
    batch: fjall::OwnedWriteBatch,
}

impl FjallAtomicBatch {
    /// Create a new atomic batch from a `FjallStorage`.
    #[must_use]
    pub fn new(storage: &FjallStorage) -> Self {
        Self {
            batch: storage.db().batch(),
        }
    }

    /// Add a put operation for a specific keyspace.
    pub fn put(&mut self, backend: &FjallBackend, key: &[u8], value: &[u8]) {
        self.batch.insert(backend.keyspace(), key, value);
    }

    /// Add a remove operation for a specific keyspace.
    pub fn remove(&mut self, backend: &FjallBackend, key: &[u8]) {
        self.batch.remove(backend.keyspace(), key);
    }

    /// Commit the batch atomically.
    ///
    /// # Errors
    ///
    /// Returns `StorageError` if the commit fails.
    pub fn commit(self) -> Result<(), StorageError> {
        self.batch.commit().map_err(map_fjall_err)
    }
}

impl std::fmt::Debug for FjallAtomicBatch {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("FjallAtomicBatch").finish_non_exhaustive()
    }
}

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

    fn temp_storage() -> (tempfile::TempDir, FjallStorage) {
        let dir = tempfile::tempdir().expect("create temp dir");
        let storage = FjallStorage::open(dir.path()).expect("open storage");
        (dir, storage)
    }

    #[test]
    fn open_and_write() {
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        items.put(b"key1", b"value1").unwrap();
        let val = items.get(b"key1").unwrap();
        assert_eq!(val.as_deref(), Some(b"value1".as_slice()));
    }

    #[test]
    fn delete_key() {
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        items.put(b"key1", b"value1").unwrap();
        items.delete(b"key1").unwrap();
        assert_eq!(items.get(b"key1").unwrap(), None);
    }

    #[test]
    fn scan_prefix() {
        let (_dir, storage) = temp_storage();
        let users = storage.backend(EntityKind::User);
        users.put(b"pre_a", b"1").unwrap();
        users.put(b"pre_b", b"2").unwrap();
        users.put(b"other", b"3").unwrap();

        let results: Vec<_> = users
            .scan_prefix(b"pre_")
            .collect::<Result<Vec<_>, _>>()
            .unwrap();

        assert_eq!(results.len(), 2);
        assert!(results.iter().all(|(k, _)| k.starts_with(b"pre_")));
    }

    #[test]
    fn scan_empty_prefix_enumerates_all() {
        // scan_prefix(&[]) is the full-keyspace enumeration path used on
        // restart. It must return every key, and (post-fix) it streams rather
        // than materializing the whole keyspace up front.
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        items.put(b"a", b"1").unwrap();
        items.put(b"b", b"2").unwrap();
        items.put(b"c", b"3").unwrap();

        let all: Vec<_> = items
            .scan_prefix(b"")
            .collect::<Result<Vec<_>, _>>()
            .unwrap();
        assert_eq!(all.len(), 3);
        let keys: Vec<_> = all.iter().map(|(k, _)| k.clone()).collect();
        assert_eq!(keys, vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]);
    }

    #[test]
    fn scan_prefix_propagates_mid_stream_error_via_collect() {
        // The streaming adapter maps each fjall guard through
        // `into_inner().map_err(map_fjall_err)?`, so a torn read / decompress
        // failure surfaces as an `Err(StorageError)` ITEM partway through
        // iteration rather than a panic. We cannot inject a torn read into fjall
        // in a fast unit test, so we pin the contract every caller relies on with
        // a synthetic `PrefixIterator` of the exact same shape: one good entry,
        // then an injected mid-stream Err, then more.
        let iter: super::PrefixIterator<'static> = Box::new(
            [
                Ok((b"a".to_vec(), b"1".to_vec())),
                Err(StorageError::Corruption {
                    message: "torn read mid-stream".into(),
                }),
                Ok((b"b".to_vec(), b"2".to_vec())),
            ]
            .into_iter(),
        );

        // `collect::<Result<Vec>>` (what scan_prefix tests and the careful
        // callers use) MUST surface the Err and short-circuit — it must not be
        // silently dropped.
        let collected: Result<Vec<_>, _> = iter.collect();
        assert!(
            matches!(collected, Err(StorageError::Corruption { .. })),
            "mid-stream Err must propagate through collect::<Result<Vec>>"
        );
    }

    #[test]
    fn scan_prefix_flatten_silently_swallows_error() {
        // Counterpart hazard documented in the review: `.flatten()` over the same
        // `Result`-yielding adapter DROPS an Err item. This test exists so the
        // swallowing behavior is explicit and a future caller cannot
        // accidentally rely on `.flatten()` for a path where the Err matters.
        let iter: super::PrefixIterator<'static> = Box::new(
            [
                Ok((b"a".to_vec(), b"1".to_vec())),
                Err(StorageError::Corruption {
                    message: "torn read mid-stream".into(),
                }),
                Ok((b"b".to_vec(), b"2".to_vec())),
            ]
            .into_iter(),
        );
        // `.flatten()` yields only the Ok payloads; the Err vanishes.
        let kept: Vec<_> = iter.flatten().collect();
        assert_eq!(kept.len(), 2, ".flatten() drops the mid-stream Err item");
    }

    #[test]
    fn scan_prefix_is_lazy() {
        // The streaming adapter must yield items on demand: taking only the
        // first element of a multi-key scan must produce exactly one result
        // without forcing the rest. (Correctness proxy for laziness; the
        // snapshot guard is owned by the iterator, so partial consumption is
        // sound.)
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        for i in 0u8..100 {
            items.put(&[b'k', i], b"v").unwrap();
        }
        let first = items.scan_prefix(b"k").next();
        let (k, _) = first.expect("at least one entry").unwrap();
        assert_eq!(k, vec![b'k', 0]);
    }

    #[test]
    fn entity_kind_isolation() {
        let (_dir, storage) = temp_storage();
        storage
            .backend(EntityKind::Item)
            .put(b"key", b"item")
            .unwrap();
        storage
            .backend(EntityKind::User)
            .put(b"key", b"user")
            .unwrap();

        assert_eq!(
            storage
                .backend(EntityKind::Item)
                .get(b"key")
                .unwrap()
                .as_deref(),
            Some(b"item".as_slice())
        );
        assert_eq!(
            storage
                .backend(EntityKind::User)
                .get(b"key")
                .unwrap()
                .as_deref(),
            Some(b"user".as_slice())
        );
        assert_eq!(
            storage.backend(EntityKind::Creator).get(b"key").unwrap(),
            None
        );
    }

    #[test]
    fn persistence_across_reopen() {
        let dir = tempfile::tempdir().expect("create temp dir");

        // Write data and flush
        {
            let storage = FjallStorage::open(dir.path()).unwrap();
            storage
                .backend(EntityKind::Item)
                .put(b"persistent", b"data")
                .unwrap();
            storage.flush_all().unwrap();
        }

        // Reopen and verify data survives
        {
            let storage = FjallStorage::open(dir.path()).unwrap();
            let val = storage
                .backend(EntityKind::Item)
                .get(b"persistent")
                .unwrap();
            assert_eq!(val.as_deref(), Some(b"data".as_slice()));
        }
    }

    #[test]
    fn flush_all_succeeds() {
        let (_dir, storage) = temp_storage();
        storage.backend(EntityKind::Item).put(b"k", b"v").unwrap();
        storage.flush_all().unwrap();
    }

    #[test]
    fn write_batch() {
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);

        items.put(b"existing", b"old").unwrap();

        let mut batch = WriteBatch::new();
        batch.put(b"new1".to_vec(), b"val1".to_vec());
        batch.put(b"new2".to_vec(), b"val2".to_vec());
        batch.delete(b"existing".to_vec());

        items.write_batch(batch).unwrap();

        assert_eq!(
            items.get(b"new1").unwrap().as_deref(),
            Some(b"val1".as_slice())
        );
        assert_eq!(
            items.get(b"new2").unwrap().as_deref(),
            Some(b"val2".as_slice())
        );
        assert_eq!(items.get(b"existing").unwrap(), None);
    }

    #[test]
    fn write_batch_is_all_or_nothing_on_crash() {
        // A crash mid-batch (before commit) must leave durable state untouched,
        // mirroring InMemoryBackend's atomicity guarantee. We arm a one-shot
        // crash after the first op is staged, run write_batch under
        // catch_unwind, then assert NONE of the batch landed.
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        items.put(b"seed", b"seed_val").unwrap();

        let mut batch = WriteBatch::new();
        batch.put(b"a".to_vec(), b"1".to_vec());
        batch.put(b"b".to_vec(), b"2".to_vec());
        batch.delete(b"seed".to_vec());

        // Crash after staging the first op into the OwnedWriteBatch.
        fault::arm_write_batch_crash(1);
        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            // Returns Result, but the crash panics before commit; ignore output.
            let _ = items.write_batch(batch);
        }));
        fault::clear_write_batch_crash();
        assert!(result.is_err(), "crash hook should have fired mid-batch");

        // All-or-nothing: neither put landed and the delete did not apply.
        assert_eq!(items.get(b"a").unwrap(), None, "partial put 'a' leaked");
        assert_eq!(items.get(b"b").unwrap(), None, "partial put 'b' leaked");
        assert_eq!(
            items.get(b"seed").unwrap().as_deref(),
            Some(b"seed_val".as_slice()),
            "delete of 'seed' must not have applied on crash"
        );
    }

    #[test]
    fn write_batch_commits_all_when_no_crash() {
        // Companion to the crash test: with no fault armed, the full batch
        // commits atomically.
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        items.put(b"seed", b"seed_val").unwrap();

        let mut batch = WriteBatch::new();
        batch.put(b"a".to_vec(), b"1".to_vec());
        batch.put(b"b".to_vec(), b"2".to_vec());
        batch.delete(b"seed".to_vec());

        items.write_batch(batch).unwrap();

        assert_eq!(items.get(b"a").unwrap().as_deref(), Some(b"1".as_slice()));
        assert_eq!(items.get(b"b").unwrap().as_deref(), Some(b"2".as_slice()));
        assert_eq!(items.get(b"seed").unwrap(), None);
    }

    #[test]
    fn atomic_batch_cross_keyspace() {
        let (_dir, storage) = temp_storage();
        let mut batch = FjallAtomicBatch::new(&storage);

        batch.put(storage.backend(EntityKind::Item), b"item_key", b"item_val");
        batch.put(storage.backend(EntityKind::User), b"user_key", b"user_val");

        batch.commit().unwrap();

        assert_eq!(
            storage
                .backend(EntityKind::Item)
                .get(b"item_key")
                .unwrap()
                .as_deref(),
            Some(b"item_val".as_slice())
        );
        assert_eq!(
            storage
                .backend(EntityKind::User)
                .get(b"user_key")
                .unwrap()
                .as_deref(),
            Some(b"user_val".as_slice())
        );
    }

    #[test]
    fn overwrite_value() {
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        items.put(b"key", b"v1").unwrap();
        items.put(b"key", b"v2").unwrap();
        assert_eq!(
            items.get(b"key").unwrap().as_deref(),
            Some(b"v2".as_slice())
        );
    }

    #[test]
    fn delete_nonexistent_is_ok() {
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        items.delete(b"nonexistent").unwrap();
    }

    #[test]
    fn get_missing_returns_none() {
        let (_dir, storage) = temp_storage();
        let items = storage.backend(EntityKind::Item);
        assert_eq!(items.get(b"missing").unwrap(), None);
    }
}
