//! `mmap`-backed [`Vfs`] implementation. Gated behind the `mmap` Cargo
//! feature.
//!
//! [`MmapFs`] maps each unique `.dat` / `.index` / `.index2` file into the
//! process address space exactly once and serves every subsequent
//! [`Vfs::open_read`] from the shared mapping. Random reads become memcpys
//! from the page cache; seeks become field stores.
//!
//! # Caching strategy
//!
//! The internal cache is an unbounded `HashMap<PathBuf, Arc<Mmap>>`
//! keyed by canonicalised path. A real install carries a few dozen
//! `.dat` / `.index` / `.index2` files (one per
//! `(category, expansion, chunk, variant)` slot, capped by the
//! cross-product of `KNOWN_CATEGORIES × 16 × 8 × 2`), so an unbounded
//! cache is bounded in practice by the file count of the install.
//! Entries live as long as the [`MmapFs`] by default; consumers can
//! release individual mappings via [`MmapFs::evict`] or the whole
//! cache via [`MmapFs::clear`].
//!
//! Two-level keying: the cache is also populated under the raw input
//! path on first miss, so repeated `open_read` calls with the same
//! surface path skip [`std::fs::canonicalize`] on the hot path. The
//! canonical key is the source of truth for "are these the same
//! file?"; the raw-key entry is a cheap shortcut that points at the
//! same `Arc<Mmap>`.
//!
//! The cache hash map sits behind a [`std::sync::RwLock`]: lookups
//! (the steady-state hot path once every relevant file has been
//! touched) take the read lock and are uncontended across threads;
//! the first-touch insert path takes the write lock for the short
//! window between "not found under the read lock" and "now inserted".
//! A [`std::sync::Mutex`] would also work — the insert is fast — but
//! the read-heavy nature of the workload (the verifier opens the
//! same dat thousands of times during a scan) prefers the readers/
//! writer split. `parking_lot` is not pulled in: the std primitive
//! is sufficient, and the rest of the crate stays on `std` synchroni-
//! sation (see [`InMemoryFs`]).
//!
//! # Safety
//!
//! [`memmap2::Mmap::map`] is `unsafe`. The aliasing precondition is
//! **no concurrent writes to the mapped file by any process** —
//! mutations through a sibling writer (memory or filesystem) violate
//! the shared-immutable-bytes assumption the [`Mmap`] type encodes,
//! and reading a torn page produces undefined behaviour. The `SqPack`
//! files this crate is built to read are written by `zipatch-rs` /
//! `gaveloc-patcher` during patch application — using [`MmapFs`] while
//! a patcher is mutating the install is **unsound**. Consumers must
//! sequence patch application before any [`MmapFs`]-backed reader and
//! treat the install as immutable for the [`MmapFs`]'s lifetime;
//! [`StdFs`] is the right choice when concurrency with a patcher is
//! required.
//!
//! Beyond the patcher case, the contract also rules out: external
//! tooling (mod loaders, manual edits, package managers) that
//! rewrites files under a live install; live-truncation by a sibling
//! `open(.. truncate(true))` (the mapped region's tail becomes
//! invalid); and any OS-level deduplication / overlay-filesystem
//! pass that flips the file's backing bytes without a fresh `open`.
//!
//! # Read-only
//!
//! [`MmapFs`] implements [`Vfs`] only. It deliberately does **not**
//! implement [`VfsWrite`]: `MmapMut` + `msync` would let the writer
//! mutate the same mapping the readers cached, and invalidating
//! every outstanding `Arc<Mmap>` snapshot atomically is a problem
//! we'd rather not own when [`StdFs`] already handles writes
//! correctly. Mixed-backend setups are explicitly supported — open a
//! [`crate::SqPack`] with [`MmapFs`] for reads and use a separate
//! [`StdFs`] (or any other [`VfsWrite`] impl) for the write path.
//!
//! # Composes with `parallel`
//!
//! [`MmapFs`] is `Send + Sync` and the cached `Arc<Mmap>` values are
//! cheaply cloneable, so a parallel verifier (the
//! [`crate::SqPack::enumerate_archives_parallel`] /
//! `verify_parallel` family) backed by [`MmapFs`] shares one mapping
//! per `.dat` across every rayon worker. Compare with [`StdFs`],
//! where each worker opens its own `std::fs::File`. The mmap path is
//! the more memory-efficient configuration for a parallel scan of a
//! many-archive install.
//!
//! [`InMemoryFs`]: crate::vfs::InMemoryFs
//! [`StdFs`]: crate::vfs::StdFs
//! [`VfsWrite`]: crate::vfs::VfsWrite

use std::collections::HashMap;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};

use memmap2::Mmap;
use tracing::debug;

use crate::vfs::{StdFs, Vfs, VfsMetadata, VfsRead};

/// `mmap`-backed [`Vfs`] implementation.
///
/// Maintains an internal `HashMap<PathBuf, Arc<Mmap>>` keyed by
/// canonicalised path — the first [`Vfs::open_read`] for a given file
/// creates the mapping; every subsequent call serves a fresh cursor
/// over the cached `Arc<Mmap>`. Cheap to clone (the inner state is
/// `Arc`-shared) and cheap to share across threads (the cache is
/// `RwLock`-guarded and the `Mmap` values are `Send + Sync`).
///
/// See the module-level rustdoc for the full safety contract, caching
/// strategy, and composition notes.
///
/// # Example
///
/// ```no_run
/// # #[cfg(feature = "mmap")]
/// # fn run() -> std::io::Result<()> {
/// use std::sync::Arc;
/// use sqpack_rs::{MmapFs, SqPack, Vfs};
///
/// // Open an install with the mmap backend.
/// let vfs: Arc<dyn Vfs> = Arc::new(MmapFs::new());
/// let sq = SqPack::open_with_vfs("/path/to/ffxiv", vfs).expect("open");
/// let _bytes = sq.read_file("exd/root.exl").expect("read");
/// # Ok(())
/// # }
/// ```
///
/// # Safety contract
///
/// Constructing an [`MmapFs`] is safe. **Reading through it is only
/// sound when the underlying files are not mutated externally for the
/// lifetime of the [`MmapFs`].** See the module-level rustdoc for the
/// full discussion — TL;DR: do not run [`MmapFs`] concurrently with a
/// patcher, mod loader, or any other writer against the install.
///
/// `#[non_exhaustive]` is set so that future revisions can add private
/// configuration (e.g. an eviction cap, a populate-on-construct hook)
/// without breaking source compatibility for downstream code that
/// constructs `MmapFs` via the public `new` / `with_capacity`
/// constructors.
#[derive(Debug, Default, Clone)]
#[cfg_attr(docsrs, doc(cfg(feature = "mmap")))]
#[non_exhaustive]
pub struct MmapFs {
    inner: Arc<MmapFsInner>,
    /// Stateless [`StdFs`] used to compose the non-mmap-accelerated
    /// methods (`exists`, `metadata`, `read_dir`). Zero-sized, `Copy`;
    /// duplication cost is nil and the alternative — inlining the
    /// `std::fs` calls — invites future drift between the two
    /// backends.
    std_fs: StdFs,
}

/// Shared cache cell sat behind an `Arc` so clones of [`MmapFs`] share
/// one map.
///
/// Kept as a separate struct (rather than inlined into [`MmapFs`]) so
/// future fields that only make sense on the inner — e.g. an eviction
/// cap counter, an LRU queue, per-file statistics — can land without
/// reshaping the public type. Today it's a one-field wrapper; the
/// `Arc<MmapFsInner>` indirection inside `MmapFs` is the cost we pay
/// for that headroom.
#[derive(Debug, Default)]
struct MmapFsInner {
    files: RwLock<HashMap<PathBuf, Arc<Mmap>>>,
}

impl MmapFs {
    /// Construct an empty [`MmapFs`] with no mappings cached.
    ///
    /// # Safety contract
    ///
    /// Constructing the [`MmapFs`] is safe. The unsafe boundary is
    /// crossed on the first [`Vfs::open_read`] for each path, where
    /// the mapping is created — see the module-level safety contract.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Construct an [`MmapFs`] with a pre-sized cache.
    ///
    /// A hint, not a bound — the cache will grow past `capacity` if
    /// more unique paths are opened. Useful when the consumer knows
    /// the install's archive count ahead of time and wants to skip
    /// the `HashMap`'s incremental resizes; a real install carries a
    /// few dozen archives, so the default constructor's zero-capacity
    /// start is fine for most callers.
    ///
    /// Naming mirrors [`std::collections::HashMap::with_capacity`] and
    /// [`std::vec::Vec::with_capacity`]. `MmapFs::with_capacity(0)`
    /// behaves identically to [`MmapFs::new`].
    ///
    /// # Safety contract
    ///
    /// Same as [`MmapFs::new`].
    #[must_use]
    pub fn with_capacity(capacity: usize) -> Self {
        Self {
            inner: Arc::new(MmapFsInner {
                files: RwLock::new(HashMap::with_capacity(capacity)),
            }),
            std_fs: StdFs::new(),
        }
    }

    /// Evict the cached mapping for `path` (raw-input key first, then
    /// canonical). Returns `true` if any entry was removed.
    ///
    /// Useful when a consumer knows a specific archive will not be
    /// touched again and wants to release its address-space reservation
    /// without throwing the whole cache away. Subsequent opens against
    /// the same path will re-map on first touch.
    ///
    /// Eviction is best-effort with respect to outstanding
    /// [`MmapReader`] cursors — they hold an `Arc<Mmap>` and the OS
    /// mapping stays alive until the last cursor drops. This method
    /// removes the cache's reference; the mapping is released only
    /// when no reader still holds it.
    ///
    /// # Panics
    ///
    /// Panics if the internal cache lock has been poisoned by a prior
    /// panic in another thread.
    #[must_use = "evict returns whether anything was removed; ignore the return only if the call is best-effort"]
    pub fn evict(&self, path: &Path) -> bool {
        let mut files = self
            .inner
            .files
            .write()
            .expect("MmapFs cache lock poisoned");
        let raw_removed = files.remove(path).is_some();
        // Best-effort canonical eviction: if the path resolves, also
        // drop its canonical entry. A failing canonicalize (file gone)
        // is fine — the raw-key removal above is all we can do.
        let canonical_removed = if let Ok(canonical) = std::fs::canonicalize(path) {
            files.remove(&canonical).is_some()
        } else {
            false
        };
        raw_removed || canonical_removed
    }

    /// Drop every cached mapping. Subsequent opens re-map on first
    /// touch.
    ///
    /// As with [`MmapFs::evict`], outstanding [`MmapReader`] cursors
    /// keep their underlying OS mapping alive until they drop; this
    /// method only releases the cache's references.
    ///
    /// # Panics
    ///
    /// Panics if the internal cache lock has been poisoned by a prior
    /// panic in another thread (a poisoned lock is unrecoverable for
    /// the lifetime of the `MmapFs`).
    pub fn clear(&self) {
        self.inner
            .files
            .write()
            .expect("MmapFs cache lock poisoned")
            .clear();
    }

    /// Number of entries currently cached.
    ///
    /// Counts both raw-key and canonical-key entries — two distinct
    /// surface paths that resolve to the same file contribute two
    /// entries to this count even though they share one `Arc<Mmap>`.
    /// Useful primarily for testing and operator diagnostics.
    ///
    /// # Panics
    ///
    /// Panics if the internal cache lock has been poisoned by a prior
    /// panic in another thread.
    #[must_use]
    pub fn len(&self) -> usize {
        self.inner
            .files
            .read()
            .expect("MmapFs cache lock poisoned")
            .len()
    }

    /// Whether the cache is empty. Equivalent to `self.len() == 0`.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Look up `path` in the cache, mapping it on a miss.
    ///
    /// Two-level cache for cheap hot-path repeats:
    /// 1. Read-lock probe under the raw input path. If a hit, return
    ///    without touching the filesystem. This is the steady-state
    ///    path once a consumer has called `open_read(p)` once.
    /// 2. On miss, canonicalise the path and re-probe under the
    ///    canonical key. A hit here means two different surface paths
    ///    resolve to the same file — we cache the raw key alongside
    ///    so the next hit on either path is `O(1)`.
    /// 3. On a second miss, open + map the file, then insert under
    ///    both the canonical key and the raw input key.
    ///
    /// The canonical key is the source of truth for "are these the
    /// same file?"; the raw key is a shortcut. Note that this means
    /// relative-path lookups that resolve differently across CWD
    /// changes can leave stale raw-key entries pointing at the old
    /// canonical target — use [`MmapFs::evict`] or [`MmapFs::clear`]
    /// to release them. In practice the install paths flowing through
    /// `SqPack::open` are absolute, so this only matters for code
    /// that deliberately constructs `MmapFs` against a moving CWD.
    fn get_or_map(&self, path: &Path) -> io::Result<Arc<Mmap>> {
        // Level 1: raw-key probe under the read lock. Hot path.
        {
            let files = self.inner.files.read().expect("MmapFs cache lock poisoned");
            if let Some(existing) = files.get(path) {
                return Ok(Arc::clone(existing));
            }
        }

        // Level 2: canonicalise and re-probe. This is also the path
        // that surfaces `NotFound` to the caller when the file is
        // missing.
        let canonical = std::fs::canonicalize(path)?;
        {
            let files = self.inner.files.read().expect("MmapFs cache lock poisoned");
            if let Some(existing) = files.get(&canonical) {
                // Promote: also cache under the raw input key so the
                // next hit skips canonicalisation. Done under a brief
                // upgrade to the write lock.
                let arc = Arc::clone(existing);
                drop(files);
                let mut files = self
                    .inner
                    .files
                    .write()
                    .expect("MmapFs cache lock poisoned");
                files
                    .entry(path.to_path_buf())
                    .or_insert_with(|| Arc::clone(&arc));
                return Ok(arc);
            }
        }

        // Level 3: not cached at all. Map and insert under both keys.
        let file = std::fs::File::open(&canonical)?;
        let len = file.metadata().map(|m| m.len()).ok();
        // SAFETY: `memmap2::Mmap::map(&file)` requires that the mapped
        // file is not mutated by any process for the lifetime of the
        // returned `Mmap`. The [`MmapFs`] type's safety contract (see
        // the module-level rustdoc) propagates that requirement to
        // the consumer: by routing reads through `MmapFs`, the
        // consumer asserts the install is immutable for the
        // `MmapFs`'s lifetime. The returned `Mmap` is held inside an
        // `Arc` and only ever exposed as `&[u8]` via `Mmap::deref`,
        // so we never produce a mutable alias of the mapped bytes
        // from inside the crate.
        #[allow(unsafe_code)]
        let mmap = unsafe { Mmap::map(&file)? };
        let arc = Arc::new(mmap);

        let mut files = self
            .inner
            .files
            .write()
            .expect("MmapFs cache lock poisoned");
        // Recheck under the write lock — another thread may have
        // inserted the same key between our read and write lock
        // acquisitions. If so, return their mapping (the duplicate
        // we just created is dropped when `arc` goes out of scope).
        if let Some(existing) = files.get(&canonical).map(Arc::clone) {
            files
                .entry(path.to_path_buf())
                .or_insert_with(|| Arc::clone(&existing));
            return Ok(existing);
        }
        // Event message kept as a literal because `tracing`'s macro
        // syntax only accepts literals in message position; the
        // canonical name lives in
        // `crate::tracing_schema::event_names::MMAP_MAP` and the
        // assertion below pins literal == constant.
        debug!(
            path = %canonical.display(),
            len = ?len,
            "mmap.map",
        );
        files.insert(canonical.clone(), Arc::clone(&arc));
        // Insert the raw-input key too so repeats skip
        // canonicalisation. `canonical == path` is harmless (the
        // second insert is a no-op on the same key).
        if path != canonical.as_path() {
            files.insert(path.to_path_buf(), Arc::clone(&arc));
        }
        Ok(arc)
    }
}

impl Vfs for MmapFs {
    /// Open `path` for reading via the cached mmap.
    ///
    /// The first call for a given path creates a fresh mapping; later
    /// calls reuse the cached `Arc<Mmap>` and return a new cursor
    /// over the same shared bytes.
    ///
    /// # Soundness
    ///
    /// Sound only when `path` (and every previously-opened path
    /// cached by this [`MmapFs`]) is not concurrently mutated. See the
    /// module-level rustdoc.
    fn open_read(&self, path: &Path) -> io::Result<Box<dyn VfsRead>> {
        let mmap = self.get_or_map(path)?;
        Ok(Box::new(MmapReader::from(mmap)))
    }

    /// Delegates to [`StdFs::exists`].
    ///
    /// `metadata` / `exists` / `read_dir` are not accelerated by mmap
    /// — they touch directory entries / inodes, not file bytes — so
    /// they compose with a stateless [`StdFs`] field rather than
    /// duplicating the `std::fs` calls. A `metadata` call against an
    /// uncanonicalised path can disagree with the mmapped bytes if a
    /// symlink target changes underneath the install, but that
    /// already violates the [`MmapFs`] safety contract; the
    /// composition keeps the surface aligned with [`StdFs`] for
    /// consumers that mix backends.
    fn exists(&self, path: &Path) -> bool {
        self.std_fs.exists(path)
    }

    fn metadata(&self, path: &Path) -> io::Result<VfsMetadata> {
        self.std_fs.metadata(path)
    }

    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
        self.std_fs.read_dir(path)
    }
}

/// `Read + Seek` handle over a shared mmap.
///
/// Holds an `Arc<Mmap>` to keep the mapping alive for the reader's
/// lifetime, plus a `pos: u64` cursor. The implementation tracks
/// position directly rather than wrapping `std::io::Cursor<&'static [u8]>`
/// over a `transmute`'d slice — that prototype shape used a
/// `'static`-lifetime lie (sound, but unsafe-block per reader) where
/// rolling our own cursor needs zero `unsafe`. Same hot-path cost
/// (`pos += n`, `memcpy` out of the slice), one fewer safety
/// invariant to document.
///
/// `Send` (required by [`VfsRead`]); not `Sync` (the trait does not
/// require it, and we benefit from a non-`Sync` reader for the same
/// reason `std::io::Cursor<&[u8]>` is not `Sync` — concurrent reads
/// would race on the cursor).
struct MmapReader {
    /// Keeps the underlying OS mapping alive for the cursor's
    /// lifetime. Cloned cheaply from [`MmapFs`]'s cache on construction.
    mmap: Arc<Mmap>,
    /// Current cursor position, in bytes from the start of the
    /// mapping. May exceed `mmap.len()` after a [`Seek::seek`] past
    /// the end (the `std::io::Cursor` and `std::io::Seek` contracts
    /// both allow this — subsequent [`Read::read`] calls return
    /// `Ok(0)`, and a sufficiently small [`SeekFrom::End`] returns
    /// the position to the in-range region).
    pos: u64,
}

impl From<Arc<Mmap>> for MmapReader {
    fn from(mmap: Arc<Mmap>) -> Self {
        Self { mmap, pos: 0 }
    }
}

/// Consolidated bounds-checked seek arithmetic for an `mmap`-backed
/// cursor. Mirrors the `i128` widening the three [`SeekFrom`] variants
/// would otherwise duplicate, centralises the
/// `#[allow(clippy::cast_*)]` annotations to one site, and rejects
/// positions that would silently truncate when cast back to `u64`.
fn checked_seek_to(current_pos: u64, len: u64, from: SeekFrom) -> io::Result<u64> {
    #[allow(clippy::cast_possible_wrap)]
    let new_pos: i128 = match from {
        SeekFrom::Start(n) => i128::from(n),
        SeekFrom::Current(d) => i128::from(current_pos) + i128::from(d),
        SeekFrom::End(d) => i128::from(len) + i128::from(d),
    };
    if new_pos < 0 || new_pos > i128::from(u64::MAX) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "seek position out of range",
        ));
    }
    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
    let unsigned = new_pos as u64;
    Ok(unsigned)
}

impl Read for MmapReader {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        let bytes: &[u8] = &self.mmap;
        let len_u64 = bytes.len() as u64;
        if self.pos >= len_u64 {
            return Ok(0);
        }
        // `self.pos < len_u64 <= usize::MAX as u64` (because `bytes`
        // is `&[u8]` whose `len()` fits in `usize`), so narrowing to
        // `usize` is lossless here.
        #[allow(clippy::cast_possible_truncation)]
        let pos = self.pos as usize;
        let available = &bytes[pos..];
        let n = available.len().min(buf.len());
        buf[..n].copy_from_slice(&available[..n]);
        self.pos = self.pos.saturating_add(n as u64);
        Ok(n)
    }

    /// One-shot extend from the tail of the mapping. The default
    /// trait impl reads into 32-byte-doubling chunks, which is wasteful
    /// when the source is a memory region whose length we already know:
    /// a 1 GiB `.dat` would walk through ~25 doubling reallocations and
    /// the same amount of redundant memcpy bookkeeping. Overriding here
    /// reserves once, copies once, and updates the cursor.
    fn read_to_end(&mut self, buf: &mut Vec<u8>) -> io::Result<usize> {
        let bytes: &[u8] = &self.mmap;
        let len_u64 = bytes.len() as u64;
        if self.pos >= len_u64 {
            return Ok(0);
        }
        #[allow(clippy::cast_possible_truncation)]
        let pos = self.pos as usize;
        let tail = &bytes[pos..];
        buf.extend_from_slice(tail);
        self.pos = self.pos.saturating_add(tail.len() as u64);
        Ok(tail.len())
    }
}

impl Seek for MmapReader {
    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
        let bytes: &[u8] = &self.mmap;
        let len = bytes.len() as u64;
        let new_pos = checked_seek_to(self.pos, len, pos)?;
        self.pos = new_pos;
        Ok(self.pos)
    }
}

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

    fn assert_send<T: Send>() {}
    fn assert_send_sync_clone<T: Send + Sync + Clone>() {}

    fn write_temp(name: &str, bytes: &[u8]) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!(
            "sqpack-rs-mmap-test-{}-{name}.bin",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&p);
        let mut f = std::fs::File::create(&p).expect("create temp file");
        f.write_all(bytes).expect("write temp bytes");
        f.sync_all().expect("sync temp file");
        p
    }

    #[test]
    fn open_read_round_trips() {
        let p = write_temp("round-trip", b"hello mmap");
        let fs = MmapFs::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 mmap");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn second_open_reuses_cached_mapping() {
        let p = write_temp("cache", b"abcdefgh");
        let fs = MmapFs::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"abcdefgh");
        }
        let mut r = fs.open_read(&p).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"abcdefgh");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn missing_path_is_not_found() {
        let fs = MmapFs::new();
        let r = fs.open_read(Path::new("/nonexistent/sqpack-rs-mmap-missing"));
        match r {
            Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound),
            Ok(_) => panic!("expected NotFound on missing path"),
        }
    }

    #[test]
    fn seek_round_trips() {
        let p = write_temp("seek", &(0u8..32).collect::<Vec<_>>());
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).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]);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn seek_past_end_then_read_returns_eof() {
        // Seek beyond the file length is legal per `std::io::Seek`;
        // a subsequent read returns 0 bytes, not an error.
        let p = write_temp("seek-past-end", b"abc");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        let new_pos = r.seek(SeekFrom::Start(100)).unwrap();
        assert_eq!(new_pos, 100);
        let mut buf = [0u8; 4];
        assert_eq!(r.read(&mut buf).unwrap(), 0);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn seek_negative_returns_invalid_input() {
        let p = write_temp("seek-neg", b"abc");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        let err = r.seek(SeekFrom::Current(-1)).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn metadata_reports_len_and_kind() {
        let p = write_temp("metadata", b"123456");
        let fs = MmapFs::new();
        let m = fs.metadata(&p).unwrap();
        assert_eq!(m.len, 6);
        assert!(m.is_file);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn exists_true_for_present_path() {
        let p = write_temp("exists", b"x");
        let fs = MmapFs::new();
        assert!(fs.exists(&p));
        let _ = std::fs::remove_file(&p);
    }

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

    #[test]
    fn clones_share_cache() {
        let p = write_temp("clone", b"shared");
        let a = MmapFs::new();
        let b = a.clone();
        let mut r1 = a.open_read(&p).unwrap();
        let mut r2 = b.open_read(&p).unwrap();
        let mut buf1 = Vec::new();
        let mut buf2 = Vec::new();
        r1.read_to_end(&mut buf1).unwrap();
        r2.read_to_end(&mut buf2).unwrap();
        assert_eq!(buf1, buf2);
        assert_eq!(buf1, b"shared");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn vfs_object_safety_pin() {
        // Compile-time check: Arc<dyn Vfs> is the public storage
        // shape used by SqPack, so MmapFs must coerce cleanly.
        fn accept(_: Arc<dyn Vfs>) {}
        accept(Arc::new(MmapFs::new()));
    }

    #[test]
    fn with_capacity_does_not_lose_files() {
        let p = write_temp("cap-hint", b"cap-hint-bytes");
        let fs = MmapFs::with_capacity(8);
        let mut r = fs.open_read(&p).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"cap-hint-bytes");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn mmap_reader_is_send() {
        // VfsRead requires Send; assert it explicitly so the proof
        // doesn't drift if the trait bound changes.
        assert_send::<MmapReader>();
    }

    #[test]
    fn mmap_fs_is_send_sync_clone() {
        assert_send_sync_clone::<MmapFs>();
    }

    #[test]
    fn distinct_surface_paths_share_one_mapping() {
        // Two surface paths that canonicalise to the same target file
        // share one `Arc<Mmap>` (the cache stores two keys both
        // pointing at the same arc — the canonical key and the raw
        // input key, possibly more if the consumer opens via several
        // different surface paths). Verify cache fill stays bounded.
        let p = write_temp("dedup", b"dedup-bytes");
        let canonical = std::fs::canonicalize(&p).unwrap();
        let fs = MmapFs::new();
        assert_eq!(fs.len(), 0);
        let _ = fs.open_read(&p).unwrap();
        let len_after_first = fs.len();
        // First open inserts the canonical key; the raw key is only
        // inserted as a separate entry when the raw path differs from
        // the canonical path. On some platforms (e.g. CI tmpdirs that
        // are already canonical) the raw key == canonical key.
        assert!(
            (1..=2).contains(&len_after_first),
            "expected 1 or 2 entries after first open, got {len_after_first}",
        );
        // Second open via the canonical path. Must reuse — the cache
        // should grow by at most one slot (if the canonical key is new
        // because the first surface path differed).
        let _ = fs.open_read(&canonical).unwrap();
        let len_after_second = fs.len();
        assert!(
            len_after_second <= 2,
            "expected <=2 entries after second open, got {len_after_second}",
        );
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn zero_byte_file_read_to_end_is_clean() {
        let p = write_temp("empty", b"");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        let mut buf = Vec::new();
        let n = r.read_to_end(&mut buf).unwrap();
        assert_eq!(n, 0);
        assert!(buf.is_empty());
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn mmap_map_event_name_matches_schema() {
        // Pin the emission-site literal to the schema constant. If
        // either is renamed, this assertion catches the drift before
        // a downstream subscriber's filter goes silent.
        assert_eq!(crate::tracing_schema::event_names::MMAP_MAP, "mmap.map");
    }

    #[test]
    fn evict_and_clear_release_cache_slots() {
        let p = write_temp("evict", b"evict-bytes");
        let fs = MmapFs::new();
        let _ = fs.open_read(&p).unwrap();
        assert!(!fs.is_empty());
        let removed = fs.evict(&p);
        assert!(removed);
        // After evicting both raw and canonical keys for `p` the
        // cache should be empty (only the one file was opened).
        assert!(fs.is_empty());

        let _ = fs.open_read(&p).unwrap();
        assert!(!fs.is_empty());
        fs.clear();
        assert!(fs.is_empty());
        let _ = std::fs::remove_file(&p);
    }

    // ---- seek variants not exercised by seek_round_trips ----

    #[test]
    fn seek_current_positive_advances_cursor() {
        // SeekFrom::Current(positive delta) — not covered by `seek_round_trips`
        // which only uses Start and End(-n).
        let p = write_temp("seek-cur-pos", &(0u8..16).collect::<Vec<_>>());
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        // Start at 4, advance by 4, read 4 bytes.
        r.seek(SeekFrom::Start(4)).unwrap();
        let new_pos = r.seek(SeekFrom::Current(4)).unwrap();
        assert_eq!(new_pos, 8);
        let mut buf = [0u8; 4];
        r.read_exact(&mut buf).unwrap();
        assert_eq!(buf, [8, 9, 10, 11]);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn seek_end_zero_positions_at_eof() {
        // SeekFrom::End(0) must land exactly at the file length.
        let p = write_temp("seek-end-zero", b"abcde");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        let pos = r.seek(SeekFrom::End(0)).unwrap();
        assert_eq!(pos, 5);
        // Reading at EOF returns 0 bytes.
        let mut buf = [0u8; 1];
        assert_eq!(r.read(&mut buf).unwrap(), 0);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn seek_end_positive_positions_past_eof() {
        // SeekFrom::End(positive) seeks beyond the file end. The seek
        // must succeed (legal per std::io::Seek); the subsequent read
        // returns 0 bytes.
        let p = write_temp("seek-end-pos", b"abc");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        let pos = r.seek(SeekFrom::End(10)).unwrap();
        assert_eq!(pos, 13);
        let mut buf = [0u8; 4];
        assert_eq!(r.read(&mut buf).unwrap(), 0);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn seek_current_negative_from_nonzero_returns_invalid_input() {
        // SeekFrom::Current(-(pos+1)) when pos > 0 must produce InvalidInput,
        // not silent wrap-around. The existing `seek_negative_returns_invalid_input`
        // only tests this at pos == 0; this test exercises it from a
        // non-zero position where the magnitude still exceeds the position.
        let p = write_temp("seek-cur-neg-nonzero", b"abcdef");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        r.seek(SeekFrom::Start(3)).unwrap();
        // -4 < 0 after adding the current position 3 (-1 in absolute terms).
        let err = r.seek(SeekFrom::Current(-4)).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
        let _ = std::fs::remove_file(&p);
    }

    // ---- overflow rejection: the H3 fix ----

    #[test]
    fn seek_overflow_exceeding_u64_max_returns_invalid_input() {
        // SeekFrom::Current(1) from position u64::MAX would produce a value
        // that exceeds u64::MAX. checked_seek_to must reject this with
        // InvalidInput rather than allowing silent truncation. This is the
        // bug the H3 fix addressed.
        let p = write_temp("seek-overflow", b"data");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        // First park the cursor at u64::MAX via SeekFrom::Start.
        r.seek(SeekFrom::Start(u64::MAX)).unwrap();
        // Now adding 1 would overflow a u64.
        let err = r.seek(SeekFrom::Current(1)).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
        let _ = std::fs::remove_file(&p);
    }

    // ---- read_to_end after partial advance ----

    #[test]
    fn read_to_end_after_partial_read_returns_tail() {
        // The read_to_end override must honour the current cursor position,
        // not always return the full file. A bug here would surface as
        // returning bytes already consumed.
        let p = write_temp("read-to-end-tail", b"hello world");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        // Read the first 6 bytes via ordinary read.
        let mut prefix = [0u8; 6];
        r.read_exact(&mut prefix).unwrap();
        assert_eq!(&prefix, b"hello ");
        // read_to_end must return only the remaining "world".
        let mut tail = Vec::new();
        let n = r.read_to_end(&mut tail).unwrap();
        assert_eq!(n, 5);
        assert_eq!(tail, b"world");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn read_to_end_at_eof_returns_zero() {
        // read_to_end called when the cursor is already at EOF must return 0
        // and leave the output buffer unchanged. This exercises the early-return
        // branch of the override.
        let p = write_temp("read-to-end-eof", b"xyz");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        r.seek(SeekFrom::End(0)).unwrap();
        let mut buf = b"pre-existing".to_vec();
        let n = r.read_to_end(&mut buf).unwrap();
        assert_eq!(n, 0);
        // The pre-existing content must be untouched.
        assert_eq!(buf, b"pre-existing");
        let _ = std::fs::remove_file(&p);
    }

    // ---- read after seek past EOF ----

    #[test]
    fn read_to_end_after_seek_past_eof_returns_zero() {
        // read_to_end (the override) when pos > len must return 0, same as
        // the raw read impl. Confirm the two fast-paths agree.
        let p = write_temp("rte-past-eof", b"ab");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        r.seek(SeekFrom::Start(1000)).unwrap();
        let mut buf = Vec::new();
        let n = r.read_to_end(&mut buf).unwrap();
        assert_eq!(n, 0);
        assert!(buf.is_empty());
        let _ = std::fs::remove_file(&p);
    }

    // ---- read_dir ----

    #[test]
    fn read_dir_yields_entries_for_real_directory() {
        // read_dir is delegated to StdFs; smoke-test that MmapFs exposes
        // it correctly. Use the system temp dir as a known-present directory.
        let fs = MmapFs::new();
        let tmp = std::env::temp_dir();
        let entries = fs.read_dir(&tmp).unwrap();
        // The temp dir always contains at least the test files we create;
        // just assert the call succeeds and returns a Vec.
        let _ = entries;
    }

    #[test]
    fn read_dir_on_file_returns_error() {
        // Calling read_dir on a regular file must return an error, not an
        // empty Vec. The exact error kind is OS-dependent (NotADirectory on
        // Linux, Other on some platforms) — pin only that it IS an error.
        let p = write_temp("read-dir-file", b"x");
        let fs = MmapFs::new();
        let result = fs.read_dir(&p);
        assert!(result.is_err(), "read_dir on a file must return an error");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn read_dir_on_missing_path_returns_error() {
        let fs = MmapFs::new();
        let result = fs.read_dir(Path::new("/nonexistent/sqpack-rs-mmap-read-dir-missing"));
        assert!(result.is_err());
    }

    // ---- metadata error path ----

    #[test]
    fn metadata_on_missing_path_returns_not_found() {
        let fs = MmapFs::new();
        let err = fs
            .metadata(Path::new("/nonexistent/sqpack-rs-mmap-meta-missing"))
            .unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::NotFound);
    }

    // ---- evict semantics ----

    #[test]
    fn evict_absent_path_returns_false() {
        // evict on a path that was never cached must return false without
        // panicking. The docstring says "returns true if any entry was removed."
        let fs = MmapFs::new();
        let removed = fs.evict(Path::new("/nonexistent/sqpack-rs-mmap-evict-absent"));
        assert!(!removed);
    }

    #[test]
    fn double_evict_same_path_second_call_returns_false() {
        // First evict returns true; second evict on the same (now-absent)
        // path must return false.
        let p = write_temp("double-evict", b"de");
        let fs = MmapFs::new();
        let _ = fs.open_read(&p).unwrap();
        assert!(fs.evict(&p), "first evict must return true");
        assert!(
            !fs.evict(&p),
            "second evict on empty cache must return false"
        );
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn evict_does_not_break_outstanding_reader() {
        // An open MmapReader holds its own Arc<Mmap>; evicting the cache
        // entry must not affect the reader's ability to continue reading.
        let p = write_temp("evict-live-reader", b"still readable after evict");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        // Advance past the first 6 bytes so the remaining read is meaningful.
        r.seek(SeekFrom::Start(6)).unwrap();
        // Evict while the reader is live.
        let removed = fs.evict(&p);
        assert!(removed);
        assert!(fs.is_empty());
        // Reader must still work — it holds its own Arc<Mmap>.
        let mut tail = Vec::new();
        r.read_to_end(&mut tail).unwrap();
        assert_eq!(tail, b"readable after evict");
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn clear_does_not_break_outstanding_reader() {
        // Same contract as evict_does_not_break_outstanding_reader but via
        // clear(), which drops all cache entries at once.
        let p = write_temp("clear-live-reader", b"intact after clear");
        let fs = MmapFs::new();
        let mut r = fs.open_read(&p).unwrap();
        fs.clear();
        assert!(fs.is_empty());
        // Reader's Arc<Mmap> must keep the mapping alive.
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"intact after clear");
        let _ = std::fs::remove_file(&p);
    }

    // ---- with_capacity(0) identical to new() ----

    #[test]
    fn with_capacity_zero_is_equivalent_to_new() {
        // The docstring says "MmapFs::with_capacity(0) behaves identically
        // to MmapFs::new()". Verify the cache starts empty and can grow.
        let p = write_temp("cap-zero", b"cap-zero-bytes");
        let fs = MmapFs::with_capacity(0);
        assert!(fs.is_empty());
        let mut r = fs.open_read(&p).unwrap();
        let mut buf = Vec::new();
        r.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, b"cap-zero-bytes");
        assert!(!fs.is_empty());
        let _ = std::fs::remove_file(&p);
    }

    // ---- open_read on a directory ----

    #[test]
    fn open_read_on_directory_returns_error() {
        // Opening a directory via mmap must return an error. The exact
        // error kind is OS-dependent — pin only that it IS an error.
        let fs = MmapFs::new();
        let tmp = std::env::temp_dir();
        let result = fs.open_read(&tmp);
        assert!(
            result.is_err(),
            "open_read on a directory must return an error"
        );
    }

    // ---- concurrent open_read race: exactly one mmap created ----

    #[test]
    fn concurrent_open_read_same_path_creates_one_mapping() {
        // Multiple threads simultaneously calling open_read for the same
        // uncached path must not create duplicate mappings. After all threads
        // finish, the cache must contain at most 2 entries for the one file
        // (canonical key + optional raw-key shortcut).
        use std::sync::Barrier;
        use std::thread;

        let p = write_temp("race-open", &vec![0u8; 256]);
        let fs = MmapFs::new();
        let fs = Arc::new(fs);
        let barrier = Arc::new(Barrier::new(8));

        let handles: Vec<_> = (0..8)
            .map(|_| {
                let fs_c = Arc::clone(&fs);
                let p_c = p.clone();
                let bar = Arc::clone(&barrier);
                thread::spawn(move || {
                    // All threads hit open_read at the same time.
                    bar.wait();
                    let mut r = fs_c.open_read(&p_c).unwrap();
                    let mut buf = Vec::new();
                    r.read_to_end(&mut buf).unwrap();
                    assert_eq!(buf.len(), 256);
                })
            })
            .collect();

        for h in handles {
            h.join().expect("thread panicked");
        }

        // Cache must stay bounded: at most 2 entries for the one real file.
        let cache_len = fs.len();
        assert!(
            (1..=2).contains(&cache_len),
            "expected 1 or 2 cache entries after concurrent opens, got {cache_len}"
        );

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