use std::path::Path;
use std::process::Command;
use std::time::Duration;
use wait_timeout::ChildExt;

/// Maximum time any single test command is allowed to run before being killed.
const TEST_TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes

/// Detect whether the base branch in a worktree is `master` or `main`.
/// Falls back to `"master"` if neither is found.
pub(crate) fn detect_worktree_base_branch(wt_path: &Path) -> String {
    for branch in &["master", "main"] {
        let ok = Command::new("git")
            .args(["rev-parse", "--verify", branch])
            .current_dir(wt_path)
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false);
        if ok {
            return branch.to_string();
        }
    }
    "master".to_string()
}

/// Return `true` if the git worktree at `wt_path` has commits on its current
/// branch that are not present on the base branch (`master` or `main`).
///
/// Used during server startup reconciliation to detect stories whose agent work
/// was committed while the server was offline.
pub(crate) fn worktree_has_committed_work(wt_path: &Path) -> bool {
    let base_branch = detect_worktree_base_branch(wt_path);
    let output = Command::new("git")
        .args(["log", &format!("{base_branch}..HEAD"), "--oneline"])
        .current_dir(wt_path)
        .output();
    match output {
        Ok(out) if out.status.success() => {
            !String::from_utf8_lossy(&out.stdout).trim().is_empty()
        }
        _ => false,
    }
}

/// Check whether the given directory has any uncommitted git changes.
/// Returns `Err` with a descriptive message if there are any.
pub(crate) fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
    let output = Command::new("git")
        .args(["status", "--porcelain"])
        .current_dir(path)
        .output()
        .map_err(|e| format!("Failed to run git status: {e}"))?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    if !stdout.trim().is_empty() {
        return Err(format!(
            "Worktree has uncommitted changes. Please commit all work before \
             the agent exits:\n{stdout}"
        ));
    }
    Ok(())
}

/// Run the project's test suite.
///
/// Uses `script/test` if present, treating it as the canonical single test entry point.
/// Falls back to `cargo nextest run` / `cargo test` when `script/test` is absent.
/// Returns `(tests_passed, output)`.
pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> {
    let script_test = path.join("script").join("test");
    if script_test.exists() {
        let mut output = String::from("=== script/test ===\n");
        let (success, out) = run_command_with_timeout(&script_test, &[], path)?;
        output.push_str(&out);
        output.push('\n');
        return Ok((success, output));
    }

    // Fallback: cargo nextest run / cargo test
    let mut output = String::from("=== tests ===\n");
    let (success, test_out) = match run_command_with_timeout("cargo", &["nextest", "run"], path) {
        Ok(result) => result,
        Err(_) => {
            // nextest not available — fall back to cargo test
            run_command_with_timeout("cargo", &["test"], path)
                .map_err(|e| format!("Failed to run cargo test: {e}"))?
        }
    };
    output.push_str(&test_out);
    output.push('\n');
    Ok((success, output))
}

/// Run a command with a timeout. Returns `(success, combined_output)`.
/// Kills the child process if it exceeds `TEST_TIMEOUT`.
///
/// Stdout and stderr are drained in background threads to avoid a pipe-buffer
/// deadlock: if the child fills the 64 KB OS pipe buffer while the parent
/// blocks on `waitpid`, neither side can make progress.
fn run_command_with_timeout(
    program: impl AsRef<std::ffi::OsStr>,
    args: &[&str],
    dir: &Path,
) -> Result<(bool, String), String> {
    // On Linux, execve can return ETXTBSY (26) briefly after a file is written
    // before the kernel releases its "write open" state. Retry once after a
    // short pause to handle this race condition.
    let mut last_err = None;
    let mut cmd = Command::new(&program);
    cmd.args(args)
        .current_dir(dir)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    let mut child = loop {
        match cmd.spawn() {
            Ok(c) => break c,
            Err(e) if e.raw_os_error() == Some(26) => {
                // ETXTBSY — wait briefly and retry once
                if last_err.is_some() {
                    return Err(format!("Failed to spawn command: {e}"));
                }
                last_err = Some(e);
                std::thread::sleep(std::time::Duration::from_millis(50));
            }
            Err(e) => return Err(format!("Failed to spawn command: {e}")),
        }
    };

    // Drain stdout/stderr in background threads so the pipe buffers never fill.
    let stdout_handle = child.stdout.take().map(|r| {
        std::thread::spawn(move || {
            let mut s = String::new();
            let mut r = r;
            std::io::Read::read_to_string(&mut r, &mut s).ok();
            s
        })
    });
    let stderr_handle = child.stderr.take().map(|r| {
        std::thread::spawn(move || {
            let mut s = String::new();
            let mut r = r;
            std::io::Read::read_to_string(&mut r, &mut s).ok();
            s
        })
    });

    match child.wait_timeout(TEST_TIMEOUT) {
        Ok(Some(status)) => {
            let stdout = stdout_handle
                .and_then(|h| h.join().ok())
                .unwrap_or_default();
            let stderr = stderr_handle
                .and_then(|h| h.join().ok())
                .unwrap_or_default();
            Ok((status.success(), format!("{stdout}{stderr}")))
        }
        Ok(None) => {
            // Timed out — kill the child.
            let _ = child.kill();
            let _ = child.wait();
            Err(format!(
                "Command timed out after {} seconds",
                TEST_TIMEOUT.as_secs()
            ))
        }
        Err(e) => Err(format!("Failed to wait for command: {e}")),
    }
}

/// Run `cargo clippy` and the project test suite (via `script/test` if present,
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
/// Returns `(gates_passed, combined_output)`.
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
    let mut all_output = String::new();
    let mut all_passed = true;

    // ── cargo clippy ──────────────────────────────────────────────
    let clippy = Command::new("cargo")
        .args(["clippy", "--all-targets", "--all-features"])
        .current_dir(path)
        .output()
        .map_err(|e| format!("Failed to run cargo clippy: {e}"))?;

    all_output.push_str("=== cargo clippy ===\n");
    let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
    let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
    if !clippy_stdout.is_empty() {
        all_output.push_str(&clippy_stdout);
    }
    if !clippy_stderr.is_empty() {
        all_output.push_str(&clippy_stderr);
    }
    all_output.push('\n');

    if !clippy.status.success() {
        all_passed = false;
    }

    // ── tests (script/test if available, else cargo nextest/test) ─
    let (test_success, test_out) = run_project_tests(path)?;
    all_output.push_str(&test_out);
    if !test_success {
        all_passed = false;
    }

    Ok((all_passed, all_output))
}

/// Run `script/test_coverage` in the given directory if the script exists.
///
/// Used as a QA gate before advancing a story from `3_qa/` to `4_merge/`.
/// Returns `(passed, output)`.  If the script does not exist, returns `(true, …)`.
pub(crate) fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> {
    let script = path.join("script").join("test_coverage");
    if !script.exists() {
        return Ok((
            true,
            "script/test_coverage not found; coverage gate skipped.\n".to_string(),
        ));
    }

    let mut output = String::from("=== script/test_coverage ===\n");
    let result = Command::new(&script)
        .current_dir(path)
        .output()
        .map_err(|e| format!("Failed to run script/test_coverage: {e}"))?;

    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&result.stdout),
        String::from_utf8_lossy(&result.stderr)
    );
    output.push_str(&combined);
    output.push('\n');

    Ok((result.status.success(), output))
}

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

    fn init_git_repo(repo: &std::path::Path) {
        Command::new("git")
            .args(["init"])
            .current_dir(repo)
            .output()
            .unwrap();
        Command::new("git")
            .args(["config", "user.email", "test@test.com"])
            .current_dir(repo)
            .output()
            .unwrap();
        Command::new("git")
            .args(["config", "user.name", "Test"])
            .current_dir(repo)
            .output()
            .unwrap();
        Command::new("git")
            .args(["commit", "--allow-empty", "-m", "init"])
            .current_dir(repo)
            .output()
            .unwrap();
    }

    // ── run_project_tests tests ───────────────────────────────────

    #[cfg(unix)]
    #[test]
    fn run_project_tests_uses_script_test_when_present_and_passes() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path();
        let script_dir = path.join("script");
        fs::create_dir_all(&script_dir).unwrap();
        let script_test = script_dir.join("test");
        fs::write(&script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n").unwrap();
        let mut perms = fs::metadata(&script_test).unwrap().permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script_test, perms).unwrap();

        let (passed, output) = run_project_tests(path).unwrap();
        assert!(passed, "script/test exiting 0 should pass");
        assert!(output.contains("script/test"), "output should mention script/test");
    }

    #[cfg(unix)]
    #[test]
    fn run_project_tests_reports_failure_when_script_test_exits_nonzero() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path();
        let script_dir = path.join("script");
        fs::create_dir_all(&script_dir).unwrap();
        let script_test = script_dir.join("test");
        fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap();
        let mut perms = fs::metadata(&script_test).unwrap().permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script_test, perms).unwrap();

        let (passed, output) = run_project_tests(path).unwrap();
        assert!(!passed, "script/test exiting 1 should fail");
        assert!(output.contains("script/test"), "output should mention script/test");
    }

    // ── run_coverage_gate tests ───────────────────────────────────────────────

    #[cfg(unix)]
    #[test]
    fn coverage_gate_passes_when_script_absent() {
        use tempfile::tempdir;
        let tmp = tempdir().unwrap();
        let (passed, output) = run_coverage_gate(tmp.path()).unwrap();
        assert!(passed, "coverage gate should pass when script is absent");
        assert!(
            output.contains("not found"),
            "output should mention script not found"
        );
    }

    #[cfg(unix)]
    #[test]
    fn coverage_gate_passes_when_script_exits_zero() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path();
        let script_dir = path.join("script");
        fs::create_dir_all(&script_dir).unwrap();
        let script = script_dir.join("test_coverage");
        fs::write(
            &script,
            "#!/usr/bin/env bash\necho 'Rust line coverage: 85%'\necho 'PASS: Coverage 85% meets threshold 0%'\nexit 0\n",
        )
        .unwrap();
        let mut perms = fs::metadata(&script).unwrap().permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script, perms).unwrap();

        let (passed, output) = run_coverage_gate(path).unwrap();
        assert!(passed, "coverage gate should pass when script exits 0");
        assert!(
            output.contains("script/test_coverage"),
            "output should mention script/test_coverage"
        );
    }

    #[cfg(unix)]
    #[test]
    fn coverage_gate_fails_when_script_exits_nonzero() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path();
        let script_dir = path.join("script");
        fs::create_dir_all(&script_dir).unwrap();
        let script = script_dir.join("test_coverage");
        fs::write(
            &script,
            "#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n",
        )
        .unwrap();
        let mut perms = fs::metadata(&script).unwrap().permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script, perms).unwrap();

        let (passed, output) = run_coverage_gate(path).unwrap();
        assert!(!passed, "coverage gate should fail when script exits 1");
        assert!(
            output.contains("script/test_coverage"),
            "output should mention script/test_coverage"
        );
    }

    // ── worktree_has_committed_work tests ─────────────────────────────────────

    #[test]
    fn worktree_has_committed_work_false_on_fresh_repo() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = tmp.path();
        // init_git_repo creates the initial commit on the default branch.
        // HEAD IS the base branch — no commits ahead.
        init_git_repo(repo);
        assert!(!worktree_has_committed_work(repo));
    }

    #[test]
    fn worktree_has_committed_work_true_after_commit_on_feature_branch() {
        use std::fs;
        let tmp = tempfile::tempdir().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        // Create a git worktree on a feature branch.
        let wt_path = tmp.path().join("wt");
        Command::new("git")
            .args([
                "worktree",
                "add",
                &wt_path.to_string_lossy(),
                "-b",
                "feature/story-99_test",
            ])
            .current_dir(&project_root)
            .output()
            .unwrap();

        // No commits on the feature branch yet — same as base branch.
        assert!(!worktree_has_committed_work(&wt_path));

        // Add a commit to the feature branch in the worktree.
        fs::write(wt_path.join("work.txt"), "done").unwrap();
        Command::new("git")
            .args(["add", "."])
            .current_dir(&wt_path)
            .output()
            .unwrap();
        Command::new("git")
            .args([
                "-c",
                "user.email=test@test.com",
                "-c",
                "user.name=Test",
                "commit",
                "-m",
                "coder: implement story",
            ])
            .current_dir(&wt_path)
            .output()
            .unwrap();

        // Now the feature branch is ahead of the base branch.
        assert!(worktree_has_committed_work(&wt_path));
    }
}
