//! Unified archive extraction (inspired by `secana/archive`).
//!
//! Provides a single [`Extractor`] interface for extracting files from
//! ZIP, TAR (gz/bz2/xz/zst/lz4), 7-Zip and single-file compression
//! formats entirely in memory.

use std::io::{self, Cursor, Read};
use std::path::{Component, Path, PathBuf};

use crate::error::{Error, Result};
use crate::format::ArchiveFormat;

/// A single file extracted from an archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExtractedFile {
    /// Relative path within the archive.
    pub path: String,
    /// File contents.
    #[serde(skip)]
    pub data: Vec<u8>,
    /// Size in bytes.
    pub size: u64,
    /// Whether this entry represents a directory.
    pub is_directory: bool,
}

/// Configurable archive extractor with safety limits.
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)]
pub struct Extractor {
    max_file_size: u64,
    max_total_size: u64,
    max_entries: usize,
    max_compression_ratio: f64,
}

impl Default for Extractor {
    fn default() -> Self {
        Self::new()
    }
}

impl Extractor {
    /// Create a new extractor with default safety limits.
    ///
    /// Defaults: 100 MiB per file, 1 GiB total, 100k entries, 1000:1 ratio.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            max_file_size: 100 * 1024 * 1024,
            max_total_size: 1024 * 1024 * 1024,
            max_entries: 100_000,
            max_compression_ratio: 1000.0,
        }
    }

    /// Set maximum allowed size for a single extracted file.
    #[must_use]
    pub const fn with_max_file_size(mut self, limit: u64) -> Self {
        self.max_file_size = limit;
        self
    }

    /// Set maximum total extraction size.
    #[must_use]
    pub const fn with_max_total_size(mut self, limit: u64) -> Self {
        self.max_total_size = limit;
        self
    }

    /// Set maximum number of entries.
    #[must_use]
    pub const fn with_max_entries(mut self, limit: usize) -> Self {
        self.max_entries = limit;
        self
    }

    /// Set maximum compression ratio (bomb protection).
    #[must_use]
    pub const fn with_max_compression_ratio(mut self, ratio: f64) -> Self {
        self.max_compression_ratio = ratio;
        self
    }

    /// Extract all files from archive data in the given format.
    ///
    /// # Errors
    ///
    /// Returns error on I/O failure, size limit violation, path traversal,
    /// or unsupported format.
    pub fn extract(&self, data: &[u8], format: ArchiveFormat) -> Result<Vec<ExtractedFile>> {
        // Archives with their own container formats
        if let Some(result) = self.try_extract_archive(data, format) {
            return result;
        }
        // Compressed TAR archives
        if let Some(result) = self.try_extract_compressed_tar(data, format) {
            return result;
        }
        // Single-file compression formats
        if let Some(result) = Self::try_decompress_single(data, format) {
            return self.extract_single(result?);
        }
        Err(Error::UnsupportedFormat(format.to_string()))
    }

    /// Try to extract a container archive format (ZIP, TAR, 7z, AR, DEB, CAB).
    fn try_extract_archive(
        &self,
        data: &[u8],
        format: ArchiveFormat,
    ) -> Option<Result<Vec<ExtractedFile>>> {
        match format {
            ArchiveFormat::Zip => Some(self.extract_zip(data)),
            ArchiveFormat::Tar => Some(self.extract_tar(data)),
            ArchiveFormat::SevenZip => Some(self.extract_7z(data)),
            ArchiveFormat::Ar => Some(self.extract_ar(data)),
            ArchiveFormat::Deb => Some(self.extract_deb(data)),
            ArchiveFormat::Cab => Some(self.extract_cab(data)),
            _ => None,
        }
    }

    /// Try to extract a compressed TAR archive.
    fn try_extract_compressed_tar(
        &self,
        data: &[u8],
        format: ArchiveFormat,
    ) -> Option<Result<Vec<ExtractedFile>>> {
        let decompressed = match format {
            ArchiveFormat::TarGz => decompress_gz(data),
            ArchiveFormat::TarBz2 => decompress_bz2(data),
            ArchiveFormat::TarXz => decompress_xz(data),
            ArchiveFormat::TarZst => decompress_zstd(data),
            ArchiveFormat::TarLz4 => decompress_lz4(data),
            _ => return None,
        };
        Some(decompressed.and_then(|d| self.extract_tar(&d)))
    }

    /// Try to decompress a single-file compression format.
    fn try_decompress_single(data: &[u8], format: ArchiveFormat) -> Option<Result<Vec<u8>>> {
        match format {
            ArchiveFormat::Gz => Some(decompress_gz(data)),
            ArchiveFormat::Bz2 => Some(decompress_bz2(data)),
            ArchiveFormat::Xz => Some(decompress_xz(data)),
            ArchiveFormat::Lz4 => Some(decompress_lz4(data)),
            ArchiveFormat::Zstd => Some(decompress_zstd(data)),
            ArchiveFormat::Brotli => Some(decompress_brotli(data)),
            ArchiveFormat::Snappy => Some(decompress_snappy(data)),
            ArchiveFormat::Lzma => Some(decompress_lzma(data)),
            ArchiveFormat::Bz3 => Some(decompress_bz3(data)),
            _ => None,
        }
    }

    /// Extract files from an archive at a filesystem path.
    ///
    /// Required for formats like RAR that need a file path (not byte slice).
    ///
    /// # Errors
    ///
    /// Returns an error on extraction failure or unsupported format.
    pub fn extract_path(&self, path: &Path, format: ArchiveFormat) -> Result<Vec<ExtractedFile>> {
        #[cfg(feature = "rar")]
        if format == ArchiveFormat::Rar {
            return self.extract_rar(path);
        }
        let data = std::fs::read(path)?;
        self.extract(&data, format)
    }

    /// List archive contents from a filesystem path.
    ///
    /// Required for formats like RAR that need a file path.
    ///
    /// # Errors
    ///
    /// Returns an error on parse failure or unsupported format.
    pub fn list_path(&self, path: &Path, format: ArchiveFormat) -> Result<Vec<ArchiveEntry>> {
        #[cfg(feature = "rar")]
        if format == ArchiveFormat::Rar {
            return self.list_rar(path);
        }
        let data = std::fs::read(path)?;
        self.list(&data, format)
    }

    /// List archive contents without extracting data.
    ///
    /// # Errors
    ///
    /// Returns error on I/O failure or unsupported format.
    pub fn list(&self, data: &[u8], format: ArchiveFormat) -> Result<Vec<ArchiveEntry>> {
        match format {
            ArchiveFormat::Zip => self.list_zip(data),
            ArchiveFormat::Tar
            | ArchiveFormat::TarGz
            | ArchiveFormat::TarBz2
            | ArchiveFormat::TarXz
            | ArchiveFormat::TarZst
            | ArchiveFormat::TarLz4 => {
                let tar_data = match format {
                    ArchiveFormat::TarGz => decompress_gz(data)?,
                    ArchiveFormat::TarBz2 => decompress_bz2(data)?,
                    ArchiveFormat::TarXz => decompress_xz(data)?,
                    ArchiveFormat::TarZst => decompress_zstd(data)?,
                    ArchiveFormat::TarLz4 => decompress_lz4(data)?,
                    _ => data.to_vec(),
                };
                self.list_tar(&tar_data)
            },
            ArchiveFormat::Ar => self.list_ar(data),
            ArchiveFormat::Cab => self.list_cab(data),
            other => Err(Error::UnsupportedFormat(other.to_string())),
        }
    }

    /// Validate whether archive data can be decompressed without writing output.
    ///
    /// Attempts a full in-memory extraction and reports whether it succeeded,
    /// along with entry count and total uncompressed size.
    ///
    /// # Errors
    ///
    /// Returns an error on I/O failure or unsupported format.
    pub fn validate(&self, data: &[u8], format: ArchiveFormat) -> Result<ValidationResult> {
        match self.extract(data, format) {
            Ok(files) => {
                let total: u64 = files.iter().map(|f| f.size).sum();
                // Check compression ratio
                #[allow(clippy::cast_precision_loss)]
                let compressed_size = data.len() as f64;
                if compressed_size > 0.0 {
                    #[allow(clippy::cast_precision_loss)]
                    let ratio = total as f64 / compressed_size;
                    if ratio > self.max_compression_ratio {
                        return Err(Error::CompressionBomb {
                            ratio,
                            limit: self.max_compression_ratio,
                        });
                    }
                }
                Ok(ValidationResult {
                    format,
                    entry_count: files.len(),
                    total_uncompressed_size: total,
                    is_valid: true,
                })
            },
            Err(e) => Err(e),
        }
    }

    /// Extract files from a ZIP archive.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid ZIP data, size limit violation, or I/O error.
    fn extract_zip(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
        let cursor = Cursor::new(data);
        let mut archive =
            zip::ZipArchive::new(cursor).map_err(|e| Error::InvalidArchive(e.to_string()))?;
        let mut files = Vec::new();
        let mut total_size: u64 = 0;

        if archive.len() > self.max_entries {
            return Err(Error::TooManyEntries {
                count: archive.len(),
                limit: self.max_entries,
            });
        }

        for i in 0..archive.len() {
            let mut entry = archive.by_index(i)?;

            if entry.encrypted() {
                return Err(Error::EncryptedNotSupported);
            }

            let path = sanitize_path(entry.name())?;
            let is_directory = entry.is_dir();

            if is_directory {
                files.push(ExtractedFile {
                    path,
                    // zero-alloc: Vec::new() doesn't heap-allocate until pushed to
                    data: Vec::new(),
                    size: 0,
                    is_directory: true,
                });
                continue;
            }

            let size = entry.size();
            if size > self.max_file_size {
                return Err(Error::FileTooLarge {
                    size,
                    limit: self.max_file_size,
                });
            }

            total_size = total_size.saturating_add(size);
            if total_size > self.max_total_size {
                return Err(Error::TotalSizeLimitExceeded {
                    limit: self.max_total_size,
                });
            }

            let mut buf = Vec::with_capacity(size.try_into().unwrap_or(0));
            entry.read_to_end(&mut buf)?;

            files.push(ExtractedFile {
                path,
                data: buf,
                size,
                is_directory: false,
            });
        }

        Ok(files)
    }

    /// Extract files from a TAR archive.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid TAR data, size limit violation, or I/O error.
    fn extract_tar(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
        let cursor = Cursor::new(data);
        let mut archive = tar::Archive::new(cursor);
        let mut files = Vec::new();
        let mut total_size: u64 = 0;

        for entry in archive.entries()? {
            let mut entry = entry?;

            // Reject symlinks and hardlinks for security
            let entry_type = entry.header().entry_type();
            if matches!(entry_type, tar::EntryType::Symlink | tar::EntryType::Link) {
                let link_name = entry
                    .path()
                    .map(|p| p.to_string_lossy().into_owned())
                    .unwrap_or_default();
                return Err(Error::SymlinkRejected(link_name));
            }

            let path = entry.path()?.to_str().map(String::from).unwrap_or_default();
            let safe_path = sanitize_path(&path)?;
            let is_directory = entry_type.is_dir();
            let size = entry.header().size()?;

            if files.len() >= self.max_entries {
                return Err(Error::TooManyEntries {
                    count: files.len(),
                    limit: self.max_entries,
                });
            }

            if is_directory {
                files.push(ExtractedFile {
                    path: safe_path,
                    // zero-alloc: Vec::new() doesn't heap-allocate until pushed to
                    data: Vec::new(),
                    size: 0,
                    is_directory: true,
                });
                continue;
            }

            if size > self.max_file_size {
                return Err(Error::FileTooLarge {
                    size,
                    limit: self.max_file_size,
                });
            }

            total_size = total_size.saturating_add(size);
            if total_size > self.max_total_size {
                return Err(Error::TotalSizeLimitExceeded {
                    limit: self.max_total_size,
                });
            }

            let mut buf = Vec::with_capacity(size.try_into().unwrap_or(0));
            entry.read_to_end(&mut buf)?;

            files.push(ExtractedFile {
                path: safe_path,
                data: buf,
                size,
                is_directory: false,
            });
        }

        Ok(files)
    }

    /// Extract files from a 7-Zip archive via a temporary directory.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid 7z data, size limit violation, or I/O error.
    fn extract_7z(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
        // Extract to a temporary directory, then read files back
        let tmp = tempfile::tempdir().map_err(Error::Io)?;
        let cursor = Cursor::new(data);

        sevenz_rust::decompress(cursor, tmp.path()).map_err(|e| Error::SevenZip(e.to_string()))?;

        let mut files = Vec::new();
        let mut total_size: u64 = 0;
        collect_7z_files(tmp.path(), tmp.path(), self, &mut files, &mut total_size)?;

        Ok(files)
    }

    /// Wrap single-file decompressed data as an extracted file.
    ///
    /// # Errors
    ///
    /// Returns an error if the decompressed size exceeds the file size limit.
    fn extract_single(&self, decompressed: Vec<u8>) -> Result<Vec<ExtractedFile>> {
        let size = decompressed.len() as u64;
        if size > self.max_file_size {
            return Err(Error::FileTooLarge {
                size,
                limit: self.max_file_size,
            });
        }

        Ok(vec![ExtractedFile {
            path: "decompressed".into(),
            data: decompressed,
            size,
            is_directory: false,
        }])
    }

    /// List entries in a ZIP archive without extracting data.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid ZIP data or I/O error.
    fn list_zip(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
        let cursor = Cursor::new(data);
        let mut archive =
            zip::ZipArchive::new(cursor).map_err(|e| Error::InvalidArchive(e.to_string()))?;
        let mut entries = Vec::new();

        for i in 0..archive.len() {
            let entry = archive.by_index_raw(i)?;
            entries.push(ArchiveEntry {
                path: entry.name().to_string(),
                compressed_size: entry.compressed_size(),
                uncompressed_size: entry.size(),
                is_directory: entry.is_dir(),
                compression_method: format!("{}", entry.compression()),
            });
        }

        Ok(entries)
    }

    /// List entries in a TAR archive without extracting data.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid TAR data or I/O error.
    fn list_tar(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
        let cursor = Cursor::new(data);
        let mut archive = tar::Archive::new(cursor);
        let mut entries = Vec::new();

        for entry in archive.entries()? {
            let entry = entry?;
            let path = entry.path()?.to_str().map(String::from).unwrap_or_default();
            let size = entry.header().size()?;

            entries.push(ArchiveEntry {
                path,
                compressed_size: size,
                uncompressed_size: size,
                is_directory: entry.header().entry_type().is_dir(),
                compression_method: "stored".into(),
            });
        }

        Ok(entries)
    }
}

/// Result of a dry-run validation (no data written to disk).
#[derive(Debug, Clone, serde::Serialize)]
pub struct ValidationResult {
    /// Detected archive format.
    pub format: ArchiveFormat,
    /// Number of entries found.
    pub entry_count: usize,
    /// Total uncompressed size in bytes.
    pub total_uncompressed_size: u64,
    /// Whether the archive can be fully decompressed.
    pub is_valid: bool,
}

/// Metadata about a single archive entry (for listing).
#[derive(Debug, Clone, serde::Serialize)]
pub struct ArchiveEntry {
    pub path: String,
    pub compressed_size: u64,
    pub uncompressed_size: u64,
    pub is_directory: bool,
    pub compression_method: String,
}

/// Sanitize archive paths to prevent path traversal.
///
/// # Errors
///
/// Returns an error if the path contains parent directory (`..`) or prefix components.
pub fn sanitize_path(raw: &str) -> Result<String> {
    let path = Path::new(raw);
    let mut safe = PathBuf::new();

    for component in path.components() {
        match component {
            Component::Normal(c) => safe.push(c),
            Component::RootDir | Component::CurDir => {},
            Component::ParentDir => {
                return Err(Error::PathTraversal(raw.to_string()));
            },
            Component::Prefix(_) => {
                return Err(Error::PathTraversal(raw.to_string()));
            },
        }
    }

    Ok(safe.to_string_lossy().into_owned())
}

// --- Decompression helpers ---

/// Recursively collect files extracted from 7z temp directory.
///
/// # Errors
///
/// Returns an error on I/O failure or size limit violation.
fn collect_7z_files(
    base: &Path,
    dir: &Path,
    extractor: &Extractor,
    files: &mut Vec<ExtractedFile>,
    total_size: &mut u64,
) -> Result<()> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let rel = path
            .strip_prefix(base)
            .map_err(|_| Error::PathTraversal("7z extraction escaped temp dir".into()))?
            .to_string_lossy()
            .into_owned();

        if path.is_dir() {
            files.push(ExtractedFile {
                path: rel,
                // zero-alloc: Vec::new() doesn't heap-allocate until pushed to
                data: Vec::new(),
                size: 0,
                is_directory: true,
            });
            collect_7z_files(base, &path, extractor, files, total_size)?;
        } else {
            let data = std::fs::read(&path)?;
            let size = data.len() as u64;

            if size > extractor.max_file_size {
                return Err(Error::FileTooLarge {
                    size,
                    limit: extractor.max_file_size,
                });
            }

            *total_size = total_size.saturating_add(size);
            if *total_size > extractor.max_total_size {
                return Err(Error::TotalSizeLimitExceeded {
                    limit: extractor.max_total_size,
                });
            }

            files.push(ExtractedFile {
                path: rel,
                data,
                size,
                is_directory: false,
            });
        }
    }
    Ok(())
}

/// Decompress gzip data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_gz(data: &[u8]) -> Result<Vec<u8>> {
    let mut decoder = flate2::read::GzDecoder::new(data);
    let mut out = Vec::new();
    decoder.read_to_end(&mut out)?;
    Ok(out)
}

/// Decompress bzip2 data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_bz2(data: &[u8]) -> Result<Vec<u8>> {
    let mut decoder = bzip2::read::BzDecoder::new(data);
    let mut out = Vec::new();
    decoder.read_to_end(&mut out)?;
    Ok(out)
}

/// Decompress XZ/LZMA data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_xz(data: &[u8]) -> Result<Vec<u8>> {
    let mut out = Vec::new();
    lzma_rs::xz_decompress(&mut io::Cursor::new(data), &mut out)?;
    Ok(out)
}

/// Decompress Zstandard data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_zstd(data: &[u8]) -> Result<Vec<u8>> {
    let decoder = zstd::Decoder::new(data)?;
    let mut out = Vec::new();
    io::BufReader::new(decoder).read_to_end(&mut out)?;
    Ok(out)
}

/// Decompress LZ4 frame data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_lz4(data: &[u8]) -> Result<Vec<u8>> {
    let mut decoder = lz4_flex::frame::FrameDecoder::new(data);
    let mut out = Vec::new();
    decoder
        .read_to_end(&mut out)
        .map_err(|e| Error::Lz4(e.to_string()))?;
    Ok(out)
}

/// Decompress Brotli data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_brotli(data: &[u8]) -> Result<Vec<u8>> {
    let mut decoder = brotli::Decompressor::new(data, 4096);
    let mut out = Vec::new();
    decoder
        .read_to_end(&mut out)
        .map_err(|e| Error::Brotli(e.to_string()))?;
    Ok(out)
}

/// Decompress Snappy frame data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_snappy(data: &[u8]) -> Result<Vec<u8>> {
    let mut decoder = snap::read::FrameDecoder::new(data);
    let mut out = Vec::new();
    decoder
        .read_to_end(&mut out)
        .map_err(|e| Error::Snappy(e.to_string()))?;
    Ok(out)
}

/// Decompress standalone LZMA data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_lzma(data: &[u8]) -> Result<Vec<u8>> {
    let mut out = Vec::new();
    lzma_rs::lzma_decompress(&mut io::Cursor::new(data), &mut out)?;
    Ok(out)
}

/// Decompress `BZip3` data.
///
/// # Errors
///
/// Returns an error if decompression fails.
fn decompress_bz3(data: &[u8]) -> Result<Vec<u8>> {
    let mut decoder = bzip3::read::Bz3Decoder::new(Cursor::new(data))
        .map_err(|e| Error::InvalidArchive(format!("bzip3: {e}")))?;
    let mut out = Vec::new();
    decoder
        .read_to_end(&mut out)
        .map_err(|e| Error::InvalidArchive(format!("bzip3 decompression: {e}")))?;
    Ok(out)
}

/// Compress data using `Zopfli` (high-ratio DEFLATE/gzip).
///
/// Produces standard gzip output that can be decompressed with any gzip reader.
/// `Zopfli` prioritizes compression ratio over speed.
///
/// # Errors
///
/// Returns an error on I/O failure.
pub fn compress_zopfli(data: &[u8]) -> Result<Vec<u8>> {
    use std::io::Write;
    let options = zopfli::Options::default();
    let mut encoder = zopfli::GzipEncoder::new(options, zopfli::BlockType::Dynamic, Vec::new())
        .map_err(|e| Error::Io(std::io::Error::other(format!("zopfli init: {e}"))))?;
    encoder
        .write_all(data)
        .map_err(|e| Error::Io(std::io::Error::other(format!("zopfli write: {e}"))))?;
    encoder
        .finish()
        .map_err(|e| Error::Io(std::io::Error::other(format!("zopfli finish: {e}"))))
}

// --- New archive format extractors ---

impl Extractor {
    /// Extract files from a Unix AR archive.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid AR data or I/O error.
    fn extract_ar(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
        let cursor = Cursor::new(data);
        let mut archive = ar::Archive::new(cursor);
        let mut files = Vec::new();
        let mut total_size: u64 = 0;

        while let Some(entry) = archive.next_entry() {
            let mut entry = entry.map_err(|e| Error::Ar(e.to_string()))?;
            let name = String::from_utf8_lossy(entry.header().identifier()).into_owned();
            let size = entry.header().size();

            if files.len() >= self.max_entries {
                return Err(Error::TooManyEntries {
                    count: files.len(),
                    limit: self.max_entries,
                });
            }

            if size > self.max_file_size {
                return Err(Error::FileTooLarge {
                    size,
                    limit: self.max_file_size,
                });
            }

            total_size = total_size.saturating_add(size);
            if total_size > self.max_total_size {
                return Err(Error::TotalSizeLimitExceeded {
                    limit: self.max_total_size,
                });
            }

            let mut buf = Vec::with_capacity(size.try_into().unwrap_or(0));
            entry
                .read_to_end(&mut buf)
                .map_err(|e| Error::Ar(e.to_string()))?;

            files.push(ExtractedFile {
                path: name,
                data: buf,
                size,
                is_directory: false,
            });
        }

        Ok(files)
    }

    /// List entries in a Unix AR archive.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid AR data or I/O error.
    fn list_ar(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
        let cursor = Cursor::new(data);
        let mut archive = ar::Archive::new(cursor);
        let mut entries = Vec::new();

        while let Some(entry) = archive.next_entry() {
            let entry = entry.map_err(|e| Error::Ar(e.to_string()))?;
            let name = String::from_utf8_lossy(entry.header().identifier()).into_owned();
            let size = entry.header().size();

            entries.push(ArchiveEntry {
                path: name,
                compressed_size: size,
                uncompressed_size: size,
                is_directory: false,
                compression_method: "stored".into(),
            });
        }

        Ok(entries)
    }

    /// Extract a Debian package (AR containing `data.tar.*`).
    ///
    /// # Errors
    ///
    /// Returns an error on invalid DEB data, parse failure, or I/O error.
    fn extract_deb(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
        let cursor = Cursor::new(data);
        let mut archive = ar::Archive::new(cursor);

        while let Some(entry) = archive.next_entry() {
            let mut entry = entry.map_err(|e| Error::Ar(e.to_string()))?;
            let name = String::from_utf8_lossy(entry.header().identifier()).into_owned();

            // Look for data.tar.* member
            if name.starts_with("data.tar") {
                let mut member_data = Vec::new();
                entry
                    .read_to_end(&mut member_data)
                    .map_err(|e| Error::Ar(e.to_string()))?;

                // Detect inner compression via magic bytes
                let inner_fmt = crate::format::detect_format(&member_data);
                let tar_data = match inner_fmt {
                    ArchiveFormat::Gz => decompress_gz(&member_data)?,
                    ArchiveFormat::Xz => decompress_xz(&member_data)?,
                    ArchiveFormat::Bz2 => decompress_bz2(&member_data)?,
                    ArchiveFormat::Zstd => decompress_zstd(&member_data)?,
                    ArchiveFormat::Lzma => decompress_lzma(&member_data)?,
                    _ => member_data,
                };
                return self.extract_tar(&tar_data);
            }
        }

        Err(Error::InvalidArchive(
            "DEB package missing data.tar.* member".into(),
        ))
    }

    /// Extract files from a Windows Cabinet archive.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid CAB data or I/O error.
    fn extract_cab(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
        let cursor = Cursor::new(data);
        let mut cabinet = cab::Cabinet::new(cursor).map_err(|e| Error::Cab(e.to_string()))?;

        // Collect file names first (borrow checker requires this)
        let file_names: Vec<String> = cabinet
            .folder_entries()
            .flat_map(|folder| folder.file_entries().map(|fe| fe.name().to_string()))
            .collect();

        let mut files = Vec::new();
        let mut total_size: u64 = 0;

        for name in file_names {
            if files.len() >= self.max_entries {
                return Err(Error::TooManyEntries {
                    count: files.len(),
                    limit: self.max_entries,
                });
            }

            let mut reader = cabinet
                .read_file(&name)
                .map_err(|e| Error::Cab(e.to_string()))?;
            let mut buf = Vec::new();
            reader
                .read_to_end(&mut buf)
                .map_err(|e| Error::Cab(e.to_string()))?;

            let size = buf.len() as u64;
            if size > self.max_file_size {
                return Err(Error::FileTooLarge {
                    size,
                    limit: self.max_file_size,
                });
            }

            total_size = total_size.saturating_add(size);
            if total_size > self.max_total_size {
                return Err(Error::TotalSizeLimitExceeded {
                    limit: self.max_total_size,
                });
            }

            files.push(ExtractedFile {
                path: name,
                data: buf,
                size,
                is_directory: false,
            });
        }

        Ok(files)
    }

    /// List entries in a Windows Cabinet archive.
    ///
    /// # Errors
    ///
    /// Returns an error on invalid CAB data or I/O error.
    fn list_cab(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
        let cursor = Cursor::new(data);
        let cabinet = cab::Cabinet::new(cursor).map_err(|e| Error::Cab(e.to_string()))?;
        let mut entries = Vec::new();

        for folder in cabinet.folder_entries() {
            let method = format!("{:?}", folder.compression_type());
            for file_entry in folder.file_entries() {
                entries.push(ArchiveEntry {
                    path: file_entry.name().to_string(),
                    compressed_size: u64::from(file_entry.uncompressed_size()),
                    uncompressed_size: u64::from(file_entry.uncompressed_size()),
                    is_directory: false,
                    compression_method: method.clone(),
                });
            }
        }

        Ok(entries)
    }

    /// Extract files from a RAR archive (requires filesystem path).
    ///
    /// # Errors
    ///
    /// Returns an error on invalid RAR data, encrypted entries, or I/O error.
    #[cfg(feature = "rar")]
    fn extract_rar(&self, path: &Path) -> Result<Vec<ExtractedFile>> {
        let archive = unrar::Archive::new(path)
            .open_for_processing()
            .map_err(|e| Error::Rar(format!("{e}")))?;

        let mut files = Vec::new();
        let mut total_size: u64 = 0;
        let mut cursor = archive;

        loop {
            let header = match cursor.read_header() {
                Ok(Some(h)) => h,
                Ok(None) => break,
                Err(e) => return Err(Error::Rar(format!("{e}"))),
            };

            let entry = header.entry();
            let name = entry.filename.to_string_lossy().into_owned();
            let is_directory = entry.is_directory();
            let size = entry.unpacked_size;

            if files.len() >= self.max_entries {
                return Err(Error::TooManyEntries {
                    count: files.len(),
                    limit: self.max_entries,
                });
            }

            if is_directory {
                files.push(ExtractedFile {
                    path: name,
                    // zero-alloc: Vec::new() doesn't heap-allocate until pushed to
                    data: Vec::new(),
                    size: 0,
                    is_directory: true,
                });
                cursor = match header.skip() {
                    Ok(a) => a,
                    Err(e) => return Err(Error::Rar(format!("{e}"))),
                };
                continue;
            }

            if size > self.max_file_size {
                return Err(Error::FileTooLarge {
                    size,
                    limit: self.max_file_size,
                });
            }

            total_size = total_size.saturating_add(size);
            if total_size > self.max_total_size {
                return Err(Error::TotalSizeLimitExceeded {
                    limit: self.max_total_size,
                });
            }

            let (data, next) = match header.read() {
                Ok(result) => result,
                Err(e) => return Err(Error::Rar(format!("{e}"))),
            };

            files.push(ExtractedFile {
                path: name,
                data,
                size,
                is_directory: false,
            });

            cursor = next;
        }

        Ok(files)
    }

    /// List entries in a RAR archive (requires filesystem path).
    ///
    /// # Errors
    ///
    /// Returns an error on invalid RAR data or I/O error.
    #[cfg(feature = "rar")]
    fn list_rar(&self, path: &Path) -> Result<Vec<ArchiveEntry>> {
        let archive = unrar::Archive::new(path)
            .open_for_listing()
            .map_err(|e| Error::Rar(format!("{e}")))?;

        let mut entries = Vec::new();
        for result in archive {
            let entry = result.map_err(|e| Error::Rar(format!("{e}")))?;
            entries.push(ArchiveEntry {
                path: entry.filename.to_string_lossy().into_owned(),
                compressed_size: entry.unpacked_size,
                uncompressed_size: entry.unpacked_size,
                is_directory: entry.is_directory(),
                compression_method: "rar".into(),
            });
        }

        Ok(entries)
    }
}

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

    #[test]
    fn test_sanitize_path_normal() {
        assert_eq!(
            sanitize_path("foo/bar.txt").ok(),
            Some("foo/bar.txt".into())
        );
    }

    #[test]
    fn test_sanitize_path_traversal() {
        assert!(sanitize_path("../../../etc/passwd").is_err());
    }

    #[test]
    fn test_sanitize_path_absolute() {
        // Absolute paths should have root stripped
        let result = sanitize_path("/etc/passwd");
        assert!(result.is_ok());
        assert_eq!(result.ok(), Some("etc/passwd".into()));
    }

    #[test]
    fn test_extractor_defaults() {
        let ext = Extractor::new();
        assert_eq!(ext.max_file_size, 100 * 1024 * 1024);
        assert_eq!(ext.max_total_size, 1024 * 1024 * 1024);
    }

    #[test]
    fn test_extractor_custom_limits() {
        let ext = Extractor::new()
            .with_max_file_size(1024)
            .with_max_total_size(4096);
        assert_eq!(ext.max_file_size, 1024);
        assert_eq!(ext.max_total_size, 4096);
    }

    #[test]
    fn test_sanitize_path_curdir() {
        let result = sanitize_path("./foo/bar.txt");
        assert!(result.is_ok());
        assert_eq!(result.ok(), Some("foo/bar.txt".into()));
    }

    #[test]
    fn test_sanitize_path_empty() {
        let result = sanitize_path("");
        assert!(result.is_ok());
        assert_eq!(result.ok(), Some(String::new()));
    }

    #[test]
    fn test_sanitize_path_windows_prefix() {
        let result = sanitize_path("C:\\windows\\system32");
        // On Unix this just becomes normal path components
        assert!(result.is_ok());
    }

    #[test]
    fn test_validate_unsupported_format() {
        let ext = Extractor::new();
        let result = ext.validate(&[], ArchiveFormat::Unknown);
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_empty_data() {
        let ext = Extractor::new();
        let result = ext.validate(&[], ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    #[test]
    fn test_extract_single_respects_limit() {
        let ext = Extractor::new().with_max_file_size(10);
        let big_data = vec![0u8; 100];
        let result = ext.extract_single(big_data);
        assert!(result.is_err());
    }

    // ─── ZIP roundtrip (from zip crate patterns) ─────────────────

    fn make_zip_in_memory(files: &[(&str, &[u8])]) -> Vec<u8> {
        use std::io::Write;
        let buf = Cursor::new(Vec::new());
        let mut writer = zip::ZipWriter::new(buf);
        for (name, data) in files {
            writer
                .start_file::<&str, ()>(*name, zip::write::SimpleFileOptions::default())
                .unwrap();
            writer.write_all(data).unwrap();
        }
        writer.finish().unwrap().into_inner()
    }

    #[test]
    fn test_zip_roundtrip_single_file() {
        let content = b"Hello from ZIP roundtrip test!";
        let zip_data = make_zip_in_memory(&[("test.txt", content)]);
        let ext = Extractor::new();
        let files = ext.extract(&zip_data, ArchiveFormat::Zip).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "test.txt");
        assert_eq!(files[0].data, content);
    }

    #[test]
    fn test_zip_roundtrip_multiple_files() {
        let zip_data = make_zip_in_memory(&[
            ("a.txt", b"alpha"),
            ("b.txt", b"bravo"),
            ("c.txt", b"charlie"),
        ]);
        let ext = Extractor::new();
        let files = ext.extract(&zip_data, ArchiveFormat::Zip).unwrap();
        assert_eq!(files.len(), 3);
        assert_eq!(files[0].data, b"alpha");
        assert_eq!(files[1].data, b"bravo");
        assert_eq!(files[2].data, b"charlie");
    }

    #[test]
    fn test_zip_magic_bytes() {
        let zip_data = make_zip_in_memory(&[("x.txt", b"x")]);
        // PK\x03\x04 signature
        assert_eq!(&zip_data[0..4], &[0x50, 0x4B, 0x03, 0x04]);
    }

    #[test]
    fn test_zip_empty_file() {
        let zip_data = make_zip_in_memory(&[("empty.txt", b"")]);
        let ext = Extractor::new();
        let files = ext.extract(&zip_data, ArchiveFormat::Zip).unwrap();
        assert_eq!(files.len(), 1);
        assert!(files[0].data.is_empty());
        assert_eq!(files[0].size, 0);
    }

    #[test]
    fn test_zip_list_roundtrip() {
        let zip_data = make_zip_in_memory(&[("file1.txt", b"data1"), ("file2.txt", b"data2data2")]);
        let ext = Extractor::new();
        let entries = ext.list(&zip_data, ArchiveFormat::Zip).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].path, "file1.txt");
        assert_eq!(entries[1].path, "file2.txt");
        assert_eq!(entries[1].uncompressed_size, 10);
    }

    #[test]
    fn test_zip_validate_roundtrip() {
        let zip_data = make_zip_in_memory(&[("v.txt", b"validate me")]);
        let ext = Extractor::new();
        let result = ext.validate(&zip_data, ArchiveFormat::Zip).unwrap();
        assert!(result.is_valid);
        assert_eq!(result.entry_count, 1);
        assert_eq!(result.total_uncompressed_size, 11);
    }

    #[test]
    fn test_zip_entry_count_limit() {
        let mut files: Vec<(String, Vec<u8>)> = Vec::new();
        for i in 0..10 {
            files.push((format!("file{i}.txt"), vec![0u8; 1]));
        }
        let refs: Vec<(&str, &[u8])> = files
            .iter()
            .map(|(n, d)| (n.as_str(), d.as_slice()))
            .collect();
        let zip_data = make_zip_in_memory(&refs);
        let ext = Extractor::new().with_max_entries(5);
        let result = ext.extract(&zip_data, ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    // ─── TAR roundtrip (from tar crate patterns) ─────────────────

    fn make_tar_in_memory(files: &[(&str, &[u8])]) -> Vec<u8> {
        let mut builder = tar::Builder::new(Vec::new());
        for (name, data) in files {
            let mut header = tar::Header::new_gnu();
            header.set_size(data.len() as u64);
            header.set_mode(0o644);
            header.set_cksum();
            builder.append_data(&mut header, *name, *data).unwrap();
        }
        builder.into_inner().unwrap()
    }

    #[test]
    fn test_tar_roundtrip_single_file() {
        let content = b"TAR roundtrip content";
        let tar_data = make_tar_in_memory(&[("hello.txt", content)]);
        let ext = Extractor::new();
        let files = ext.extract(&tar_data, ArchiveFormat::Tar).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "hello.txt");
        assert_eq!(files[0].data, content);
    }

    #[test]
    fn test_tar_roundtrip_multiple() {
        let tar_data =
            make_tar_in_memory(&[("one.txt", b"1"), ("two.txt", b"22"), ("three.txt", b"333")]);
        let ext = Extractor::new();
        let files = ext.extract(&tar_data, ArchiveFormat::Tar).unwrap();
        assert_eq!(files.len(), 3);
        assert_eq!(files[2].data, b"333");
    }

    #[test]
    fn test_tar_list_roundtrip() {
        let tar_data = make_tar_in_memory(&[("a.bin", b"abc"), ("b.bin", b"defgh")]);
        let ext = Extractor::new();
        let entries = ext.list(&tar_data, ArchiveFormat::Tar).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].uncompressed_size, 3);
        assert_eq!(entries[1].uncompressed_size, 5);
    }

    #[test]
    fn test_tar_gz_roundtrip() {
        let tar_data = make_tar_in_memory(&[("gz.txt", b"gzip me")]);
        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
        io::copy(&mut Cursor::new(&tar_data), &mut encoder).unwrap();
        let gz_data = encoder.finish().unwrap();

        let ext = Extractor::new();
        let files = ext.extract(&gz_data, ArchiveFormat::TarGz).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].data, b"gzip me");
    }

    #[test]
    fn test_tar_bz2_roundtrip() {
        let tar_data = make_tar_in_memory(&[("bz2.txt", b"bzip2 me")]);
        let mut encoder = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::default());
        io::copy(&mut Cursor::new(&tar_data), &mut encoder).unwrap();
        let bz2_data = encoder.finish().unwrap();

        let ext = Extractor::new();
        let files = ext.extract(&bz2_data, ArchiveFormat::TarBz2).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].data, b"bzip2 me");
    }

    #[test]
    fn test_tar_zstd_roundtrip() {
        let tar_data = make_tar_in_memory(&[("zst.txt", b"zstd me")]);
        let zst_data = zstd::encode_all(Cursor::new(&tar_data), 3).unwrap();

        let ext = Extractor::new();
        let files = ext.extract(&zst_data, ArchiveFormat::TarZst).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].data, b"zstd me");
    }

    #[test]
    fn test_tar_lz4_roundtrip() {
        let tar_data = make_tar_in_memory(&[("lz4.txt", b"lz4 me")]);
        let lz4_data = crate::lz4::compress_frame(&tar_data).unwrap();

        let ext = Extractor::new();
        let files = ext.extract(&lz4_data, ArchiveFormat::TarLz4).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].data, b"lz4 me");
    }

    // ─── AR roundtrip (from ar crate patterns) ───────────────────

    fn make_ar_in_memory(files: &[(&str, &[u8])]) -> Vec<u8> {
        let mut builder = ar::Builder::new(Vec::new());
        for (name, data) in files {
            let mut header = ar::Header::new(name.as_bytes().to_vec(), data.len() as u64);
            header.set_mtime(1_700_000_000);
            header.set_uid(1000);
            header.set_gid(1000);
            header.set_mode(0o10_0644);
            builder.append(&header, *data).unwrap();
        }
        builder.into_inner().unwrap()
    }

    #[test]
    fn test_ar_roundtrip_single() {
        let ar_data = make_ar_in_memory(&[("test.o", b"object file data")]);
        let ext = Extractor::new();
        let files = ext.extract(&ar_data, ArchiveFormat::Ar).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].path, "test.o");
        assert_eq!(files[0].data, b"object file data");
    }

    #[test]
    fn test_ar_roundtrip_multiple() {
        let ar_data = make_ar_in_memory(&[("a.o", b"aaa"), ("b.o", b"bbb"), ("c.o", b"ccc")]);
        let ext = Extractor::new();
        let files = ext.extract(&ar_data, ArchiveFormat::Ar).unwrap();
        assert_eq!(files.len(), 3);
    }

    #[test]
    fn test_ar_list_roundtrip() {
        let ar_data = make_ar_in_memory(&[("lib.a", b"12345")]);
        let ext = Extractor::new();
        let entries = ext.list(&ar_data, ArchiveFormat::Ar).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].path, "lib.a");
        assert_eq!(entries[0].uncompressed_size, 5);
    }

    #[test]
    fn test_ar_magic_bytes() {
        let ar_data = make_ar_in_memory(&[("x", b"y")]);
        assert_eq!(&ar_data[..7], b"!<arch>");
    }

    // ─── Compression roundtrips (from flate2/bzip2/zstd/lz4/snap/brotli/lzma patterns) ─

    fn roundtrip_compression(data: &[u8], format: ArchiveFormat, compress: fn(&[u8]) -> Vec<u8>) {
        let compressed = compress(data);
        let ext = Extractor::new();
        let files = ext.extract(&compressed, format).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].data, data);
    }

    fn compress_gz(data: &[u8]) -> Vec<u8> {
        let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
        std::io::Write::write_all(&mut enc, data).unwrap();
        enc.finish().unwrap()
    }

    fn compress_bz2(data: &[u8]) -> Vec<u8> {
        let mut enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::default());
        std::io::Write::write_all(&mut enc, data).unwrap();
        enc.finish().unwrap()
    }

    fn compress_zstd(data: &[u8]) -> Vec<u8> {
        zstd::encode_all(Cursor::new(data), 3).unwrap()
    }

    fn compress_lz4(data: &[u8]) -> Vec<u8> {
        crate::lz4::compress_frame(data).unwrap()
    }

    fn compress_snappy(data: &[u8]) -> Vec<u8> {
        let mut enc = snap::write::FrameEncoder::new(Vec::new());
        std::io::Write::write_all(&mut enc, data).unwrap();
        enc.into_inner().unwrap()
    }

    #[test]
    fn test_gz_roundtrip() {
        roundtrip_compression(b"gzip roundtrip data!", ArchiveFormat::Gz, compress_gz);
    }

    #[test]
    fn test_bz2_roundtrip() {
        roundtrip_compression(b"bzip2 roundtrip data!", ArchiveFormat::Bz2, compress_bz2);
    }

    #[test]
    fn test_zstd_roundtrip() {
        roundtrip_compression(b"zstd roundtrip data!", ArchiveFormat::Zstd, compress_zstd);
    }

    #[test]
    fn test_lz4_roundtrip() {
        roundtrip_compression(b"lz4 roundtrip data!", ArchiveFormat::Lz4, compress_lz4);
    }

    #[test]
    fn test_snappy_roundtrip() {
        roundtrip_compression(
            b"snappy roundtrip data!",
            ArchiveFormat::Snappy,
            compress_snappy,
        );
    }

    #[test]
    fn test_gz_roundtrip_large() {
        let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
        roundtrip_compression(&data, ArchiveFormat::Gz, compress_gz);
    }

    #[test]
    fn test_bz2_roundtrip_large() {
        let data: Vec<u8> = (0..50_000).map(|i: i32| i.to_le_bytes()[0]).collect();
        roundtrip_compression(&data, ArchiveFormat::Bz2, compress_bz2);
    }

    #[test]
    fn test_zstd_roundtrip_large() {
        let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
        roundtrip_compression(&data, ArchiveFormat::Zstd, compress_zstd);
    }

    #[test]
    fn test_lz4_roundtrip_large() {
        let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
        roundtrip_compression(&data, ArchiveFormat::Lz4, compress_lz4);
    }

    #[test]
    fn test_snappy_roundtrip_large() {
        let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
        roundtrip_compression(&data, ArchiveFormat::Snappy, compress_snappy);
    }

    #[test]
    fn test_gz_roundtrip_empty() {
        roundtrip_compression(b"", ArchiveFormat::Gz, compress_gz);
    }

    #[test]
    fn test_zstd_roundtrip_empty() {
        roundtrip_compression(b"", ArchiveFormat::Zstd, compress_zstd);
    }

    #[test]
    fn test_lz4_roundtrip_empty() {
        roundtrip_compression(b"", ArchiveFormat::Lz4, compress_lz4);
    }

    // ─── Corrupted data tests ────────────────────────────────────

    #[test]
    fn test_corrupted_zip_data() {
        let ext = Extractor::new();
        let result = ext.extract(b"PK\x03\x04CORRUPT", ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    #[test]
    fn test_corrupted_gz_data() {
        let ext = Extractor::new();
        let result = ext.extract(b"\x1f\x8bCORRUPT", ArchiveFormat::Gz);
        assert!(result.is_err());
    }

    #[test]
    fn test_corrupted_bz2_data() {
        let ext = Extractor::new();
        let result = ext.extract(b"BZh9CORRUPT", ArchiveFormat::Bz2);
        assert!(result.is_err());
    }

    #[test]
    fn test_corrupted_zstd_data() {
        let ext = Extractor::new();
        let result = ext.extract(b"\x28\xb5\x2f\xfdCORRUPT", ArchiveFormat::Zstd);
        assert!(result.is_err());
    }

    #[test]
    fn test_corrupted_lz4_data() {
        let ext = Extractor::new();
        let result = ext.extract(b"\x04\x22\x4d\x18CORRUPT", ArchiveFormat::Lz4);
        assert!(result.is_err());
    }

    #[test]
    fn test_corrupted_ar_data() {
        let ext = Extractor::new();
        let result = ext.extract(b"!<arch>\nCORRUPT", ArchiveFormat::Ar);
        assert!(result.is_err());
    }

    #[test]
    fn test_truncated_zip() {
        let zip_data = make_zip_in_memory(&[("test.txt", b"data")]);
        let truncated = &zip_data[..zip_data.len() / 2];
        let ext = Extractor::new();
        let result = ext.extract(truncated, ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    // ─── File size limit tests ───────────────────────────────────

    #[test]
    fn test_zip_file_size_limit() {
        let big_content = vec![0u8; 1024];
        let zip_data = make_zip_in_memory(&[("big.bin", &big_content)]);
        let ext = Extractor::new().with_max_file_size(500);
        let result = ext.extract(&zip_data, ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    #[test]
    fn test_zip_total_size_limit() {
        let zip_data = make_zip_in_memory(&[
            ("a.bin", &vec![0u8; 300]),
            ("b.bin", &vec![0u8; 300]),
            ("c.bin", &vec![0u8; 300]),
        ]);
        let ext = Extractor::new().with_max_total_size(500);
        let result = ext.extract(&zip_data, ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    #[test]
    fn test_tar_file_size_limit() {
        let big_content = vec![0u8; 1024];
        let tar_data = make_tar_in_memory(&[("big.bin", &big_content)]);
        let ext = Extractor::new().with_max_file_size(500);
        let result = ext.extract(&tar_data, ArchiveFormat::Tar);
        assert!(result.is_err());
    }

    #[test]
    fn test_tar_total_size_limit() {
        let tar_data = make_tar_in_memory(&[
            ("a.bin", &vec![0u8; 300]),
            ("b.bin", &vec![0u8; 300]),
            ("c.bin", &vec![0u8; 300]),
        ]);
        let ext = Extractor::new().with_max_total_size(500);
        let result = ext.extract(&tar_data, ArchiveFormat::Tar);
        assert!(result.is_err());
    }

    #[test]
    fn test_tar_entry_count_limit() {
        let mut files: Vec<(String, Vec<u8>)> = Vec::new();
        for i in 0..10 {
            files.push((format!("f{i}.txt"), vec![0u8; 1]));
        }
        let refs: Vec<(&str, &[u8])> = files
            .iter()
            .map(|(n, d)| (n.as_str(), d.as_slice()))
            .collect();
        let tar_data = make_tar_in_memory(&refs);
        let ext = Extractor::new().with_max_entries(5);
        let result = ext.extract(&tar_data, ArchiveFormat::Tar);
        assert!(result.is_err());
    }

    // ─── Path traversal tests ────────────────────────────────────

    #[test]
    fn test_sanitize_path_double_dot_middle() {
        assert!(sanitize_path("foo/../bar").is_err());
    }

    #[test]
    fn test_sanitize_path_dot_dot_only() {
        assert!(sanitize_path("..").is_err());
    }

    #[test]
    fn test_sanitize_path_complex_traversal() {
        assert!(sanitize_path("a/b/../../../../../../etc/shadow").is_err());
    }

    // ─── Validate method tests ───────────────────────────────────

    #[test]
    fn test_validate_tar_gz() {
        let tar_data = make_tar_in_memory(&[("v.txt", b"validate tar gz")]);
        let gz_data = compress_gz(&tar_data);
        let ext = Extractor::new();
        let result = ext.validate(&gz_data, ArchiveFormat::TarGz).unwrap();
        assert!(result.is_valid);
        assert_eq!(result.entry_count, 1);
    }

    #[test]
    fn test_validate_ar() {
        let ar_data = make_ar_in_memory(&[("v.o", b"validate ar")]);
        let ext = Extractor::new();
        let result = ext.validate(&ar_data, ArchiveFormat::Ar).unwrap();
        assert!(result.is_valid);
        assert_eq!(result.entry_count, 1);
    }

    #[test]
    fn test_validate_corrupted_returns_error() {
        let ext = Extractor::new();
        let result = ext.validate(b"NOT_AN_ARCHIVE", ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    // ─── safe_unzip-derived security tests ───────────────────────

    #[test]
    fn test_zip_blocks_zip_slip_traversal() {
        // Adapted from safe_unzip security_test.rs
        // Create ZIP with path traversal entry
        use std::io::Write;
        let buf = Cursor::new(Vec::new());
        let mut writer = zip::ZipWriter::new(buf);
        writer
            .start_file::<&str, ()>("../../evil.txt", zip::write::SimpleFileOptions::default())
            .unwrap();
        writer.write_all(b"malicious content").unwrap();
        let zip_data = writer.finish().unwrap().into_inner();

        let ext = Extractor::new();
        let result = ext.extract(&zip_data, ArchiveFormat::Zip);
        // sanitize_path should reject the traversal
        assert!(result.is_err());
    }

    #[test]
    fn test_zip_blocks_double_dot_in_path() {
        use std::io::Write;
        let buf = Cursor::new(Vec::new());
        let mut writer = zip::ZipWriter::new(buf);
        writer
            .start_file::<&str, ()>(
                "foo/../../bar.txt",
                zip::write::SimpleFileOptions::default(),
            )
            .unwrap();
        writer.write_all(b"escape attempt").unwrap();
        let zip_data = writer.finish().unwrap().into_inner();

        let ext = Extractor::new();
        let result = ext.extract(&zip_data, ArchiveFormat::Zip);
        assert!(result.is_err());
    }

    #[test]
    fn test_zip_path_depth_limit() {
        use std::io::Write;
        // Create deeply nested path: a/b/c/.../z (51 levels)
        let deep_path = (0..51)
            .map(|i| format!("d{i}"))
            .collect::<Vec<_>>()
            .join("/")
            + "/file.txt";
        let buf = Cursor::new(Vec::new());
        let mut writer = zip::ZipWriter::new(buf);
        writer
            .start_file::<&str, ()>(&deep_path, zip::write::SimpleFileOptions::default())
            .unwrap();
        writer.write_all(b"deep").unwrap();
        let zip_data = writer.finish().unwrap().into_inner();

        // Should succeed with default limits but the path is sanitized
        let ext = Extractor::new();
        let result = ext.extract(&zip_data, ArchiveFormat::Zip);
        assert!(result.is_ok());
    }

    #[test]
    fn test_zopfli_roundtrip() {
        let original = b"Hello, Zopfli compression test! ".repeat(100);
        let compressed = compress_zopfli(&original).unwrap();
        // Zopfli output is standard gzip — decompress with flate2
        let mut decoder = flate2::read::GzDecoder::new(&compressed[..]);
        let mut decompressed = Vec::new();
        decoder.read_to_end(&mut decompressed).unwrap();
        assert_eq!(original, decompressed);
    }

    #[test]
    fn test_zopfli_produces_smaller_output() {
        let data = b"AAAAAAAAAA".repeat(1000);
        let zopfli_out = compress_zopfli(&data).unwrap();
        let flate2_out = compress_gz(&data);
        // Zopfli should produce output no larger than flate2
        assert!(
            zopfli_out.len() <= flate2_out.len() + 20,
            "zopfli={} flate2={}",
            zopfli_out.len(),
            flate2_out.len()
        );
    }

    #[test]
    fn test_safe_unzip_verify_valid() {
        let zip_data = make_zip_in_memory(&[("test.txt", b"hello safe_unzip")]);
        let result = safe_unzip::verify_bytes(&zip_data);
        assert!(result.is_ok());
    }

    #[test]
    fn test_safe_unzip_verify_corrupted() {
        let result = safe_unzip::verify_bytes(b"NOT_A_ZIP");
        assert!(result.is_err());
    }

    #[test]
    fn test_safe_unzip_list_entries() {
        let zip_data = make_zip_in_memory(&[("a.txt", b"alpha"), ("b.txt", b"bravo")]);
        let entries = safe_unzip::list_zip(Cursor::new(&zip_data));
        assert!(entries.is_ok());
        let entries = entries.unwrap();
        assert_eq!(entries.len(), 2);
    }

    #[test]
    fn test_safe_unzip_extract_to_disk() {
        let zip_data = make_zip_in_memory(&[("safe.txt", b"safe extraction test")]);
        let tmp = tempfile::tempdir().unwrap();
        let extractor = safe_unzip::Extractor::new(tmp.path()).unwrap();
        let result = extractor.extract(Cursor::new(&zip_data));
        assert!(result.is_ok());
        let report = result.unwrap();
        assert_eq!(report.files_extracted, 1);
        assert!(tmp.path().join("safe.txt").exists());
    }

    #[test]
    fn test_safe_unzip_blocks_zip_slip() {
        use std::io::Write;
        let buf = Cursor::new(Vec::new());
        let mut writer = zip::ZipWriter::new(buf);
        writer
            .start_file::<&str, ()>("../../escape.txt", zip::write::SimpleFileOptions::default())
            .unwrap();
        writer.write_all(b"evil").unwrap();
        let zip_data = writer.finish().unwrap().into_inner();

        let tmp = tempfile::tempdir().unwrap();
        let extractor = safe_unzip::Extractor::new(tmp.path()).unwrap();
        let result = extractor.extract(Cursor::new(&zip_data));
        assert!(result.is_err());
    }

    #[test]
    fn test_safe_unzip_enforces_limits() {
        let zip_data = make_zip_in_memory(&[("big.bin", &vec![0u8; 1024])]);
        let tmp = tempfile::tempdir().unwrap();
        let limits = safe_unzip::Limits {
            max_total_bytes: 100,
            max_file_count: 10,
            max_single_file: 100,
            max_path_depth: 50,
        };
        let extractor = safe_unzip::Extractor::new(tmp.path())
            .unwrap()
            .limits(limits);
        let result = extractor.extract(Cursor::new(&zip_data));
        assert!(result.is_err());
    }

    #[test]
    fn test_safe_unzip_validate_first_mode() {
        let zip_data = make_zip_in_memory(&[("valid.txt", b"validate first")]);
        let tmp = tempfile::tempdir().unwrap();
        let extractor = safe_unzip::Extractor::new(tmp.path())
            .unwrap()
            .mode(safe_unzip::ExtractionMode::ValidateFirst);
        let result = extractor.extract(Cursor::new(&zip_data));
        assert!(result.is_ok());
    }
}
