//! Filesystem abstraction for the read-side `SqPack` API.
//!
//! Every path-taking entry point in this crate — [`crate::DatFile::open`],
//! [`crate::Index::open`], [`crate::Index2::open`], [`crate::SqPack::open`]
//! — ultimately resolves to one of three [`Vfs`] operations: open a file
//! for reading, check whether a path exists, or stat a path's metadata.
//! Wrapping those three operations behind a trait lets the test suite (and
//! downstream consumers like the `gaveloc` launcher) supply a mock
//! filesystem without touching disk.
//!
//! Two concrete impls ship with the crate; a third is opt-in:
//!
//! - [`StdFs`] — wraps `std::fs`. The default used by [`crate::SqPack::open`].
//! - [`InMemoryFs`] — pure in-memory backing. The full hand-built `.index` /
//!   `.dat` fixtures used by the test suite can be loaded into an
//!   `InMemoryFs` and parsed without ever writing to a real tempdir.
//! - [`crate::MmapFs`] (feature `mmap`) — mmap-backed read-only [`Vfs`].
//!   One mapping per file, cached behind an `Arc<Mmap>`; random reads
//!   become memcpys from the page cache rather than `read(2)` /
//!   `lseek(2)` syscalls. Default-off; see the [`crate::mmap_fs`]
//!   module-level rustdoc for the safety contract.
//!
//! The trait surface for reading is intentionally minimal — every method
//! corresponds directly to a call site. Mutation lives behind a separate
//! [`VfsWrite`] trait so a read-only consumer does not pay for the extra
//! surface area; both concrete impls implement both traits. The companion
//! `zipatch-rs` crate carries its own writable sister trait for the apply
//! layer.
//!
//! # Handle erasure
//!
//! [`Vfs::open_read`] returns a boxed trait object so that types holding
//! file handles ([`crate::DatFile`]) can stay non-generic. The read hot
//! path is dominated by DEFLATE decompression; the virtual-call overhead
//! of `Box<dyn VfsRead>` is in the noise.
//!
//! # Thread safety
//!
//! [`Vfs`] requires `Send + Sync`, and [`crate::SqPack`] stores an
//! `Arc<dyn Vfs>` internally so a single configured handle can be shared
//! across threads. [`StdFs`] is zero-sized and trivially `Send + Sync`;
//! [`InMemoryFs`] uses an `Arc<Mutex<_>>` interior — clones share storage
//! and the lock is held only for the duration of a single syscall-shaped
//! operation.

use std::collections::BTreeMap;
use std::io::{self, Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

/// Snapshot of a path's metadata. Only the fields the read layer reads.
#[derive(Debug, Clone, Copy)]
pub struct VfsMetadata {
    /// File length in bytes.
    pub len: u64,
    /// `true` if the entry is a regular file.
    pub is_file: bool,
}

/// Readable, seekable handle returned by [`Vfs::open_read`].
///
/// Auto-implemented for every `Read + Seek + Send` type — the trait is a
/// shorthand the apply-style read path can name as `Box<dyn VfsRead>`.
pub trait VfsRead: Read + Seek + Send {}

impl<T: Read + Seek + Send + ?Sized> VfsRead for T {}

/// Writable, seekable handle returned by [`VfsWrite::open_write`].
///
/// Adds an explicit [`VfsWriteHandle::sync_all`] entry point on top of the
/// `Write + Seek` super-traits so the append-only writer can flush a file's
/// bytes to durable storage before renaming an updated index over its
/// predecessor. The handle is returned as a `Box<dyn VfsWriteHandle>` to
/// stay object-safe for the trait-object [`VfsWrite::open_write`] return.
pub trait VfsWriteHandle: Write + Seek + Send {
    /// Flush every byte written so far to durable storage (or to the
    /// backend's equivalent persistence boundary, for in-memory backends).
    ///
    /// On [`StdFs`] this resolves to `std::fs::File::sync_all`. On
    /// [`InMemoryFs`] it is a no-op — there is no persistence layer below
    /// the `BTreeMap`, but the method is offered so a single sequence of
    /// `open_write`/`write`/`sync_all`/`rename` calls runs uniformly across
    /// both backends.
    ///
    /// # Errors
    ///
    /// Returns the same `io::Error` shapes the underlying syscall produces.
    fn sync_all(&mut self) -> io::Result<()>;
}

/// Read-only filesystem abstraction backing [`crate::SqPack`].
///
/// Implementations must be safe to share across threads (`Send + Sync`) so
/// a configured [`crate::SqPack`] handle can be cloned and moved to a
/// worker thread.
pub trait Vfs: Send + Sync {
    /// Open `path` for reading.
    ///
    /// # Errors
    ///
    /// Returns the same `io::Error` shapes `std::fs::File::open` produces
    /// against the underlying backing (e.g. `NotFound`, `PermissionDenied`).
    fn open_read(&self, path: &Path) -> io::Result<Box<dyn VfsRead>>;

    /// Return `true` if any entry exists at `path`.
    fn exists(&self, path: &Path) -> bool;

    /// Return metadata for `path`. Returns `NotFound` if the path does not exist.
    ///
    /// # Errors
    ///
    /// Returns `io::Error` if the path cannot be stat'd (missing, permission,
    /// I/O failure).
    fn metadata(&self, path: &Path) -> io::Result<VfsMetadata>;

    /// Return entries directly inside `path`. Order is implementation-defined.
    ///
    /// Currently unused inside this crate — exposed for downstream consumers
    /// (e.g. the launcher's integrity-scan code) that compose a `Vfs`-backed
    /// scan against the same backend `sqpack-rs` opens with.
    ///
    /// # Errors
    ///
    /// Returns `io::Error` if `path` is not a readable directory.
    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
}

/// Writable companion to [`Vfs`]. Used by the append-only writer in
/// [`crate::write`] to mutate `.dat` files and atomically swap rewritten
/// `.index` / `.index2` files into place.
///
/// Kept separate from [`Vfs`] so a read-only consumer never has to think
/// about writes: every type bound that names [`Vfs`] (e.g.
/// [`crate::SqPack`]) continues to type-check with a backend that
/// implements only the read trait. Both concrete impls shipped with the
/// crate ([`StdFs`] / [`InMemoryFs`]) implement both traits, so the same
/// handle can drive both pipelines.
///
/// # Atomicity
///
/// The trait deliberately does not surface a "transactional swap" entry
/// point — the writer builds atomicity by writing fresh bytes to a
/// sibling tmpfile, calling [`VfsWriteHandle::sync_all`] on that handle,
/// and then renaming over the target via [`VfsWrite::rename`]. The
/// [`VfsWrite::rename`] contract is therefore strict: it must be atomic
/// at the backend's granularity (POSIX-style `rename(2)` for [`StdFs`];
/// a single-shot map mutation under the inner mutex for [`InMemoryFs`]).
///
/// Implementations must be safe to share across threads (`Send + Sync`).
pub trait VfsWrite: Send + Sync {
    /// Open `path` for writing, truncating any existing contents.
    ///
    /// The returned handle is positioned at offset 0. Used by the
    /// append-only writer to populate tmpfiles before renaming them over
    /// their final names; the dat-append path uses [`VfsWrite::open_append`]
    /// instead because it must preserve the existing dat contents.
    ///
    /// # Errors
    ///
    /// Returns the same `io::Error` shapes
    /// `std::fs::OpenOptions::create(true).truncate(true).write(true)`
    /// produces against the underlying backing.
    fn open_write(&self, path: &Path) -> io::Result<Box<dyn VfsWriteHandle>>;

    /// Open `path` for appending — existing bytes are preserved and the
    /// handle is positioned at the file's current end.
    ///
    /// Used by the append-only writer to extend a `.dat` file at its EOF
    /// without rewriting earlier bytes.
    ///
    /// # Errors
    ///
    /// Returns the same `io::Error` shapes
    /// `std::fs::OpenOptions::append(true).write(true)` produces against
    /// the underlying backing.
    fn open_append(&self, path: &Path) -> io::Result<Box<dyn VfsWriteHandle>>;

    /// Rename `from` to `to`, atomically per the backend's contract.
    ///
    /// Used as the visibility cutover for index rewrites — the new bytes
    /// are written to a `.tmp` sibling, fsync'd, and then renamed over the
    /// real name in a single step. If the rename never happens the new
    /// bytes never become visible to a reader.
    ///
    /// # Errors
    ///
    /// Returns `io::Error` if the rename fails (e.g. cross-device link,
    /// missing source, permission denied).
    fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;

    /// Remove the file at `path`. Used to clean up stale tmpfiles left
    /// behind by a prior failed transaction.
    ///
    /// # Errors
    ///
    /// Returns `io::Error` if the file cannot be removed.
    fn remove_file(&self, path: &Path) -> io::Result<()>;
}

// ---------------------------------------------------------------------------
// StdFs
// ---------------------------------------------------------------------------

/// Default [`Vfs`] backed by `std::fs`. Stateless, cheap to clone.
#[derive(Debug, Default, Clone, Copy)]
pub struct StdFs;

impl StdFs {
    /// Construct a new instance.
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

impl Vfs for StdFs {
    fn open_read(&self, path: &Path) -> io::Result<Box<dyn VfsRead>> {
        Ok(Box::new(std::fs::File::open(path)?))
    }

    fn exists(&self, path: &Path) -> bool {
        path.exists()
    }

    fn metadata(&self, path: &Path) -> io::Result<VfsMetadata> {
        let m = std::fs::metadata(path)?;
        Ok(VfsMetadata {
            len: m.len(),
            is_file: m.is_file(),
        })
    }

    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
        let mut out = Vec::new();
        for entry in std::fs::read_dir(path)? {
            out.push(entry?.path());
        }
        Ok(out)
    }
}

impl VfsWriteHandle for std::fs::File {
    fn sync_all(&mut self) -> io::Result<()> {
        std::fs::File::sync_all(self)
    }
}

impl VfsWrite for StdFs {
    fn open_write(&self, path: &Path) -> io::Result<Box<dyn VfsWriteHandle>> {
        let f = std::fs::OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(true)
            .open(path)?;
        Ok(Box::new(f))
    }

    fn open_append(&self, path: &Path) -> io::Result<Box<dyn VfsWriteHandle>> {
        // We use `read(true).write(true)` rather than the OS `append(true)`
        // flag so the writer can `Seek::seek(SeekFrom::End(0))` explicitly
        // and capture the EOF offset before writing. POSIX `O_APPEND`
        // would force every write to land at EOF regardless of the
        // current cursor, which makes the "capture offset, then write"
        // sequence racy on platforms that take the `O_APPEND` semantics
        // literally.
        let f = std::fs::OpenOptions::new()
            .read(true)
            .write(true)
            .open(path)?;
        Ok(Box::new(f))
    }

    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
        std::fs::rename(from, to)
    }

    fn remove_file(&self, path: &Path) -> io::Result<()> {
        std::fs::remove_file(path)
    }
}

// ---------------------------------------------------------------------------
// InMemoryFs
// ---------------------------------------------------------------------------

/// Shared storage shape for [`InMemoryFs`]. Lives behind an `Arc<Mutex<_>>`
/// so clones of the outer handle share the same map.
#[derive(Debug, Default)]
struct MemFsInner {
    files: BTreeMap<PathBuf, Vec<u8>>,
    dirs: std::collections::BTreeSet<PathBuf>,
}

/// In-memory [`Vfs`] backing — a sorted map (`BTreeMap`) from path to
/// file contents. Useful for tests, fixture-driven benchmarks, and
/// sandboxed environments where the read layer must not touch the host
/// filesystem.
///
/// Cheap to clone — all clones share the same underlying storage, so a
/// caller can populate one handle with [`InMemoryFs::insert`] and hand a
/// clone to [`crate::SqPack::open_with_vfs`] while keeping the populating
/// reference to add files later.
///
/// # Path semantics
///
/// Paths are stored verbatim — no canonicalisation, no separator
/// normalisation. The caller is responsible for using the same path shape
/// when populating and when looking up. The `sqpack-rs` callers build all
/// of their paths via [`std::path::PathBuf::push`], which uses the host
/// separator; tests that populate the [`InMemoryFs`] should do the same.
#[derive(Debug, Default, Clone)]
pub struct InMemoryFs {
    inner: Arc<Mutex<MemFsInner>>,
}

impl InMemoryFs {
    /// Construct an empty in-memory filesystem.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Insert (or overwrite) a file at `path`.
    ///
    /// Also records every ancestor directory of `path` so a subsequent
    /// [`Vfs::read_dir`] sees the parent. The file is stored as a clone of
    /// the supplied bytes — the caller retains ownership of the original
    /// slice.
    ///
    /// # Panics
    ///
    /// Panics if the internal mutex is poisoned.
    pub fn insert(&self, path: impl Into<PathBuf>, bytes: impl Into<Vec<u8>>) {
        let path = path.into();
        let mut inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        let mut ancestor = path.parent().map(Path::to_path_buf);
        while let Some(d) = ancestor.take() {
            if d.as_os_str().is_empty() {
                break;
            }
            inner.dirs.insert(d.clone());
            ancestor = d.parent().map(Path::to_path_buf);
        }
        inner.files.insert(path, bytes.into());
    }

    /// Take a snapshot of every file's contents.
    ///
    /// # Panics
    ///
    /// Panics if the internal mutex is poisoned.
    #[must_use]
    pub fn snapshot_files(&self) -> BTreeMap<PathBuf, Vec<u8>> {
        let inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        inner.files.clone()
    }
}

/// Read handle into an [`InMemoryFs`]. Holds a private snapshot of the
/// file's bytes so the underlying mutex is released for the duration of
/// the read.
struct MemReadHandle {
    bytes: Vec<u8>,
    cursor: u64,
}

impl Read for MemReadHandle {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        let pos = usize::try_from(self.cursor).unwrap_or(usize::MAX);
        if pos >= self.bytes.len() {
            return Ok(0);
        }
        let available = &self.bytes[pos..];
        let n = available.len().min(buf.len());
        buf[..n].copy_from_slice(&available[..n]);
        #[allow(clippy::cast_possible_truncation)]
        let widened = n as u64;
        self.cursor += widened;
        Ok(n)
    }
}

impl Seek for MemReadHandle {
    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
        #[allow(clippy::cast_possible_wrap)]
        let new_pos: i128 = match pos {
            io::SeekFrom::Start(n) => i128::from(n),
            io::SeekFrom::Current(d) => i128::from(self.cursor) + i128::from(d),
            io::SeekFrom::End(d) => i128::from(self.bytes.len() as u64) + i128::from(d),
        };
        if new_pos < 0 {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "negative seek position",
            ));
        }
        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
        let unsigned = new_pos as u64;
        self.cursor = unsigned;
        Ok(self.cursor)
    }
}

/// Writable handle into an [`InMemoryFs`]. Buffers writes locally and
/// commits the final byte vector back into the shared map either on
/// [`MemWriteHandle::sync_all`] / [`Drop`].
///
/// The handle starts out with an empty buffer (for `open_write`, which
/// truncates) or with a clone of the existing file's bytes (for
/// `open_append`, which preserves them).
struct MemWriteHandle {
    inner: Arc<Mutex<MemFsInner>>,
    path: PathBuf,
    bytes: Vec<u8>,
    cursor: u64,
    /// Flushed by `sync_all` or `Drop`. Once true we stop re-publishing on
    /// `Drop` so a successful `sync_all` followed by `Drop` doesn't
    /// overwrite a subsequent reader's snapshot with the same bytes.
    /// Tracks "buffer is in the map already".
    flushed: bool,
}

impl MemWriteHandle {
    fn publish(&mut self) {
        let mut inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        // Record every ancestor directory so a subsequent metadata / read_dir
        // call sees the parent. Mirrors `InMemoryFs::insert`.
        let mut ancestor = self.path.parent().map(Path::to_path_buf);
        while let Some(d) = ancestor.take() {
            if d.as_os_str().is_empty() {
                break;
            }
            inner.dirs.insert(d.clone());
            ancestor = d.parent().map(Path::to_path_buf);
        }
        inner.files.insert(self.path.clone(), self.bytes.clone());
    }
}

impl Write for MemWriteHandle {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let pos = usize::try_from(self.cursor)
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "cursor overflows usize"))?;
        if pos > self.bytes.len() {
            // Sparse-write hole: zero-fill up to `pos` before appending.
            self.bytes.resize(pos, 0);
        }
        let end = pos.saturating_add(buf.len());
        if end > self.bytes.len() {
            self.bytes.resize(end, 0);
        }
        self.bytes[pos..end].copy_from_slice(buf);
        #[allow(clippy::cast_possible_truncation)]
        let widened = buf.len() as u64;
        self.cursor += widened;
        self.flushed = false;
        Ok(buf.len())
    }

    fn flush(&mut self) -> io::Result<()> {
        // `flush` is a noop — bytes never leave the in-memory buffer until
        // `sync_all` (or `Drop`) lands them in the shared map.
        Ok(())
    }
}

impl Seek for MemWriteHandle {
    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
        #[allow(clippy::cast_possible_wrap)]
        let new_pos: i128 = match pos {
            io::SeekFrom::Start(n) => i128::from(n),
            io::SeekFrom::Current(d) => i128::from(self.cursor) + i128::from(d),
            io::SeekFrom::End(d) => i128::from(self.bytes.len() as u64) + i128::from(d),
        };
        if new_pos < 0 {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "negative seek position",
            ));
        }
        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
        let unsigned = new_pos as u64;
        self.cursor = unsigned;
        Ok(self.cursor)
    }
}

impl VfsWriteHandle for MemWriteHandle {
    fn sync_all(&mut self) -> io::Result<()> {
        self.publish();
        self.flushed = true;
        Ok(())
    }
}

impl Drop for MemWriteHandle {
    fn drop(&mut self) {
        // Mirror `std::fs::File`: dropping a writer without `sync_all` still
        // publishes the bytes to the backing store (the kernel's page cache,
        // here the `BTreeMap`). The atomicity contract is built by the
        // caller via `rename`, not by withholding bytes on drop.
        if !self.flushed {
            self.publish();
        }
    }
}

impl VfsWrite for InMemoryFs {
    fn open_write(&self, path: &Path) -> io::Result<Box<dyn VfsWriteHandle>> {
        Ok(Box::new(MemWriteHandle {
            inner: Arc::clone(&self.inner),
            path: path.to_path_buf(),
            bytes: Vec::new(),
            cursor: 0,
            flushed: false,
        }))
    }

    fn open_append(&self, path: &Path) -> io::Result<Box<dyn VfsWriteHandle>> {
        let bytes = {
            let inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
            inner
                .files
                .get(path)
                .cloned()
                .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?
        };
        #[allow(clippy::cast_possible_truncation)]
        let cursor = bytes.len() as u64;
        Ok(Box::new(MemWriteHandle {
            inner: Arc::clone(&self.inner),
            path: path.to_path_buf(),
            bytes,
            cursor,
            flushed: false,
        }))
    }

    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
        let mut inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        let bytes = inner
            .files
            .remove(from)
            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
        // Same-dir rename: re-record ancestor dirs for the destination so
        // `exists` / `read_dir` continue to see the parent. In practice the
        // destination's parent is already in `dirs` from the original
        // `insert`, but the writer occasionally renames into a path whose
        // parent we have not previously touched.
        let mut ancestor = to.parent().map(Path::to_path_buf);
        while let Some(d) = ancestor.take() {
            if d.as_os_str().is_empty() {
                break;
            }
            inner.dirs.insert(d.clone());
            ancestor = d.parent().map(Path::to_path_buf);
        }
        inner.files.insert(to.to_path_buf(), bytes);
        Ok(())
    }

    fn remove_file(&self, path: &Path) -> io::Result<()> {
        let mut inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        inner
            .files
            .remove(path)
            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
        Ok(())
    }
}

impl Vfs for InMemoryFs {
    fn open_read(&self, path: &Path) -> io::Result<Box<dyn VfsRead>> {
        let inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        let Some(bytes) = inner.files.get(path) else {
            return Err(io::Error::from(io::ErrorKind::NotFound));
        };
        let bytes = bytes.clone();
        Ok(Box::new(MemReadHandle { bytes, cursor: 0 }))
    }

    fn exists(&self, path: &Path) -> bool {
        let inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        inner.files.contains_key(path) || inner.dirs.contains(path)
    }

    fn metadata(&self, path: &Path) -> io::Result<VfsMetadata> {
        let inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        if let Some(bytes) = inner.files.get(path) {
            #[allow(clippy::cast_possible_truncation)]
            let widened = bytes.len() as u64;
            Ok(VfsMetadata {
                len: widened,
                is_file: true,
            })
        } else if inner.dirs.contains(path) {
            Ok(VfsMetadata {
                len: 0,
                is_file: false,
            })
        } else {
            Err(io::Error::from(io::ErrorKind::NotFound))
        }
    }

    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
        let inner = self.inner.lock().expect("InMemoryFs mutex poisoned");
        if !inner.dirs.contains(path) && !inner.files.keys().any(|p| p.parent() == Some(path)) {
            return Err(io::Error::from(io::ErrorKind::NotFound));
        }
        let mut out = Vec::new();
        for p in inner.files.keys() {
            if p.parent() == Some(path) {
                out.push(p.clone());
            }
        }
        for d in &inner.dirs {
            if d.parent() == Some(path) {
                out.push(d.clone());
            }
        }
        Ok(out)
    }
}

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

    #[test]
    fn stdfs_open_read_round_trips() {
        // Use a temp file via std::env::temp_dir + std::fs (no tempfile crate).
        let mut p = std::env::temp_dir();
        p.push(format!("sqpack-rs-vfs-test-{}.bin", std::process::id()));
        let _ = std::fs::remove_file(&p);
        std::fs::write(&p, b"hello vfs").unwrap();

        let fs = StdFs::new();
        let mut r = fs.open_read(&p).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"hello vfs");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn stdfs_exists_true_for_present_path() {
        let fs = StdFs::new();
        // current working dir is always present.
        let cwd = std::env::current_dir().unwrap();
        assert!(fs.exists(&cwd));
    }

    #[test]
    fn stdfs_exists_false_for_missing_path() {
        let fs = StdFs::new();
        assert!(!fs.exists(Path::new("/nonexistent/sqpack-rs-vfs-test-missing")));
    }

    #[test]
    fn memfs_insert_then_open_read_round_trips() {
        let fs = InMemoryFs::new();
        fs.insert("/a/b.txt", b"world".to_vec());
        let mut r = fs.open_read(Path::new("/a/b.txt")).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"world");
    }

    #[test]
    fn memfs_open_read_missing_is_not_found() {
        let fs = InMemoryFs::new();
        // `Box<dyn VfsRead>` doesn't implement Debug, so unwrap_err is
        // unavailable. Pattern-match instead.
        let result = fs.open_read(Path::new("/missing"));
        match result {
            Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound),
            Ok(_) => panic!("expected NotFound on missing path"),
        }
    }

    #[test]
    fn memfs_exists_for_files_and_ancestor_dirs() {
        let fs = InMemoryFs::new();
        fs.insert("/a/b/c.bin", b"x".to_vec());
        assert!(fs.exists(Path::new("/a/b/c.bin")));
        // Ancestor dirs are auto-recorded by insert().
        assert!(fs.exists(Path::new("/a/b")));
        assert!(fs.exists(Path::new("/a")));
        assert!(!fs.exists(Path::new("/a/b/missing")));
    }

    #[test]
    fn memfs_metadata_reports_len_and_kind() {
        let fs = InMemoryFs::new();
        fs.insert("/x", b"abcdef".to_vec());
        let m = fs.metadata(Path::new("/x")).unwrap();
        assert_eq!(m.len, 6);
        assert!(m.is_file);

        // Ancestor dir is not a file.
        let d = fs.metadata(Path::new("/")).unwrap();
        assert!(!d.is_file);
    }

    #[test]
    fn memfs_read_dir_yields_children() {
        let fs = InMemoryFs::new();
        fs.insert("/d/a", b"1".to_vec());
        fs.insert("/d/b", b"2".to_vec());
        let mut entries = fs.read_dir(Path::new("/d")).unwrap();
        entries.sort();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0], PathBuf::from("/d/a"));
        assert_eq!(entries[1], PathBuf::from("/d/b"));
    }

    #[test]
    fn memfs_clones_share_storage() {
        let a = InMemoryFs::new();
        let b = a.clone();
        a.insert("/shared", b"hi".to_vec());
        let mut r = b.open_read(Path::new("/shared")).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"hi");
    }

    #[test]
    fn memfs_read_handle_seek_round_trips() {
        let fs = InMemoryFs::new();
        fs.insert("/seek", (0u8..32).collect::<Vec<_>>());
        let mut r = fs.open_read(Path::new("/seek")).unwrap();
        r.seek(SeekFrom::Start(8)).unwrap();
        let mut buf = [0u8; 4];
        r.read_exact(&mut buf).unwrap();
        assert_eq!(buf, [8, 9, 10, 11]);
        r.seek(SeekFrom::End(-4)).unwrap();
        r.read_exact(&mut buf).unwrap();
        assert_eq!(buf, [28, 29, 30, 31]);
    }

    #[test]
    fn vfs_is_object_safe_via_arc_dyn() {
        // Compile-time check: Arc<dyn Vfs> is the public storage shape used
        // by SqPack, so the trait must stay object-safe. Pin that here.
        fn accept(_: Arc<dyn Vfs>) {}
        accept(Arc::new(StdFs::new()));
        accept(Arc::new(InMemoryFs::new()));
    }

    // ---- VfsWrite trait ----

    #[test]
    fn vfs_write_is_object_safe_via_arc_dyn() {
        // Symmetric with `vfs_is_object_safe_via_arc_dyn` — the appender
        // stores `Arc<dyn VfsWrite>` so the trait must stay object-safe.
        fn accept(_: Arc<dyn VfsWrite>) {}
        accept(Arc::new(StdFs::new()));
        accept(Arc::new(InMemoryFs::new()));
    }

    #[test]
    fn memfs_open_write_truncates_and_writes() {
        let fs = InMemoryFs::new();
        fs.insert("/x", b"existing".to_vec());
        let mut w = fs.open_write(Path::new("/x")).unwrap();
        w.write_all(b"new").unwrap();
        w.sync_all().unwrap();
        drop(w);
        // Truncating open: the original bytes are gone after the publish.
        let m = fs.metadata(Path::new("/x")).unwrap();
        assert_eq!(m.len, 3);
    }

    #[test]
    fn memfs_open_append_preserves_existing_bytes() {
        let fs = InMemoryFs::new();
        fs.insert("/y", b"abc".to_vec());
        let mut w = fs.open_append(Path::new("/y")).unwrap();
        // open_append leaves the cursor at EOF.
        assert_eq!(w.stream_position().unwrap(), 3);
        w.write_all(b"def").unwrap();
        w.sync_all().unwrap();
        drop(w);
        let mut r = fs.open_read(Path::new("/y")).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"abcdef");
    }

    #[test]
    fn memfs_open_append_on_missing_path_returns_not_found() {
        let fs = InMemoryFs::new();
        // Box<dyn VfsWriteHandle> has no Debug impl, so unwrap_err is
        // unavailable. Pattern-match on the Result instead.
        match fs.open_append(Path::new("/missing")) {
            Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound),
            Ok(_) => panic!("expected NotFound on missing path"),
        }
    }

    #[test]
    fn memfs_rename_replaces_destination() {
        let fs = InMemoryFs::new();
        fs.insert("/old", b"src".to_vec());
        fs.insert("/new", b"dst".to_vec());
        fs.rename(Path::new("/old"), Path::new("/new")).unwrap();
        assert!(!fs.exists(Path::new("/old")));
        let mut r = fs.open_read(Path::new("/new")).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"src");
    }

    #[test]
    fn memfs_rename_missing_source_is_not_found() {
        let fs = InMemoryFs::new();
        let err = fs
            .rename(Path::new("/missing"), Path::new("/dst"))
            .unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::NotFound);
    }

    #[test]
    fn memfs_remove_file_clears_entry() {
        let fs = InMemoryFs::new();
        fs.insert("/tmp", b"x".to_vec());
        fs.remove_file(Path::new("/tmp")).unwrap();
        assert!(!fs.exists(Path::new("/tmp")));
    }

    #[test]
    fn memfs_write_handle_drop_publishes_without_sync() {
        // Mirrors `std::fs::File`: dropping without an explicit sync still
        // makes the bytes visible. The atomicity contract is via `rename`,
        // not via "no publish on drop".
        let fs = InMemoryFs::new();
        {
            let mut w = fs.open_write(Path::new("/drop")).unwrap();
            w.write_all(b"hello").unwrap();
        }
        let mut r = fs.open_read(Path::new("/drop")).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"hello");
    }

    #[test]
    fn stdfs_open_write_and_rename_round_trip() {
        let mut a = std::env::temp_dir();
        a.push(format!("sqpack-rs-vfs-write-{}.tmp", std::process::id()));
        let mut b = std::env::temp_dir();
        b.push(format!("sqpack-rs-vfs-write-{}.final", std::process::id()));
        let _ = std::fs::remove_file(&a);
        let _ = std::fs::remove_file(&b);

        let fs = StdFs::new();
        let mut w = fs.open_write(&a).unwrap();
        w.write_all(b"std-write").unwrap();
        w.sync_all().unwrap();
        drop(w);

        fs.rename(&a, &b).unwrap();
        assert!(!a.exists());
        assert!(b.exists());
        let mut r = fs.open_read(&b).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"std-write");

        let _ = std::fs::remove_file(&b);
    }
}
