//! Hash/path verification, manifest building, and integrity scanning.
//!
//! The module provides two complementary verification strategies, each with a
//! manifest type, a builder, and a verifier:
//!
//! **Path-keyed** (`SqPackPathManifest` / `SqPackPathVerifier` /
//! `SqPackPathManifestBuilder`): identifies files by their game-path string
//! (e.g. `"exd/item.exh"`). Suitable for targeted "repair a specific file"
//! workflows. The builder hashes each named file; the verifier re-hashes and
//! compares against the manifest.
//!
//! **Hash-keyed** (`SqPackHashManifest` / `SqPackHashVerifier` /
//! `SqPackHashManifestBuilder`): identifies files by their raw index hash key
//! ([`IndexHashKey`]) rather than a resolved path. Suitable for full-install
//! integrity scans that do not require a known path list. The builder walks
//! every archive in the install.
//!
//! Both strategies support optional cancellation via [`crate::cancel::CancelToken`]
//! and progress callbacks via observer traits. The `parallel` feature flag adds
//! Rayon-backed `verify_parallel` / `build_parallel` counterparts via
//! [`crate::parallel`].

use std::collections::BTreeMap;

use sha2::{Digest, Sha256};
use tracing::{debug, debug_span, info, info_span};

use crate::cancel::CancelToken;
use crate::dat::FileKind;
use crate::error::{Error, Result};
use crate::observer::{HashScanObserver, NoopObserver, PathVerifyObserver};
use crate::route::{ArchiveTriple, Category};
use crate::sqpack::SqPack;
use crate::tracing_schema::span_names;

/// Expected SHA-256 hash and uncompressed byte size for a single game-path entry.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub struct PathManifestEntry {
    /// SHA-256 of the file's decompressed bytes.
    pub hash: [u8; 32],
    /// Uncompressed file size in bytes.
    pub size: u32,
}

impl PathManifestEntry {
    /// Construct a new entry with the given hash and size.
    #[must_use]
    pub fn new(hash: [u8; 32], size: u32) -> Self {
        PathManifestEntry { hash, size }
    }
}

/// Path-keyed manifest: maps game-path strings to expected hash+size.
///
/// Built by [`SqPackPathManifestBuilder`] and consumed by [`SqPackPathVerifier`].
/// The `serde` feature enables serialisation for offline storage.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub struct SqPackPathManifest {
    /// Map from game-path (e.g. `"exd/item.exh"`) to expected metadata.
    pub entries: BTreeMap<String, PathManifestEntry>,
}

impl SqPackPathManifest {
    /// Create an empty manifest.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Number of entries.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` if the manifest contains no entries.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Look up the expected metadata for `game_path`.
    #[must_use]
    pub fn get(&self, game_path: &str) -> Option<&PathManifestEntry> {
        self.entries.get(game_path)
    }
}

/// Result of a single [`SqPackPathManifestBuilder::add`] call.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathManifestAddOutcome {
    /// The file was hashed and added to the manifest.
    Added(PathManifestEntry),
    /// The file kind cannot be hashed (e.g. `Empty`); the entry was not added.
    Skipped { game_path: String, kind: FileKind },
    /// The build was already cancelled; no action was taken.
    Cancelled,
}

/// Incrementally builds a [`SqPackPathManifest`] by hashing named game-path files.
///
/// Obtain via [`SqPackPathManifestBuilder::new`], optionally attach a cancel
/// token and observer, then call [`add`](SqPackPathManifestBuilder::add) for
/// each path. Finalize with [`finish`](SqPackPathManifestBuilder::finish).
pub struct SqPackPathManifestBuilder<'a> {
    sq: &'a SqPack,
    entries: BTreeMap<String, PathManifestEntry>,
    cancel: Option<CancelToken>,
    observer: Box<dyn PathVerifyObserver + 'a>,
    cancelled: bool,
    scratch: Vec<u8>,
}

impl<'a> SqPackPathManifestBuilder<'a> {
    /// Create a builder backed by `sqpack`.
    #[must_use]
    pub fn new(sqpack: &'a SqPack) -> Self {
        Self {
            sq: sqpack,
            entries: BTreeMap::new(),
            cancel: None,
            observer: Box::new(NoopObserver),
            cancelled: false,
            scratch: Vec::with_capacity(16 * 1024),
        }
    }

    /// Attach a [`CancelToken`](crate::cancel::CancelToken) to allow cooperative cancellation.
    #[must_use]
    pub fn with_cancel_token(mut self, token: CancelToken) -> Self {
        self.cancel = Some(token);
        self
    }

    /// Attach a progress/cancellation observer.
    #[must_use]
    pub fn with_observer<O>(mut self, observer: O) -> Self
    where
        O: PathVerifyObserver + 'a,
    {
        self.observer = Box::new(observer);
        self
    }

    /// Hash the file at `game_path` and record it in the manifest.
    ///
    /// Returns [`PathManifestAddOutcome::Skipped`] for file kinds that cannot
    /// be meaningfully hashed (e.g. `Empty`). Returns
    /// [`PathManifestAddOutcome::Cancelled`] if cancellation was signalled
    /// before the call.
    pub fn add(&mut self, game_path: &str) -> Result<PathManifestAddOutcome> {
        if self.cancelled {
            return Ok(PathManifestAddOutcome::Cancelled);
        }
        if self.should_stop() {
            self.cancelled = true;
            return Ok(PathManifestAddOutcome::Cancelled);
        }

        let span = debug_span!(span_names::MANIFEST_ADD, path = %game_path);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let mut stream = match self.sq.read_file_streaming(game_path) {
            Ok(stream) => stream,
            Err(Error::UnsupportedFileKind {
                kind: kind @ (FileKind::Model | FileKind::Texture),
            }) => {
                return self.add_via_typed(game_path, kind, started_at);
            }
            Err(Error::UnsupportedFileKind { kind }) => {
                debug!(
                    path = %game_path,
                    kind = ?kind,
                    "manifest.add skipped (unverifiable kind)",
                );
                self.observer.on_skipped(game_path, kind);
                return Ok(PathManifestAddOutcome::Skipped {
                    game_path: game_path.to_owned(),
                    kind,
                });
            }
            Err(err) => return Err(err),
        };

        if stream.file_info().kind() == FileKind::Empty {
            debug!(
                path = %game_path,
                kind = ?FileKind::Empty,
                "manifest.add skipped (unverifiable kind)",
            );
            self.observer.on_skipped(game_path, FileKind::Empty);
            return Ok(PathManifestAddOutcome::Skipped {
                game_path: game_path.to_owned(),
                kind: FileKind::Empty,
            });
        }

        let raw_file_size = stream.raw_file_size();
        let mut hasher = Sha256::new();
        while let Some(res) = stream.read_block_into(&mut self.scratch) {
            res?;
            hasher.update(&self.scratch);
        }
        let hash: [u8; 32] = hasher.finalize().into();
        let entry = PathManifestEntry {
            hash,
            size: raw_file_size,
        };
        self.entries.insert(game_path.to_owned(), entry);

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        debug!(
            path = %game_path,
            size = raw_file_size,
            elapsed_ms,
            "manifest.add recorded",
        );
        self.observer.on_file_verified(game_path, raw_file_size);
        Ok(PathManifestAddOutcome::Added(entry))
    }

    fn add_via_typed(
        &mut self,
        game_path: &str,
        kind: FileKind,
        started_at: std::time::Instant,
    ) -> Result<PathManifestAddOutcome> {
        let payload = self.sq.read_file_typed(game_path)?;
        let bytes = payload.bytes();
        #[allow(clippy::cast_possible_truncation)]
        let raw_file_size = bytes.len() as u32;
        let mut hasher = Sha256::new();
        hasher.update(bytes);
        let hash: [u8; 32] = hasher.finalize().into();
        let entry = PathManifestEntry {
            hash,
            size: raw_file_size,
        };
        self.entries.insert(game_path.to_owned(), entry);

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        debug!(
            path = %game_path,
            size = raw_file_size,
            kind = ?kind,
            elapsed_ms,
            "manifest.add recorded (typed)",
        );
        self.observer.on_file_verified(game_path, raw_file_size);
        Ok(PathManifestAddOutcome::Added(entry))
    }

    fn should_stop(&mut self) -> bool {
        if let Some(c) = self.cancel.as_ref() {
            if c.is_cancelled() {
                return true;
            }
        }
        self.observer.should_cancel()
    }

    /// Number of entries added so far.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` if no entries have been added yet.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// `true` if the build was stopped by a cancel signal.
    #[must_use]
    pub fn cancelled(&self) -> bool {
        self.cancelled
    }

    /// Consume the builder and return the completed manifest.
    #[must_use]
    pub fn finish(self) -> SqPackPathManifest {
        let count = self.entries.len();
        info!(
            entries = count,
            cancelled = self.cancelled,
            "manifest.finish",
        );
        SqPackPathManifest {
            entries: self.entries,
        }
    }
}

impl std::fmt::Debug for SqPackPathManifestBuilder<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SqPackPathManifestBuilder")
            .field("entries", &self.entries.len())
            .field("has_cancel_token", &self.cancel.is_some())
            .field("cancelled", &self.cancelled)
            .finish_non_exhaustive()
    }
}

/// Reason a path-keyed verification entry did not pass.
///
/// Returned inside [`PathMismatch`] from [`SqPackPathVerifier`] and
/// [`SqPackPathVerifier::verify_streaming`].
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathMismatchKind {
    /// Hashes differ between the manifest and the live file.
    HashMismatch {
        expected: [u8; 32],
        actual: [u8; 32],
    },
    /// Uncompressed sizes differ.
    SizeMismatch { expected: u32, actual: u32 },
    /// The game-path was not found in the archive.
    Missing,
    /// The file kind cannot be verified (e.g. `Empty` or an unsupported kind).
    Unverifiable { kind: FileKind },
    /// An I/O or parse error occurred while reading the file.
    ReadError {
        message: String,
        error_kind: Option<std::io::ErrorKind>,
    },
}

/// A single path-keyed verification failure.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathMismatch {
    /// Game path that failed verification.
    pub game_path: String,
    /// Archive category inferred from the game path.
    pub category: Category,
    /// What went wrong.
    pub kind: PathMismatchKind,
}

impl PathMismatch {
    pub(crate) fn new(game_path: String, category: Category, kind: PathMismatchKind) -> Self {
        Self {
            game_path,
            category,
            kind,
        }
    }
}

/// Summary of a path-keyed verification run from [`SqPackPathVerifier`].
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PathVerifyReport {
    /// Files that failed any check (hash, size, missing, or unverifiable).
    pub mismatches: Vec<PathMismatch>,
    /// Files that passed all checks.
    pub verified_count: usize,
    /// Files that were skipped (unverifiable kind).
    pub skipped_count: usize,
    /// `true` if the run was stopped early by a cancel signal.
    pub cancelled: bool,
}

impl PathVerifyReport {
    /// Group mismatches by archive [`Category`] for display or filtering.
    #[must_use]
    pub fn by_category(&self) -> BTreeMap<Category, Vec<&PathMismatch>> {
        let mut grouped: BTreeMap<Category, Vec<&PathMismatch>> = BTreeMap::new();
        for m in &self.mismatches {
            grouped.entry(m.category).or_default().push(m);
        }
        grouped
    }

    #[must_use]
    pub fn is_clean(&self) -> bool {
        self.mismatches.is_empty() && self.skipped_count == 0 && !self.cancelled
    }

    #[must_use]
    pub fn total(&self) -> usize {
        self.verified_count + self.mismatches.len()
    }
}

/// Verifies a [`SqPackPathManifest`] against live install data.
///
/// Supports sequential ([`verify`](SqPackPathVerifier::verify)),
/// streaming ([`verify_streaming`](SqPackPathVerifier::verify_streaming)),
/// and (with the `parallel` feature) parallel
/// ([`verify_parallel`](SqPackPathVerifier::verify_parallel)) modes.
pub struct SqPackPathVerifier<'a> {
    sq: &'a SqPack,
    cancel: Option<CancelToken>,
    observer: Box<dyn PathVerifyObserver + 'a>,
}

impl<'a> SqPackPathVerifier<'a> {
    /// Create a verifier backed by `sqpack`.
    #[must_use]
    pub fn new(sqpack: &'a SqPack) -> Self {
        Self {
            sq: sqpack,
            cancel: None,
            observer: Box::new(NoopObserver),
        }
    }

    /// Attach a cancel token for cooperative cancellation.
    #[must_use]
    pub fn with_cancel_token(mut self, token: CancelToken) -> Self {
        self.cancel = Some(token);
        self
    }

    /// Attach a progress/cancellation observer.
    #[must_use]
    pub fn with_observer<O>(mut self, observer: O) -> Self
    where
        O: PathVerifyObserver + 'a,
    {
        self.observer = Box::new(observer);
        self
    }

    /// Verify all entries in `manifest`, collecting results into a [`PathVerifyReport`].
    pub fn verify(&mut self, manifest: &SqPackPathManifest) -> PathVerifyReport {
        let span = info_span!(span_names::VERIFY_RUN, entries = manifest.entries.len());
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let mut stream = self.verify_streaming(manifest);
        let mut mismatches = Vec::new();
        for mismatch in stream.by_ref() {
            mismatches.push(mismatch);
        }
        let cancelled = stream.cancelled;
        let verified_count = stream.verified_count();
        let skipped_count = stream.skipped_count();
        drop(stream);

        let report = PathVerifyReport {
            mismatches,
            verified_count,
            skipped_count,
            cancelled,
        };

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            entries = manifest.entries.len(),
            verified = report.verified_count,
            skipped = report.skipped_count,
            mismatches = report.mismatches.len(),
            cancelled = report.cancelled,
            elapsed_ms,
            "verify.run",
        );
        report
    }

    /// Return a streaming iterator that yields mismatches one at a time.
    ///
    /// Useful when the caller wants to react to each failure immediately rather
    /// than accumulating them all into a [`PathVerifyReport`].
    pub fn verify_streaming<'v, 'm>(
        &'v mut self,
        manifest: &'m SqPackPathManifest,
    ) -> PathVerifyStream<'v, 'a, 'm> {
        PathVerifyStream {
            sq: self.sq,
            cancel: self.cancel.as_ref(),
            observer: &mut *self.observer,
            iter: manifest.entries.iter(),
            verified_count: 0,
            skipped_count: 0,
            cancelled: false,
            scratch: Vec::with_capacity(16 * 1024),
        }
    }

    #[cfg(feature = "parallel")]
    #[cfg_attr(docsrs, doc(cfg(feature = "parallel")))]
    pub fn verify_parallel(&mut self, manifest: &SqPackPathManifest) -> PathVerifyReport {
        crate::parallel::verify_parallel(self, manifest)
    }

    #[cfg(feature = "parallel")]
    pub(crate) fn sq_internal(&self) -> &'a SqPack {
        self.sq
    }

    #[cfg(feature = "parallel")]
    pub(crate) fn cancel_internal(&self) -> Option<&CancelToken> {
        self.cancel.as_ref()
    }
}

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

/// Streaming iterator of path-keyed verification mismatches.
///
/// Produced by [`SqPackPathVerifier::verify_streaming`]. Yields only
/// [`PathMismatch`] items (clean files are silently counted). I/O errors
/// are surfaced as [`PathMismatchKind::ReadError`] mismatches without
/// halting iteration. Stops on cancel signal.
pub struct PathVerifyStream<'v, 'a, 'm> {
    sq: &'a SqPack,
    cancel: Option<&'v CancelToken>,
    observer: &'v mut (dyn PathVerifyObserver + 'a),
    iter: std::collections::btree_map::Iter<'m, String, PathManifestEntry>,
    verified_count: usize,
    skipped_count: usize,
    cancelled: bool,
    scratch: Vec<u8>,
}

impl PathVerifyStream<'_, '_, '_> {
    /// Number of entries that passed verification so far.
    #[must_use]
    pub fn verified_count(&self) -> usize {
        self.verified_count
    }

    /// Number of entries skipped (unverifiable kind) so far.
    #[must_use]
    pub fn skipped_count(&self) -> usize {
        self.skipped_count
    }

    /// `true` if iteration was stopped by a cancel signal.
    #[must_use]
    pub fn cancelled(&self) -> bool {
        self.cancelled
    }

    fn should_stop(&mut self) -> bool {
        if let Some(c) = self.cancel {
            if c.is_cancelled() {
                return true;
            }
        }
        self.observer.should_cancel()
    }

    #[allow(clippy::too_many_lines)]
    fn verify_one(
        &mut self,
        game_path: &str,
        expected: &PathManifestEntry,
    ) -> Option<PathMismatch> {
        let category = ArchiveTriple::from_game_path(game_path)
            .map_or_else(Category::placeholder, |t| t.category);

        let mut stream = match self.sq.read_file_streaming(game_path) {
            Ok(s) => s,
            Err(Error::PathNotFound { .. }) => {
                let m =
                    PathMismatch::new(game_path.to_owned(), category, PathMismatchKind::Missing);
                self.observer.on_mismatch(&m);
                return Some(m);
            }
            Err(Error::UnsupportedFileKind {
                kind: kind @ (FileKind::Model | FileKind::Texture),
            }) => {
                return self.verify_one_typed(game_path, category, kind, expected);
            }
            Err(Error::UnsupportedFileKind { kind }) => {
                self.skipped_count += 1;
                debug!(
                    path = %game_path,
                    kind = ?kind,
                    "verify entry unverifiable",
                );
                self.observer.on_skipped(game_path, kind);
                let m = PathMismatch::new(
                    game_path.to_owned(),
                    category,
                    PathMismatchKind::Unverifiable { kind },
                );
                self.observer.on_mismatch(&m);
                return Some(m);
            }
            Err(err) => {
                let error_kind = err.io_error_kind();
                let m = PathMismatch::new(
                    game_path.to_owned(),
                    category,
                    PathMismatchKind::ReadError {
                        message: err.to_string(),
                        error_kind,
                    },
                );
                self.observer.on_mismatch(&m);
                return Some(m);
            }
        };

        if stream.file_info().kind() == FileKind::Empty {
            self.skipped_count += 1;
            debug!(
                path = %game_path,
                kind = ?FileKind::Empty,
                "verify entry unverifiable",
            );
            self.observer.on_skipped(game_path, FileKind::Empty);
            let m = PathMismatch::new(
                game_path.to_owned(),
                category,
                PathMismatchKind::Unverifiable {
                    kind: FileKind::Empty,
                },
            );
            self.observer.on_mismatch(&m);
            return Some(m);
        }

        let actual_size = stream.raw_file_size();
        if actual_size != expected.size {
            debug!(
                path = %game_path,
                expected_size = expected.size,
                actual_size,
                "verify size mismatch",
            );
            let m = PathMismatch::new(
                game_path.to_owned(),
                category,
                PathMismatchKind::SizeMismatch {
                    expected: expected.size,
                    actual: actual_size,
                },
            );
            self.observer.on_mismatch(&m);
            return Some(m);
        }

        let mut hasher = Sha256::new();
        while let Some(res) = stream.read_block_into(&mut self.scratch) {
            if let Err(err) = res {
                let error_kind = err.io_error_kind();
                let m = PathMismatch::new(
                    game_path.to_owned(),
                    category,
                    PathMismatchKind::ReadError {
                        message: err.to_string(),
                        error_kind,
                    },
                );
                self.observer.on_mismatch(&m);
                return Some(m);
            }
            hasher.update(&self.scratch);
        }
        let actual: [u8; 32] = hasher.finalize().into();
        if actual != expected.hash {
            debug!(path = %game_path, "verify hash mismatch");
            let m = PathMismatch::new(
                game_path.to_owned(),
                category,
                PathMismatchKind::HashMismatch {
                    expected: expected.hash,
                    actual,
                },
            );
            self.observer.on_mismatch(&m);
            return Some(m);
        }

        self.verified_count += 1;
        self.observer.on_file_verified(game_path, actual_size);
        None
    }

    fn verify_one_typed(
        &mut self,
        game_path: &str,
        category: Category,
        kind: FileKind,
        expected: &PathManifestEntry,
    ) -> Option<PathMismatch> {
        let payload = match self.sq.read_file_typed(game_path) {
            Ok(p) => p,
            Err(err) => {
                let error_kind = err.io_error_kind();
                let m = PathMismatch::new(
                    game_path.to_owned(),
                    category,
                    PathMismatchKind::ReadError {
                        message: err.to_string(),
                        error_kind,
                    },
                );
                self.observer.on_mismatch(&m);
                return Some(m);
            }
        };
        let bytes = payload.bytes();
        #[allow(clippy::cast_possible_truncation)]
        let actual_size = bytes.len() as u32;
        if actual_size != expected.size {
            debug!(
                path = %game_path,
                expected_size = expected.size,
                actual_size,
                kind = ?kind,
                "verify size mismatch (typed)",
            );
            let m = PathMismatch::new(
                game_path.to_owned(),
                category,
                PathMismatchKind::SizeMismatch {
                    expected: expected.size,
                    actual: actual_size,
                },
            );
            self.observer.on_mismatch(&m);
            return Some(m);
        }
        let mut hasher = Sha256::new();
        hasher.update(bytes);
        let actual: [u8; 32] = hasher.finalize().into();
        if actual != expected.hash {
            debug!(path = %game_path, kind = ?kind, "verify hash mismatch (typed)");
            let m = PathMismatch::new(
                game_path.to_owned(),
                category,
                PathMismatchKind::HashMismatch {
                    expected: expected.hash,
                    actual,
                },
            );
            self.observer.on_mismatch(&m);
            return Some(m);
        }
        self.verified_count += 1;
        self.observer.on_file_verified(game_path, actual_size);
        None
    }
}

impl Iterator for PathVerifyStream<'_, '_, '_> {
    type Item = PathMismatch;

    fn next(&mut self) -> Option<Self::Item> {
        if self.cancelled {
            return None;
        }
        loop {
            if self.should_stop() {
                self.cancelled = true;
                return None;
            }
            let (game_path, expected) = self.iter.next()?;
            if let Some(event) = self.verify_one(game_path, expected) {
                return Some(event);
            }
        }
    }
}

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

use crate::header::DatLocation;
use crate::sqpack::EnumeratedArchive;

/// Unique key for a hash-keyed manifest entry: index format, archive triple, and raw hash.
///
/// The three variants correspond to the two index file formats (`.index` and
/// `.index2`) plus the synonym table used when multiple paths hash-collide in
/// a V1 index.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub enum IndexHashKey {
    /// Entry from a `.index` (V1) file — 64-bit combined path hash.
    V1 { triple: ArchiveTriple, hash: u64 },
    /// Entry from a `.index2` (V2) file — 32-bit full-path hash.
    V2 { triple: ArchiveTriple, hash: u32 },
    /// Entry from the V1 synonym table; used when two paths share the same 64-bit hash.
    V1Synonym {
        triple: ArchiveTriple,
        hash: u64,
        /// Disambiguates among paths that share the same `hash`.
        conflict_index: u32,
    },
}

impl IndexHashKey {
    /// Archive triple (category, expansion, chunk) this key belongs to.
    #[must_use]
    pub fn triple(&self) -> ArchiveTriple {
        match self {
            IndexHashKey::V1 { triple, .. }
            | IndexHashKey::V2 { triple, .. }
            | IndexHashKey::V1Synonym { triple, .. } => *triple,
        }
    }

    /// Archive [`Category`] for this key; shorthand for `self.triple().category`.
    #[must_use]
    pub fn category(&self) -> Category {
        self.triple().category
    }
}

/// Expected SHA-256 hash, uncompressed size, and optional known path for a hash-keyed entry.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub struct HashManifestEntry {
    /// SHA-256 of the file's decompressed bytes.
    pub hash: [u8; 32],
    /// Uncompressed file size in bytes.
    pub size: u32,
    /// Optional game path if known at build time (e.g. from the synonym table).
    pub path: Option<String>,
}

impl HashManifestEntry {
    /// Construct an entry without a known path.
    #[must_use]
    pub fn new(hash: [u8; 32], size: u32) -> Self {
        Self {
            hash,
            size,
            path: None,
        }
    }

    /// Attach a known game path to this entry.
    #[must_use]
    pub fn with_path(mut self, path: impl Into<String>) -> Self {
        self.path = Some(path.into());
        self
    }
}

/// Hash-keyed manifest: maps [`IndexHashKey`]s to expected hash+size.
///
/// Built by [`SqPackHashManifestBuilder`] and consumed by [`SqPackHashVerifier`].
/// The custom `serde` impl serialises the entries as a sequence of `(key, value)` pairs
/// to preserve ordering while remaining compatible with JSON arrays.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub struct SqPackHashManifest {
    /// Map from index hash key to expected metadata.
    pub entries: BTreeMap<IndexHashKey, HashManifestEntry>,
}

#[cfg(feature = "serde")]
impl serde::Serialize for SqPackHashManifest {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        use serde::ser::SerializeSeq;
        let mut seq = serializer.serialize_seq(Some(self.entries.len()))?;
        for (k, v) in &self.entries {
            seq.serialize_element(&(k, v))?;
        }
        seq.end()
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for SqPackHashManifest {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let pairs: Vec<(IndexHashKey, HashManifestEntry)> = Vec::deserialize(deserializer)?;
        let mut entries = BTreeMap::new();
        for (k, v) in pairs {
            entries.insert(k, v);
        }
        Ok(Self { entries })
    }
}

impl SqPackHashManifest {
    /// Create an empty manifest.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Number of entries.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` if the manifest contains no entries.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Look up the expected metadata for `key`.
    #[must_use]
    pub fn get(&self, key: &IndexHashKey) -> Option<&HashManifestEntry> {
        self.entries.get(key)
    }
}

/// Reason a single entry could not be added to a [`SqPackHashManifest`] during a build.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HashManifestBuildIssueKind {
    /// The file kind cannot be hashed (e.g. `Empty`).
    Unverifiable { kind: FileKind },
    /// An I/O or parse error occurred.
    ReadError { message: String },
}

/// A single entry that could not be hashed during [`SqPackHashManifestBuilder::build`].
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HashManifestBuildIssue {
    /// Key of the entry that could not be hashed.
    pub key: IndexHashKey,
    /// Reason for the failure.
    pub kind: HashManifestBuildIssueKind,
}

/// Summary of a [`SqPackHashManifestBuilder::build`] run.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HashManifestBuildReport {
    /// The manifest that was built (possibly incomplete if cancelled or there were issues).
    pub manifest: SqPackHashManifest,
    /// Number of entries successfully hashed.
    pub verified_count: usize,
    /// Number of entries skipped due to issues.
    pub skipped_count: usize,
    /// `true` if the build was stopped early by a cancel signal.
    pub cancelled: bool,
    /// Entries that could not be hashed.
    pub issues: Vec<HashManifestBuildIssue>,
}

impl HashManifestBuildReport {
    /// Total entries attempted (`verified_count` + `skipped_count`).
    #[must_use]
    pub fn total(&self) -> usize {
        self.verified_count + self.skipped_count
    }
}

/// Builds a [`SqPackHashManifest`] by scanning every archive in the install.
///
/// Unlike [`SqPackPathManifestBuilder`], no path list is required: every
/// entry in every `.index` and `.index2` file is hashed.
pub struct SqPackHashManifestBuilder<'a> {
    sq: &'a SqPack,
    cancel: Option<CancelToken>,
    observer: Box<dyn HashScanObserver + 'a>,
    scratch: Vec<u8>,
}

impl<'a> SqPackHashManifestBuilder<'a> {
    /// Create a builder backed by `sqpack`.
    #[must_use]
    pub fn new(sqpack: &'a SqPack) -> Self {
        Self {
            sq: sqpack,
            cancel: None,
            observer: Box::new(NoopObserver),
            scratch: Vec::with_capacity(16 * 1024),
        }
    }

    /// Attach a cancel token for cooperative cancellation.
    #[must_use]
    pub fn with_cancel_token(mut self, token: CancelToken) -> Self {
        self.cancel = Some(token);
        self
    }

    /// Attach a progress/cancellation observer.
    #[must_use]
    pub fn with_observer<O>(mut self, observer: O) -> Self
    where
        O: HashScanObserver + 'a,
    {
        self.observer = Box::new(observer);
        self
    }

    /// Walk every archive in the install, hash all entries, and return the manifest.
    pub fn build(&mut self) -> Result<HashManifestBuildReport> {
        let span = info_span!(span_names::HASH_MANIFEST_BUILD);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let mut manifest = SqPackHashManifest::new();
        let mut issues: Vec<HashManifestBuildIssue> = Vec::new();
        let mut verified_count: usize = 0;
        let mut cancelled = false;

        for archive_result in self.sq.enumerate_archives() {
            if Self::should_stop(self.cancel.as_ref(), self.observer.as_mut()) {
                cancelled = true;
                break;
            }

            let archive = match archive_result {
                Ok(a) => a,
                Err(err) => {
                    let key = IndexHashKey::V1 {
                        triple: ArchiveTriple {
                            category: Category::placeholder(),
                            expansion: 0,
                            chunk: 0,
                        },
                        hash: 0,
                    };
                    let issue = HashManifestBuildIssue {
                        key,
                        kind: HashManifestBuildIssueKind::ReadError {
                            message: err.to_string(),
                        },
                    };
                    self.observer.on_entry_build_issue(&issue);
                    issues.push(issue);
                    continue;
                }
            };

            match archive {
                EnumeratedArchive::V1(triple, idx) => {
                    if Self::scan_v1(
                        self.sq,
                        self.cancel.as_ref(),
                        self.observer.as_mut(),
                        &mut self.scratch,
                        triple,
                        &idx,
                        &mut manifest,
                        &mut issues,
                        &mut verified_count,
                    ) {
                        cancelled = true;
                        break;
                    }
                }
                EnumeratedArchive::V2(triple, idx) => {
                    if Self::scan_v2(
                        self.sq,
                        self.cancel.as_ref(),
                        self.observer.as_mut(),
                        &mut self.scratch,
                        triple,
                        &idx,
                        &mut manifest,
                        &mut issues,
                        &mut verified_count,
                    ) {
                        cancelled = true;
                        break;
                    }
                }
            }
        }

        let skipped_count = issues.len();
        let total = verified_count + skipped_count;

        let report = HashManifestBuildReport {
            manifest,
            verified_count,
            skipped_count,
            cancelled,
            issues,
        };

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            entries = total,
            verified = verified_count,
            skipped = skipped_count,
            cancelled,
            elapsed_ms,
            "hash_manifest.build",
        );
        Ok(report)
    }

    #[cfg(feature = "parallel")]
    #[cfg_attr(docsrs, doc(cfg(feature = "parallel")))]
    pub fn build_parallel(&mut self) -> Result<HashManifestBuildReport> {
        crate::parallel::hash_build_parallel(self)
    }

    #[cfg(feature = "parallel")]
    pub(crate) fn sq_internal(&self) -> &'a SqPack {
        self.sq
    }

    #[cfg(feature = "parallel")]
    pub(crate) fn cancel_internal(&self) -> Option<&CancelToken> {
        self.cancel.as_ref()
    }

    fn should_stop(cancel: Option<&CancelToken>, observer: &mut dyn HashScanObserver) -> bool {
        if let Some(c) = cancel {
            if c.is_cancelled() {
                return true;
            }
        }
        observer.should_cancel()
    }

    #[allow(clippy::too_many_arguments)]
    fn scan_v1(
        sq: &SqPack,
        cancel: Option<&CancelToken>,
        observer: &mut dyn HashScanObserver,
        scratch: &mut Vec<u8>,
        triple: ArchiveTriple,
        idx: &crate::index::Index,
        manifest: &mut SqPackHashManifest,
        issues: &mut Vec<HashManifestBuildIssue>,
        verified_count: &mut usize,
    ) -> bool {
        for entry in idx {
            if Self::should_stop(cancel, observer) {
                return true;
            }
            if entry.is_synonym() {
                for syn in idx.synonyms_by_hash(entry.hash) {
                    if Self::should_stop(cancel, observer) {
                        return true;
                    }
                    let syn_key = IndexHashKey::V1Synonym {
                        triple,
                        hash: syn.hash(),
                        conflict_index: syn.conflict_index(),
                    };
                    let stored_path = syn.path().into_owned();
                    Self::hash_one(
                        sq,
                        observer,
                        scratch,
                        triple,
                        syn_key,
                        syn.dat_location(),
                        manifest,
                        issues,
                        verified_count,
                        Some(stored_path),
                    );
                }
                continue;
            }
            let key = IndexHashKey::V1 {
                triple,
                hash: entry.hash,
            };
            let Some(location) = entry.dat_location() else {
                continue;
            };
            Self::hash_one(
                sq,
                observer,
                scratch,
                triple,
                key,
                location,
                manifest,
                issues,
                verified_count,
                None,
            );
        }
        false
    }

    #[allow(clippy::too_many_arguments)]
    fn scan_v2(
        sq: &SqPack,
        cancel: Option<&CancelToken>,
        observer: &mut dyn HashScanObserver,
        scratch: &mut Vec<u8>,
        triple: ArchiveTriple,
        idx: &crate::index2::Index2,
        manifest: &mut SqPackHashManifest,
        issues: &mut Vec<HashManifestBuildIssue>,
        verified_count: &mut usize,
    ) -> bool {
        for entry in idx {
            if Self::should_stop(cancel, observer) {
                return true;
            }
            let key = IndexHashKey::V2 {
                triple,
                hash: entry.hash,
            };
            let location = entry.dat_location();
            Self::hash_one(
                sq,
                observer,
                scratch,
                triple,
                key,
                location,
                manifest,
                issues,
                verified_count,
                None,
            );
        }
        false
    }

    #[allow(clippy::too_many_arguments)]
    fn hash_one(
        sq: &SqPack,
        observer: &mut dyn HashScanObserver,
        scratch: &mut Vec<u8>,
        triple: ArchiveTriple,
        key: IndexHashKey,
        location: DatLocation,
        manifest: &mut SqPackHashManifest,
        issues: &mut Vec<HashManifestBuildIssue>,
        verified_count: &mut usize,
        path_hint: Option<String>,
    ) {
        let mut dat = match sq.open_dat_for(triple, location.data_file_id) {
            Ok(d) => d,
            Err(err) => {
                let issue = HashManifestBuildIssue {
                    key,
                    kind: HashManifestBuildIssueKind::ReadError {
                        message: err.to_string(),
                    },
                };
                observer.on_entry_build_issue(&issue);
                issues.push(issue);
                return;
            }
        };
        let mut stream = match dat.read_file_streaming(location.byte_offset) {
            Ok(s) => s,
            Err(Error::UnsupportedFileKind {
                kind: kind @ (FileKind::Model | FileKind::Texture),
            }) => {
                Self::hash_one_typed(
                    &mut dat,
                    observer,
                    triple,
                    key,
                    location,
                    kind,
                    manifest,
                    issues,
                    verified_count,
                    path_hint,
                );
                return;
            }
            Err(Error::UnsupportedFileKind { kind }) => {
                let issue = HashManifestBuildIssue {
                    key,
                    kind: HashManifestBuildIssueKind::Unverifiable { kind },
                };
                observer.on_entry_build_issue(&issue);
                issues.push(issue);
                return;
            }
            Err(err) => {
                let issue = HashManifestBuildIssue {
                    key,
                    kind: HashManifestBuildIssueKind::ReadError {
                        message: err.to_string(),
                    },
                };
                observer.on_entry_build_issue(&issue);
                issues.push(issue);
                return;
            }
        };
        if stream.file_info().kind() == FileKind::Empty {
            let issue = HashManifestBuildIssue {
                key,
                kind: HashManifestBuildIssueKind::Unverifiable {
                    kind: FileKind::Empty,
                },
            };
            observer.on_entry_build_issue(&issue);
            issues.push(issue);
            return;
        }
        let raw_file_size = stream.raw_file_size();
        let mut hasher = Sha256::new();
        while let Some(res) = stream.read_block_into(scratch) {
            if let Err(err) = res {
                let issue = HashManifestBuildIssue {
                    key,
                    kind: HashManifestBuildIssueKind::ReadError {
                        message: err.to_string(),
                    },
                };
                observer.on_entry_build_issue(&issue);
                issues.push(issue);
                return;
            }
            hasher.update(scratch.as_slice());
        }
        let hash: [u8; 32] = hasher.finalize().into();
        let mut entry = HashManifestEntry::new(hash, raw_file_size);
        entry.path = path_hint;
        manifest.entries.insert(key, entry);
        *verified_count += 1;
        observer.on_entry_hashed(&key, raw_file_size);
    }

    #[allow(clippy::too_many_arguments)]
    fn hash_one_typed(
        dat: &mut crate::dat::DatFile,
        observer: &mut dyn HashScanObserver,
        _triple: ArchiveTriple,
        key: IndexHashKey,
        location: DatLocation,
        kind: FileKind,
        manifest: &mut SqPackHashManifest,
        issues: &mut Vec<HashManifestBuildIssue>,
        verified_count: &mut usize,
        path_hint: Option<String>,
    ) {
        let payload = match dat.read_file_typed(location.byte_offset) {
            Ok(p) => p,
            Err(err) => {
                let issue = HashManifestBuildIssue {
                    key,
                    kind: HashManifestBuildIssueKind::ReadError {
                        message: err.to_string(),
                    },
                };
                observer.on_entry_build_issue(&issue);
                issues.push(issue);
                return;
            }
        };
        let bytes = payload.bytes();
        #[allow(clippy::cast_possible_truncation)]
        let raw_file_size = bytes.len() as u32;
        let mut hasher = Sha256::new();
        hasher.update(bytes);
        let hash: [u8; 32] = hasher.finalize().into();
        let mut entry = HashManifestEntry::new(hash, raw_file_size);
        entry.path = path_hint;
        manifest.entries.insert(key, entry);
        *verified_count += 1;
        debug!(
            kind = ?kind,
            raw_file_size,
            "hash_manifest.build typed entry",
        );
        observer.on_entry_hashed(&key, raw_file_size);
    }
}

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

/// A single hash-keyed verification failure.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HashMismatch {
    /// The index key that failed.
    pub key: IndexHashKey,
    /// What went wrong; uses the same variant set as [`PathMismatchKind`].
    pub kind: PathMismatchKind,
    /// Optional game path if known (from the synonym table or a path list).
    pub path: Option<String>,
}

impl HashMismatch {
    #[cfg(any(test, feature = "parallel"))]
    pub(crate) fn new(key: IndexHashKey, kind: PathMismatchKind, path: Option<String>) -> Self {
        Self { key, kind, path }
    }
}

/// Summary of a hash-keyed verification run from [`SqPackHashVerifier`].
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HashVerifyReport {
    /// Entries that failed any check.
    pub mismatches: Vec<HashMismatch>,
    /// Entries that passed all checks.
    pub verified_count: usize,
    /// Entries skipped (unverifiable kind).
    pub skipped_count: usize,
    /// `true` if the run was stopped early by a cancel signal.
    pub cancelled: bool,
    /// Entries present in the install but absent from the manifest.
    pub extras: Vec<IndexHashKey>,
}

impl HashVerifyReport {
    /// `true` if every entry verified successfully (no mismatches, no skips, no extras, not cancelled).
    #[must_use]
    pub fn is_clean(&self) -> bool {
        self.mismatches.is_empty()
            && self.skipped_count == 0
            && !self.cancelled
            && self.extras.is_empty()
    }

    /// Total entries processed (`verified_count` + mismatches).
    #[must_use]
    pub fn total(&self) -> usize {
        self.verified_count + self.mismatches.len()
    }

    /// Group mismatches by archive [`Category`] for display or filtering.
    #[must_use]
    pub fn by_category(&self) -> BTreeMap<Category, Vec<&HashMismatch>> {
        let mut grouped: BTreeMap<Category, Vec<&HashMismatch>> = BTreeMap::new();
        for m in &self.mismatches {
            grouped.entry(m.key.category()).or_default().push(m);
        }
        grouped
    }
}

/// Verifies a [`SqPackHashManifest`] against live install data by walking all archives.
///
/// Supports sequential ([`verify`](SqPackHashVerifier::verify)),
/// streaming ([`verify_streaming`](SqPackHashVerifier::verify_streaming)),
/// and (with the `parallel` feature) parallel
/// ([`verify_parallel`](SqPackHashVerifier::verify_parallel)) modes.
pub struct SqPackHashVerifier<'a> {
    sq: &'a SqPack,
    cancel: Option<CancelToken>,
    observer: Box<dyn HashScanObserver + 'a>,
}

impl<'a> SqPackHashVerifier<'a> {
    /// Create a verifier backed by `sqpack`.
    #[must_use]
    pub fn new(sqpack: &'a SqPack) -> Self {
        Self {
            sq: sqpack,
            cancel: None,
            observer: Box::new(NoopObserver),
        }
    }

    /// Attach a cancel token for cooperative cancellation.
    #[must_use]
    pub fn with_cancel_token(mut self, token: CancelToken) -> Self {
        self.cancel = Some(token);
        self
    }

    /// Attach a progress/cancellation observer.
    #[must_use]
    pub fn with_observer<O>(mut self, observer: O) -> Self
    where
        O: HashScanObserver + 'a,
    {
        self.observer = Box::new(observer);
        self
    }

    /// Verify all entries in `manifest`, collecting results into a [`HashVerifyReport`].
    pub fn verify(&mut self, manifest: &SqPackHashManifest) -> HashVerifyReport {
        let span = info_span!(
            span_names::HASH_VERIFY_RUN,
            entries = manifest.entries.len()
        );
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let mut stream = self.verify_streaming(manifest);
        let mut mismatches: Vec<HashMismatch> = Vec::new();
        let mut extras: Vec<IndexHashKey> = Vec::new();
        for event in stream.by_ref() {
            match event {
                HashVerifyEvent::PathMismatch(m) => mismatches.push(m),
                HashVerifyEvent::Extra(k) => extras.push(k),
            }
        }
        let verified_count = stream.verified_count();
        let skipped_count = stream.skipped_count();
        let cancelled = stream.cancelled();
        drop(stream);

        let report = HashVerifyReport {
            mismatches,
            verified_count,
            skipped_count,
            cancelled,
            extras,
        };

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            entries = manifest.entries.len(),
            verified = report.verified_count,
            skipped = report.skipped_count,
            mismatches = report.mismatches.len(),
            extras = report.extras.len(),
            cancelled = report.cancelled,
            elapsed_ms,
            "hash_verify.run",
        );
        report
    }

    /// Return a streaming iterator that yields verification events one at a time.
    pub fn verify_streaming<'v, 'm>(
        &'v mut self,
        manifest: &'m SqPackHashManifest,
    ) -> HashVerifyStream<'v, 'a, 'm> {
        HashVerifyStream {
            sq: self.sq,
            cancel: self.cancel.as_ref(),
            observer: &mut *self.observer,
            manifest,
            walker: self.sq.enumerate_archives(),
            current: None,
            phase: HashVerifyPhase::Walk,
            seen: std::collections::BTreeSet::new(),
            missing_iter: None,
            synonym_queue: std::collections::VecDeque::new(),
            verified_count: 0,
            skipped_count: 0,
            cancelled: false,
            scratch: Vec::with_capacity(16 * 1024),
        }
    }

    #[cfg(feature = "parallel")]
    #[cfg_attr(docsrs, doc(cfg(feature = "parallel")))]
    pub fn verify_parallel(&mut self, manifest: &SqPackHashManifest) -> HashVerifyReport {
        crate::parallel::hash_verify_parallel(self, manifest)
    }

    #[cfg(feature = "parallel")]
    pub(crate) fn sq_internal(&self) -> &'a SqPack {
        self.sq
    }

    #[cfg(feature = "parallel")]
    pub(crate) fn cancel_internal(&self) -> Option<&CancelToken> {
        self.cancel.as_ref()
    }
}

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

#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HashVerifyEvent {
    PathMismatch(HashMismatch),
    Extra(IndexHashKey),
}

impl HashVerifyEvent {
    #[must_use]
    pub fn into_mismatch(self) -> Option<HashMismatch> {
        match self {
            HashVerifyEvent::PathMismatch(m) => Some(m),
            HashVerifyEvent::Extra(_) => None,
        }
    }

    #[must_use]
    pub fn into_extra(self) -> Option<IndexHashKey> {
        match self {
            HashVerifyEvent::Extra(k) => Some(k),
            HashVerifyEvent::PathMismatch(_) => None,
        }
    }
}

enum CurrentArchive {
    V1 {
        triple: ArchiveTriple,
        idx: crate::index::Index,
        cursor: usize,
    },
    V2 {
        triple: ArchiveTriple,
        idx: crate::index2::Index2,
        cursor: usize,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HashVerifyPhase {
    Walk,
    Reconcile,
    Done,
}

enum StepOutcome {
    Event(HashVerifyEvent),
    NoEvent,
    NoCurrent,
    Cancel,
}

/// Streaming iterator that verifies hash-keyed manifest entries one at a time.
///
/// Produced by [`SqPackHashVerifier::verify_streaming`]. Each call to
/// [`Iterator::next`] skips passing entries internally and yields a
/// [`HashVerifyEvent`] only for mismatches or extras. Returns `None` when
/// all entries have been processed or cancellation is signalled.
///
/// Use [`HashVerifyStream::verified_count`] and
/// [`HashVerifyStream::skipped_count`] to track progress.
pub struct HashVerifyStream<'v, 'a, 'm> {
    sq: &'a SqPack,
    cancel: Option<&'v CancelToken>,
    observer: &'v mut (dyn HashScanObserver + 'a),
    manifest: &'m SqPackHashManifest,
    walker: crate::sqpack::ArchiveWalker<'a>,
    current: Option<CurrentArchive>,
    phase: HashVerifyPhase,
    seen: std::collections::BTreeSet<IndexHashKey>,
    missing_iter: Option<std::collections::btree_map::Iter<'m, IndexHashKey, HashManifestEntry>>,
    synonym_queue: std::collections::VecDeque<PendingSynonym>,
    verified_count: usize,
    skipped_count: usize,
    cancelled: bool,
    scratch: Vec<u8>,
}

#[derive(Debug, Clone)]
struct PendingSynonym {
    triple: ArchiveTriple,
    key: IndexHashKey,
    location: DatLocation,
}

impl HashVerifyStream<'_, '_, '_> {
    /// Number of entries that have been checked so far (pass or fail).
    #[must_use]
    pub fn verified_count(&self) -> usize {
        self.verified_count
    }

    /// Number of entries skipped because their file kind was unverifiable.
    #[must_use]
    pub fn skipped_count(&self) -> usize {
        self.skipped_count
    }

    /// Return `true` if the stream stopped early due to a cancel signal.
    #[must_use]
    pub fn cancelled(&self) -> bool {
        self.cancelled
    }

    fn should_stop(&mut self) -> bool {
        if let Some(c) = self.cancel {
            if c.is_cancelled() {
                return true;
            }
        }
        self.observer.should_cancel()
    }

    fn step(&mut self) -> StepOutcome {
        match self.phase {
            HashVerifyPhase::Walk => self.step_walk(),
            HashVerifyPhase::Reconcile => self.step_reconcile(),
            HashVerifyPhase::Done => {
                debug_assert!(
                    false,
                    "step() called on Done phase — next() should have short-circuited",
                );
                StepOutcome::Cancel
            }
        }
    }

    #[allow(clippy::too_many_lines)]
    fn step_walk(&mut self) -> StepOutcome {
        if !self.synonym_queue.is_empty() {
            if self.should_stop() {
                return StepOutcome::Cancel;
            }
            let pending = self.synonym_queue.pop_front().expect("non-empty queue");
            return self.compare_one(pending.triple, pending.key, pending.location);
        }

        if self.current.is_none() {
            if self.should_stop() {
                return StepOutcome::Cancel;
            }
            let Some(archive_result) = self.walker.next() else {
                self.phase = HashVerifyPhase::Reconcile;
                self.missing_iter = Some(self.manifest.entries.iter());
                return StepOutcome::NoEvent;
            };
            match archive_result {
                Ok(EnumeratedArchive::V1(triple, idx)) => {
                    self.current = Some(CurrentArchive::V1 {
                        triple,
                        idx,
                        cursor: 0,
                    });
                    return StepOutcome::NoEvent;
                }
                Ok(EnumeratedArchive::V2(triple, idx)) => {
                    self.current = Some(CurrentArchive::V2 {
                        triple,
                        idx,
                        cursor: 0,
                    });
                    return StepOutcome::NoEvent;
                }
                Err(err) => {
                    let key = IndexHashKey::V1 {
                        triple: ArchiveTriple {
                            category: Category::placeholder(),
                            expansion: 0,
                            chunk: 0,
                        },
                        hash: 0,
                    };
                    self.seen.insert(key);
                    let error_kind = err.io_error_kind();
                    let m = HashMismatch {
                        key,
                        kind: PathMismatchKind::ReadError {
                            message: err.to_string(),
                            error_kind,
                        },
                        path: None,
                    };
                    self.observer.on_entry_mismatch(&m);
                    return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
                }
            }
        }

        if self.should_stop() {
            return StepOutcome::Cancel;
        }

        let next_entry = match self.current.as_mut() {
            Some(CurrentArchive::V1 {
                triple,
                idx,
                cursor,
            }) => {
                let entries = idx.entries();
                if *cursor >= entries.len() {
                    None
                } else {
                    let entry = entries[*cursor];
                    *cursor += 1;
                    if entry.is_synonym() {
                        for syn in idx.synonyms_by_hash(entry.hash) {
                            let syn_key = IndexHashKey::V1Synonym {
                                triple: *triple,
                                hash: syn.hash(),
                                conflict_index: syn.conflict_index(),
                            };
                            self.synonym_queue.push_back(PendingSynonym {
                                triple: *triple,
                                key: syn_key,
                                location: syn.dat_location(),
                            });
                        }
                        Some(EntryStep::Skip)
                    } else {
                        let key = IndexHashKey::V1 {
                            triple: *triple,
                            hash: entry.hash,
                        };
                        if let Some(location) = entry.dat_location() {
                            Some(EntryStep::Compare {
                                triple: *triple,
                                key,
                                location,
                            })
                        } else {
                            Some(EntryStep::Skip)
                        }
                    }
                }
            }
            Some(CurrentArchive::V2 {
                triple,
                idx,
                cursor,
            }) => {
                let entries = idx.entries();
                if *cursor >= entries.len() {
                    None
                } else {
                    let entry = entries[*cursor];
                    *cursor += 1;
                    let key = IndexHashKey::V2 {
                        triple: *triple,
                        hash: entry.hash,
                    };
                    Some(EntryStep::Compare {
                        triple: *triple,
                        key,
                        location: entry.dat_location(),
                    })
                }
            }
            None => None,
        };

        match next_entry {
            None => {
                self.current = None;
                StepOutcome::NoCurrent
            }
            Some(EntryStep::Skip) => StepOutcome::NoEvent,
            Some(EntryStep::Compare {
                triple,
                key,
                location,
            }) => self.compare_one(triple, key, location),
        }
    }

    fn step_reconcile(&mut self) -> StepOutcome {
        loop {
            if self.should_stop() {
                return StepOutcome::Cancel;
            }
            let iter = self
                .missing_iter
                .as_mut()
                .expect("missing_iter set when entering reconcile phase");
            let Some((key, expected)) = iter.next() else {
                self.phase = HashVerifyPhase::Done;
                return StepOutcome::NoCurrent;
            };
            if self.seen.contains(key) {
                continue;
            }
            let m = HashMismatch {
                key: *key,
                kind: PathMismatchKind::Missing,
                path: expected.path.clone(),
            };
            self.observer.on_entry_mismatch(&m);
            return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
        }
    }

    #[allow(clippy::too_many_lines)]
    fn compare_one(
        &mut self,
        triple: ArchiveTriple,
        key: IndexHashKey,
        location: DatLocation,
    ) -> StepOutcome {
        let Some(expected) = self.manifest.entries.get(&key) else {
            self.observer.on_entry_extra(&key);
            return StepOutcome::Event(HashVerifyEvent::Extra(key));
        };
        self.seen.insert(key);
        let path_hint = expected.path.clone();

        let mut dat = match self.sq.open_dat_for(triple, location.data_file_id) {
            Ok(d) => d,
            Err(err) => {
                let error_kind = err.io_error_kind();
                let m = HashMismatch {
                    key,
                    kind: PathMismatchKind::ReadError {
                        message: err.to_string(),
                        error_kind,
                    },
                    path: path_hint,
                };
                self.observer.on_entry_mismatch(&m);
                return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
            }
        };
        let mut stream = match dat.read_file_streaming(location.byte_offset) {
            Ok(s) => s,
            Err(Error::UnsupportedFileKind {
                kind: kind @ (FileKind::Model | FileKind::Texture),
            }) => {
                return self.compare_one_typed(
                    &mut dat,
                    key,
                    location,
                    kind,
                    expected.hash,
                    expected.size,
                    path_hint,
                );
            }
            Err(Error::UnsupportedFileKind { kind }) => {
                self.skipped_count += 1;
                let m = HashMismatch {
                    key,
                    kind: PathMismatchKind::Unverifiable { kind },
                    path: path_hint,
                };
                self.observer.on_entry_mismatch(&m);
                return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
            }
            Err(err) => {
                let error_kind = err.io_error_kind();
                let m = HashMismatch {
                    key,
                    kind: PathMismatchKind::ReadError {
                        message: err.to_string(),
                        error_kind,
                    },
                    path: path_hint,
                };
                self.observer.on_entry_mismatch(&m);
                return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
            }
        };
        if stream.file_info().kind() == FileKind::Empty {
            self.skipped_count += 1;
            let m = HashMismatch {
                key,
                kind: PathMismatchKind::Unverifiable {
                    kind: FileKind::Empty,
                },
                path: path_hint,
            };
            self.observer.on_entry_mismatch(&m);
            return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
        }
        let actual_size = stream.raw_file_size();
        if actual_size != expected.size {
            let m = HashMismatch {
                key,
                kind: PathMismatchKind::SizeMismatch {
                    expected: expected.size,
                    actual: actual_size,
                },
                path: path_hint,
            };
            self.observer.on_entry_mismatch(&m);
            return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
        }
        let mut hasher = Sha256::new();
        while let Some(res) = stream.read_block_into(&mut self.scratch) {
            if let Err(err) = res {
                let error_kind = err.io_error_kind();
                let m = HashMismatch {
                    key,
                    kind: PathMismatchKind::ReadError {
                        message: err.to_string(),
                        error_kind,
                    },
                    path: path_hint,
                };
                self.observer.on_entry_mismatch(&m);
                return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
            }
            hasher.update(&self.scratch);
        }
        let actual: [u8; 32] = hasher.finalize().into();
        if actual != expected.hash {
            let m = HashMismatch {
                key,
                kind: PathMismatchKind::HashMismatch {
                    expected: expected.hash,
                    actual,
                },
                path: path_hint,
            };
            self.observer.on_entry_mismatch(&m);
            return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
        }
        self.verified_count += 1;
        self.observer.on_entry_hashed(&key, actual_size);
        StepOutcome::NoEvent
    }

    #[allow(clippy::too_many_arguments)]
    fn compare_one_typed(
        &mut self,
        dat: &mut crate::dat::DatFile,
        key: IndexHashKey,
        location: DatLocation,
        kind: FileKind,
        expected_hash: [u8; 32],
        expected_size: u32,
        path_hint: Option<String>,
    ) -> StepOutcome {
        let payload = match dat.read_file_typed(location.byte_offset) {
            Ok(p) => p,
            Err(err) => {
                let error_kind = err.io_error_kind();
                let m = HashMismatch {
                    key,
                    kind: PathMismatchKind::ReadError {
                        message: err.to_string(),
                        error_kind,
                    },
                    path: path_hint,
                };
                self.observer.on_entry_mismatch(&m);
                return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
            }
        };
        let bytes = payload.bytes();
        #[allow(clippy::cast_possible_truncation)]
        let actual_size = bytes.len() as u32;
        if actual_size != expected_size {
            let m = HashMismatch {
                key,
                kind: PathMismatchKind::SizeMismatch {
                    expected: expected_size,
                    actual: actual_size,
                },
                path: path_hint,
            };
            self.observer.on_entry_mismatch(&m);
            return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
        }
        let mut hasher = Sha256::new();
        hasher.update(bytes);
        let actual: [u8; 32] = hasher.finalize().into();
        if actual != expected_hash {
            let m = HashMismatch {
                key,
                kind: PathMismatchKind::HashMismatch {
                    expected: expected_hash,
                    actual,
                },
                path: path_hint,
            };
            self.observer.on_entry_mismatch(&m);
            return StepOutcome::Event(HashVerifyEvent::PathMismatch(m));
        }
        self.verified_count += 1;
        debug!(kind = ?kind, "hash_verify typed entry verified");
        self.observer.on_entry_hashed(&key, actual_size);
        StepOutcome::NoEvent
    }
}

enum EntryStep {
    Compare {
        triple: ArchiveTriple,
        key: IndexHashKey,
        location: DatLocation,
    },
    Skip,
}

impl Iterator for HashVerifyStream<'_, '_, '_> {
    type Item = HashVerifyEvent;

    fn next(&mut self) -> Option<Self::Item> {
        if self.cancelled || self.phase == HashVerifyPhase::Done {
            return None;
        }
        loop {
            match self.step() {
                StepOutcome::Event(ev) => return Some(ev),
                StepOutcome::NoEvent | StepOutcome::NoCurrent => {
                    if self.phase == HashVerifyPhase::Done {
                        return None;
                    }
                }
                StepOutcome::Cancel => {
                    self.cancelled = true;
                    self.phase = HashVerifyPhase::Done;
                    return None;
                }
            }
        }
    }
}

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

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

    #[test]
    fn manifest_default_is_empty() {
        let m = SqPackPathManifest::new();
        assert!(m.is_empty());
        assert_eq!(m.len(), 0);
        assert!(m.get("anything").is_none());
    }

    #[test]
    fn manifest_entry_ctor_round_trips() {
        let hash = [7u8; 32];
        let e = PathManifestEntry::new(hash, 42);
        assert_eq!(e.hash, hash);
        assert_eq!(e.size, 42);
    }

    #[test]
    fn report_default_is_clean_when_empty_and_zero_counts() {
        let r = PathVerifyReport::default();
        assert!(r.is_clean());
        assert_eq!(r.total(), 0);
    }

    #[test]
    fn report_is_clean_only_when_no_mismatches_and_no_skips() {
        let r = PathVerifyReport {
            mismatches: vec![],
            verified_count: 5,
            skipped_count: 0,
            cancelled: false,
        };
        assert!(r.is_clean());

        let r = PathVerifyReport {
            mismatches: vec![],
            verified_count: 5,
            skipped_count: 1,
            cancelled: false,
        };
        assert!(!r.is_clean(), "skipped entries make the report not-clean");

        let r = PathVerifyReport {
            mismatches: vec![PathMismatch::new(
                "exd/x".into(),
                Category::try_from_name("exd").unwrap(),
                PathMismatchKind::Missing,
            )],
            verified_count: 5,
            skipped_count: 0,
            cancelled: false,
        };
        assert!(!r.is_clean(), "mismatches make the report not-clean");
    }

    #[test]
    fn report_by_category_groups_and_sorts() {
        let exd = Category::try_from_name("exd").unwrap();
        let bg = Category::try_from_name("bg").unwrap();
        let r = PathVerifyReport {
            mismatches: vec![
                PathMismatch::new("exd/a".into(), exd, PathMismatchKind::Missing),
                PathMismatch::new("bg/b".into(), bg, PathMismatchKind::Missing),
                PathMismatch::new("exd/c".into(), exd, PathMismatchKind::Missing),
            ],
            verified_count: 0,
            skipped_count: 0,
            cancelled: false,
        };
        let grouped = r.by_category();
        assert_eq!(grouped.len(), 2);
        assert_eq!(grouped[&exd].len(), 2);
        assert_eq!(grouped[&bg].len(), 1);
        let keys: Vec<_> = grouped.keys().copied().collect();
        assert_eq!(keys, vec![bg, exd]);
    }

    #[test]
    fn report_total_counts_verified_plus_mismatches_no_double_count() {
        let exd = Category::try_from_name("exd").unwrap();
        let r = PathVerifyReport {
            mismatches: vec![
                PathMismatch::new("exd/a".into(), exd, PathMismatchKind::Missing),
                PathMismatch::new(
                    "exd/b".into(),
                    exd,
                    PathMismatchKind::Unverifiable {
                        kind: FileKind::Model,
                    },
                ),
            ],
            verified_count: 3,
            skipped_count: 1,
            cancelled: false,
        };
        assert_eq!(r.total(), 5);
    }

    #[test]
    fn verify_types_are_send_and_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        fn assert_send<T: Send>() {}

        assert_send_sync::<SqPackPathManifest>();
        assert_send_sync::<PathManifestEntry>();
        assert_send::<SqPackPathManifestBuilder<'_>>();
        assert_send::<SqPackPathVerifier<'_>>();
        assert_send_sync::<PathVerifyReport>();
        assert_send_sync::<PathMismatch>();
        assert_send_sync::<PathMismatchKind>();
        assert_send_sync::<PathManifestAddOutcome>();
        assert_send::<PathVerifyStream<'_, '_, '_>>();
    }

    fn dummy_triple() -> ArchiveTriple {
        ArchiveTriple {
            category: Category::try_from_name("exd").unwrap(),
            expansion: 0,
            chunk: 0,
        }
    }

    #[test]
    fn hash_manifest_default_is_empty() {
        let m = SqPackHashManifest::new();
        assert!(m.is_empty());
        assert_eq!(m.len(), 0);
        let key = IndexHashKey::V2 {
            triple: dummy_triple(),
            hash: 0x12_34_56_78,
        };
        assert!(m.get(&key).is_none());
    }

    #[test]
    fn hash_manifest_entry_ctor_round_trips() {
        let hash = [9u8; 32];
        let e = HashManifestEntry::new(hash, 7);
        assert_eq!(e.hash, hash);
        assert_eq!(e.size, 7);
        assert!(e.path.is_none());
        let annotated = e.with_path("exd/foo.exh");
        assert_eq!(annotated.path.as_deref(), Some("exd/foo.exh"));
    }

    #[test]
    fn hash_key_triple_and_category_accessors() {
        let triple = dummy_triple();
        let k1 = IndexHashKey::V1 { triple, hash: 1 };
        let k2 = IndexHashKey::V2 { triple, hash: 2 };
        assert_eq!(k1.triple(), triple);
        assert_eq!(k2.triple(), triple);
        assert_eq!(k1.category(), triple.category);
        assert_eq!(k2.category(), triple.category);
    }

    #[test]
    fn hash_verify_report_is_clean_requires_all_four_predicates() {
        let r = HashVerifyReport::default();
        assert!(r.is_clean(), "default report must be clean");
        assert_eq!(r.total(), 0);

        let r = HashVerifyReport {
            skipped_count: 1,
            ..Default::default()
        };
        assert!(!r.is_clean());

        let r = HashVerifyReport {
            extras: vec![IndexHashKey::V2 {
                triple: dummy_triple(),
                hash: 1,
            }],
            ..Default::default()
        };
        assert!(!r.is_clean(), "extras must make the report not-clean");

        let r = HashVerifyReport {
            cancelled: true,
            ..Default::default()
        };
        assert!(!r.is_clean());

        let r = HashVerifyReport {
            mismatches: vec![HashMismatch::new(
                IndexHashKey::V2 {
                    triple: dummy_triple(),
                    hash: 1,
                },
                PathMismatchKind::Missing,
                None,
            )],
            ..Default::default()
        };
        assert!(!r.is_clean());
    }

    #[test]
    fn hash_verify_report_by_category_groups_by_triple_category() {
        let exd = Category::try_from_name("exd").unwrap();
        let bg = Category::try_from_name("bg").unwrap();
        let exd_triple = ArchiveTriple {
            category: exd,
            expansion: 0,
            chunk: 0,
        };
        let bg_triple = ArchiveTriple {
            category: bg,
            expansion: 0,
            chunk: 0,
        };
        let r = HashVerifyReport {
            mismatches: vec![
                HashMismatch::new(
                    IndexHashKey::V2 {
                        triple: exd_triple,
                        hash: 1,
                    },
                    PathMismatchKind::Missing,
                    None,
                ),
                HashMismatch::new(
                    IndexHashKey::V2 {
                        triple: bg_triple,
                        hash: 2,
                    },
                    PathMismatchKind::Missing,
                    None,
                ),
                HashMismatch::new(
                    IndexHashKey::V2 {
                        triple: exd_triple,
                        hash: 3,
                    },
                    PathMismatchKind::Missing,
                    None,
                ),
            ],
            ..Default::default()
        };
        let grouped = r.by_category();
        assert_eq!(grouped.len(), 2);
        assert_eq!(grouped[&exd].len(), 2);
        assert_eq!(grouped[&bg].len(), 1);
        let keys: Vec<_> = grouped.keys().copied().collect();
        assert_eq!(keys, vec![bg, exd]);
    }

    #[test]
    fn hash_build_report_total_counts_verified_plus_skipped() {
        let r = HashManifestBuildReport {
            verified_count: 5,
            skipped_count: 2,
            issues: vec![
                HashManifestBuildIssue {
                    key: IndexHashKey::V2 {
                        triple: dummy_triple(),
                        hash: 1,
                    },
                    kind: HashManifestBuildIssueKind::Unverifiable {
                        kind: FileKind::Model,
                    },
                },
                HashManifestBuildIssue {
                    key: IndexHashKey::V2 {
                        triple: dummy_triple(),
                        hash: 2,
                    },
                    kind: HashManifestBuildIssueKind::ReadError {
                        message: "x".into(),
                    },
                },
            ],
            ..Default::default()
        };
        assert_eq!(r.total(), 7);
    }

    #[test]
    fn hash_key_ord_v1_before_v2_then_by_hash() {
        let triple = dummy_triple();
        let v1_low = IndexHashKey::V1 { triple, hash: 1 };
        let v1_high = IndexHashKey::V1 { triple, hash: 2 };
        let v2_low = IndexHashKey::V2 { triple, hash: 1 };
        assert!(v1_low < v1_high, "V1 ordering by hash");
        assert!(v1_high < v2_low, "V1 sorts before V2");
    }

    #[test]
    fn hash_key_ord_v1synonym_sorts_after_v2() {
        let triple = dummy_triple();
        let v2_high = IndexHashKey::V2 {
            triple,
            hash: u32::MAX,
        };
        let syn_low = IndexHashKey::V1Synonym {
            triple,
            hash: 1,
            conflict_index: 1,
        };
        let syn_same_hash_higher_ci = IndexHashKey::V1Synonym {
            triple,
            hash: 1,
            conflict_index: 2,
        };
        let syn_higher_hash = IndexHashKey::V1Synonym {
            triple,
            hash: 2,
            conflict_index: 1,
        };
        assert!(
            v2_high < syn_low,
            "V2 (even u32::MAX hash) must sort before any V1Synonym",
        );
        assert!(
            syn_low < syn_same_hash_higher_ci,
            "V1Synonym with same hash: lower conflict_index sorts first",
        );
        assert!(
            syn_same_hash_higher_ci < syn_higher_hash,
            "V1Synonym: higher hash sorts after lower hash regardless of conflict_index",
        );
    }

    #[test]
    fn hash_key_v1synonym_category_accessor() {
        let triple = dummy_triple();
        let key = IndexHashKey::V1Synonym {
            triple,
            hash: 0xDEAD_BEEF,
            conflict_index: 7,
        };
        assert_eq!(key.triple(), triple, "triple() must return wrapped triple");
        assert_eq!(
            key.category(),
            triple.category,
            "category() must delegate to triple().category",
        );
    }

    #[test]
    fn hash_verify_types_are_send_and_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        fn assert_send<T: Send>() {}

        assert_send_sync::<SqPackHashManifest>();
        assert_send_sync::<HashManifestEntry>();
        assert_send_sync::<IndexHashKey>();
        assert_send_sync::<HashManifestBuildIssue>();
        assert_send_sync::<HashManifestBuildIssueKind>();
        assert_send_sync::<HashManifestBuildReport>();
        assert_send_sync::<HashVerifyReport>();
        assert_send_sync::<HashMismatch>();
        assert_send::<SqPackHashManifestBuilder<'_>>();
        assert_send::<SqPackHashVerifier<'_>>();
    }

    #[test]
    fn hash_verify_event_into_mismatch_extracts_only_mismatch_variant() {
        let m = HashMismatch::new(
            IndexHashKey::V2 {
                triple: dummy_triple(),
                hash: 1,
            },
            PathMismatchKind::Missing,
            None,
        );
        let ev = HashVerifyEvent::PathMismatch(m.clone());
        assert_eq!(ev.into_mismatch(), Some(m));

        let extra = IndexHashKey::V2 {
            triple: dummy_triple(),
            hash: 2,
        };
        let ev = HashVerifyEvent::Extra(extra);
        assert_eq!(
            ev.into_mismatch(),
            None,
            "Extra variant cannot be extracted as a mismatch",
        );
    }

    #[test]
    fn hash_verify_event_into_extra_extracts_only_extra_variant() {
        let extra = IndexHashKey::V1 {
            triple: dummy_triple(),
            hash: 0xDEAD_BEEF,
        };
        let ev = HashVerifyEvent::Extra(extra);
        assert_eq!(ev.into_extra(), Some(extra));

        let m = HashMismatch::new(
            IndexHashKey::V2 {
                triple: dummy_triple(),
                hash: 1,
            },
            PathMismatchKind::Missing,
            None,
        );
        let ev = HashVerifyEvent::PathMismatch(m);
        assert_eq!(
            ev.into_extra(),
            None,
            "PathMismatch variant cannot be extracted as an extra",
        );
    }

    #[test]
    fn hash_verify_stream_types_send_invariants() {
        fn assert_send_sync<T: Send + Sync>() {}
        fn assert_send<T: Send>() {}

        assert_send_sync::<HashVerifyEvent>();
        assert_send::<HashVerifyStream<'_, '_, '_>>();
    }
}
