//! Functions for applying patches to a work directory.
use crate::system_tools::{SystemTools, Tool};

use super::SourceError;
use rattler_build_types::LateBoundPath;

use std::io::Write;
use std::{
    collections::HashSet,
    ffi::OsStr,
    path::{Path, PathBuf},
};

use flickzeug::{
    ApplyConfig, ApplyError, ApplyOutcome, Diff, FuzzyConfig, HunkRangeStrategy, ParsePatchError,
    ParserConfig, Patch, apply_bytes_reporting, apply_bytes_with_config,
    patch_from_bytes_with_config,
};
use fs_err::File;
use itertools::Itertools;

/// Summarize a single patch file by reading and parsing it.
pub fn summarize_single_patch(
    patch_path: &Path,
    work_dir: &Path,
) -> Result<PatchStats, SourceError> {
    let data = fs_err::read(patch_path).map_err(SourceError::Io)?;
    let patch = patch_from_bytes(&data)
        .map_err(|_| SourceError::PatchParseFailed(patch_path.to_path_buf()))?;
    summarize_patch(&patch, work_dir)
}

/// Normalizes backup file paths (.orig/.bak) to their actual file paths.
///
/// A `--- foo.orig` / `+++ foo` pair can come from two distinct workflows:
///
/// 1. In-place edit: the workdir contains `foo`; `.orig` is just a saved
///    backup left next to the patched copy (the typical output of editing
///    with `patch -b` or comparing a saved-aside copy against the edited
///    file). The patch should be applied to `foo` in place.
///
/// 2. Source-with-`.orig`: the workdir contains only `foo.orig` (e.g. the
///    recipe used `file_name: foo.orig` so the original upstream source
///    landed under that name) and the patch is meant to read from `.orig`
///    and write the result to `foo`. This matches GNU `patch -p0`'s
///    behavior, which uses whichever side of the diff exists on disk.
///
/// We disambiguate by checking which of the two files actually exists in
/// the work directory. If the bare-name target exists, we collapse onto
/// it (case 1). Otherwise the paths are left split so the apply step
/// reads from `.orig` and writes the bare name (case 2).
fn normalize_backup_paths(
    original_path: Option<PathBuf>,
    modified_path: Option<PathBuf>,
    work_dir: &Path,
) -> (Option<PathBuf>, Option<PathBuf>) {
    let (Some(orig), Some(modified)) = (&original_path, &modified_path) else {
        return (original_path, modified_path);
    };

    if orig == modified {
        return (original_path, modified_path);
    }

    if let (Some(orig_stem), Some(orig_ext)) = (orig.file_stem(), orig.extension())
        && let Some(mod_filename) = modified.file_name()
        && matches!(orig_ext.to_str(), Some("orig" | "bak"))
        && orig_stem == mod_filename
        && work_dir.join(modified).exists()
    {
        return (Some(modified.clone()), Some(modified.clone()));
    }

    (original_path, modified_path)
}

/// Extract all file paths from a patch that should be used for strip level detection.
/// Only includes files that already exist (i.e., are being modified or deleted),
/// not files being newly created from /dev/null.
///
/// Both sides of a `.orig`/bare-name pair are returned (no collapsing here):
/// strip-level detection uses an "any path exists" check, so leaving both
/// candidates in the set lets the workflow that ships only `foo.orig` resolve
/// just as well as the in-place workflow that ships only `foo`.
fn parse_patch_existing_files(patch: &Patch<[u8]>) -> HashSet<PathBuf> {
    let mut affected_files = HashSet::new();

    for diff in patch {
        let original_path = diff
            .original()
            .and_then(|p| std::str::from_utf8(p).ok())
            .map(PathBuf::from);

        let modified_path = diff
            .modified()
            .and_then(|p| std::str::from_utf8(p).ok())
            .map(PathBuf::from);

        // Only include files that the patch modifies or deletes. These already
        // exist in the work directory, so they are reliable for strip level
        // guessing. New files (original is None or /dev/null) don't exist yet,
        // so a coincidental match at a different strip level would be incorrect.
        let modifies_existing_file = original_path
            .as_ref()
            .is_some_and(|p| p != Path::new("/dev/null"));

        if modifies_existing_file {
            if let Some(path) = original_path {
                affected_files.insert(path);
            }
            if let Some(path) = modified_path {
                affected_files.insert(path);
            }
        }
    }

    affected_files
}

/// Extract all file paths mentioned in a patch (both new and existing files).
fn parse_patch_all_files(patch: &Patch<[u8]>) -> HashSet<PathBuf> {
    let mut affected_files = HashSet::new();

    for diff in patch {
        let original_path = diff
            .original()
            .and_then(|p| std::str::from_utf8(p).ok())
            .map(PathBuf::from);

        let modified_path = diff
            .modified()
            .and_then(|p| std::str::from_utf8(p).ok())
            .map(PathBuf::from);

        if let Some(path) = original_path {
            affected_files.insert(path);
        }
        if let Some(path) = modified_path {
            affected_files.insert(path);
        }
    }

    affected_files
}

fn patch_from_bytes(input: &[u8]) -> Result<Patch<'_, [u8]>, ParsePatchError> {
    patch_from_bytes_with_config(
        input,
        ParserConfig {
            hunk_strategy: HunkRangeStrategy::Recount,
            skip_order_check: true,
            strip_ab_prefix: true,
        },
    )
}

/// Parse patch bytes without stripping a/b prefixes.
/// Used for GNU patch which handles prefix stripping itself via the -p flag.
fn patch_from_bytes_raw(input: &[u8]) -> Result<Patch<'_, [u8]>, ParsePatchError> {
    patch_from_bytes_with_config(
        input,
        ParserConfig {
            hunk_strategy: HunkRangeStrategy::Recount,
            skip_order_check: true,
            strip_ab_prefix: false,
        },
    )
}

/// The fuzzy-matching configuration used for all patch application. Shared by
/// `apply` and the already-applied detection so both agree on what "applies".
fn apply_config() -> ApplyConfig {
    ApplyConfig {
        fuzzy_config: FuzzyConfig {
            max_fuzz: 2,
            ignore_whitespace: true,
            ignore_case: false,
        },
        ..Default::default()
    }
}

fn apply(base_image: &[u8], diff: &Diff<'_, [u8]>) -> Result<Vec<u8>, ApplyError> {
    apply_bytes_with_config(base_image, diff, &apply_config()).map(|(content, _stats)| content)
}

// Returns number by which all patch paths must be stripped to be
// successfully applied, or returns and error if no such number could
// be determined.
fn guess_strip_level(patch: &Patch<[u8]>, work_dir: &Path) -> Result<usize, SourceError> {
    // Only use files that the patch modifies/deletes (not newly created from /dev/null)
    // for strip level detection. New files don't exist yet in the work directory,
    // so using them would risk false matches at incorrect strip levels.
    let existing_files = parse_patch_existing_files(patch);

    if !existing_files.is_empty() {
        let max_components = existing_files
            .iter()
            .map(|p| p.components().count())
            .max()
            .unwrap_or(0);

        for strip_level in 0..max_components {
            // We check for _any_ path existing here. Sometimes patches reference
            // files that are deleted (e.g. `foobar.orig`) and thus it's more robust
            // to look for the first match in the affected files.
            let any_paths_exist = existing_files.iter().any(|p| {
                let path: PathBuf = p.components().skip(strip_level).collect();
                // Skip empty paths - they can falsely match the work_dir itself
                !path.as_os_str().is_empty() && work_dir.join(path).exists()
            });
            if any_paths_exist {
                return Ok(strip_level);
            }
        }
    }

    // No existing files matched (e.g. all files are new from /dev/null).
    // Check if paths have git-style a/b prefixes, which need strip level 1.
    let all_files = parse_patch_all_files(patch);
    let has_ab_prefix = all_files.iter().any(|p| {
        p.components()
            .next()
            .and_then(|c| c.as_os_str().to_str())
            .is_some_and(|s| s == "a" || s == "b")
    });
    if has_ab_prefix {
        return Ok(1);
    }

    // Default to strip level 0 since flickzeug already strips a/b prefixes.
    Ok(0)
}

fn custom_patch_stripped_paths(
    diff: &Diff<'_, [u8]>,
    strip_level: usize,
    work_dir: &Path,
) -> (Option<PathBuf>, Option<PathBuf>) {
    let strip_path = |path_bytes: &[u8]| -> Option<PathBuf> {
        std::str::from_utf8(path_bytes).ok().and_then(|p| {
            let path: PathBuf = PathBuf::from(p).components().skip(strip_level).collect();
            // Skip empty paths - they would resolve to work_dir itself
            if path.as_os_str().is_empty() {
                return None;
            }
            Some(path)
        })
    };

    let original_path = diff.original().and_then(strip_path);
    let modified_path = diff.modified().and_then(strip_path);

    normalize_backup_paths(original_path, modified_path, work_dir)
}

fn write_patch_content(content: &[u8], path: &Path) -> Result<(), SourceError> {
    if let Some(parent) = path.parent() {
        fs_err::create_dir_all(parent).map_err(SourceError::Io)?;
    }

    // We want to be able to write to file.
    if path.exists() {
        let mut perms = fs_err::metadata(path)
            .map_err(SourceError::Io)?
            .permissions();
        if perms.readonly() {
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                let user_write = 0o200;
                perms.set_mode(perms.mode() | user_write);
            }
            #[cfg(not(unix))]
            {
                // Assume this means windows
                #[allow(clippy::permissions_set_readonly_false)]
                perms.set_readonly(false);
            }
            fs_err::set_permissions(path, perms).map_err(SourceError::Io)?;
        }
    }

    let mut new_file = File::create(path).map_err(SourceError::Io)?;
    new_file.write_all(content).map_err(SourceError::Io)?;

    Ok(())
}

#[cfg(windows)]
fn temp_copy<P: AsRef<Path>>(src_path: P) -> std::io::Result<tempfile::NamedTempFile> {
    let mut src = File::open(src_path.as_ref())?;
    let mut tmp = tempfile::NamedTempFile::new()?;
    std::io::copy(&mut src, &mut tmp)?;
    Ok(tmp)
}

#[allow(dead_code)]
pub(crate) fn apply_patch_gnu(
    system_tools: &SystemTools,
    work_dir: &Path,
    patch_file_path: &Path,
) -> Result<(), SourceError> {
    let patch_file_content = fs_err::read(patch_file_path).map_err(SourceError::Io)?;

    // Use raw parsing (no a/b prefix stripping) for GNU patch since it handles
    // prefix stripping itself via the -p flag.
    let patch = patch_from_bytes_raw(&patch_file_content)
        .map_err(|_| SourceError::PatchParseFailed(patch_file_path.to_path_buf()))?;
    let strip_level = guess_strip_level(&patch, work_dir)?;

    tracing::debug!("Patch {} will be applied", patch_file_path.display());

    // GNU patch treats some paths incorrectly on windows
    #[cfg(windows)]
    let patch_tmp = temp_copy(patch_file_path)?;
    #[cfg(windows)]
    let patch_file_path = patch_tmp.path();

    let mut tool = system_tools
        .call(Tool::Patch)
        .map_err(|_| SourceError::PatchExeNotFound)?;
    let cmd_builder = tool
        .arg(format!("-p{}", strip_level))
        .arg("--no-backup-if-mismatch")
        .arg("-i")
        .arg(String::from(patch_file_path.to_string_lossy()))
        .arg("-d")
        .arg(String::from(work_dir.to_string_lossy()));
    let output = cmd_builder.output()?;

    if !output.status.success() {
        return Err(SourceError::PatchFailed(format!(
            "{}\n`patch` failed with a combination of flags.\n\n{}",
            patch_file_path.display(),
            {
                let stderr = String::from_utf8_lossy(&output.stderr);
                let stdout = String::from_utf8_lossy(&output.stdout);
                format!(
                    "With the the command:\n\n\t{} {}\n\nThe stdout was:\n\n\t{}\n\nThe stderr was:\n\n\t{}\n\n",
                    cmd_builder.get_program().to_string_lossy(),
                    cmd_builder
                        .get_args()
                        .map(OsStr::to_string_lossy)
                        .format(" "),
                    stdout.lines().format("\n\t"),
                    stderr.lines().format("\n\t")
                )
            }
        )));
    }

    Ok(())
}

/// Apply a patch file to the working directory using the `patch` command.
pub fn apply_patch_custom(work_dir: &Path, patch_file_path: &Path) -> Result<(), SourceError> {
    let patch_file_content = fs_err::read(patch_file_path).map_err(SourceError::Io)?;

    let patch = patch_from_bytes(&patch_file_content)
        .map_err(|_| SourceError::PatchParseFailed(patch_file_path.to_path_buf()))?;
    let strip_level = guess_strip_level(&patch, work_dir)?;

    // Track whether every diff in the patch turned out to be a no-op because it
    // was already applied (e.g. the change was merged upstream). If so, we warn
    // that the patch can likely be dropped from the recipe rather than silently
    // claiming to have applied it.
    let mut saw_diff = false;
    let mut all_already_applied = true;

    for diff in patch {
        let file_paths = custom_patch_stripped_paths(&diff, strip_level, work_dir);
        let absolute_file_paths = (
            file_paths.0.map(|o| work_dir.join(&o)),
            file_paths.1.map(|m| work_dir.join(&m)),
        );

        tracing::debug!(
            "Patch will be applied:\n\tFrom: {:#?}\n\tTo:{:#?}",
            absolute_file_paths.0,
            absolute_file_paths.1
        );

        match absolute_file_paths {
            (None, None) => continue,
            (None, Some(m)) => {
                saw_diff = true;
                let new_file_content = apply(&[], &diff).map_err(SourceError::PatchApplyError)?;
                // Already applied if the target already holds the produced content.
                if fs_err::read(&m).is_ok_and(|existing| existing == new_file_content) {
                    continue;
                }
                all_already_applied = false;
                write_patch_content(&new_file_content, &m)?;
            }
            (Some(o), None) => {
                saw_diff = true;
                // Already applied if the file to delete is already gone.
                if o.exists() {
                    all_already_applied = false;
                    fs_err::remove_file(&o).map_err(SourceError::Io)?;
                }
            }
            (Some(o), Some(m)) => {
                saw_diff = true;
                // Check if the original file exists
                // If it doesn't, treat this as creating a new file
                if !o.exists() {
                    // A rename whose source is already gone and whose
                    // destination exists is already applied; otherwise create
                    // the new file from scratch.
                    if o != m && m.exists() {
                        continue;
                    }
                    all_already_applied = false;
                    let new_file_content =
                        apply(&[], &diff).map_err(SourceError::PatchApplyError)?;
                    write_patch_content(&new_file_content, &m)?;
                } else {
                    let old_file_content = fs_err::read(&o).map_err(SourceError::Io)?;

                    // `apply_bytes_reporting` reports whether the file already
                    // reflects the patched state, which is reliable even under
                    // the fuzzy matching we enable (a plain forward apply can
                    // silently succeed as a no-op on already-patched content).
                    match apply_bytes_reporting(&old_file_content, &diff, &apply_config()) {
                        ApplyOutcome::AlreadyApplied(_) => continue,
                        ApplyOutcome::Applied(new_file_content, _stats) => {
                            all_already_applied = false;
                            if o != m {
                                fs_err::remove_file(&o).map_err(SourceError::Io)?;
                            }
                            write_patch_content(&new_file_content, &m)?;
                        }
                        ApplyOutcome::Failed(e) => return Err(SourceError::PatchApplyError(e)),
                    }
                }
            }
        }
    }

    if saw_diff && all_already_applied {
        tracing::warn!(
            "Patch '{}' appears to be already applied; skipping. It may no longer be necessary and can likely be removed from the recipe.",
            patch_file_path.display()
        );
    }

    Ok(())
}

/// Applies all patches in a list of patches to the specified work directory.
///
/// Patch paths may reference a restricted set of late-bound build directory
/// variables (`SRC_DIR`, `RECIPE_DIR`, `BUILD_DIR`) which are resolved here. A
/// patch that does not reference any of these resolves to a path relative to
/// the recipe directory, preserving the previous behavior.
///
/// * `dest_dir` is the directory the patch is applied to (the source folder).
/// * `recipe_dir` / `src_dir` / `build_dir` are used both for late-bound
///   resolution and (for `recipe_dir`) as the base for relative patch paths.
pub(crate) fn apply_patches(
    patches: &[LateBoundPath],
    dest_dir: &Path,
    recipe_dir: &Path,
    src_dir: &Path,
    build_dir: &Path,
    apply_patch: impl Fn(&Path, &Path) -> Result<(), SourceError>,
) -> Result<(), SourceError> {
    for patch in patches {
        // Resolve any late-bound variables (`${{ SRC_DIR }}`, ...). For a plain
        // relative patch path this is a no-op and the path stays relative.
        let resolved = patch.resolve(|var| match var {
            "SRC_DIR" => Some(src_dir.to_path_buf()),
            "RECIPE_DIR" => Some(recipe_dir.to_path_buf()),
            "BUILD_DIR" => Some(build_dir.to_path_buf()),
            _ => None,
        });

        // Joining an absolute path (e.g. a resolved `${{ SRC_DIR }}/...`) onto
        // `recipe_dir` discards the base, so absolute and recipe-relative paths
        // are both handled correctly.
        let patch_file_path = recipe_dir.join(&resolved);

        tracing::info!("Applying patch: {}", patch_file_path.to_string_lossy());

        if !patch_file_path.exists() {
            return Err(SourceError::PatchNotFound(patch_file_path));
        }

        apply_patch(dest_dir, patch_file_path.as_path())?;
    }
    Ok(())
}

/// Summarized statistics of patch operations.
#[derive(Debug, Default)]
pub struct PatchStats {
    /// Files that have been modified (both original and modified exist).
    pub changed: Vec<PathBuf>,
    /// Files that have been added (original is /dev/null).
    pub added: Vec<PathBuf>,
    /// Files that have been removed (modified is /dev/null).
    pub removed: Vec<PathBuf>,
}

/// Summarize a single diff into added, removed, and changed files.
pub fn summarize_patch(diff: &Patch<[u8]>, work_dir: &Path) -> Result<PatchStats, SourceError> {
    let mut stats = PatchStats::default();
    let strip_level = guess_strip_level(diff, work_dir)?;
    for hunk in diff {
        let (orig_path, mod_path) = custom_patch_stripped_paths(hunk, strip_level, work_dir);
        match (orig_path, mod_path) {
            // Both original and modified exist: record original (prefix stripped) as changed file
            (Some(orig), Some(_mod)) => stats.changed.push(orig),
            // Only modified exists: new file added
            (None, Some(modified)) => stats.added.push(modified),
            // Only original exists: file removed
            (Some(orig), None) => stats.removed.push(orig),
            _ => {}
        }
    }
    Ok(stats)
}

#[cfg(test)]
mod tests {
    use crate::source::copy_dir::CopyDir;

    use super::*;
    use line_ending::LineEnding;

    use tempfile::TempDir;

    #[test]
    fn test_parse_patch() {
        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let patches_dir = manifest_dir.join("../../test-data/patches");

        // for all patches, just try parsing the patch
        for entry in patches_dir.read_dir().unwrap() {
            let patch = entry.unwrap();
            let patch_path = patch.path();
            if patch_path.extension() != Some("patch".as_ref()) {
                continue;
            }

            let patch_file_content =
                fs_err::read(&patch_path).expect("Could not read file contents");
            let _ = patch_from_bytes(&patch_file_content).expect("Failed to parse patch file");

            println!("Parsing patch: {}", patch_path.display());
        }
    }

    #[test]
    fn get_affected_files() {
        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let patches_dir = manifest_dir.join("../../test-data/patch_application/patches");

        let patch_file_content =
            fs_err::read(patches_dir.join("test.patch")).expect("Could not read file contents");
        let patch = patch_from_bytes(&patch_file_content).expect("Failed to parse patch file");

        let patched_paths = parse_patch_all_files(&patch);
        assert_eq!(patched_paths.len(), 1);
        assert!(patched_paths.contains(&PathBuf::from("text.md")));

        let patch_file_content =
            fs_err::read(patches_dir.join("0001-increase-minimum-cmake-version.patch"))
                .expect("Could not read file contents");
        let patch = patch_from_bytes(&patch_file_content).expect("Failed to parse patch file");
        let patched_paths = parse_patch_all_files(&patch);
        assert_eq!(patched_paths.len(), 1);
        assert!(patched_paths.contains(&PathBuf::from("CMakeLists.txt")));
    }

    fn setup_patch_test_dir() -> (TempDir, PathBuf) {
        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let patch_test_dir = manifest_dir.join("../../test-data/patch_application");

        let tempdir = TempDir::new().unwrap();
        let _ = CopyDir::new(&patch_test_dir, tempdir.path()).run().unwrap();

        (tempdir, patch_test_dir)
    }

    #[test]
    fn test_apply_patches() {
        let (tempdir, _) = setup_patch_test_dir();

        // Test with normal patch
        apply_patches(
            &[LateBoundPath::new("test.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .unwrap();

        let text_md = tempdir.path().join("workdir/text.md");
        let text_md = fs_err::read_to_string(&text_md).unwrap();
        assert!(text_md.contains("Oh, wow, I was patched! Thank you soooo much!"));
    }

    #[test]
    fn test_already_applied_patch_is_skipped() {
        // Applying the same patch a second time should be detected as
        // already-applied and skipped, leaving the file untouched rather than
        // corrupting it or erroring out.
        let (tempdir, _) = setup_patch_test_dir();
        let work_dir = tempdir.path().join("workdir");
        let patches_dir = tempdir.path().join("patches");

        apply_patches(
            &[LateBoundPath::new(
                "0001-increase-minimum-cmake-version.patch",
            )],
            &work_dir,
            &patches_dir,
            &patches_dir,
            &patches_dir,
            apply_patch_custom,
        )
        .expect("first application should succeed");

        let cmake_list = work_dir.join("CMakeLists.txt");
        let after_first = fs_err::read_to_string(&cmake_list).unwrap();
        assert!(after_first.contains("cmake_minimum_required(VERSION 3.12)"));

        // The patch is now fully applied. Re-applying it must be detected as a
        // no-op and skipped rather than corrupting the file or erroring.
        apply_patches(
            &[LateBoundPath::new(
                "0001-increase-minimum-cmake-version.patch",
            )],
            &work_dir,
            &patches_dir,
            &patches_dir,
            &patches_dir,
            apply_patch_custom,
        )
        .expect("re-applying an already-applied patch should not error");

        // The file must be byte-for-byte identical after the second (skipped)
        // application.
        let after_second = fs_err::read_to_string(&cmake_list).unwrap();
        assert_eq!(
            after_first, after_second,
            "already-applied patch must not modify the file again"
        );
    }

    #[test]
    fn test_deletion_only_patch_is_applied() {
        // A patch that only deletes lines must actually be applied, not be
        // misdetected as "already applied" and skipped. Regression test for
        // https://github.com/prefix-dev/rattler-build/issues/2693
        let (tempdir, _) = setup_patch_test_dir();
        let work_dir = tempdir.path().join("workdir");
        let patches_dir = tempdir.path().join("patches");

        apply_patches(
            &[LateBoundPath::new("test_deletion_only.patch")],
            &work_dir,
            &patches_dir,
            &patches_dir,
            &patches_dir,
            apply_patch_custom,
        )
        .expect("deletion-only patch should apply");

        let content = fs_err::read_to_string(work_dir.join("deletion_only.cmake")).unwrap();
        assert!(
            !content.contains("add_compile_options(/MTd)"),
            "deletion-only patch was not applied:\n{content}"
        );
        assert!(content.contains("add_compile_options(/utf-8)"));

        // Re-applying it now must be detected as already applied and skipped.
        apply_patches(
            &[LateBoundPath::new("test_deletion_only.patch")],
            &work_dir,
            &patches_dir,
            &patches_dir,
            &patches_dir,
            apply_patch_custom,
        )
        .expect("re-applying an already-applied deletion-only patch should not error");

        let after_second = fs_err::read_to_string(work_dir.join("deletion_only.cmake")).unwrap();
        assert_eq!(content, after_second);
    }

    #[test]
    fn test_deletion_only_patch_with_stale_context_fails() {
        // Same deletion-only patch shape, but its trailing context references a
        // line that does not exist in the target file (patch written against a
        // newer upstream). GNU patch rejects this; we must error out instead of
        // skipping it as "already applied".
        let (tempdir, _) = setup_patch_test_dir();
        let work_dir = tempdir.path().join("workdir");
        let patches_dir = tempdir.path().join("patches");

        let before = fs_err::read_to_string(work_dir.join("deletion_only_stale.cmake")).unwrap();

        let result = apply_patches(
            &[LateBoundPath::new("test_deletion_only_stale_context.patch")],
            &work_dir,
            &patches_dir,
            &patches_dir,
            &patches_dir,
            apply_patch_custom,
        );
        assert!(
            result.is_err(),
            "patch with non-matching context must fail to apply"
        );

        // The target file must be left untouched.
        let after = fs_err::read_to_string(work_dir.join("deletion_only_stale.cmake")).unwrap();
        assert_eq!(before, after);
    }

    #[test]
    fn test_apply_patches_with_orig() {
        let (tempdir, _) = setup_patch_test_dir();

        // Test with normal patch
        apply_patches(
            &[LateBoundPath::new("test_with_orig.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .unwrap();

        let text_md = tempdir.path().join("workdir/text.md");
        let text_md = fs_err::read_to_string(&text_md).unwrap();
        assert!(text_md.contains("Oh, wow, I was patched! Thank you soooo much!"));
    }

    #[test]
    fn test_apply_patches_with_orig_only_source() {
        // Regression test for https://github.com/conda/conda-launchers/pull/18
        // (and the corresponding rattler-build issue): when the workdir holds
        // only `<file>.orig` (e.g. because the recipe used
        // `file_name: launcher.c.orig` to name the downloaded source), a patch
        // with `--- launcher.c.orig` / `+++ launcher.c` must read from the
        // `.orig`, apply the diff, write the result to `launcher.c`, and
        // remove the `.orig`. This mirrors GNU `patch -p0`'s behavior for the
        // same input.
        let (tempdir, _) = setup_patch_test_dir();

        // Sanity: the fixture ships only the `.orig`, no bare-name file.
        let work_dir = tempdir.path().join("workdir");
        assert!(work_dir.join("launcher.c.orig").exists());
        assert!(!work_dir.join("launcher.c").exists());

        apply_patches(
            &[LateBoundPath::new("conda_launcher.patch")],
            &work_dir,
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .expect("patch should apply when only the .orig source is present");

        let patched = work_dir.join("launcher.c");
        assert!(patched.exists(), "patched file should land at the +++ name");
        let content = fs_err::read_to_string(&patched).unwrap();
        assert!(
            content.contains("Anaconda/Setuptools variant"),
            "patched content should be present: {content}",
        );
        assert!(
            content.contains("EXIT_SUCCESS"),
            "patched content should be present: {content}",
        );
        assert!(
            !work_dir.join("launcher.c.orig").exists(),
            "the .orig source should be consumed (matches GNU patch -p0 behavior)",
        );
    }

    #[test]
    fn test_apply_patches_with_bak() {
        let (tempdir, _) = setup_patch_test_dir();

        // Test with .bak extension patch
        apply_patches(
            &[LateBoundPath::new("test_with_bak.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .unwrap();

        let text_md = tempdir.path().join("workdir/text.md");
        let text_md = fs_err::read_to_string(&text_md).unwrap();
        assert!(text_md.contains("This was patched using .bak extension!"));
    }

    #[test]
    fn test_apply_patches_mixed_existing_files() {
        let (tempdir, _) = setup_patch_test_dir();

        // Test with patch that references both existing and non-existing files
        apply_patches(
            &[LateBoundPath::new("test_simple_mixed.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .unwrap();

        let existing_file = tempdir.path().join("workdir/existing_file.txt");
        let existing_file = fs_err::read_to_string(&existing_file).unwrap();
        assert!(existing_file.contains("Mixed files patch applied!"));

        let new_file = tempdir.path().join("workdir/new_file.txt");
        let new_file = fs_err::read_to_string(&new_file).unwrap();
        assert!(new_file.contains("This is a new file."));
        assert!(new_file.contains("Created by patch application."));
    }

    #[test]
    fn test_strip_level_detection_edge_case() {
        let (tempdir, _) = setup_patch_test_dir();

        // Test with patch that has deep paths but only some files exist
        apply_patches(
            &[LateBoundPath::new("test_strip_level_edge_case.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .unwrap();

        let deep_file = tempdir
            .path()
            .join("workdir/deep/nested/directory/deep_file.txt");
        let deep_file = fs_err::read_to_string(&deep_file).unwrap();
        assert!(deep_file.contains("Strip level test applied!"));
    }

    #[test]
    fn test_strip_level_algorithm_comparison() {
        // Test to demonstrate the difference between 'any' and 'all' logic
        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let patch_file = manifest_dir
            .join("../../test-data/patch_application/patches/test_strip_level_edge_case.patch");
        let patch_content = fs_err::read(&patch_file).unwrap();
        let patch = patch_from_bytes(&patch_content).unwrap();

        let (tempdir, _) = setup_patch_test_dir();
        let work_dir = tempdir.path().join("workdir");

        // The current implementation should find strip level 3 based on existing deep file
        // (was 4 before flickzeug started stripping a/b prefixes)
        let strip_level = guess_strip_level(&patch, &work_dir).unwrap();
        assert_eq!(
            strip_level, 3,
            "Current 'any' logic should find strip level 3"
        );

        // Let's also test what the old 'all' logic would have found
        // (This is what the function used to do before the PR)
        let patched_files = parse_patch_all_files(&patch);
        let max_components = patched_files
            .iter()
            .map(|p| p.components().count())
            .max()
            .unwrap_or(0);

        let mut old_algorithm_result = None;
        for strip_level in 0..max_components {
            let all_paths_exist = patched_files.iter().all(|p| {
                let path: PathBuf = p.components().skip(strip_level).collect();
                work_dir.join(path).exists()
            });
            if all_paths_exist {
                old_algorithm_result = Some(strip_level);
                break;
            }
        }

        // The old algorithm would not have found any strip level where ALL files exist
        // because nonexistent_deep.txt.orig doesn't exist
        assert_eq!(
            old_algorithm_result, None,
            "Old 'all' logic should not find a valid strip level"
        );
    }

    #[test]
    fn test_strip_level_with_all_new_files() {
        // Test that when all files are new (from /dev/null), the strip level
        // doesn't incorrectly match the workdir itself when paths become empty
        // after stripping.
        let patch_content = b"--- /dev/null
+++ b/new_file.txt
@@ -0,0 +1 @@
+new content
";
        let patch = patch_from_bytes(patch_content).unwrap();

        let tempdir = TempDir::new().unwrap();
        // Work dir exists but contains no files matching the patch
        let strip_level = guess_strip_level(&patch, tempdir.path()).unwrap();

        // Should return 0 since flickzeug already strips the b/ prefix
        assert_eq!(
            strip_level, 0,
            "Strip level should be 0 since a/b prefixes are already stripped"
        );
    }

    #[test]
    fn test_backup_file_logic_edge_cases() {
        // Workdir holds the bare-name file → collapse onto it (in-place edit).
        let work_dir = TempDir::new().unwrap();
        fs_err::write(work_dir.path().join("file.txt"), b"original line\n").unwrap();

        let patch_content = b"--- a/file.txt.orig\t2021-12-27 12:22:09.000000000 -0800\n+++ a/file.txt\t2021-12-27 12:22:14.000000000 -0800\n@@ -1 +1,2 @@\n original line\n+new line\n";
        let patch = patch_from_bytes(patch_content).unwrap();
        let diff = patch.into_iter().next().unwrap();

        // strip_level=0 because flickzeug already strips a/b prefixes
        let paths = custom_patch_stripped_paths(&diff, 0, work_dir.path());
        assert_eq!(paths.0, Some(PathBuf::from("file.txt")));
        assert_eq!(paths.1, Some(PathBuf::from("file.txt")));

        // Same with .bak extension.
        let patch_content = b"--- a/file.txt.bak\t2021-12-27 12:22:09.000000000 -0800\n+++ a/file.txt\t2021-12-27 12:22:14.000000000 -0800\n@@ -1 +1,2 @@\n original line\n+new line\n";
        let patch = patch_from_bytes(patch_content).unwrap();
        let diff = patch.into_iter().next().unwrap();

        let paths = custom_patch_stripped_paths(&diff, 0, work_dir.path());
        assert_eq!(paths.0, Some(PathBuf::from("file.txt")));
        assert_eq!(paths.1, Some(PathBuf::from("file.txt")));

        // Workdir holds only the `.orig` source → leave paths split so the
        // apply step reads from `.orig` and writes the bare name.
        let orig_only_dir = TempDir::new().unwrap();
        fs_err::write(
            orig_only_dir.path().join("file.txt.orig"),
            b"original line\n",
        )
        .unwrap();

        let patch_content = b"--- a/file.txt.orig\t2021-12-27 12:22:09.000000000 -0800\n+++ a/file.txt\t2021-12-27 12:22:14.000000000 -0800\n@@ -1 +1,2 @@\n original line\n+new line\n";
        let patch = patch_from_bytes(patch_content).unwrap();
        let diff = patch.into_iter().next().unwrap();

        let paths = custom_patch_stripped_paths(&diff, 0, orig_only_dir.path());
        assert_eq!(paths.0, Some(PathBuf::from("file.txt.orig")));
        assert_eq!(paths.1, Some(PathBuf::from("file.txt")));

        // Filename mismatch: backup-collapse must never apply when the
        // `.orig` stem doesn't match the bare-name file.
        let patch_content = b"--- a/different.txt.orig\t2021-12-27 12:22:09.000000000 -0800\n+++ a/file.txt\t2021-12-27 12:22:14.000000000 -0800\n@@ -1 +1,2 @@\n original line\n+new line\n";
        let patch = patch_from_bytes(patch_content).unwrap();
        let diff = patch.into_iter().next().unwrap();

        let paths = custom_patch_stripped_paths(&diff, 0, work_dir.path());
        assert_eq!(paths.0, Some(PathBuf::from("different.txt.orig")));
        assert_eq!(paths.1, Some(PathBuf::from("file.txt")));
    }

    #[test]
    fn test_apply_patches_with_crlf() {
        let (tempdir, _) = setup_patch_test_dir();

        // Test with CRLF patch
        let patch = tempdir.path().join("patches/test.patch");
        let text = fs_err::read_to_string(&patch).unwrap();
        let clrf_patch = LineEnding::CRLF.apply(&text);

        fs_err::write(tempdir.path().join("patches/test_crlf.patch"), clrf_patch).unwrap();

        // Test with CRLF patch
        apply_patches(
            &[LateBoundPath::new("test_crlf.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .unwrap();

        let text_md = tempdir.path().join("workdir/text.md");
        let text_md = fs_err::read_to_string(&text_md).unwrap();
        assert!(text_md.contains("Oh, wow, I was patched! Thank you soooo much!"));
    }

    #[test]
    fn test_apply_pure_rename_patch() {
        let (tempdir, _) = setup_patch_test_dir();

        // Apply patch with pure renames (100% similarity, no content changes)
        apply_patches(
            &[LateBoundPath::new("test_pure_rename.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .expect("Pure rename patch should apply successfully");

        // Check that the __init__.py file was deleted
        let init_file = tempdir.path().join("workdir/tinygrad/frontend/__init__.py");
        assert!(
            !init_file.exists(),
            "frontend/__init__.py should be deleted"
        );

        // Check that onnx.py was renamed from frontend to nn
        let old_onnx = tempdir.path().join("workdir/tinygrad/frontend/onnx.py");
        let new_onnx = tempdir.path().join("workdir/tinygrad/nn/onnx.py");
        assert!(!old_onnx.exists(), "frontend/onnx.py should not exist");
        assert!(new_onnx.exists(), "nn/onnx.py should exist");
        let onnx_content = fs_err::read_to_string(&new_onnx).unwrap();
        assert_eq!(
            onnx_content, "# onnx code\n",
            "onnx.py content should be preserved"
        );

        // Check that torch.py was renamed from frontend to nn
        let old_torch = tempdir.path().join("workdir/tinygrad/frontend/torch.py");
        let new_torch = tempdir.path().join("workdir/tinygrad/nn/torch.py");
        assert!(!old_torch.exists(), "frontend/torch.py should not exist");
        assert!(new_torch.exists(), "nn/torch.py should exist");
        let torch_content = fs_err::read_to_string(&new_torch).unwrap();
        assert_eq!(
            torch_content, "# torch code\n",
            "torch.py content should be preserved"
        );
    }

    #[test]
    fn test_apply_create_delete_patch() {
        let (tempdir, _) = setup_patch_test_dir();

        // Create the file that will be deleted
        let to_delete = tempdir.path().join("workdir/to_be_deleted.txt");
        fs_err::write(&to_delete, "This file will be deleted\nby the patch\n").unwrap();

        // Apply patch with creation and deletion
        apply_patches(
            &[LateBoundPath::new("test_create_delete.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .expect("Create/delete patch should apply successfully");

        // Check that the file was deleted
        assert!(!to_delete.exists(), "to_be_deleted.txt should be deleted");

        // Check that the new file was created with correct content
        let created_file = tempdir.path().join("workdir/newly_created.txt");
        assert!(created_file.exists(), "newly_created.txt should exist");
        let content = fs_err::read_to_string(&created_file).unwrap();
        assert!(content.contains("This is a newly created file"));
        assert!(content.contains("via patch application"));
    }

    #[test]
    fn test_apply_multiple_new_files_patch() {
        let (tempdir, _) = setup_patch_test_dir();

        // Apply patch that creates multiple new files in one patch
        apply_patches(
            &[LateBoundPath::new("test_multiple_new_files.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .expect("Multiple new files patch should apply successfully");

        // Check that all new files were created with correct content
        let first_file = tempdir.path().join("workdir/first_new_file.txt");
        assert!(first_file.exists(), "first_new_file.txt should exist");
        let content = fs_err::read_to_string(&first_file).unwrap();
        assert!(content.contains("This is the first new file"));

        let second_file = tempdir.path().join("workdir/second_new_file.txt");
        assert!(second_file.exists(), "second_new_file.txt should exist");
        let content = fs_err::read_to_string(&second_file).unwrap();
        assert!(content.contains("This is the second new file"));

        let third_file = tempdir.path().join("workdir/subdir/third_new_file.txt");
        assert!(
            third_file.exists(),
            "subdir/third_new_file.txt should exist"
        );
        let content = fs_err::read_to_string(&third_file).unwrap();
        assert!(content.contains("This is the third new file"));
    }

    #[test]
    fn test_apply_0001_increase_minimum_cmake_version_patch() {
        let (tempdir, _) = setup_patch_test_dir();

        apply_patches(
            &[LateBoundPath::new(
                "0001-increase-minimum-cmake-version.patch",
            )],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .expect("Patch 0001-increase-minimum-cmake-version.patch should apply successfully");

        // Read the cmake list file and make sure that it contains `cmake_minimum_required(VERSION 3.12)`
        let cmake_list = tempdir.path().join("workdir/CMakeLists.txt");
        let cmake_list = fs_err::read_to_string(&cmake_list).unwrap();
        assert!(cmake_list.contains("cmake_minimum_required(VERSION 3.12)"));
    }

    #[test]
    fn test_apply_git_patch_in_git_ignored() {
        let (tempdir, _) = setup_patch_test_dir();

        // Apply the patches in the working directory
        apply_patches(
            &[LateBoundPath::new(
                "0001-increase-minimum-cmake-version.patch",
            )],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        )
        .expect("Patch 0001-increase-minimum-cmake-version.patch should apply successfully");

        // Read the cmake list file and make sure that it contains `cmake_minimum_required(VERSION 3.12)`
        let cmake_list = tempdir.path().join("workdir/CMakeLists.txt");
        let cmake_list = fs_err::read_to_string(&cmake_list).unwrap();
        assert!(cmake_list.contains("cmake_minimum_required(VERSION 3.12)"));
    }

    #[test]
    fn test_missing_patch_file_returns_error() {
        let (tempdir, _) = setup_patch_test_dir();

        // Try to apply a patch that doesn't exist (simulating a typo in the patch filename)
        // This could happen e.g. when git format-patch creates a file with a double period
        // due to a commit message ending with a period, and the user accidentally removes one period
        let result = apply_patches(
            &[LateBoundPath::new("nonexistent-patch-file..patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        );

        // The build should fail with PatchNotFound error, not silently continue
        assert!(result.is_err(), "Missing patch file should cause an error");
        let err = result.unwrap_err();
        assert!(
            matches!(err, SourceError::PatchNotFound(_)),
            "Expected PatchNotFound error, got: {:?}",
            err
        );
    }

    #[test]
    fn test_patch_with_nonexistent_file_no_panic() {
        // Regression test for https://github.com/prefix-dev/rattler-build/issues/2137
        // When a patch references a file that doesn't exist in the work directory
        // (e.g., because file_name renamed it), the patch should fail with a proper
        // PatchApplyError rather than panicking or producing "Is a directory" error.
        let (tempdir, _) = setup_patch_test_dir();

        // Apply a patch that references "original_name.md" which doesn't exist
        // in the workdir (simulates file_name renaming the downloaded file).
        // This should NOT panic or produce "Is a directory" error.
        let result = apply_patches(
            &[LateBoundPath::new("test_renamed_file.patch")],
            &tempdir.path().join("workdir"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            &tempdir.path().join("patches"),
            apply_patch_custom,
        );

        // The patch should fail with a PatchApplyError (not "Is a directory")
        // since the referenced file doesn't exist and the patch expects existing content.
        assert!(
            matches!(result, Err(SourceError::PatchApplyError(_))),
            "Expected PatchApplyError, got: {:?}",
            result
        );
    }

    #[test]
    fn test_git_format_patch_new_file_issue_2177() {
        // Regression test for https://github.com/prefix-dev/rattler-build/issues/2177
        // A git format-patch creating a new file from /dev/null must actually
        // create the file, not silently skip it or fail with "Is a directory".
        //
        // Root cause: flickzeug strips a/b prefixes when a `diff --git` header
        // is present, so "b/pyproject.toml" becomes "pyproject.toml" (1 component).
        // If guess_strip_level falls back to 1 (because no files match — the file
        // is new), stripping 1 component from "pyproject.toml" gives an empty
        // path. This caused either an "Is a directory" error (v0.57.2) or a
        // silent skip (after the empty-path guard was added).
        let (tempdir, _) = setup_patch_test_dir();

        let patch_content =
            b"From c73bf83977f540a63a974841f79be1e2d7d6616e Mon Sep 17 00:00:00 2001
From: Matthew Feickert <matthew.feickert@cern.ch>
Date: Tue, 3 Jun 2025 23:18:31 -0600
Subject: [PATCH] fix: Add pyproject.toml for build-system

---
 pyproject.toml | 3 +++
 1 file changed, 3 insertions(+)
 create mode 100644 pyproject.toml

diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..638dd9c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = [\"setuptools>=61.0\"]
+build-backend = \"setuptools.build_meta\"
--
2.49.0
";

        let work_dir = tempdir.path().join("workdir");
        let patches_dir = tempdir.path().join("patches");
        fs_err::write(patches_dir.join("git_format_new_file.patch"), patch_content).unwrap();

        let result = apply_patches(
            &[LateBoundPath::new("git_format_new_file.patch")],
            &work_dir,
            &patches_dir,
            &patches_dir,
            &patches_dir,
            apply_patch_custom,
        );
        assert!(result.is_ok(), "Patch should not error: {:?}", result.err());

        let pyproject = work_dir.join("pyproject.toml");
        assert!(
            pyproject.exists(),
            "pyproject.toml should be created by the patch"
        );
        let content = fs_err::read_to_string(&pyproject).unwrap();
        assert!(content.contains("[build-system]"));
        assert!(content.contains("setuptools"));
    }

    #[test]
    fn test_git_format_patch_new_file_in_subdirectory_issue_2396() {
        // Regression test for https://github.com/prefix-dev/rattler-build/discussions/2396
        // A git format-patch creating a new file in a subdirectory (python/__init__.py)
        // must create it in the correct subdirectory, not at the work root.
        //
        // The bug: when the source already has an __init__.py at the root,
        // guess_strip_level finds it at strip_level=1 (stripping "python/" from
        // "python/__init__.py" yields "__init__.py" which exists), so it returns 1.
        // Then custom_patch_stripped_paths strips "python/" and the file is created
        // at __init__.py instead of python/__init__.py.
        let (tempdir, _) = setup_patch_test_dir();

        let patch_content =
            b"From faf9fb6712febcc4d868e8c5a7009ea564950cfe Mon Sep 17 00:00:00 2001
From: Test User <test@example.com>
Date: Tue, 31 Mar 2026 22:45:11 -0700
Subject: [PATCH 3/3] add init.py

---
 python/__init__.py | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 python/__init__.py

diff --git a/python/__init__.py b/python/__init__.py
new file mode 100644
index 0000000..0f283b0
--- /dev/null
+++ b/python/__init__.py
@@ -0,0 +1 @@
+# ensure package
--
2.45.2
";

        let work_dir = tempdir.path().join("workdir");
        let patches_dir = tempdir.path().join("patches");

        // Simulate source code that already has an __init__.py at root level
        fs_err::write(work_dir.join("__init__.py"), b"# root init").unwrap();

        fs_err::write(patches_dir.join("add_init_py.patch"), patch_content).unwrap();

        let result = apply_patches(
            &[LateBoundPath::new("add_init_py.patch")],
            &work_dir,
            &patches_dir,
            &patches_dir,
            &patches_dir,
            apply_patch_custom,
        );
        assert!(result.is_ok(), "Patch should not error: {:?}", result.err());

        let init_py = work_dir.join("python/__init__.py");
        assert!(
            init_py.exists(),
            "python/__init__.py should be created in the subdirectory, not at the work root"
        );
        let content = fs_err::read_to_string(&init_py).unwrap();
        assert!(content.contains("# ensure package"));
    }
}
