use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

use crate::lifecycle::BuildContext;

/// Compile Java source files in the main source directory.
///
/// When the source tree contains more than one file and the host has multiple
/// CPUs, compilation uses a **two-phase strategy**:
///
/// 1. **Phase 1 – annotation processing** (`javac -proc:only`): a single
///    `javac` process runs all annotation processors so that generated sources
///    are complete before parallel compilation begins.
/// 2. **Phase 2 – compilation** (`javac -proc:none`): the original *and*
///    generated source files are split into chunks and compiled by parallel
///    `javac` processes.  Each process receives `-sourcepath` (so cross-chunk
///    type references resolve) and `-implicit:none` (so only the explicitly
///    listed files produce `.class` output, avoiding write races).
///
/// # Errors
///
/// Returns an error if `javac` cannot be found, fails to create the output
/// directory, or the compilation exits with a non-zero status.
pub fn compile_sources(context: &BuildContext) -> Result<()> {
    let build = context.pom.effective_build();
    let src_dir = context.work_dir.join(&build.source_directory);
    let out_dir = context.work_dir.join(&build.output_directory);

    if !src_dir.exists() {
        println!(
            "[INFO] Source directory {} does not exist, skipping compilation",
            src_dir.display()
        );
        return Ok(());
    }

    let java_files = collect_java_files(&src_dir);
    if java_files.is_empty() {
        println!("[INFO] No Java source files found in {}", src_dir.display());
        return Ok(());
    }

    std::fs::create_dir_all(&out_dir)
        .with_context(|| format!("Failed to create output directory: {}", out_dir.display()))?;

    // Resolve compile-scope dependency JARs for classpath
    let deps = crate::dependency::resolve_dependencies(
        &context.pom,
        &context.local_repo,
        &context.properties,
    )?;
    let cp_sep = if cfg!(windows) { ";" } else { ":" };
    let mut cp_entries = vec![out_dir.to_string_lossy().into_owned()];
    cp_entries.extend(deps.iter().map(|d| d.path.to_string_lossy().into_owned()));
    let classpath = cp_entries.join(cp_sep);

    let gen_src_dir = context
        .work_dir
        .join("target")
        .join("generated-sources")
        .join("annotations");

    println!("[INFO] Compiling {} Java source files", java_files.len());
    run_javac_parallel(&java_files, &classpath, &out_dir, &src_dir, &gen_src_dir)?;

    println!("[INFO] Compilation successful");
    Ok(())
}

/// Compile Java test source files.
///
/// Uses the same two-phase strategy as [`compile_sources`].
///
/// # Errors
///
/// Returns an error if `javac` cannot be found, fails to create the test output
/// directory, or the compilation exits with a non-zero status.
pub fn compile_test_sources(context: &BuildContext, classpath_extras: &[PathBuf]) -> Result<()> {
    let build = context.pom.effective_build();
    let test_src_dir = context.work_dir.join(&build.test_source_directory);
    let test_out_dir = context.work_dir.join(&build.test_output_directory);
    let out_dir = context.work_dir.join(&build.output_directory);

    if !test_src_dir.exists() {
        println!(
            "[INFO] Test source directory {} does not exist, skipping test compilation",
            test_src_dir.display()
        );
        return Ok(());
    }

    let java_files = collect_java_files(&test_src_dir);
    if java_files.is_empty() {
        println!(
            "[INFO] No Java test source files found in {}",
            test_src_dir.display()
        );
        return Ok(());
    }

    std::fs::create_dir_all(&test_out_dir).with_context(|| {
        format!(
            "Failed to create test output directory: {}",
            test_out_dir.display()
        )
    })?;

    let cp_sep = if cfg!(windows) { ";" } else { ":" };
    let mut cp_parts: Vec<String> = vec![
        out_dir.to_string_lossy().into_owned(),
        test_out_dir.to_string_lossy().into_owned(),
    ];
    cp_parts.extend(
        classpath_extras
            .iter()
            .map(|p| p.to_string_lossy().into_owned()),
    );
    let classpath = cp_parts.join(cp_sep);

    let gen_src_dir = context
        .work_dir
        .join("target")
        .join("generated-test-sources")
        .join("annotations");

    println!(
        "[INFO] Compiling {} Java test source files",
        java_files.len()
    );
    run_javac_parallel(
        &java_files,
        &classpath,
        &test_out_dir,
        &test_src_dir,
        &gen_src_dir,
    )?;

    println!("[INFO] Test compilation successful");
    Ok(())
}

/// Determine the number of parallel `javac` processes to use.
///
/// Returns the lesser of the available hardware parallelism and the number of
/// files, with a minimum of 1.
#[must_use]
fn parallelism_for(file_count: usize) -> usize {
    let cpus = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
    cpus.min(file_count).max(1)
}

/// Run `javac` using a two-phase strategy when multiple processes are needed.
///
/// **Phase 1 – annotation processing** (`-proc:only`): a single `javac`
/// process runs all annotation processors so that generated source files are
/// complete before parallel compilation begins.  Generated sources are written
/// to `gen_src_dir`.
///
/// **Phase 2 – compilation** (`-proc:none`): the original *and* generated
/// source files are split into chunks and compiled by parallel `javac`
/// processes.  Each process receives `-sourcepath` (so cross-chunk type
/// references resolve) and `-implicit:none` (so only the explicitly listed
/// files produce `.class` output, avoiding write races).
///
/// When only a single process is needed, `javac` is invoked directly with
/// inherited stdio (no two-phase split, no extra flags).
///
/// # Errors
///
/// Returns an error if any `javac` process fails or cannot be started.
fn run_javac_parallel(
    java_files: &[PathBuf],
    classpath: &str,
    out_dir: &Path,
    source_path: &Path,
    gen_src_dir: &Path,
) -> Result<()> {
    let jobs = parallelism_for(java_files.len());

    // Single-process path: inherited stdio, no two-phase split.
    if jobs <= 1 {
        return run_javac_streaming(java_files, classpath, out_dir);
    }

    // --- Two-phase strategy ---
    prepare_gen_src_dir(gen_src_dir)?;

    println!("[INFO] Phase 1: Running annotation processors");
    run_annotation_processing(java_files, classpath, out_dir, source_path, gen_src_dir)?;

    // Collect generated sources for phase 2.
    let generated_files = collect_java_files(gen_src_dir);
    let mut all_files: Vec<PathBuf> = java_files.to_vec();
    all_files.extend(generated_files);
    // Ensure deterministic ordering of files before chunking so that
    // chunk boundaries and labels are stable across runs/hosts.
    all_files.sort();

    // Build extended sourcepath covering both original and generated sources.
    // `join_paths` avoids lossy UTF-8 conversion and picks the correct
    // platform separator automatically.
    let extended_source_path =
        std::env::join_paths([source_path, gen_src_dir]).with_context(|| {
            format!(
                "Failed to build sourcepath from {} and {}",
                source_path.display(),
                gen_src_dir.display()
            )
        })?;

    // Strip the destination output directory from the phase-2 classpath so
    // that parallel javac processes do not read from a directory that is being
    // concurrently written to.  Dependency JARs (and, for test compilation,
    // the already-compiled main output directory) remain on the classpath.
    let cp_sep = if cfg!(windows) { ";" } else { ":" };
    let out_dir_str = out_dir.to_string_lossy();
    let phase2_classpath: String = classpath
        .split(cp_sep)
        .filter(|entry| *entry != out_dir_str.as_ref())
        .collect::<Vec<_>>()
        .join(cp_sep);

    // Recompute parallelism from the total file count (original + generated).
    let phase2_jobs = parallelism_for(all_files.len());
    compile_chunks_parallel(
        &all_files,
        &phase2_classpath,
        out_dir,
        &extended_source_path,
        phase2_jobs,
    )
}

/// Prepare the generated-sources directory for a fresh annotation processing
/// pass by removing stale files from any previous run.
///
/// # Errors
///
/// Returns an error if the directory cannot be cleaned or created.
fn prepare_gen_src_dir(gen_src_dir: &Path) -> Result<()> {
    if gen_src_dir.exists() {
        std::fs::remove_dir_all(gen_src_dir).with_context(|| {
            format!(
                "Failed to clean generated sources directory: {}",
                gen_src_dir.display()
            )
        })?;
    }
    std::fs::create_dir_all(gen_src_dir).with_context(|| {
        format!(
            "Failed to create generated sources directory: {}",
            gen_src_dir.display()
        )
    })
}

/// Execute phase-2 compilation: split files into chunks and compile in parallel
/// with `-proc:none` and `-implicit:none`.
///
/// # Errors
///
/// Returns an error if any `javac` chunk process fails or cannot be started.
fn compile_chunks_parallel(
    all_files: &[PathBuf],
    classpath: &str,
    out_dir: &Path,
    source_path: &std::ffi::OsStr,
    phase2_jobs: usize,
) -> Result<()> {
    let chunk_size = all_files.len().div_ceil(phase2_jobs);
    let chunks: Vec<&[PathBuf]> = all_files.chunks(chunk_size).collect();

    println!(
        "[INFO] Phase 2: Compiling across {} parallel javac processes (-proc:none)",
        chunks.len()
    );

    std::thread::scope(|s| {
        let handles: Vec<_> = chunks
            .into_iter()
            .enumerate()
            .map(|(idx, chunk)| {
                s.spawn(move || (idx, run_javac(chunk, classpath, out_dir, source_path)))
            })
            .collect();

        let mut outputs = Vec::new();
        let mut errors: Vec<(Option<usize>, anyhow::Error)> = Vec::new();
        for handle in handles {
            match handle.join() {
                Ok((idx, Ok((stdout, stderr, compile_err)))) => {
                    outputs.push((idx, stdout, stderr));
                    if let Some(e) = compile_err {
                        errors.push((Some(idx), e));
                    }
                }
                Ok((idx, Err(e))) => {
                    errors.push((Some(idx), e));
                }
                Err(panic) => {
                    let panic_msg = panic
                        .downcast_ref::<&str>()
                        .map(|s| (*s).to_owned())
                        .or_else(|| panic.downcast_ref::<String>().cloned())
                        .unwrap_or_else(|| "unknown panic payload".to_owned());
                    errors.push((
                        None,
                        anyhow::anyhow!("parallel javac thread panicked: {panic_msg}"),
                    ));
                }
            }
        }

        // Print captured output in chunk order so the terminal is
        // deterministic.
        outputs.sort_by_key(|(idx, _, _)| *idx);
        for (_, stdout, stderr) in &outputs {
            emit_javac_output(stdout, stderr);
        }

        if errors.is_empty() {
            Ok(())
        } else {
            errors.sort_by_key(|(idx, _)| idx.unwrap_or(usize::MAX));
            anyhow::bail!(
                "Parallel compilation failed:\n{}",
                format_compilation_errors(&errors)
            )
        }
    })
}

/// Format aggregated compilation errors into a single diagnostic message.
fn format_compilation_errors(errors: &[(Option<usize>, anyhow::Error)]) -> String {
    errors
        .iter()
        .map(|(idx, e)| match idx {
            Some(i) => format!("Chunk {i}:\n{e:#}"),
            None => format!("Thread panic:\n{e:#}"),
        })
        .collect::<Vec<_>>()
        .join("\n---\n")
}

/// Print captured javac stdout/stderr to the respective standard streams.
fn emit_javac_output(stdout: &str, stderr: &str) {
    use std::io::Write;
    if !stdout.is_empty() {
        print!("{stdout}");
        let _ = std::io::stdout().flush();
    }
    if !stderr.is_empty() {
        eprint!("{stderr}");
        let _ = std::io::stderr().flush();
    }
}

/// Build a `javac` [`Command`](std::process::Command) with the common flags.
///
/// Callers add extra flags (e.g. `-sourcepath`, `-proc:none`) as needed.
fn javac_command(files: &[PathBuf], classpath: &str, out_dir: &Path) -> std::process::Command {
    let mut cmd = std::process::Command::new("javac");
    cmd.arg("-cp").arg(classpath).arg("-d").arg(out_dir);
    for file in files {
        cmd.arg(file);
    }
    cmd
}

/// Phase 1 of two-phase compilation: run annotation processors only.
///
/// Invokes a single `javac -proc:only -s <gen_src_dir>` process with inherited
/// stdio so processor diagnostics stream in real time.
///
/// # Errors
///
/// Returns an error if `javac` cannot be spawned or annotation processing
/// exits with a non-zero status.
fn run_annotation_processing(
    files: &[PathBuf],
    classpath: &str,
    out_dir: &Path,
    source_path: &Path,
    gen_src_dir: &Path,
) -> Result<()> {
    let status = javac_command(files, classpath, out_dir)
        .arg("-sourcepath")
        .arg(source_path)
        .arg("-proc:only")
        .arg("-s")
        .arg(gen_src_dir)
        .status()
        .with_context(|| {
            "Failed to invoke javac for annotation processing. \
             Make sure a JDK is installed and javac is on your PATH."
        })?;

    if !status.success() {
        anyhow::bail!(
            "Annotation processing failed (exit code {:?})",
            status.code()
        );
    }
    Ok(())
}

/// Invoke a single `javac -proc:none` process with captured stdio.
///
/// Used exclusively in phase 2 of two-phase compilation where annotation
/// processors have already been run.
///
/// Returns the captured `(stdout, stderr)` **and** an optional compilation
/// error.  Returning output even on failure lets the caller emit it
/// consistently (e.g. sequentially after joining parallel threads).
///
/// # Errors
///
/// Returns `Err` only if `javac` cannot be spawned at all.  A non-zero exit
/// code is reported via the `Option<anyhow::Error>` in the `Ok` tuple.
fn run_javac(
    files: &[PathBuf],
    classpath: &str,
    out_dir: &Path,
    source_path: &std::ffi::OsStr,
) -> Result<(String, String, Option<anyhow::Error>)> {
    let output = javac_command(files, classpath, out_dir)
        .arg("-sourcepath")
        .arg(source_path)
        .arg("-implicit:none")
        .arg("-proc:none")
        .output()
        .with_context(|| {
            "Failed to invoke javac. Make sure a JDK is installed and javac is on your PATH."
        })?;

    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();

    let compile_err = if output.status.success() {
        None
    } else {
        Some(anyhow::anyhow!(
            "javac failed (exit code {:?})",
            output.status.code()
        ))
    };

    Ok((stdout, stderr, compile_err))
}

/// Invoke `javac` with inherited stdio so output streams in real time.
///
/// Used for the single-process path where no interleaving can occur and no
/// extra flags (`-sourcepath`, `-implicit:none`) are added.
///
/// # Errors
///
/// Returns an error if `javac` cannot be spawned or exits with a non-zero
/// status.
fn run_javac_streaming(files: &[PathBuf], classpath: &str, out_dir: &Path) -> Result<()> {
    let status = javac_command(files, classpath, out_dir)
        .status()
        .with_context(|| {
            "Failed to invoke javac. Make sure a JDK is installed and javac is on your PATH."
        })?;

    if !status.success() {
        anyhow::bail!("javac failed (exit code {:?})", status.code());
    }

    Ok(())
}

fn collect_java_files(dir: &Path) -> Vec<PathBuf> {
    WalkDir::new(dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().is_some_and(|ext| ext == "java"))
        .map(walkdir::DirEntry::into_path)
        .collect()
}

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

    #[test]
    fn test_parallelism_for_returns_at_least_one() {
        assert!(parallelism_for(0) >= 1);
        assert!(parallelism_for(1) >= 1);
    }

    #[test]
    fn test_parallelism_for_capped_by_file_count() {
        // When there are only 2 files, parallelism should not exceed 2.
        assert!(parallelism_for(2) <= 2);
    }

    #[test]
    fn test_parallelism_for_capped_by_cpus() {
        let cpus = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
        // With a huge number of files, parallelism should not exceed CPU count.
        assert_eq!(parallelism_for(100_000), cpus);
    }

    #[test]
    fn test_collect_java_files_finds_nested() {
        let tmp = TempDir::new().unwrap();
        let pkg = tmp.path().join("com").join("example");
        std::fs::create_dir_all(&pkg).unwrap();
        std::fs::write(pkg.join("Foo.java"), "class Foo {}").unwrap();
        std::fs::write(pkg.join("Bar.java"), "class Bar {}").unwrap();
        // Non-java file should be ignored
        std::fs::write(pkg.join("README.txt"), "ignored").unwrap();

        let files = collect_java_files(tmp.path());
        assert_eq!(files.len(), 2);
        assert!(files.iter().all(|p| p.extension().unwrap() == "java"));
    }

    #[test]
    fn test_collect_java_files_empty_dir() {
        let tmp = TempDir::new().unwrap();
        let files = collect_java_files(tmp.path());
        assert!(files.is_empty());
    }

    #[test]
    fn test_prepare_gen_src_dir_cleans_stale_files() {
        let tmp = TempDir::new().unwrap();
        let gen = tmp.path().join("gen");
        std::fs::create_dir_all(&gen).unwrap();
        std::fs::write(gen.join("Stale.java"), "class Stale {}").unwrap();

        prepare_gen_src_dir(&gen).unwrap();
        assert!(gen.exists());
        assert!(collect_java_files(&gen).is_empty());
    }

    #[test]
    fn test_prepare_gen_src_dir_creates_missing() {
        let tmp = TempDir::new().unwrap();
        let gen = tmp.path().join("does-not-exist").join("annotations");

        prepare_gen_src_dir(&gen).unwrap();
        assert!(gen.exists());
    }

    #[test]
    fn test_format_compilation_errors_chunk_before_panic() {
        let errors: Vec<(Option<usize>, anyhow::Error)> = vec![
            (Some(0), anyhow::anyhow!("chunk 0 failed")),
            (Some(2), anyhow::anyhow!("chunk 2 failed")),
            (None, anyhow::anyhow!("panic")),
        ];
        let msg = format_compilation_errors(&errors);

        let idx_chunk0 = msg
            .find("Chunk 0:")
            .expect("formatted message must contain Chunk 0 line");
        let idx_chunk2 = msg
            .find("Chunk 2:")
            .expect("formatted message must contain Chunk 2 line");
        let idx_panic = msg
            .find("Thread panic:")
            .expect("formatted message must contain Thread panic line");

        // Chunks should appear before the thread panic, and in chunk-index order.
        assert!(
            idx_chunk0 < idx_chunk2,
            "Chunk 0 must appear before Chunk 2"
        );
        assert!(
            idx_chunk0 < idx_panic,
            "Chunk 0 must appear before Thread panic"
        );
        assert!(
            idx_chunk2 < idx_panic,
            "Chunk 2 must appear before Thread panic"
        );
    }

    #[test]
    fn test_javac_command_does_not_include_sourcepath() {
        let tmp = TempDir::new().unwrap();
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let file = tmp.path().join("Main.java");
        std::fs::write(&file, "").unwrap();

        let cmd = javac_command(std::slice::from_ref(&file), ".", &out);
        let args: Vec<_> = cmd
            .get_args()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();

        // The shared helper should NOT contain -sourcepath.
        assert!(
            !args.contains(&"-sourcepath".to_owned()),
            "javac_command must not add -sourcepath; callers add it themselves"
        );
        // It should contain the standard flags.
        assert!(args.contains(&"-cp".to_owned()));
        assert!(args.contains(&"-d".to_owned()));
        assert!(args.contains(&file.to_string_lossy().into_owned()));
    }

    /// Create a temporary stub `javac` script that exits with the given code.
    ///
    /// Returns a [`TempDir`] whose path should be prepended to `PATH`.
    #[cfg(unix)]
    fn stub_javac(exit_code: i32) -> TempDir {
        use std::os::unix::fs::PermissionsExt;
        let bin = TempDir::new().unwrap();
        let javac = bin.path().join("javac");
        std::fs::write(&javac, format!("#!/bin/sh\nexit {exit_code}\n")).unwrap();
        std::fs::set_permissions(&javac, std::fs::Permissions::from_mode(0o755)).unwrap();
        bin
    }

    /// Serializes tests that modify `PATH` to avoid data races.
    #[cfg(unix)]
    static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// RAII guard that restores `PATH` in its `Drop` impl, ensuring the
    /// environment variable is always restored even if the test panics.
    #[cfg(unix)]
    struct PathGuard {
        original: Option<std::ffi::OsString>,
    }

    #[cfg(unix)]
    impl PathGuard {
        /// Prepend `dir` to `PATH` and return a guard that restores the
        /// original value when dropped.
        fn prepend(dir: &std::path::Path) -> Self {
            let original = std::env::var_os("PATH");
            // Build a new PATH using OsString-aware helpers to avoid corrupting
            // non-UTF-8 bytes and to use the platform-specific separator.
            let mut paths: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
            if let Some(prev) = &original {
                paths.extend(std::env::split_paths(prev));
            }
            match std::env::join_paths(&paths) {
                Ok(joined) => std::env::set_var("PATH", &joined),
                Err(_) => std::env::set_var("PATH", dir),
            }
            Self { original }
        }
    }

    #[cfg(unix)]
    impl Drop for PathGuard {
        fn drop(&mut self) {
            match &self.original {
                Some(val) => std::env::set_var("PATH", val),
                None => std::env::remove_var("PATH"),
            }
        }
    }

    #[cfg(unix)]
    #[test]
    fn test_run_javac_streaming_success_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_javac(0);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("Main.java"), "").unwrap();
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let files = collect_java_files(&src);

        let result = run_javac_streaming(&files, "", &out);

        assert!(result.is_ok());
    }

    #[cfg(unix)]
    #[test]
    fn test_run_javac_streaming_failure_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_javac(1);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("Main.java"), "").unwrap();
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let files = collect_java_files(&src);

        let result = run_javac_streaming(&files, "", &out);

        assert!(result.is_err());
        let err_msg = format!("{:#}", result.unwrap_err());
        assert!(
            err_msg.contains("javac failed"),
            "unexpected error: {err_msg}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_compile_chunks_parallel_success_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_javac(0);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        for i in 0..4 {
            std::fs::write(src.join(format!("C{i}.java")), "").unwrap();
        }
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let files = collect_java_files(&src);
        let sp = std::ffi::OsString::from(src.as_os_str());

        let result = compile_chunks_parallel(&files, "", &out, &sp, 2);

        assert!(result.is_ok());
    }

    #[cfg(unix)]
    #[test]
    fn test_compile_chunks_parallel_failure_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_javac(1);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        for i in 0..4 {
            std::fs::write(src.join(format!("C{i}.java")), "").unwrap();
        }
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let files = collect_java_files(&src);
        let sp = std::ffi::OsString::from(src.as_os_str());

        let result = compile_chunks_parallel(&files, "", &out, &sp, 2);

        assert!(result.is_err());
        let err_msg = format!("{:#}", result.unwrap_err());
        assert!(
            err_msg.contains("Parallel compilation failed"),
            "unexpected error: {err_msg}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_run_annotation_processing_success_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_javac(0);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("Main.java"), "").unwrap();
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let gen = tmp.path().join("gen");
        std::fs::create_dir_all(&gen).unwrap();
        let files = collect_java_files(&src);

        let result = run_annotation_processing(&files, "", &out, &src, &gen);

        assert!(result.is_ok());
    }

    #[cfg(unix)]
    #[test]
    fn test_run_annotation_processing_failure_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_javac(1);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("Main.java"), "").unwrap();
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let gen = tmp.path().join("gen");
        std::fs::create_dir_all(&gen).unwrap();
        let files = collect_java_files(&src);

        let result = run_annotation_processing(&files, "", &out, &src, &gen);

        assert!(result.is_err());
        let err_msg = format!("{:#}", result.unwrap_err());
        assert!(
            err_msg.contains("Annotation processing failed"),
            "unexpected error: {err_msg}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_run_javac_parallel_two_phase_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_javac(0);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let src = tmp.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        // Need multiple files to trigger the two-phase path (jobs > 1).
        for i in 0..4 {
            std::fs::write(src.join(format!("C{i}.java")), "").unwrap();
        }
        let out = tmp.path().join("out");
        std::fs::create_dir_all(&out).unwrap();
        let gen = tmp.path().join("gen");
        let files = collect_java_files(&src);

        let result = run_javac_parallel(&files, "", &out, &src, &gen);

        assert!(result.is_ok());
    }
}
