use anyhow::{Result, anyhow};
use brush_parser::ast::FunctionDefinition;
use std::io::Write;
use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus};
use std::time::{Duration, Instant};
use tracing::{debug, trace};

use crate::output;

/// Tracks live xtrace streaming state: which test holds the output lock and how
/// far we've read into its xtrace.log.
struct XtraceStreamer {
    /// Name of the test currently holding the xtrace output lock.
    holder: Option<String>,
    /// File handle for the current holder's xtrace.log.
    file: Option<std::fs::File>,
    /// How many bytes have been printed so far.
    offset: u64,
}

impl XtraceStreamer {
    fn new() -> Self {
        Self {
            holder: None,
            file: None,
            offset: 0,
        }
    }

    /// Acquire the lock for a test if no one currently holds it.
    fn try_acquire(&mut self, pending: &PendingTest) {
        if self.holder.is_some() {
            return;
        }
        let xtrace_path = pending.context.as_ref().unwrap().join("xtrace.log");
        if let Ok(f) = std::fs::File::open(&xtrace_path) {
            eprintln!("\x1b[2m--- xtrace: {} ---\x1b[0m", pending.name);
            self.holder = Some(pending.name.clone());
            self.file = Some(f);
            self.offset = 0;
        }
    }

    /// Check whether the current holder's xtrace.log has new data.
    fn has_new(&mut self) -> bool {
        let Some(ref mut f) = self.file else {
            return false;
        };
        f.metadata().is_ok_and(|m| m.len() > self.offset)
    }

    /// Print any new bytes from the current holder's xtrace.log.
    fn flush_new(&mut self) {
        let Some(ref mut f) = self.file else { return };
        if f.seek(SeekFrom::Start(self.offset)).is_err() {
            return;
        }
        let mut buf = Vec::new();
        if f.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
            self.offset += buf.len() as u64;
            let _ = write!(std::io::stderr(), "\x1b[2m");
            let _ = std::io::stderr().write_all(&buf);
            let _ = write!(std::io::stderr(), "\x1b[0m");
        }
    }

    /// Release the lock (flush remaining output first).
    fn release(&mut self) {
        self.flush_new();
        self.holder = None;
        self.file = None;
        self.offset = 0;
    }

    /// Check if the named test currently holds the lock.
    fn is_holder(&self, name: &str) -> bool {
        self.holder.as_deref() == Some(name)
    }

    /// Dump the full xtrace log for a test that was never streamed (e.g. it
    /// finished before the parent could open the file).
    fn dump_missed(&self, pending: &PendingTest) {
        if self.is_holder(&pending.name) {
            return; // Will be flushed via release()
        }
        let xtrace_path = pending.context.as_ref().unwrap().join("xtrace.log");
        if let Ok(content) = std::fs::read(&xtrace_path)
            && !content.is_empty()
        {
            eprintln!("\x1b[2m--- xtrace: {} ---\x1b[0m", pending.name);
            let _ = write!(std::io::stderr(), "\x1b[2m");
            let _ = std::io::stderr().write_all(&content);
            let _ = write!(std::io::stderr(), "\x1b[0m");
        }
    }
}

pub struct TestResult {
    pub name: String,
    pub passed: bool,
    pub timed_out: bool,
    pub duration: Duration,
    pub context: PathBuf,
    pub source_path: PathBuf,
    #[cfg(feature = "cgroup")]
    pub resources: Option<crate::cgroup::ResourceStats>,
}

/// State held by the parent for a spawned test child that has not yet been
/// waited on. Dropping this kills the child (if still running) and cleans up.
struct PendingTest {
    child: Child,
    /// Set to `true` when the child was killed due to exceeding `--timeout`.
    timed_out: bool,
    name: String,
    start: Instant,
    /// `None` after the path has been transferred to `TestResult`.
    context: Option<PathBuf>,
    source_path: PathBuf,
    #[cfg(feature = "cgroup")]
    cgroup: Option<crate::cgroup::TestCgroup>,
}

impl Drop for PendingTest {
    fn drop(&mut self) {
        if let Ok(None) = self.child.try_wait() {
            let _ = self.child.kill();
            let _ = self.child.wait();
        }
        if let Some(ref dir) = self.context {
            let _ = std::fs::remove_dir_all(dir);
        }
        // cgroup field drops here, removing the cgroup directory
    }
}

/// A `--override` spec: a binary to copy into the test context's `bin/` dir.
///
/// Accepted CLI forms:
/// - `/usr/bin/example` (absolute path) — copied as `bin/example`
/// - `./bin/example` (relative path) — copied as `bin/example`
/// - `example=/usr/bin/override` — copies `/usr/bin/override` to `bin/example`
#[derive(Clone, Debug)]
pub struct OverrideSpec {
    pub name: String,
    pub source: PathBuf,
}

impl std::str::FromStr for OverrideSpec {
    type Err = String;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        if let Some((name, path)) = s.split_once('=') {
            if name.is_empty() || path.is_empty() {
                return Err(format!("invalid override mapping: {s}"));
            }
            if name.contains('/') {
                return Err(format!("override name must not contain '/': {name}"));
            }
            return Ok(OverrideSpec {
                name: name.to_string(),
                source: PathBuf::from(path),
            });
        }

        let path = PathBuf::from(s);
        if !s.contains('/') {
            return Err(format!(
                "override `{s}` must be a path (e.g. `/usr/bin/{s}`) or a mapping (e.g. `{s}=/path/to/bin`)"
            ));
        }
        let name = path
            .file_name()
            .ok_or_else(|| format!("invalid override path: {s}"))?
            .to_string_lossy()
            .into_owned();
        Ok(OverrideSpec { name, source: path })
    }
}

pub struct RunConfig {
    pub parallel: usize,
    pub bail: bool,
    pub xtrace: bool,
    pub json: bool,
    /// When set, each test's context directory is created here and left on exit.
    /// When unset, context dirs are temporary and cleaned up automatically.
    pub save_context: Option<PathBuf>,
    pub override_cmds: Vec<OverrideSpec>,
    pub strace: Vec<String>,
    /// Wall-clock timeout per test. Tests exceeding this are killed and marked as timed out.
    pub timeout: Option<Duration>,
    /// Randomly SIGSTOP/SIGCONT individual descendant processes of each test to introduce
    /// timing non-determinism.
    pub fuzz: Option<f64>,
    /// Override the shell used to run test scripts, ignoring the script's own shebang.
    pub shebang: Option<String>,
    #[cfg(feature = "cgroup")]
    pub no_cgroups: bool,
}

pub fn run_all_tests(
    tests: Vec<(&str, &str, &[FunctionDefinition], &Path)>,
    config: &RunConfig,
) -> Result<Vec<TestResult>> {
    let mut results = Vec::new();
    let total = tests.len();
    let status = output::StatusDisplay::new(total, config.json);
    let wall_start = Instant::now();

    let max_parallel = config.parallel.max(1);
    let mut test_iter = tests.into_iter();
    let mut pending_list: Vec<PendingTest> = Vec::new();
    let mut bail_flag = false;
    let mut rng: u64 = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0xdeadbeef_cafebabe);
    let mut xtrace = if config.xtrace {
        Some(XtraceStreamer::new())
    } else {
        None
    };

    let tmp = tempfile::TempDir::new()?;

    let contexts_dir = if let Some(ref save_dir) = config.save_context {
        std::fs::create_dir_all(save_dir)?;
        save_dir
    } else {
        tmp.path()
    };

    // Seed the initial batch up to max_parallel.
    while pending_list.len() < max_parallel {
        if let Some((display_name, fn_name, all_functions, source_path)) = test_iter.next() {
            pending_list.push(spawn_test(
                display_name,
                fn_name,
                all_functions,
                source_path,
                contexts_dir.join(display_name),
                &config.override_cmds,
                &config.strace,
                config.shebang.as_deref(),
                #[cfg(feature = "cgroup")]
                config.no_cgroups,
            )?);
        } else {
            break;
        }
    }

    // If xtrace is enabled, acquire the lock for the first pending test.
    if let Some(ref mut xt) = xtrace
        && let Some(p) = pending_list.first()
    {
        status.suspend(|| xt.try_acquire(p));
    }

    // Poll loop: non-blocking reap, process completions, update status.
    while !pending_list.is_empty() {
        // Stream xtrace output from the current holder.
        if let Some(ref mut xt) = xtrace
            && xt.has_new()
        {
            status.suspend(|| xt.flush_new());
        }

        // Kill any tests that have exceeded the wall-clock timeout.
        if let Some(timeout) = config.timeout {
            for pending in pending_list.iter_mut() {
                if !pending.timed_out && pending.start.elapsed() > timeout {
                    let _ = pending.child.kill();
                    pending.timed_out = true;
                }
            }
        }

        // Randomly pause/resume individual descendant processes to introduce timing fuzziness.
        // Each tick either pauses one random running process OR resumes one random stopped
        // process — never both.
        if let Some(fuzz_level) = config.fuzz {
            for pending in pending_list.iter_mut() {
                rng ^= rng << 13;
                rng ^= rng >> 7;
                rng ^= rng << 17;
                let test_pid = pending.child.id();
                let descendants: Vec<u32> = collect_descendants(test_pid)
                    .into_iter()
                    .filter(|&pid| pid != test_pid)
                    .collect();
                if (rng as f64) / (u64::MAX as f64) >= fuzz_level {
                    // Resume a random stopped descendant.
                    let stopped: Vec<u32> = descendants
                        .iter()
                        .copied()
                        .filter(|&pid| process_state(pid) == Some('T'))
                        .collect();
                    if !stopped.is_empty() {
                        rng ^= rng << 13;
                        rng ^= rng >> 7;
                        rng ^= rng << 17;
                        let chosen = stopped[(rng as usize) % stopped.len()];
                        if unsafe { libc::kill(chosen as libc::pid_t, libc::SIGCONT) } == 0 {
                            trace!(pid = chosen, "Resumed subprocess");
                        }
                    }
                } else {
                    // Pause a random running descendant.
                    let running: Vec<u32> = descendants
                        .iter()
                        .copied()
                        .filter(|&pid| process_state(pid) != Some('T'))
                        .collect();
                    if !running.is_empty() {
                        rng ^= rng << 13;
                        rng ^= rng >> 7;
                        rng ^= rng << 17;
                        let chosen = running[(rng as usize) % running.len()];
                        if unsafe { libc::kill(chosen as libc::pid_t, libc::SIGSTOP) } == 0 {
                            trace!(pid = chosen, "Paused subprocess");
                        }
                    }
                }
            }
        }

        // Non-blocking reap: check all pending children.
        let mut reaped: Vec<(usize, ExitStatus)> = Vec::new();
        for (i, pending) in pending_list.iter_mut().enumerate() {
            match pending.child.try_wait() {
                Ok(Some(status)) => reaped.push((i, status)),
                Ok(None) => {} // still running
                Err(e) => return Err(anyhow!("try_wait failed: {e}")),
            }
        }

        // Process reaped tests in reverse index order so removal doesn't shift indices.
        reaped.sort_by_key(|b| std::cmp::Reverse(b.0));
        let mut completed: Vec<TestResult> = Vec::new();
        for (i, exit_status) in reaped {
            if let Some(ref mut xt) = xtrace {
                // If this test held the xtrace lock, release it (flushes remaining output).
                // Otherwise dump the full log for tests that finished before we could stream.
                status.suspend(|| {
                    if xt.is_holder(&pending_list[i].name) {
                        xt.release();
                    } else {
                        xt.dump_missed(&pending_list[i]);
                    }
                });
            }
            let pending = pending_list.remove(i);
            if bail_flag {
                continue; // Drop kills + cleans up
            }
            let result = build_result(pending, exit_status);
            completed.push(result);
        }

        // Print results and start new tests.
        // Sort completed by name for deterministic output within a reap batch.
        completed.sort_by(|a, b| a.name.cmp(&b.name));
        for result in completed {
            if config.json {
                output::print_test_result_json(&result);
            } else {
                status.suspend(|| output::print_test_result(&result));
            }
            if !result.passed && config.bail {
                bail_flag = true;
            }
            results.push(result);

            if !bail_flag
                && let Some((display_name, fn_name, all_functions, source_path)) = test_iter.next()
            {
                pending_list.push(spawn_test(
                    display_name,
                    fn_name,
                    all_functions,
                    source_path,
                    contexts_dir.join(display_name),
                    &config.override_cmds,
                    &config.strace,
                    config.shebang.as_deref(),
                    #[cfg(feature = "cgroup")]
                    config.no_cgroups,
                )?);
            }
        }

        if pending_list.is_empty() {
            break;
        }

        // If xtrace lock is free, acquire the next pending test.
        if let Some(ref mut xt) = xtrace
            && xt.holder.is_none()
            && let Some(p) = pending_list.first()
        {
            status.suspend(|| xt.try_acquire(p));
        }

        // Update status line with currently running tests.
        let running: Vec<(&str, Duration)> = pending_list
            .iter()
            .map(|p| {
                #[cfg(feature = "cgroup")]
                let duration = p
                    .cgroup
                    .as_ref()
                    .and_then(|cg| cg.read_cpu_time())
                    .unwrap_or_else(|| p.start.elapsed());
                #[cfg(not(feature = "cgroup"))]
                let duration = p.start.elapsed();
                (p.name.as_str(), duration)
            })
            .collect();
        status.update(&running, results.len());

        std::thread::sleep(Duration::from_millis(50));
    }

    status.finish();

    if !config.json {
        output::print_summary(&results, wall_start.elapsed());
    }

    Ok(results)
}

/// Resolve a shell name or path to an executable, falling back to `/bin/sh`
/// when the requested shell is not found.
fn resolve_shell(shell: &str) -> String {
    if shell.contains('/') {
        if std::path::Path::new(shell).exists() {
            return shell.to_string();
        }
    } else if which::which(shell).is_ok() {
        return shell.to_string();
    }
    "/bin/sh".to_string()
}

/// Spawn a child process that will run the test. Returns a `PendingTest` that
/// the caller must reap (or simply drop to kill+clean up).
#[allow(clippy::too_many_arguments)]
fn spawn_test(
    display_name: &str,
    fn_name: &str,
    all_functions: &[FunctionDefinition],
    source_path: &Path,
    context: PathBuf,
    override_cmds: &[OverrideSpec],
    strace: &[String],
    shebang: Option<&str>,
    #[cfg(feature = "cgroup")] no_cgroups: bool,
) -> Result<PendingTest> {
    if std::fs::exists(&context)? {
        std::fs::remove_dir_all(&context)?;
    }

    std::fs::create_dir_all(&context)?;

    let script_path = context.join("functions.sh");
    let mut script = String::new();
    for func in all_functions {
        script.push_str(&func.to_string());
        script.push('\n');
    }
    std::fs::write(&script_path, &script)?;

    if !override_cmds.is_empty() {
        let bin: &Path = &context.join("bin");
        std::fs::create_dir_all(bin)?;

        for spec in override_cmds {
            let src = &spec.source;
            if !src.exists() {
                return Err(anyhow!(
                    "--override: source path does not exist: {}",
                    src.display()
                ));
            }
            let dst = bin.join(&spec.name);

            debug!(src=%src.display(), dst=%dst.display(), "Overriding command");
            std::fs::copy(src, &dst)?;
        }
    }

    if !strace.is_empty() {
        create_strace_wrappers(&context, strace)?;
    }

    let source_path_owned = source_path
        .canonicalize()
        .unwrap_or_else(|_| source_path.to_path_buf());
    let shell = if let Some(s) = shebang {
        resolve_shell(s)
    } else {
        resolve_shell(&crate::discovery::get_script_shell(&source_path_owned))
    };

    #[cfg(feature = "cgroup")]
    let cgroup = if no_cgroups {
        None
    } else {
        crate::cgroup::TestCgroup::try_create(display_name)
    };

    let runner_content = build_runner_script(fn_name, &script_path, &context, strace);
    // <shell> -c <script> <source_path>: passing source_path as argv[0]
    // makes $0 inside the test functions refer to the original script.
    let source_str = source_path_owned.to_str().unwrap_or("bash").to_string();
    let mut cmd = Command::new(&shell);
    cmd.args(["-c", &runner_content, &source_str]);

    // Place the child into its cgroup after fork, before exec.
    // TODO don't include test setup in cgroup
    #[cfg(feature = "cgroup")]
    if let Some(ref cg) = cgroup {
        let procs_path = cg.procs_path();
        unsafe {
            cmd.pre_exec(move || {
                let pid = std::process::id().to_string();
                let _ = std::fs::write(&procs_path, pid);
                Ok(())
            });
        }
    }

    let start = Instant::now();
    let child = cmd.spawn().map_err(|e| anyhow!("spawn failed: {e}"))?;

    Ok(PendingTest {
        child,
        timed_out: false,
        name: display_name.to_string(),
        start,
        context: Some(context),
        source_path: source_path_owned,
        #[cfg(feature = "cgroup")]
        cgroup,
    })
}

/// Build a `TestResult` from a `PendingTest` whose child has already exited
/// with the given status.
fn build_result(mut pending: PendingTest, status: ExitStatus) -> TestResult {
    let duration = pending.start.elapsed();
    let timed_out = pending.timed_out;
    let passed = !timed_out && status.success();

    // Read stats before dropping cgroup (which removes the directory).
    #[cfg(feature = "cgroup")]
    let resources = pending.cgroup.as_ref().map(|cg| cg.read_stats());

    TestResult {
        name: pending.name.clone(),
        passed,
        timed_out,
        duration,
        context: pending.context.take().unwrap(),
        source_path: pending.source_path.clone(),
        #[cfg(feature = "cgroup")]
        resources,
    }
    // pending drops here: reaped=true skips kill/wait, tmp_dir=None skips
    // dir removal, cgroup drops removing the cgroup directory.
}

/// Build the shell script content that sources the function definitions and
/// runs the named test. Used with `/bin/sh -c <content> <source_path>` so
/// that `$0` inside test functions refers to the original script.
fn build_runner_script(
    test_name: &str,
    functions_path: &Path,
    working_dir: &Path,
    strace: &[String],
) -> String {
    let mut s = String::new();

    // bin/ is always first so --override binaries take precedence.
    let bin_dir = working_dir.join("bin");
    s.push_str(&format!("export PATH={}:$PATH\n", bin_dir.display()));

    // Strace wrappers dir must precede bin/ so wrappers intercept calls.
    if !strace.is_empty() {
        let strace_bin = working_dir.join("strace_bin");
        s.push_str(&format!("export PATH={}:$PATH\n", strace_bin.display()));
    }

    // Redirect both stdout and stderr to log files, then enable xtrace.
    let stdout = working_dir.join("stdout.log");
    let xtrace = working_dir.join("xtrace.log");
    s.push_str(&format!(
        "exec 1>{} 2>{}\n",
        stdout.display(),
        xtrace.display()
    ));
    s.push_str("set -e\n");

    // Source function definitions, then enable xtrace and invoke the test function.
    s.push_str(&format!(". {}\n", functions_path.display()));
    s.push_str("PS4='+$LINENO: '\n");
    s.push_str("set -x\n");
    s.push_str(test_name);
    s.push('\n');

    s
}

/// Collect the PID of `root` and all of its descendants by walking
/// `/proc/<pid>/task/<pid>/children` recursively.
fn collect_descendants(root: u32) -> Vec<u32> {
    let mut result = vec![root];
    let mut queue = vec![root];
    while let Some(pid) = queue.pop() {
        let path = format!("/proc/{}/task/{}/children", pid, pid);
        if let Ok(s) = std::fs::read_to_string(path) {
            for child in s.split_whitespace().filter_map(|t| t.parse::<u32>().ok()) {
                result.push(child);
                queue.push(child);
            }
        }
    }
    result
}

/// Read the single-character process state from `/proc/<pid>/stat`.
/// Returns `None` if the file cannot be read (e.g. the process has already exited).
fn process_state(pid: u32) -> Option<char> {
    let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
    // Format: "pid (comm) state ..."  — comm may contain spaces/parens, so
    // find the *last* ')' to reliably locate the state field.
    let after_comm = stat.rfind(')')?.checked_add(2)?;
    stat[after_comm..].chars().next()
}

fn create_strace_wrappers(working_dir: &Path, commands: &[String]) -> Result<()> {
    let strace_bin = working_dir.join("strace_bin");
    std::fs::create_dir_all(&strace_bin)?;

    let strace_dir = working_dir.join("strace");
    std::fs::create_dir_all(&strace_dir)?;

    for cmd in commands {
        let real_path =
            which::which(cmd).map_err(|_| anyhow!("--strace: command not found: {cmd}"))?;

        let wrapper = strace_bin.join(cmd);
        let strace_out = strace_dir.join(format!("{cmd}.log"));
        let script = format!(
            "#!/bin/sh\nexec strace -f -o {} {} \"$@\"\n",
            strace_out.display(),
            real_path.display(),
        );
        std::fs::write(&wrapper, script)?;
        std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755))?;
    }

    Ok(())
}

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

    /// Blocking wait + build result, for tests only.
    fn wait_and_collect(mut pending: PendingTest) -> TestResult {
        let status = pending.child.wait().expect("wait failed");
        build_result(pending, status)
    }

    /// Parse `script` content and run `test_name` via spawn_test + wait_and_collect.
    fn run_inline(script: &str, test_name: &str) -> TestResult {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("t.sh");
        fs::write(&path, script).unwrap();
        let tf = crate::parser::parse_test_file(&path).unwrap();
        let ctx = TempDir::new().unwrap().keep();
        let pending =
            spawn_test(test_name, test_name, &tf.functions, &path, ctx, &[], &[], None, #[cfg(feature = "cgroup")] false).unwrap();
        wait_and_collect(pending)
    }

    #[test]
    fn execute_passing_test() {
        assert!(run_inline("test_pass() {\n  true\n}\n", "test_pass").passed);
    }

    #[test]
    fn execute_failing_test() {
        assert!(!run_inline("test_fail() {\n  false\n}\n", "test_fail").passed);
    }

    #[test]
    fn execute_test_with_helper() {
        assert!(run_inline(
            "get_value() {\n  echo 42\n}\ntest_helper() {\n  val=$(get_value)\n  test \"$val\" = \"42\"\n}\n",
            "test_helper",
        ).passed);
    }

    #[test]
    fn execute_test_stdout_captured() {
        let r = run_inline("test_echo() {\n  echo captured_output\n}\n", "test_echo");
        let stdout = fs::read_to_string(r.context.join("stdout.log")).unwrap();
        assert!(stdout.contains("captured_output"));
    }

    #[test]
    fn execute_test_with_override() {
        // Override `true` (always succeeds) to verify the copy lands in bin/ and runs.
        let tmp = TempDir::new().unwrap();
        let script_content = "test_override() {\n  true\n}\n";
        let path = tmp.path().join("t.sh");
        fs::write(&path, script_content).unwrap();
        let tf = crate::parser::parse_test_file(&path).unwrap();
        let ctx = TempDir::new().unwrap().keep();
        let spec = OverrideSpec {
            name: "true".into(),
            source: which::which("true").unwrap(),
        };
        let pending = spawn_test(
            "test_override",
            "test_override",
            &tf.functions,
            &path,
            ctx,
            std::slice::from_ref(&spec),
            &[],
            None,
            #[cfg(feature = "cgroup")]
            false,
        )
        .unwrap();
        let result = wait_and_collect(pending);
        assert!(result.passed);
        // bin/true should exist in the context dir
        assert!(result.context.join("bin/true").exists());
    }

    #[test]
    fn create_strace_wrappers_creates_scripts() {
        // Only run if strace and ls are available
        if which::which("ls").is_err() {
            return;
        }

        let tmp = TempDir::new().unwrap();
        let commands = vec!["ls".to_string()];

        create_strace_wrappers(tmp.path(), &commands).unwrap();

        let wrapper = tmp.path().join("strace_bin/ls");
        assert!(wrapper.exists());

        let content = fs::read_to_string(&wrapper).unwrap();
        assert!(content.starts_with("#!/bin/sh\n"));
        assert!(content.contains("strace"));
        assert!(content.contains("\"$@\""));

        // Check it's executable
        use std::os::unix::fs::PermissionsExt;
        let perms = fs::metadata(&wrapper).unwrap().permissions();
        assert!(perms.mode() & 0o111 != 0);
    }

    #[test]
    fn create_strace_wrappers_unknown_command_errors() {
        let tmp = TempDir::new().unwrap();
        let result = create_strace_wrappers(tmp.path(), &["nonexistent_cmd_xyz".to_string()]);
        assert!(result.is_err());
    }

    #[test]
    fn run_all_tests_serial() {
        let tmp = TempDir::new().unwrap();
        let script_content = "test_a() {\n  true\n}\ntest_b() {\n  true\n}\n";

        // Parse to get real FunctionDefinitions
        let path = tmp.path().join("test.sh");
        fs::write(&path, script_content).unwrap();
        let test_file = crate::parser::parse_test_file(&path).unwrap();

        let config = RunConfig {
            parallel: 1,
            bail: false,
            xtrace: false,
            json: false,
            save_context: None,
            override_cmds: vec![],
            strace: vec![],
            timeout: None,
            fuzz: None,
            shebang: None,
            #[cfg(feature = "cgroup")]
            no_cgroups: false,
        };

        let test_refs: Vec<(&str, &str, &[FunctionDefinition], &Path)> = test_file
            .tests
            .iter()
            .map(|t| {
                (
                    t.name.as_str(),
                    t.name.as_str(),
                    test_file.functions.as_slice(),
                    path.as_path(),
                )
            })
            .collect();

        let results = run_all_tests(test_refs, &config).unwrap();
        assert_eq!(results.len(), 2);
        assert!(results.iter().all(|r| r.passed));
    }

    #[test]
    fn bail_stops_after_first_failure() {
        let tmp = TempDir::new().unwrap();
        // test_fail comes first alphabetically, test_pass second
        let script_content = "test_fail() {\n  false\n}\ntest_pass() {\n  true\n}\n";

        let path = tmp.path().join("test.sh");
        fs::write(&path, script_content).unwrap();
        let test_file = crate::parser::parse_test_file(&path).unwrap();

        let config = RunConfig {
            parallel: 1,
            bail: true,
            xtrace: false,
            json: false,
            save_context: None,
            override_cmds: vec![],
            strace: vec![],
            timeout: None,
            fuzz: None,
            shebang: None,
            #[cfg(feature = "cgroup")]
            no_cgroups: false,
        };

        let test_refs: Vec<(&str, &str, &[FunctionDefinition], &Path)> = test_file
            .tests
            .iter()
            .map(|t| {
                (
                    t.name.as_str(),
                    t.name.as_str(),
                    test_file.functions.as_slice(),
                    path.as_path(),
                )
            })
            .collect();

        let results = run_all_tests(test_refs, &config).unwrap();
        // Only the failing test ran; bail stopped execution
        assert_eq!(results.len(), 1);
        assert!(!results[0].passed);
    }

    #[test]
    fn run_all_tests_parallel() {
        let tmp = TempDir::new().unwrap();
        let script_content = "test_x() {\n  true\n}\ntest_y() {\n  false\n}\n";

        let path = tmp.path().join("test.sh");
        fs::write(&path, script_content).unwrap();
        let test_file = crate::parser::parse_test_file(&path).unwrap();

        let config = RunConfig {
            parallel: 0,
            bail: false,
            xtrace: false,
            json: false,
            save_context: None,
            override_cmds: vec![],
            strace: vec![],
            timeout: None,
            fuzz: None,
            shebang: None,
            #[cfg(feature = "cgroup")]
            no_cgroups: false,
        };

        let test_refs: Vec<(&str, &str, &[FunctionDefinition], &Path)> = test_file
            .tests
            .iter()
            .map(|t| {
                (
                    t.name.as_str(),
                    t.name.as_str(),
                    test_file.functions.as_slice(),
                    path.as_path(),
                )
            })
            .collect();

        let results = run_all_tests(test_refs, &config).unwrap();
        assert_eq!(results.len(), 2);
        assert!(results.iter().any(|r| r.passed));
        assert!(results.iter().any(|r| !r.passed));
    }

    #[test]
    fn timeout_kills_slow_test() {
        let tmp = TempDir::new().unwrap();
        let script_content = "test_slow() {\n  sleep 60\n}\n";

        let path = tmp.path().join("test.sh");
        fs::write(&path, script_content).unwrap();
        let tf = crate::parser::parse_test_file(&path).unwrap();

        let config = RunConfig {
            parallel: 1,
            bail: false,
            xtrace: false,
            json: false,
            save_context: None,
            override_cmds: vec![],
            strace: vec![],
            timeout: Some(std::time::Duration::from_millis(200)),
            fuzz: None,
            shebang: None,
            #[cfg(feature = "cgroup")]
            no_cgroups: false,
        };

        let test_refs: Vec<(&str, &str, &[FunctionDefinition], &Path)> = tf
            .tests
            .iter()
            .map(|t| {
                (
                    t.name.as_str(),
                    t.name.as_str(),
                    tf.functions.as_slice(),
                    path.as_path(),
                )
            })
            .collect();

        let results = run_all_tests(test_refs, &config).unwrap();
        assert_eq!(results.len(), 1);
        assert!(!results[0].passed);
        assert!(results[0].timed_out);
    }
}
