#[macro_use]
mod utils;

use std::{
    io::Write,
    iter::once,
    path::{Path, PathBuf},
};

use anyhow::Result;
use bstr::ByteSlice;
use fs_err as fs;
use itertools::Itertools;
use memchr::memmem;
use parse_display::Display;
use pretty_assertions::assert_eq;
use proptest::sample::size_range;
use rand::{Rng, SeedableRng, rngs::SmallRng};
use strum::IntoEnumIterator as _;
use test_strategy::{Arbitrary, proptest};

use crate::utils::{assert_same_directory, testdir, write_random_content};

#[derive(Arbitrary, Clone, Copy, Debug, Display)]
#[display(style = "lowercase")]
enum DirectoryExtension {
    #[display("7z")]
    SevenZ,
    Tar,
    Tbz,
    Tbz2,
    #[cfg(feature = "bzip3")]
    Tbz3,
    Tgz,
    Tlz,
    Tlz4,
    Tlzma,
    Tsz,
    Txz,
    Tzst,
    Zip,
}

#[derive(Arbitrary, Clone, Copy, Debug, Display, strum::EnumIter)]
#[display(style = "lowercase")]
enum MainDirectoryExtension {
    #[display("7z")]
    SevenZ,
    Tar,
    Zip,
}

/// Extensions of single file compression formats
#[derive(Arbitrary, Debug, Display)]
#[display(style = "lowercase")]
enum FileExtension {
    Bz,
    Bz2,
    #[cfg(feature = "bzip3")]
    Bz3,
    Gz,
    Lz,
    Lz4,
    Lzma,
    Sz,
    Xz,
    Zst,
    Br,
}

#[derive(Arbitrary, Debug, Display)]
#[display("{0}")]
enum Extension {
    Directory(DirectoryExtension),
    File(FileExtension),
}

/// Converts a list of extension structs to string
fn merge_extensions(ext: impl ToString, exts: &[FileExtension]) -> String {
    once(ext.to_string())
        .chain(exts.iter().map(|x| x.to_string()))
        .collect::<Vec<_>>()
        .join(".")
}

/// Create random nested directories and files under the specified directory
fn create_random_files(dir: impl Into<PathBuf>, depth: u8, rng: &mut SmallRng) {
    if depth == 0 {
        return;
    }

    let dir = &dir.into();

    // create 0 to 4 random files
    for _ in 0..rng.random_range(0..=4u32) {
        write_random_content(
            &mut tempfile::Builder::new().tempfile_in(dir).unwrap().keep().unwrap().0,
            rng,
        );
    }

    // create more random files in 0 to 2 new directories
    for _ in 0..rng.random_range(0..=2u32) {
        create_random_files(tempfile::tempdir_in(dir).unwrap().path(), depth - 1, rng);
    }
}

/// Create n random files on directory dir
fn create_n_random_files(n: usize, dir: impl Into<PathBuf>, rng: &mut SmallRng) {
    let dir: &PathBuf = &dir.into();

    for _ in 0..n {
        write_random_content(
            &mut tempfile::Builder::new()
                .prefix("file")
                .tempfile_in(dir)
                .unwrap()
                .keep()
                .unwrap()
                .0,
            rng,
        );
    }
}

/// Compress and decompress a single empty file
#[proptest(cases = 200)]
fn single_empty_file(ext: Extension, #[any(size_range(0..8).lift())] exts: Vec<FileExtension>) {
    let (_tempdir, dir) = testdir().unwrap();
    let before = &dir.join("before");
    fs::create_dir(before).unwrap();
    let before_file = &before.join("file");
    let archive = &dir.join(format!("file.{}", merge_extensions(ext, &exts)));
    let after = &dir.join("after");
    fs::write(before_file, []).unwrap();
    ouch!("-A", "c", before_file, archive);
    ouch!("-A", "d", archive, "-d", after);
    assert_same_directory(before, after, false);
}

/// Compress and decompress a single file
#[proptest(cases = 150)]
fn single_file(
    ext: Extension,
    #[any(size_range(0..6).lift())] exts: Vec<FileExtension>,
    // Use faster --level for slower CI targets
    #[cfg_attr(not(any(target_arch = "arm", target_abi = "eabihf")), strategy(proptest::option::of(0i16..12)))]
    #[cfg_attr(target_arch = "arm", strategy(proptest::option::of(0i16..6)))]
    level: Option<i16>,
) {
    let (_tempdir, dir) = testdir().unwrap();
    let before = &dir.join("before");
    fs::create_dir(before).unwrap();
    let before_file = &before.join("file");
    let archive = &dir.join(format!("file.{}", merge_extensions(ext, &exts)));
    let after = &dir.join("after");
    write_random_content(
        &mut fs::File::create(before_file).unwrap(),
        &mut SmallRng::from_os_rng(),
    );
    if let Some(level) = level {
        ouch!("-A", "c", "-l", level.to_string(), before_file, archive);
    } else {
        ouch!("-A", "c", before_file, archive);
    }
    ouch!("-A", "d", archive, "-d", after);
    assert_same_directory(before, after, false);
}

/// A negative `--level` must clamp to the minimum compression, not the maximum.
///
/// Regression test: the level was cast to an unsigned integer before clamping, so
/// `-1i16 as u32` became 4294967295 and `clamp(0, 9)` returned 9, silently giving
/// maximum compression instead of minimum.
#[test]
fn negative_compression_level_is_not_maximum() {
    let (_tempdir, dir) = testdir().unwrap();
    let before_file = &dir.join("file");
    // Highly compressible content, so the compression level actually changes the output size
    fs::write(before_file, "ouch".repeat(64 * 1024)).unwrap();

    let compress_with_level = |level: &str, name: &str| {
        let archive = &dir.join(name);
        ouch!("-A", "c", format!("--level={level}"), before_file, archive);
        fs::metadata(archive).unwrap().len()
    };

    let negative = compress_with_level("-1", "negative.gz");
    let maximum = compress_with_level("9", "maximum.gz");

    assert_ne!(
        negative, maximum,
        "--level=-1 must not produce the same output as --level=9 (maximum compression)"
    );
}

/// Compress and decompress a single file over stdin.
#[proptest(cases = 200)]
fn single_file_stdin(
    ext: Extension,
    #[any(size_range(0..8).lift())] exts: Vec<FileExtension>,
    // Use faster --level for slower CI targets
    #[cfg_attr(not(any(target_arch = "arm", target_abi = "eabihf")), strategy(proptest::option::of(0i16..12)))]
    #[cfg_attr(target_arch = "arm", strategy(proptest::option::of(0i16..6)))]
    level: Option<i16>,
) {
    let (_tempdir, dir) = testdir().unwrap();
    let before = &dir.join("before");
    fs::create_dir(before).unwrap();
    let before_file = &before.join("file");
    let format = merge_extensions(&ext, &exts);
    let archive = &dir.join(format!("file.{format}"));
    let after = &dir.join("after");
    write_random_content(
        &mut fs::File::create(before_file).unwrap(),
        &mut SmallRng::from_os_rng(),
    );
    if let Some(level) = level {
        ouch!("-A", "c", "-l", level.to_string(), before_file, archive);
    } else {
        ouch!("-A", "c", before_file, archive);
    }
    crate::utils::cargo_bin()
        .args(["-A", "-y", "d", "-", "-d", after.to_str().unwrap(), "--format", &format])
        .pipe_stdin(archive)
        .unwrap()
        .assert()
        .success();

    match ext {
        Extension::Directory(_) => {}
        // Ouch writes to `ouch-output` when the input is stdin
        // Change the top-level "before" directory to match
        Extension::File(_) => fs::rename(before_file, before_file.with_file_name("ouch-output")).unwrap(),
    };

    assert_same_directory(before, after, false);
}

/// Compress and decompress a directory with random content generated with `create_random_files`
#[proptest(cases = 25)]
fn multiple_files(
    ext: DirectoryExtension,
    #[any(size_range(0..1).lift())] extra_extensions: Vec<FileExtension>,
    #[strategy(0u8..3)] depth: u8,
) {
    let (_tempdir, dir) = testdir().unwrap();
    let before = &dir.join("before");
    let before_dir = &before.join("dir");
    fs::create_dir_all(before_dir).unwrap();
    let archive = &dir.join(format!("archive.{}", merge_extensions(ext, &extra_extensions)));
    let after = &dir.join("after");
    create_random_files(before_dir, depth, &mut SmallRng::from_os_rng());
    ouch!("-A", "c", before_dir, archive);
    ouch!("-A", "d", archive, "-d", after);
    assert_same_directory(before, after, !matches!(ext, DirectoryExtension::Zip));
}

#[proptest(cases = 25)]
fn multiple_files_with_conflict_and_choice_to_overwrite(
    ext: DirectoryExtension,
    #[any(size_range(0..1).lift())] extra_extensions: Vec<FileExtension>,
    #[strategy(0u8..3)] depth: u8,
) {
    let (_tempdir, dir) = testdir().unwrap();

    let before = &dir.join("before");
    let before_dir = &before.join("dir");
    fs::create_dir_all(before_dir).unwrap();
    create_random_files(before_dir, depth, &mut SmallRng::from_os_rng());

    let after = &dir.join("after");
    let after_dir = &after.join("dir");
    fs::create_dir_all(after_dir).unwrap();
    create_random_files(after_dir, depth, &mut SmallRng::from_os_rng());

    let archive = &dir.join(format!("archive.{}", merge_extensions(ext, &extra_extensions)));
    ouch!("-A", "c", before_dir, archive);

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive)
        .arg("-d")
        .arg(after)
        .write_stdin("o")
        .assert()
        .success();

    assert_same_directory(before, after, false);
}

#[proptest(cases = 25)]
fn multiple_files_with_conflict_and_choice_to_not_overwrite(
    ext: DirectoryExtension,
    #[any(size_range(0..1).lift())] extra_extensions: Vec<FileExtension>,
    #[strategy(0u8..3)] depth: u8,
) {
    let (_tempdir, dir) = testdir().unwrap();

    let before = &dir.join("before");
    let before_dir = &before.join("dir");
    fs::create_dir_all(before_dir).unwrap();
    create_random_files(before_dir, depth, &mut SmallRng::from_os_rng());

    let after = &dir.join("after");
    let after_dir = &after.join("dir");
    fs::create_dir_all(after_dir).unwrap();

    let after_backup = &dir.join("after_backup");
    let after_backup_dir = &after_backup.join("dir");
    fs::create_dir_all(after_backup_dir).unwrap();

    // Create a file with the same name as one of the files in the after directory
    fs::write(after_dir.join("something.txt"), "Some content").unwrap();
    fs::copy(after_dir.join("something.txt"), after_backup_dir.join("something.txt")).unwrap();

    let archive = &dir.join(format!("archive.{}", merge_extensions(ext, &extra_extensions)));
    ouch!("-A", "c", before_dir, archive);

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive)
        .arg("-d")
        .arg(after)
        .arg("--no")
        .assert()
        .success();

    assert_same_directory(after, after_backup, false);
}

#[proptest(cases = 25)]
fn multiple_files_with_conflict_and_choice_to_rename(
    ext: DirectoryExtension,
    #[any(size_range(0..1).lift())] extra_extensions: Vec<FileExtension>,
) {
    let (_tempdir, root_path) = testdir().unwrap();

    let src_files_path = root_path.join("src_files");
    fs::create_dir_all(&src_files_path).unwrap();
    create_n_random_files(5, &src_files_path, &mut SmallRng::from_os_rng());

    // Make destiny already filled to force a conflict
    let dest_files_path = root_path.join("dest_files");
    fs::create_dir_all(&dest_files_path).unwrap();
    create_n_random_files(5, &dest_files_path, &mut SmallRng::from_os_rng());

    let archive = &root_path.join(format!("archive.{}", merge_extensions(ext, &extra_extensions)));
    ouch!("-A", "c", &src_files_path, archive);

    let dest_files_path_renamed = &root_path.join("dest_files_1");
    assert_eq!(false, dest_files_path_renamed.exists());

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive)
        .arg("-d")
        .arg(&dest_files_path)
        .write_stdin("r")
        .assert()
        .success();

    assert_same_directory(src_files_path, dest_files_path_renamed.join("src_files"), false);
}

#[proptest(cases = 25)]
fn multiple_files_with_conflict_and_choice_to_rename_with_already_a_renamed(
    ext: DirectoryExtension,
    #[any(size_range(0..1).lift())] extra_extensions: Vec<FileExtension>,
) {
    let (_tempdir, root_path) = testdir().unwrap();

    let src_files_path = root_path.join("src_files");
    fs::create_dir_all(&src_files_path).unwrap();
    create_n_random_files(5, &src_files_path, &mut SmallRng::from_os_rng());

    // Make destiny already filled and destiny with '_1'
    let dest_files_path = root_path.join("dest_files");
    fs::create_dir_all(&dest_files_path).unwrap();
    create_n_random_files(5, &dest_files_path, &mut SmallRng::from_os_rng());

    let dest_files_path_1 = root_path.join("dest_files_1");
    fs::create_dir_all(&dest_files_path_1).unwrap();
    create_n_random_files(5, &dest_files_path_1, &mut SmallRng::from_os_rng());

    let archive = &root_path.join(format!("archive.{}", merge_extensions(ext, &extra_extensions)));
    ouch!("-A", "c", &src_files_path, archive);

    let dest_files_path_renamed = &root_path.join("dest_files_2");
    assert_eq!(false, dest_files_path_renamed.exists());

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive)
        .arg("-d")
        .arg(&dest_files_path)
        .write_stdin("r")
        .assert()
        .success();

    assert_same_directory(src_files_path, dest_files_path_renamed.join("src_files"), false);
}

#[cfg(feature = "unrar")]
#[test]
fn unpack_rar() -> Result<(), Box<dyn std::error::Error>> {
    fn test_unpack_rar_single(input: &Path) -> Result<(), Box<dyn std::error::Error>> {
        let (_tempdir, dirpath) = testdir()?;
        let unpacked_path = &dirpath.join("testfile.txt");
        ouch!("-A", "d", input, "-d", dirpath);
        let content = fs::read_to_string(unpacked_path)?;
        assert_eq!(content, "Testing 123\n");

        Ok(())
    }

    let mut datadir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
    datadir.push("tests/data");
    ["testfile.rar3.rar.gz", "testfile.rar5.rar"]
        .iter()
        .try_for_each(|path| test_unpack_rar_single(&datadir.join(path)))?;

    Ok(())
}

#[cfg(feature = "unrar")]
#[test]
fn unpack_rar_stdin() -> Result<(), Box<dyn std::error::Error>> {
    fn test_unpack_rar_single(input: &Path, format: &str) -> Result<(), Box<dyn std::error::Error>> {
        let (_tempdir, dirpath) = testdir()?;
        let unpacked_path = &dirpath.join("testfile.txt");
        crate::utils::cargo_bin()
            .args([
                "-A",
                "-y",
                "d",
                "-",
                "-d",
                dirpath.to_str().unwrap(),
                "--format",
                format,
            ])
            .pipe_stdin(input)
            .unwrap()
            .assert()
            .success();
        let content = fs::read_to_string(unpacked_path)?;
        assert_eq!(content, "Testing 123\n");

        Ok(())
    }

    let mut datadir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
    datadir.push("tests/data");
    [("testfile.rar3.rar.gz", "rar.gz"), ("testfile.rar5.rar", "rar")]
        .iter()
        .try_for_each(|(path, format)| test_unpack_rar_single(&datadir.join(path), format))?;

    Ok(())
}

#[cfg(unix)]
#[test]
fn symlink_pack_and_unpack() -> Result<()> {
    for ext in MainDirectoryExtension::iter() {
        if let MainDirectoryExtension::SevenZ = ext {
            // 7z doesn't support symlinks
            continue;
        }
        eprintln!("ext = {ext}");

        let (_tempdir, root_path) = testdir()?;

        let src_files_path = root_path.join("src_files");
        let folder_path = src_files_path.join("folder");
        fs::create_dir_all(&folder_path)?;

        let mut files_path = ["file1.txt", "file2.txt", "file3.txt", "file4.txt", "file5.txt"]
            .into_iter()
            .map(|f| src_files_path.join(f))
            .inspect(|path| {
                let mut file = fs::File::create(path).unwrap();
                file.write_all("Some content".as_bytes()).unwrap();
            })
            .collect::<Vec<_>>();

        let dest_files_path = root_path.join("dest_files");
        fs::create_dir_all(&dest_files_path)?;

        let symlink_path = src_files_path.join(Path::new("symlink"));
        let symlink_folder_path = src_files_path.join(Path::new("symlink_folder"));
        std::os::unix::fs::symlink(&files_path[0], &symlink_path)?;
        std::os::unix::fs::symlink(&folder_path, &symlink_folder_path)?;

        files_path.push(symlink_path);

        let archive = &root_path.join(format!("archive.{ext}"));

        crate::utils::cargo_bin()
            .arg("compress")
            .args(files_path.clone())
            .arg(archive)
            .assert()
            .success();

        crate::utils::cargo_bin()
            .arg("decompress")
            .arg(archive)
            .arg("-d")
            .arg(&dest_files_path)
            .assert()
            .success();

        // check the symlink stand still
        for f in dest_files_path.as_path().read_dir()? {
            let f = f?;
            if f.file_name() == "symlink" || f.file_name() == "symlink_folder" {
                assert!(f.file_type()?.is_symlink())
            }
        }

        fs::remove_file(archive)?;
        fs::remove_dir_all(&dest_files_path)?;

        // follow-symlinks is rejected under the sandbox so keep it off here.
        crate::utils::cargo_bin()
            .env("OUCH_NO_SANDBOX", "1")
            .arg("compress")
            .arg("--follow-symlinks")
            .args(files_path)
            .arg(archive)
            .assert()
            .success();

        crate::utils::cargo_bin()
            .arg("decompress")
            .arg(archive)
            .arg("-d")
            .arg(&dest_files_path)
            .assert()
            .success();

        // check there is no symlinks
        for f in dest_files_path.as_path().read_dir()? {
            let f = f?;
            assert!(!f.file_type().unwrap().is_symlink())
        }
    }
    Ok(())
}

/// Test that broken symlinks are handled correctly by formats that support it.
#[cfg(unix)]
#[test]
fn broken_symlink_stored_successfully_when_format_supports_it() -> Result<()> {
    for ext in MainDirectoryExtension::iter() {
        eprintln!("ext = {ext}");

        let (_tempdir, dir) = testdir().unwrap();

        // Create a broken symlink (points to non-existent target)
        let broken_symlink = dir.join("broken_link");
        let broken_target = "/nonexistent/path";
        fs::os::unix::fs::symlink(broken_target, &broken_symlink).unwrap();
        let archive = dir.join(format!("archive.{ext}"));
        let output = dir.join("output");

        assert!(broken_symlink.is_symlink());

        let result = crate::utils::cargo_bin()
            .arg("compress")
            .arg(broken_symlink)
            .arg(&archive)
            .assert();

        match ext {
            MainDirectoryExtension::SevenZ => {
                result.failure();
                continue;
            }
            MainDirectoryExtension::Tar | MainDirectoryExtension::Zip => {
                result.success();
            }
        }

        crate::utils::cargo_bin()
            .arg("decompress")
            .arg(&archive)
            .arg("--dir")
            .arg(&output)
            .assert()
            .success();

        let target = fs::read_link(output.join("broken_link")).unwrap();
        assert_eq!(Path::new(&target), broken_target);
    }
    Ok(())
}

#[cfg(unix)]
#[test]
fn broken_symlink_error_when_compressing_with_follow_symlinks() {
    for ext in MainDirectoryExtension::iter() {
        eprintln!("ext = {ext}");

        let (_tempdir, dir) = testdir().unwrap();
        let input = dir.join("input");
        let output = dir.join("output");

        fs::create_dir_all(&input).unwrap();
        fs::create_dir_all(&output).unwrap();

        // Create a broken symlink
        let broken_symlink = input.join("broken_link");
        fs::os::unix::fs::symlink("/nonexistent/path", &broken_symlink).unwrap();

        let archive = dir.join(format!("archive.{ext}"));

        // follow-symlinks is rejected under the sandbox so keep it off here.
        crate::utils::cargo_bin()
            .env("OUCH_NO_SANDBOX", "1")
            .arg("compress")
            .arg("--follow-symlinks")
            .arg(&input)
            .arg(&archive)
            .assert()
            .failure();
    }
}

#[cfg(unix)]
#[test]
fn symlink_treatment_inside_nested_dirs_with_follow_symlinks_flag() {
    for (ext, follow_symlinks_flag) in MainDirectoryExtension::iter().cartesian_product([false, true]) {
        if let MainDirectoryExtension::SevenZ = ext {
            // 7z doesn't support symlinks
            continue;
        }
        eprintln!("ext = {ext}");

        let (_tempdir, dir) = testdir().unwrap();
        let input_a = dir.join("input_a");
        let input_b = dir.join("input_b");

        fs::create_dir_all(&input_a).unwrap();
        fs::create_dir_all(&input_b).unwrap();

        let input1_nested_dir = dir.join("input_a/dir1/dir2");
        fs::create_dir_all(&input1_nested_dir).unwrap();
        // create a symlink called dir3
        // points to directory at the second input folder
        fs::os::unix::fs::symlink(input_b.join("target_here"), dir.join("input_a/dir1/dir2/dir3")).unwrap();

        let input_b_nested_dir = dir.join("input_b/target_here/dir4/dir5");
        let input_b_file = dir.join("input_b/target_here/dir4/dir5/file");
        fs::create_dir_all(&input_b_nested_dir).unwrap();
        fs::write(input_b_file, "contents").unwrap();

        let archive = dir.join(format!("archive.{ext}"));

        let mut cmd = crate::utils::cargo_bin();
        // follow-symlinks is rejected under the sandbox so keep it off here.
        cmd.env("OUCH_NO_SANDBOX", "1");
        cmd.arg("compress");
        if follow_symlinks_flag {
            cmd.arg("--follow-symlinks");
        }
        cmd.arg(&input_a).arg(&archive).assert().success();

        let output = dir.join("output");
        crate::utils::cargo_bin()
            .arg("decompress")
            .arg(archive)
            .arg("--dir")
            .arg(&output)
            .assert()
            .success();

        assert_eq!(
            "contents",
            fs::read_to_string(output.join("input_a/dir1/dir2/dir3/dir4/dir5/file")).unwrap(),
        );
        assert_eq!(
            !follow_symlinks_flag,
            output.join("input_a/dir1/dir2/dir3").is_symlink(),
        );
    }
}

#[test]
fn no_git_folder_after_decompression_with_gitignore_flag_active() {
    use std::process::Command;

    let (_tempdir, dir_path) = testdir().unwrap();

    let before = dir_path.join("before");

    let decompressed = dir_path.join("decompressed");

    // Create directory and a dummy file
    fs::create_dir(&before).unwrap();
    fs::write(before.join("hello.txt"), b"Hello, world!").unwrap();

    // Run `git init` inside it
    Command::new("git")
        .arg("init")
        .current_dir(&before)
        .output()
        .expect("failed to run git init");

    assert!(before.join(".git").exists(), ".git folder should exist after git init");

    // Compress it
    let archive = dir_path.join("archive.zip");
    ouch!("c", &before, &archive, "--gitignore");

    // Decompress it
    ouch!("d", &archive, "-d", &decompressed);

    // Find the subdirectory inside decompressed (e.g., "before")
    let decompressed_subdir = fs::read_dir(&decompressed)
        .unwrap()
        .find_map(Result::ok)
        .map(|entry| entry.path())
        .expect("Expected one directory inside decompressed");

    // Assert that the decompressed folder does not include `.git/`
    assert!(
        !decompressed_subdir.join(".git").exists(),
        ".git folder should not exist after decompression"
    );
}

#[proptest(cases = 25)]
fn enable_gitignore_flag_should_work_without_git(
    ext: DirectoryExtension,
    #[any(size_range(0..1).lift())] extra_extensions: Vec<FileExtension>,
) {
    let (_tempdir, root_path) = testdir()?;
    let source_path = root_path.join(format!("in_{}", merge_extensions(ext, &extra_extensions)));
    fs::create_dir_all(&source_path)?;
    let out_path = root_path.join(format!("out_{}", merge_extensions(ext, &extra_extensions)));
    fs::create_dir_all(&out_path)?;

    let mut gitignore_file = fs::File::create(source_path.join(".gitignore"))?;
    gitignore_file.write_all(b"a")?;
    let mut ignore_file = fs::File::create(source_path.join(".ignore"))?;
    ignore_file.write_all(b"b")?;

    fs::File::create(source_path.join("a"))?;
    fs::File::create(source_path.join("b"))?;
    fs::File::create(source_path.join("c"))?;

    let archive = root_path.join(format!("archive.{}", merge_extensions(ext, &extra_extensions)));
    crate::utils::cargo_bin()
        .arg("compress")
        .arg("--gitignore")
        .arg("--hidden")
        .arg(&source_path)
        .arg(&archive)
        .assert()
        .success();

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive)
        .arg("-d")
        .arg(&out_path)
        .assert()
        .success();

    // only the file name 'c' exists
    assert_eq!(
        1,
        out_path
            .join(format!("in_{}", merge_extensions(ext, &extra_extensions)))
            .as_path()
            .read_dir()?
            .count()
    );
}

#[proptest(cases = 25)]
fn unpack_multiple_sources_into_the_same_destination_with_merge(
    ext: DirectoryExtension,
    #[any(size_range(0..1).lift())] extra_extensions: Vec<FileExtension>,
) {
    let (_tempdir, root_path) = testdir()?;
    let source_path = root_path
        .join(format!("example_{}", merge_extensions(ext, &extra_extensions)))
        .join("sub_a")
        .join("sub_b")
        .join("sub_c");

    fs::create_dir_all(&source_path)?;
    let archive = root_path.join(format!("archive.{}", merge_extensions(ext, &extra_extensions)));
    crate::utils::cargo_bin()
        .arg("compress")
        .args([
            fs::File::create(source_path.join("file1.txt"))?.path(),
            fs::File::create(source_path.join("file2.txt"))?.path(),
            fs::File::create(source_path.join("file3.txt"))?.path(),
        ])
        .arg(&archive)
        .assert()
        .success();

    fs::remove_dir_all(&source_path)?;
    fs::create_dir_all(&source_path)?;
    let archive1 = root_path.join(format!("archive1.{}", merge_extensions(ext, &extra_extensions)));
    crate::utils::cargo_bin()
        .arg("compress")
        .args([
            fs::File::create(source_path.join("file3.txt"))?.path(),
            fs::File::create(source_path.join("file4.txt"))?.path(),
            fs::File::create(source_path.join("file5.txt"))?.path(),
        ])
        .arg(&archive1)
        .assert()
        .success();

    let out_path = root_path.join(format!("out_{}", merge_extensions(ext, &extra_extensions)));
    fs::create_dir_all(&out_path)?;

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive)
        .arg("-d")
        .arg(&out_path)
        .assert()
        .success();

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive1)
        .arg("-d")
        .arg(&out_path)
        .write_stdin("m")
        .assert()
        .success();

    assert_eq!(5, out_path.as_path().read_dir()?.count());
}

#[test]
fn reading_nested_archives_with_two_archive_extensions_adjacent() {
    let archive_formats = MainDirectoryExtension::iter();

    for (first_archive, second_archive) in archive_formats.clone().cartesian_product(archive_formats.rev()) {
        let (_tempdir, dir) = testdir().unwrap();
        let in_dir = |path: &str| format!("{}/{}", dir.display(), path);

        fs::write(in_dir("a.txt"), "contents").unwrap();

        let files = [
            "a.txt",
            &format!("b.{first_archive}"),
            &format!("c.{first_archive}.{second_archive}"),
        ];
        let transformations = [first_archive, second_archive];
        let compressed_path = in_dir(files.last().unwrap());

        for (window, format) in files.windows(2).zip(transformations.iter()) {
            let [a, b] = [window[0], window[1]].map(in_dir);
            crate::utils::cargo_bin()
                .args(["compress", &a, &b, "--format", &format.to_string()])
                .assert()
                .success();
        }

        let output = crate::utils::cargo_bin()
            .args(["list", &compressed_path, "--yes"])
            .assert()
            .failure()
            .get_output()
            .clone();
        let stderr = output.stderr.to_str().unwrap();
        assert!(memmem::find(stderr.as_bytes(), b"use `--format` to specify what format to use").is_some());

        let output = crate::utils::cargo_bin()
            .args(["decompress", &compressed_path, "--dir", &in_dir("out"), "--yes"])
            .assert()
            .failure()
            .get_output()
            .clone();
        let stderr = output.stderr.to_str().unwrap();
        assert!(memmem::find(stderr.as_bytes(), b"use `--format` to specify what format to use").is_some());
    }
}

#[test]
fn reading_nested_archives_with_two_archive_extensions_interleaved() {
    let archive_formats = MainDirectoryExtension::iter();

    for (first_archive, second_archive) in archive_formats.clone().cartesian_product(archive_formats.rev()) {
        let (_tempdir, dir) = testdir().unwrap();
        let in_dir = |path: &str| format!("{}/{}", dir.display(), path);

        fs::write(in_dir("a.txt"), "contents").unwrap();

        let files = [
            "a.txt",
            &format!("c.{first_archive}"),
            &format!("d.{first_archive}.zst"),
            &format!("e.{first_archive}.zst.{second_archive}"),
            &format!("f.{first_archive}.zst.{second_archive}.lz4"),
        ];
        let transformations = [&first_archive.to_string(), "zst", &second_archive.to_string(), "lz4"];
        let compressed_path = in_dir(files.last().unwrap());

        for (window, format) in files.windows(2).zip(transformations.iter()) {
            let [a, b] = [window[0], window[1]].map(in_dir);
            crate::utils::cargo_bin()
                .args(["compress", &a, &b, "--format", format])
                .assert()
                .success();
        }

        let output = crate::utils::cargo_bin()
            .args(["list", &compressed_path, "--yes"])
            .assert()
            .failure()
            .get_output()
            .clone();
        let stderr = output.stderr.to_str().unwrap();
        assert!(memmem::find(stderr.as_bytes(), b"use `--format` to specify what format to use").is_some());

        let output = crate::utils::cargo_bin()
            .args(["decompress", &compressed_path, "--dir", &in_dir("out"), "--yes"])
            .assert()
            .failure()
            .get_output()
            .clone();
        let stderr = output.stderr.to_str().unwrap();
        assert!(memmem::find(stderr.as_bytes(), b"use `--format` to specify what format to use").is_some());
    }
}

#[test]
fn compressing_archive_with_two_archive_formats() {
    let archive_formats = MainDirectoryExtension::iter();

    for (first_archive, second_archive) in archive_formats.clone().cartesian_product(archive_formats.rev()) {
        let (_tempdir, dir_path) = testdir().unwrap();
        let dir = dir_path.display().to_string();

        let output = crate::utils::cargo_bin()
            .args([
                "compress",
                "README.md",
                &format!("{dir}/out.{first_archive}.{second_archive}"),
                "--yes",
            ])
            .assert()
            .failure()
            .get_output()
            .clone();

        let stderr = output.stderr.to_str().unwrap();
        assert!(memmem::find(stderr.as_bytes(), b"use `--format` to specify what format to use").is_some());

        let output = crate::utils::cargo_bin()
            .args([
                "compress",
                "README.md",
                &format!("{dir}/out.{first_archive}.{second_archive}"),
                "--yes",
                "--format",
                &format!("{first_archive}.{second_archive}"),
            ])
            .assert()
            .failure()
            .get_output()
            .clone();

        let stderr = output.stderr.to_str().unwrap();
        assert!(
            memmem::find(
                stderr.as_bytes(),
                b"can only be used at the start of the file extension",
            )
            .is_some()
        );

        crate::utils::cargo_bin()
            .args([
                "compress",
                "README.md",
                &format!("{dir}/out.{first_archive}.{second_archive}"),
                "--yes",
                "--format",
                &first_archive.to_string(),
            ])
            .assert()
            .success();
    }
}

#[test]
fn fail_when_compressing_archive_as_the_second_extension() {
    for archive_format in MainDirectoryExtension::iter() {
        let (_tempdir, dir_path) = testdir().unwrap();
        let dir = dir_path.display().to_string();

        let output = crate::utils::cargo_bin()
            .args([
                "compress",
                "README.md",
                &format!("{dir}/out.zst.{archive_format}"),
                "--yes",
            ])
            .assert()
            .failure()
            .get_output()
            .clone();

        let stderr = output.stderr.to_str().unwrap();
        assert!(memmem::find(stderr.as_bytes(), b"use `--format` to specify what format to use").is_some());

        let output = crate::utils::cargo_bin()
            .args([
                "compress",
                "README.md",
                &format!("{dir}/out_file"),
                "--yes",
                "--format",
                &format!("zst.{archive_format}"),
            ])
            .assert()
            .failure()
            .get_output()
            .clone();

        let stderr = output.stderr.to_str().unwrap();
        assert!(
            memmem::find(
                stderr.as_bytes(),
                format!("'{archive_format}' can only be used at the start of the file extension").as_bytes(),
            )
            .is_some()
        );
    }
}

#[test]
fn sevenz_list_should_not_failed() {
    let (_tempdir, root_path) = testdir().unwrap();
    let src_files_path = root_path.join("src_files");
    fs::create_dir_all(&src_files_path).unwrap();

    let archive = root_path.join("archive.7z.gz");
    crate::utils::cargo_bin()
        .arg("compress")
        .arg("--yes")
        .arg(fs::File::create(src_files_path.join("README.md")).unwrap().path())
        .arg(&archive)
        .assert()
        .success();

    let res = crate::utils::cargo_bin()
        .arg("list")
        .arg("--yes")
        .arg(&archive)
        .assert()
        .success();

    assert!(res.get_output().stdout.find(b"README.md").is_some());
}

#[test]
fn list_show_size_displays_uncompressed_sizes() {
    let (_tempdir, root_path) = testdir().unwrap();
    let src_files_path = root_path.join("src_files");
    fs::create_dir_all(&src_files_path).unwrap();

    let file_path = src_files_path.join("hello.txt");
    fs::write(&file_path, "hello world").unwrap(); // 11 bytes

    let archive = root_path.join("archive.tar");
    crate::utils::cargo_bin()
        .arg("compress")
        .arg("--yes")
        .arg(&file_path)
        .arg(&archive)
        .assert()
        .success();

    // With `--show-size`, the pretty-printed size is shown alongside the entry.
    let with_flag = crate::utils::cargo_bin()
        .args(["list", "--show-size"])
        .arg(&archive)
        .assert()
        .success();
    let stdout = with_flag.get_output().stdout.clone();
    assert!(stdout.find(b"hello.txt").is_some());
    assert!(stdout.find(b"11.00").is_some());

    // Without the flag, the default output is unchanged and carries no size.
    let without_flag = crate::utils::cargo_bin().arg("list").arg(&archive).assert().success();
    let stdout = without_flag.get_output().stdout.clone();
    assert!(stdout.find(b"hello.txt").is_some());
    assert!(stdout.find(b"11.00").is_none());
}

#[test]
fn list_depth_limits_shown_entries() {
    let (_tempdir, root_path) = testdir().unwrap();
    let src = root_path.join("listdir");
    fs::create_dir_all(src.join("sub")).unwrap();
    fs::write(src.join("top.txt"), "contents").unwrap();
    fs::write(src.join("sub").join("deep.txt"), "contents").unwrap();

    let archive = root_path.join("archive.tar.gz");
    crate::utils::cargo_bin()
        .args(["compress", src.to_str().unwrap(), archive.to_str().unwrap(), "--yes"])
        .assert()
        .success();

    // Without a limit the nested file shows up.
    let full = crate::utils::cargo_bin()
        .args(["list", archive.to_str().unwrap(), "--yes"])
        .assert()
        .success();
    assert!(full.get_output().stdout.find(b"deep.txt").is_some());

    // `--depth 1` only keeps the top-level directory.
    for extra in [&[][..], &["--tree"]] {
        let limited = crate::utils::cargo_bin()
            .args(["list", archive.to_str().unwrap(), "--depth", "1", "--yes"])
            .args(extra)
            .assert()
            .success();
        let stdout = limited.get_output().stdout.clone();
        assert!(stdout.find(b"listdir").is_some());
        assert!(stdout.find(b"top.txt").is_none());
        assert!(stdout.find(b"deep.txt").is_none());
    }
}

// TODO: for supporting windows hard link easier
// we should wait for this issue
// https://github.com/rust-lang/rust/issues/63010
#[cfg(unix)]
#[test]
fn tar_hardlink_pack_and_unpack() {
    use std::{fs::hard_link, os::unix::fs::MetadataExt};

    let (_tempdir, root_path) = testdir().unwrap();
    let source_path = root_path.join("hardlink");
    fs::create_dir_all(&source_path).unwrap();
    let out_path = root_path.join("out");
    fs::create_dir_all(&out_path).unwrap();

    let source = fs::File::create(source_path.join("source")).unwrap();
    let link1 = source_path.join("link1");
    let link2 = source_path.join("link2");
    hard_link(source.path(), link1.as_path()).unwrap();
    hard_link(source.path(), link2.as_path()).unwrap();

    let archive = root_path.join("archive.tar.gz");
    crate::utils::cargo_bin()
        .arg("compress")
        .arg(&source_path)
        .arg(&archive)
        .assert()
        .success();

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(archive)
        .arg("-d")
        .arg(&out_path)
        .assert()
        .success();

    let out_source_meta = fs::File::open(out_path.join("hardlink").join("source"))
        .unwrap()
        .metadata()
        .unwrap();
    let out_link1_meta = fs::File::open(out_path.join("hardlink").join("link1"))
        .unwrap()
        .metadata()
        .unwrap();
    let out_link2_meta = fs::File::open(out_path.join("hardlink").join("link2"))
        .unwrap()
        .metadata()
        .unwrap();

    assert!(out_source_meta.nlink() > 1);
    assert!(out_link1_meta.nlink() > 1);
    assert!(out_link2_meta.nlink() > 1);

    assert_eq!(out_source_meta.dev(), out_link1_meta.dev());
    assert_eq!(out_link1_meta.dev(), out_link2_meta.dev());

    assert_eq!(out_source_meta.ino(), out_link1_meta.ino());
    assert_eq!(out_link1_meta.ino(), out_link2_meta.ino());
}

#[test]
fn compress_with_rename_conflict() {
    let (_tempdir, root_path) = testdir().unwrap();

    let file_path = root_path.join("file.txt");
    fs::write(&file_path, "content").unwrap();

    let archive = root_path.join("archive.tar.gz");

    for _ in 0..3 {
        crate::utils::cargo_bin()
            .arg("compress")
            .arg(&file_path)
            .arg(&archive)
            .write_stdin("r\n")
            .assert()
            .success();
    }

    assert!(root_path.join("archive.tar.gz").exists());
    assert!(root_path.join("archive_1.tar.gz").exists());
    assert!(root_path.join("archive_2.tar.gz").exists());
}

#[test]
fn decompress_with_mismatched_extension_should_use_detected_format() {
    let (_tempdir, test_dir) = testdir().unwrap();

    let original_file = test_dir.join("input.txt");
    fs::write(&original_file, "Hello, world!").unwrap();

    let gzip_archive = test_dir.join("archive.gz");
    let misnamed_archive = test_dir.join("archive.zst");

    crate::utils::cargo_bin()
        .arg("compress")
        .arg(&original_file)
        .arg(&gzip_archive)
        .assert()
        .success();

    // Rename the .gz file to have a .zst extension (wrong extension)
    fs::rename(&gzip_archive, &misnamed_archive).unwrap();

    let output_dir = test_dir.join("output");
    fs::create_dir(&output_dir).unwrap();

    let output = crate::utils::cargo_bin()
        .arg("decompress")
        .arg(misnamed_archive)
        .arg("--dir")
        .arg(output_dir)
        .assert()
        .failure()
        .get_output()
        .clone();

    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("Format mismatch"), "Expected format mismatch error");
    assert!(stderr.contains("--format"), "Expected hint about --format flag");
}

#[proptest(cases = 10)]
fn decompress_with_unknown_extension_should_detect_format_and_ask(
    ext: FileExtension,
    contains_extension_in_filename: bool,
) {
    let (_tempdir, test_dir) = testdir()?;

    // Brotli has no magic bytes
    if let FileExtension::Br = ext {
        return Ok(());
    }

    let original_file = test_dir.join("input.txt");
    let original_content = "Hello, world!";
    fs::write(&original_file, original_content)?;

    let compressed_archive = test_dir.join(format!("file.{ext}"));

    crate::utils::cargo_bin()
        .arg("compress")
        .arg(&original_file)
        .arg(&compressed_archive)
        .assert()
        .success();

    let erased_ext_filename = if contains_extension_in_filename {
        "file.unknown"
    } else {
        "file"
    };
    let unknown_path = test_dir.join(erased_ext_filename);
    // Rename to have an unknown extension (no recognized format)
    fs::rename(&compressed_archive, &unknown_path)?;

    let output_dir = test_dir.join("output");
    fs::create_dir(&output_dir)?;

    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(&unknown_path)
        .arg("--dir")
        .arg(&output_dir)
        .write_stdin("y\n")
        .assert()
        .success();

    // Ouch adds this suffix so it doesn't conflict with other stuff
    let decompressed_file = output_dir.join("file-output");
    let decompressed_content = fs::read_to_string(&decompressed_file)?;
    assert_eq!(decompressed_content, original_content);
}

/// Helper function to test decompression of concatenated streams (issue #855).
/// Takes a file extension and a compression function that compresses a single chunk.
fn test_concatenated_streams(extension: &str, compress_chunk: impl Fn(&[u8]) -> Vec<u8>) {
    let (_tempdir, root_path) = testdir().unwrap();

    // Create content for three separate streams
    let chunks: &[&[u8]] = &[
        b"First stream content - this is stream 1\n",
        b"Second stream content - this is stream 2\n",
        b"Third stream content - this is stream 3\n",
    ];

    // Create the concatenated file
    let concatenated_path = root_path.join(format!("concatenated.{extension}"));
    {
        let mut file = fs::File::create(&concatenated_path).unwrap();
        for chunk in chunks {
            file.write_all(&compress_chunk(chunk)).unwrap();
        }
    }

    // Decompress using ouch
    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(&concatenated_path)
        .arg("-d")
        .arg(root_path)
        .arg("--yes")
        .assert()
        .success();

    // Verify the output contains all streams
    let output_path = root_path.join("concatenated");
    let output_content = fs::read(&output_path).unwrap();
    let expected_content: Vec<u8> = chunks.iter().flat_map(|c| c.iter().copied()).collect();
    assert_eq!(
        output_content, expected_content,
        "Decompressed content should contain all concatenated {extension} streams"
    );
}

/// Regression test: `--yes` should merge into a non-empty output directory rather than wiping it.
/// Previously, `--yes` defaulted to `Overwrite`, which would call `remove_dir_all` on the output
/// directory, including when that directory was `$CWD`.
#[test]
fn yes_flag_merges_into_nonempty_dir() {
    let (_tempdir, dir) = testdir().unwrap();

    // Create an archive from a source directory
    let src = dir.join("src");
    fs::create_dir_all(&src).unwrap();
    fs::write(src.join("new_file.txt"), "new content").unwrap();
    let archive = dir.join("archive.tar.gz");
    ouch!("-A", "c", &src, &archive);

    // Output directory already has a file and simulates a non-empty $CWD
    let output = dir.join("output");
    fs::create_dir_all(&output).unwrap();
    fs::write(output.join("important.txt"), "keep this").unwrap();

    crate::utils::cargo_bin()
        .current_dir(&output)
        .arg("decompress")
        .arg(&archive)
        .arg("--yes")
        .assert()
        .success();

    assert!(
        output.join("important.txt").exists(),
        "--yes wiped the output directory instead of merging"
    );
    assert!(
        output.join("archive").join("src").join("new_file.txt").exists(),
        "archive contents were not extracted into the basename subdir"
    );
}

/// `ouch d archive` with no `-d`/`--here` should extract into a basename-derived
/// subdirectory of the CWD (`./archive/`), not directly into the CWD. Pre-existing
/// CWD contents stay intact.
#[test]
fn decompress_default_creates_basename_subdir() {
    let (_tempdir, dir) = testdir().unwrap();

    let src = dir.join("src");
    fs::create_dir_all(&src).unwrap();
    fs::write(src.join("file.txt"), "content").unwrap();
    let archive = dir.join("archive.tar.gz");
    ouch!("-A", "c", &src, &archive);

    let cwd = dir.join("cwd");
    fs::create_dir_all(&cwd).unwrap();
    fs::write(cwd.join("important.txt"), "keep this").unwrap();
    fs::copy(&archive, cwd.join("archive.tar.gz")).unwrap();

    crate::utils::cargo_bin()
        .current_dir(&cwd)
        .arg("decompress")
        .arg("archive.tar.gz")
        .assert()
        .success();

    assert!(cwd.join("important.txt").exists(), "pre-existing file was lost");
    assert!(
        cwd.join("archive").join("src").join("file.txt").exists(),
        "archive contents were not extracted into the basename subdir"
    );
    assert!(
        !cwd.join("src").join("file.txt").exists(),
        "archive contents leaked directly into the CWD instead of the subdir"
    );
}

/// When the archive's single root entry has the same name as the wrapper directory
/// (e.g. `testing.tar.gz` containing `testing/`), the duplicate is flattened: the
/// user gets `cwd/testing/...` rather than `cwd/testing/testing/...`.
#[test]
fn decompress_default_flattens_duplicate_basename_wrapper() {
    let (_tempdir, dir) = testdir().unwrap();

    let src = dir.join("testing");
    fs::create_dir_all(&src).unwrap();
    fs::write(src.join("file.txt"), "content").unwrap();
    let archive = dir.join("testing.tar.gz");
    ouch!("-A", "c", &src, &archive);

    let cwd = dir.join("cwd");
    fs::create_dir_all(&cwd).unwrap();
    fs::copy(&archive, cwd.join("testing.tar.gz")).unwrap();

    crate::utils::cargo_bin()
        .current_dir(&cwd)
        .arg("decompress")
        .arg("testing.tar.gz")
        .assert()
        .success();

    assert!(
        cwd.join("testing").join("file.txt").exists(),
        "flattened content not present"
    );
    assert!(
        !cwd.join("testing").join("testing").exists(),
        "duplicate basename wrapper was not flattened"
    );
}

/// `--here` extracts directly into the current directory, like `tar -xf` and `unzip`.
/// No wrapper folder, no flatten, no overwrite prompt for pre-existing CWD contents.
#[test]
fn decompress_here_flag_extracts_into_cwd() {
    let (_tempdir, dir) = testdir().unwrap();

    let src = dir.join("src");
    fs::create_dir_all(&src).unwrap();
    fs::write(src.join("file.txt"), "content").unwrap();
    let archive = dir.join("archive.tar.gz");
    ouch!("-A", "c", &src, &archive);

    let cwd = dir.join("cwd");
    fs::create_dir_all(&cwd).unwrap();
    fs::write(cwd.join("important.txt"), "keep this").unwrap();
    fs::copy(&archive, cwd.join("archive.tar.gz")).unwrap();

    crate::utils::cargo_bin()
        .current_dir(&cwd)
        .arg("decompress")
        .arg("archive.tar.gz")
        .arg("--here")
        .assert()
        .success();

    assert!(cwd.join("important.txt").exists(), "pre-existing file was lost");
    assert!(
        cwd.join("src").join("file.txt").exists(),
        "--here did not extract directly into CWD"
    );
    assert!(
        !cwd.join("archive").exists(),
        "--here unexpectedly created a wrapper subdirectory"
    );
}

/// Test that concatenated gzip streams are fully decompressed (issue #855)
#[test]
fn decompress_concatenated_gzip_streams() {
    use flate2::{Compression, write::GzEncoder};

    test_concatenated_streams("gz", |data| {
        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
        encoder.write_all(data).unwrap();
        encoder.finish().unwrap()
    });
}

/// Test that concatenated bzip2 streams are fully decompressed (related to issue #855)
#[test]
fn decompress_concatenated_bzip2_streams() {
    use bzip2::{Compression, write::BzEncoder};

    test_concatenated_streams("bz2", |data| {
        let mut encoder = BzEncoder::new(Vec::new(), Compression::default());
        encoder.write_all(data).unwrap();
        encoder.finish().unwrap()
    });
}

/// When decompressing with `--dir out`, first `ouch` should create the new
/// folder, on a second call, it fails and asks whether or not it should be
/// overwritten, a third call with `--yes` should succeed.
#[test]
fn decompress_dir_flag_to_another_dir_and_overwrite() {
    let (_tempdir, dir) = testdir().unwrap();
    let input_folder = dir.join("folder");
    let input_file = input_folder.join("file");
    let output_file = dir.join("out/folder/file");
    let archive = dir.join("archive.zip");

    fs::create_dir(&input_folder).unwrap();
    fs::write(&input_file, "first write").unwrap();

    crate::utils::cargo_bin()
        .arg("compress")
        .arg(&input_folder)
        .arg(&archive)
        .assert()
        .success();

    // First decompress with `--dir out` — should succeed
    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(&archive)
        .args(["--dir", "out"])
        .current_dir(dir)
        .assert()
        .success();

    assert_eq!("first write", fs::read_to_string(&output_file).unwrap());

    // Recreate archive, in case unpacking succeeds we'll see the new archive
    // contents instead
    fs::remove_file(&archive).unwrap();
    fs::remove_dir_all(&input_folder).unwrap();
    fs::create_dir(&input_folder).unwrap();
    fs::write(&input_file, "second write").unwrap();
    crate::utils::cargo_bin()
        .arg("compress")
        .arg(&input_folder)
        .arg(&archive)
        .assert()
        .success();

    // Decompressing again with `--dir out` — this fails due to directory
    // conflict, we also didn't pass `--yes`.
    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(&archive)
        .args(["--dir", "out"])
        .current_dir(dir)
        .assert()
        .failure();

    assert_eq!("first write", fs::read_to_string(&output_file).unwrap());

    // Third decompress attempt, now pass `--yes` to accept overwriting.
    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(&archive)
        .args(["--dir", "out", "--yes"])
        .current_dir(dir)
        .assert()
        .success();

    assert_eq!("second write", fs::read_to_string(&output_file).unwrap());
}

/// A single file extracted into an explicit --dir is conflict-checked on the file, not the directory
#[test]
fn decompress_single_file_dir_allows_non_empty_output_dir_with_no() {
    let (_tempdir, dir) = testdir().unwrap();

    fs::write(dir.join("a"), b"alpha").unwrap();
    crate::utils::cargo_bin()
        .args(["compress", "a", "a.gz", "--yes"])
        .current_dir(dir)
        .assert()
        .success();
    fs::remove_file(dir.join("a")).unwrap();

    fs::create_dir(dir.join("out")).unwrap();
    fs::write(dir.join("out").join("other-file"), b"keep").unwrap();

    crate::utils::cargo_bin()
        .args(["decompress", "a.gz", "--dir", "out", "--no"])
        .current_dir(dir)
        .assert()
        .success();

    assert_eq!("alpha", fs::read_to_string(dir.join("out").join("a")).unwrap());
    assert_eq!("keep", fs::read_to_string(dir.join("out").join("other-file")).unwrap());
}

/// This test ensures the current behavior isn't modified by accident, even
/// if it's not the ideal behavior.
///
/// Decompressing with `--dir .` succeeds without asking questions once.
///
/// Non-ideal part: it also succeeds on the second call, without asking to
/// overwrite files, which is inconsistent with the behavior of `--dir` when
/// the given path is not `.` (or absolute path for CWD).
#[test]
fn decompress_dir_flag_current_dir_and_overwrite() {
    let (_tempdir, dir) = testdir().unwrap();
    let input_folder = dir.join("folder");
    let input_file = input_folder.join("file");
    // Decompressing with `--dir .` means the file will be overwritten at the same path
    let output_file = &input_file;
    let archive = dir.join("archive.zip");

    fs::create_dir(&input_folder).unwrap();
    fs::write(&input_file, "first write").unwrap();

    crate::utils::cargo_bin()
        .arg("compress")
        .arg(&input_folder)
        .arg(&archive)
        .assert()
        .success();

    // First decompress with `--dir .` — should succeed
    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(&archive)
        .args(["--dir", "."])
        .current_dir(dir)
        .assert()
        .success();

    assert_eq!("first write", fs::read_to_string(output_file).unwrap());

    // Recreate archive, in case unpacking succeeds we'll see the new archive
    // contents instead
    fs::remove_file(&archive).unwrap();
    fs::remove_dir_all(&input_folder).unwrap();
    fs::create_dir(&input_folder).unwrap();
    fs::write(&input_file, "second write").unwrap();
    crate::utils::cargo_bin()
        .arg("compress")
        .arg(&input_folder)
        .arg(&archive)
        .assert()
        .success();

    // Decompressing again with `--dir .` — this succeeds for now, and it's
    // inconsistent with other `--dir PATH` usages, this test ensure this isn't
    // changed by accident.
    crate::utils::cargo_bin()
        .arg("decompress")
        .arg(&archive)
        .args(["--dir", "."])
        .current_dir(dir)
        .assert()
        .success();

    assert_eq!("second write", fs::read_to_string(output_file).unwrap());
}

/// Test that concatenated lz4 frames are fully decompressed (related to issue #855)
#[test]
fn decompress_concatenated_lz4_frames() {
    use lz4_flex::frame::FrameEncoder;

    test_concatenated_streams("lz4", |data| {
        let mut encoder = FrameEncoder::new(Vec::new());
        encoder.write_all(data).unwrap();
        encoder.finish().unwrap()
    });
}

/// Zip entries whose stored mode lacks Unix file-type bits must fall back to default permissions.
#[test]
fn missing_file_type_unix_permissions() {
    let (_tempdir, test_dir) = testdir().unwrap();
    let bad_perms_zip = test_dir.join("bad_perms.zip");

    // The zip crate always ORs in S_IFREG, so write a normal entry first.
    {
        use zip::write::SimpleFileOptions;
        let file = std::fs::File::create(&bad_perms_zip).unwrap();
        let mut zip = zip::ZipWriter::new(file);
        zip.start_file("test_file.txt", SimpleFileOptions::default()).unwrap();
        zip.write_all(b"hello world").unwrap();
        zip.finish().unwrap();
    }

    // Overwrite external_attributes in the central directory with 0o777<<16: permission bits but no S_IFMT.
    let mut bytes = std::fs::read(&bad_perms_zip).unwrap();
    let cd = bytes.windows(4).position(|w| w == [0x50, 0x4b, 0x01, 0x02]).unwrap();
    bytes[cd + 38..cd + 42].copy_from_slice(&(0o777u32 << 16).to_le_bytes());
    std::fs::write(&bad_perms_zip, &bytes).unwrap();

    let out_dir = test_dir.join("out");
    crate::utils::cargo_bin()
        .arg("d")
        .arg(&bad_perms_zip)
        .arg("-d")
        .arg(&out_dir)
        .assert()
        .success();

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = std::fs::metadata(out_dir.join("test_file.txt"))
            .unwrap()
            .permissions()
            .mode();
        // The bogus 0o777 must be ignored, so the extracted file is not owner-executable.
        assert_eq!(mode & 0o100, 0, "invalid zip mode must not be applied");
        // And no setuid/setgid/sticky bits leak through.
        assert_eq!(mode & 0o7000, 0, "special bits must not be applied");
    }
}

/// Zip entries carrying setuid/setgid/sticky bits must have them stripped, keeping only permission bits.
#[test]
fn zip_special_permission_bits_are_stripped() {
    let (_tempdir, test_dir) = testdir().unwrap();
    let suid_zip = test_dir.join("suid.zip");

    // The zip crate masks special bits away, so write a normal entry then patch the bytes.
    {
        use zip::write::SimpleFileOptions;
        let file = std::fs::File::create(&suid_zip).unwrap();
        let mut zip = zip::ZipWriter::new(file);
        zip.start_file("test_file.txt", SimpleFileOptions::default()).unwrap();
        zip.write_all(b"hello world").unwrap();
        zip.finish().unwrap();
    }

    // Set external_attributes to S_IFREG | setuid | setgid | sticky | 0o755.
    let mut bytes = std::fs::read(&suid_zip).unwrap();
    let cd = bytes.windows(4).position(|w| w == [0x50, 0x4b, 0x01, 0x02]).unwrap();
    bytes[cd + 38..cd + 42].copy_from_slice(&((0o100000u32 | 0o7755) << 16).to_le_bytes());
    std::fs::write(&suid_zip, &bytes).unwrap();

    let out_dir = test_dir.join("out");
    crate::utils::cargo_bin()
        .arg("d")
        .arg(&suid_zip)
        .arg("-d")
        .arg(&out_dir)
        .assert()
        .success();

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = std::fs::metadata(out_dir.join("test_file.txt"))
            .unwrap()
            .permissions()
            .mode();
        // Permission bits are preserved.
        assert_eq!(mode & 0o777, 0o755, "permission bits must be preserved");
        // Setuid, setgid, and sticky bits are removed.
        assert_eq!(mode & 0o7000, 0, "special bits must be stripped");
    }
}

// Default mode decompression without --dir or --here is not tested elsewhere
// These lock in the layout where output goes into a new directory named after the archive

#[test]
fn decompress_single_file_default_mode_creates_named_directory() {
    let (_tempdir, dir) = testdir().unwrap();

    fs::write(dir.join("report.txt"), b"hello").unwrap();
    crate::utils::cargo_bin()
        .args(["compress", "report.txt", "report.txt.gz", "--yes"])
        .current_dir(dir)
        .assert()
        .success();
    fs::remove_file(dir.join("report.txt")).unwrap();

    crate::utils::cargo_bin()
        .args(["decompress", "report.txt.gz", "--yes"])
        .current_dir(dir)
        .assert()
        .success();

    // report.txt.gz becomes report/report.txt so the directory has no extension and the file keeps it
    assert!(dir.join("report").is_dir());
    assert_eq!(
        "hello",
        fs::read_to_string(dir.join("report").join("report.txt")).unwrap()
    );
    assert!(!dir.join("report.txt").is_dir());
}

#[test]
fn decompress_archive_default_mode_creates_named_directory() {
    let (_tempdir, dir) = testdir().unwrap();

    fs::create_dir(dir.join("payload")).unwrap();
    fs::write(dir.join("payload").join("a.txt"), b"a").unwrap();
    crate::utils::cargo_bin()
        .args(["compress", "payload", "bundle.tar", "--yes"])
        .current_dir(dir)
        .assert()
        .success();

    crate::utils::cargo_bin()
        .args(["decompress", "bundle.tar", "--yes"])
        .current_dir(dir)
        .assert()
        .success();

    assert!(dir.join("bundle").is_dir());
    assert_eq!(
        "a",
        fs::read_to_string(dir.join("bundle").join("payload").join("a.txt")).unwrap()
    );
}

// An archive whose only entry matches the wrapper name must not nest
// test.zip with a test/ folder must unpack to test/ and not test/test/
#[test]
fn decompress_archive_default_mode_does_not_duplicate_directory() {
    let (_tempdir, dir) = testdir().unwrap();

    fs::create_dir(dir.join("test")).unwrap();
    fs::write(dir.join("test").join("inner.txt"), b"y").unwrap();
    crate::utils::cargo_bin()
        .args(["compress", "test", "test.zip", "--yes"])
        .current_dir(dir)
        .assert()
        .success();
    fs::remove_dir_all(dir.join("test")).unwrap();

    crate::utils::cargo_bin()
        .args(["decompress", "test.zip", "--yes"])
        .current_dir(dir)
        .assert()
        .success();

    assert!(dir.join("test").join("inner.txt").is_file());
    assert!(!dir.join("test").join("test").exists());
}

// One run with the sandbox disabled. Checks basic functions and that the sandbox is really off.
#[cfg(target_os = "linux")]
#[test]
fn without_sandbox_runs_and_confirms_sandbox_is_off() {
    let (_tempdir, dir) = testdir().unwrap();
    let input = dir.join("data.txt");
    fs::write(&input, "no sandbox").unwrap();

    // Basic functions still work with the sandbox disabled.
    let archive = dir.join("out.tar.gz");
    crate::utils::cargo_bin()
        .arg("--no-sandbox")
        .arg("compress")
        .arg(&input)
        .arg(&archive)
        .arg("--yes")
        .assert()
        .success();
    let output = dir.join("out");
    crate::utils::cargo_bin()
        .arg("--no-sandbox")
        .arg("decompress")
        .arg(&archive)
        .arg("--dir")
        .arg(&output)
        .arg("--yes")
        .assert()
        .success();
    assert_eq!("no sandbox", fs::read_to_string(output.join("data.txt")).unwrap());

    // Sandbox is really off. With it active --follow-symlinks aborts so success proves it is disabled.
    crate::utils::cargo_bin()
        .arg("--no-sandbox")
        .arg("compress")
        .arg("--follow-symlinks")
        .arg(&input)
        .arg(dir.join("follow.tar"))
        .arg("--yes")
        .assert()
        .success();
}

// two inputs with the same basename must not silently merge into one target

#[test]
fn decompress_duplicate_basenames_skips_second_with_no() {
    let (_tempdir, dir) = testdir().unwrap();

    for (sub, content) in [("a", "AAA"), ("b", "BBB")] {
        fs::create_dir(dir.join(sub)).unwrap();
        fs::write(dir.join(sub).join("x.txt"), content).unwrap();
        crate::utils::cargo_bin()
            .args(["compress", "x.txt", "x.tar.gz", "--yes"])
            .current_dir(dir.join(sub))
            .assert()
            .success();
    }

    // the no flag answers the second conflict with skip
    crate::utils::cargo_bin()
        .args(["decompress", "a/x.tar.gz", "b/x.tar.gz", "--no"])
        .current_dir(dir)
        .assert()
        .success();

    // only the first input extracted and its content is intact
    assert_eq!("AAA", fs::read_to_string(dir.join("x").join("x.txt")).unwrap());
    assert!(!dir.join("x_1").exists());
}

#[test]
fn decompress_duplicate_basenames_prompts_and_renames() {
    let (_tempdir, dir) = testdir().unwrap();

    for (sub, content) in [("a", "AAA"), ("b", "BBB")] {
        fs::create_dir(dir.join(sub)).unwrap();
        fs::write(dir.join(sub).join("x.txt"), content).unwrap();
        crate::utils::cargo_bin()
            .args(["compress", "x.txt", "x.tar.gz", "--yes"])
            .current_dir(dir.join(sub))
            .assert()
            .success();
    }

    // the second input must hit the conflict question and get renamed
    crate::utils::cargo_bin()
        .args(["decompress", "a/x.tar.gz", "b/x.tar.gz"])
        .current_dir(dir)
        .write_stdin("r")
        .assert()
        .success();

    assert_eq!("AAA", fs::read_to_string(dir.join("x").join("x.txt")).unwrap());
    assert_eq!("BBB", fs::read_to_string(dir.join("x_1").join("x.txt")).unwrap());
}

// follow symlinks only passes when the sandbox is off so success proves gitignore disabled it
#[cfg(target_os = "linux")]
#[test]
fn gitignore_disables_sandbox_and_unlocks_follow_symlinks() {
    let (_tempdir, dir) = testdir().unwrap();
    let input = dir.join("data.txt");
    fs::write(&input, "tracked").unwrap();

    crate::utils::cargo_bin()
        .arg("compress")
        .arg("--gitignore")
        .arg("--follow-symlinks")
        .arg(&input)
        .arg(dir.join("out.tar"))
        .arg("--yes")
        .assert()
        .success();
}

/// A conflict that cannot be resolved because stdin is /dev/null must fail, not exit 0.
#[test]
fn decompress_conflict_with_dev_null_stdin_exits_nonzero() {
    use std::process::Stdio;

    let (_tempdir, dir) = testdir().unwrap();
    fs::create_dir(dir.join("d")).unwrap();
    fs::write(dir.join("d").join("f.txt"), b"hi").unwrap();

    crate::utils::cargo_bin()
        .current_dir(dir)
        .args(["compress", "d", "a.zip", "--yes"])
        .assert()
        .success();
    // first extraction creates ./a, so the next run hits a conflict
    crate::utils::cargo_bin()
        .current_dir(dir)
        .args(["decompress", "a.zip", "--yes"])
        .assert()
        .success();

    // stdin is /dev/null so the conflict cannot be answered; the run must fail instead of exiting 0
    let status = crate::utils::cargo_bin_command()
        .current_dir(dir)
        .args(["decompress", "a.zip"])
        .stdin(Stdio::null())
        .status()
        .unwrap();
    assert!(
        !status.success(),
        "decompress with an unresolvable conflict on /dev/null stdin must exit non-zero"
    );
}
