use std::path::{Path, PathBuf};
use std::process::Command;

use assert_cmd::Command as TestCommand;
use predicates::str::contains;
use tempfile::TempDir;

fn limb() -> TestCommand {
    TestCommand::cargo_bin("limb").expect("bin built")
}

fn git(dir: &Path, args: &[&str]) {
    let status = Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .status()
        .expect("spawn git");
    assert!(status.success(), "git {} failed", args.join(" "));
}

fn init_repo(dir: &Path) {
    git(dir, &["init", "-q", "-b", "main"]);
    git(dir, &["config", "user.email", "test@example.com"]);
    git(dir, &["config", "user.name", "test"]);
    git(dir, &["config", "commit.gpgsign", "false"]);
    git(dir, &["config", "core.hooksPath", "/dev/null"]);
    git(dir, &["commit", "--allow-empty", "-q", "-m", "chore: init"]);
}

fn sandbox() -> (TempDir, PathBuf) {
    let tmp = tempfile::tempdir().expect("tempdir");
    let repo = tmp.path().join("repo");
    std::fs::create_dir(&repo).expect("mkdir repo");
    init_repo(&repo);
    (tmp, repo)
}

#[test]
fn help_works() {
    limb()
        .arg("--help")
        .assert()
        .success()
        .stdout(contains("git worktree"))
        .stdout(contains("Commands:"));
}

#[test]
fn version_works() {
    limb()
        .arg("--version")
        .assert()
        .success()
        .stdout(contains("limb"));
}

#[test]
fn init_zsh_without_git_repo() {
    let tmp = tempfile::tempdir().expect("tempdir");
    limb()
        .current_dir(tmp.path())
        .args(["init", "zsh"])
        .assert()
        .success()
        .stdout(contains("gw() {"))
        .stdout(contains("gwa() {"))
        .stdout(contains("gwp() {"));
}

#[test]
fn init_custom_prefix() {
    let tmp = tempfile::tempdir().expect("tempdir");
    limb()
        .current_dir(tmp.path())
        .args(["init", "zsh", "--prefix", "lm"])
        .assert()
        .success()
        .stdout(contains("lm() {"))
        .stdout(contains("lma() {"));
}

#[test]
fn completions_emits_shell_script() {
    let tmp = tempfile::tempdir().expect("tempdir");
    limb()
        .current_dir(tmp.path())
        .args(["completions", "bash"])
        .assert()
        .success()
        .stdout(contains("_limb"));
}

#[test]
fn list_empty_repo_shows_main() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .stdout(contains("NAME"))
        .stdout(contains("main"));
}

#[test]
fn list_json_is_valid() {
    let (_tmp, repo) = sandbox();
    let out = limb()
        .current_dir(&repo)
        .args(["--json", "list"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
    assert!(parsed.is_array());
    assert_eq!(parsed.as_array().unwrap().len(), 1);
}

#[test]
fn add_creates_worktree() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-x"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .stdout(contains("feat-x"));
}

#[test]
fn cd_prints_path() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-y"])
        .assert()
        .success();
    let out = limb()
        .current_dir(&repo)
        .args(["cd", "feat-y"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let path = String::from_utf8(out).unwrap();
    assert!(
        path.contains("feat-y"),
        "expected path to contain feat-y, got: {path}"
    );
}

#[test]
fn cd_unknown_fails() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["cd", "nope"])
        .assert()
        .failure()
        .stderr(contains("no worktree named"));
}

#[test]
fn remove_deletes_worktree() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-z"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["remove", "feat-z"])
        .assert()
        .success();
    let out = limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    assert!(!String::from_utf8_lossy(&out).contains("feat-z"));
}

#[test]
fn doctor_works_in_repo() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .arg("doctor")
        .assert()
        .success()
        .stdout(contains("repo:"));
}

#[test]
fn doctor_fails_outside_repo() {
    let tmp = tempfile::tempdir().expect("tempdir");
    limb()
        .current_dir(tmp.path())
        .arg("doctor")
        .assert()
        .failure()
        .stderr(contains("not a git repository"));
}

#[test]
fn status_returns_rows() {
    let (_tmp, repo) = sandbox();
    let out = limb()
        .current_dir(&repo)
        .args(["--json", "status"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
    assert!(parsed.is_array());
    let rows = parsed.as_array().unwrap();
    assert_eq!(rows.len(), 1);
    assert!(rows[0].get("dirty_files").is_some());
}

#[test]
fn config_emits_json() {
    let (_tmp, repo) = sandbox();
    let out = limb()
        .current_dir(&repo)
        .arg("config")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
    assert!(parsed.get("repo").is_some());
}

#[test]
fn list_all_without_config_errors_actionable() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let home = tmp.path().join("home");
    std::fs::create_dir_all(&home).unwrap();
    limb()
        .env("HOME", &home)
        .env_remove("XDG_CONFIG_HOME")
        .current_dir(tmp.path())
        .args(["list", "--all"])
        .assert()
        .failure()
        .stderr(contains("projects.roots"));
}

#[test]
fn list_all_with_config_enumerates_repos() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("root");
    std::fs::create_dir_all(&root).unwrap();
    let repo_a = root.join("alpha");
    let repo_b = root.join("beta");
    std::fs::create_dir_all(&repo_a).unwrap();
    std::fs::create_dir_all(&repo_b).unwrap();
    init_repo(&repo_a);
    init_repo(&repo_b);
    let cfg_path = tmp.path().join("config.toml");
    let cfg = format!("[projects]\nroots = ['{}']\n", root.to_string_lossy());
    std::fs::write(&cfg_path, cfg).unwrap();

    let out = limb()
        .current_dir(tmp.path())
        .args([
            "--config",
            cfg_path.to_str().unwrap(),
            "--json",
            "list",
            "--all",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
    let arr = parsed.as_array().unwrap();
    assert_eq!(arr.len(), 2);
    let repos: Vec<&str> = arr.iter().map(|r| r["repo"].as_str().unwrap()).collect();
    assert!(repos.contains(&"alpha"));
    assert!(repos.contains(&"beta"));
}

#[test]
fn update_accepts_both_flags_exclusive() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["update", "--fetch-only", "--ff-only"])
        .assert()
        .failure()
        .stderr(contains("cannot be used with"));
}

#[test]
fn setup_without_config_fails_actionable() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .arg("setup")
        .assert()
        .failure()
        .stderr(contains(".limb.toml"));
}

#[cfg(unix)]
#[test]
fn setup_symlinks_shared_files() {
    let (_tmp, repo) = sandbox();
    let shared = repo.join(".shared");
    std::fs::create_dir_all(&shared).unwrap();
    std::fs::write(shared.join("mise.toml"), "[tools]\nrust = \"1.94\"\n").unwrap();
    std::fs::write(
        repo.join(".limb.toml"),
        "[worktrees]\nshared = [\"mise.toml\"]\n",
    )
    .unwrap();

    limb()
        .current_dir(&repo)
        .arg("setup")
        .assert()
        .success()
        .stderr(contains("link"));

    let link = repo.join("mise.toml");
    assert!(link.exists(), "symlink should exist");
    assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
}

#[cfg(unix)]
#[test]
fn setup_dry_run_does_not_create_files() {
    let (_tmp, repo) = sandbox();
    let shared = repo.join(".shared");
    std::fs::create_dir_all(&shared).unwrap();
    std::fs::write(shared.join("env"), "KEY=value\n").unwrap();
    std::fs::write(repo.join(".limb.toml"), "[worktrees]\nshared = [\"env\"]\n").unwrap();

    limb()
        .current_dir(&repo)
        .args(["setup", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("would"));

    assert!(!repo.join("env").exists());
}

#[cfg(unix)]
#[test]
fn setup_symlinks_nested_shared_files_with_correct_relative_path() {
    let (_tmp, repo) = sandbox();
    let shared_pub = repo.join(".shared/public");
    std::fs::create_dir_all(&shared_pub).unwrap();
    std::fs::write(shared_pub.join("settings.json"), "{}\n").unwrap();
    std::fs::write(
        repo.join(".limb.toml"),
        "[worktrees]\nshared = [\"public/settings.json\"]\n",
    )
    .unwrap();

    limb().current_dir(&repo).arg("setup").assert().success();

    let link = repo.join("public/settings.json");
    assert!(
        link.symlink_metadata().unwrap().file_type().is_symlink(),
        "expected symlink at {}",
        link.display()
    );
    assert_eq!(
        std::fs::read_to_string(&link).unwrap(),
        "{}\n",
        "symlink target should resolve to the shared file"
    );
}

#[test]
fn add_with_template_interpolates_name() {
    let (_tmp, repo) = sandbox();
    std::fs::write(
        repo.join(".limb.toml"),
        "[templates.feature]\nname_pattern = \"feat-{slug}\"\n",
    )
    .unwrap();
    limb()
        .current_dir(&repo)
        .args(["add", "--template", "feature", "auth"])
        .assert()
        .success();
    let out = limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let s = String::from_utf8_lossy(&out);
    assert!(s.contains("feat-auth"), "expected feat-auth; got:\n{s}");
}

#[test]
fn add_unknown_template_errors_with_list() {
    let (_tmp, repo) = sandbox();
    std::fs::write(
        repo.join(".limb.toml"),
        "[templates.feature]\nname_pattern = \"feat-{slug}\"\n",
    )
    .unwrap();
    limb()
        .current_dir(&repo)
        .args(["add", "--template", "missing", "x"])
        .assert()
        .failure()
        .stderr(contains("unknown template"))
        .stderr(contains("feature"));
}

#[cfg(unix)]
#[test]
fn hooks_pre_add_runs_and_can_abort() {
    let (_tmp, repo) = sandbox();
    let scripts = repo.join("scripts");
    std::fs::create_dir_all(&scripts).unwrap();
    let hook = scripts.join("pre_add.sh");
    std::fs::write(&hook, "#!/usr/bin/env sh\nexit 1\n").unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
    }
    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npre_add = \"scripts/pre_add.sh\"\n",
    )
    .unwrap();

    limb()
        .current_dir(&repo)
        .args(["add", "feat-blocked"])
        .assert()
        .failure()
        .stderr(contains("pre_add"));
}

#[cfg(unix)]
#[test]
fn hooks_post_add_runs_with_env() {
    let (_tmp, repo) = sandbox();
    let scripts = repo.join("scripts");
    std::fs::create_dir_all(&scripts).unwrap();
    let hook = scripts.join("post_add.sh");
    let marker = repo.join("marker.txt");
    let script = format!(
        "#!/usr/bin/env sh\necho \"$LIMB_WORKTREE_NAME:$LIMB_WORKTREE_PATH\" > {}\n",
        marker.display()
    );
    std::fs::write(&hook, script).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
    }
    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npost_add = \"scripts/post_add.sh\"\n",
    )
    .unwrap();

    limb()
        .current_dir(&repo)
        .args(["add", "feat-q"])
        .assert()
        .success();

    let marker_text = std::fs::read_to_string(&marker).expect("marker written");
    assert!(marker_text.contains("feat-q:"));
    assert!(marker_text.contains("feat-q\n") || marker_text.contains("feat-q"));
}

#[cfg(unix)]
#[test]
fn hooks_post_add_failure_does_not_abort_worktree() {
    use std::os::unix::fs::PermissionsExt;
    let (_tmp, repo) = sandbox();
    let scripts = repo.join("scripts");
    std::fs::create_dir_all(&scripts).unwrap();
    let hook = scripts.join("post_add.sh");
    std::fs::write(&hook, "#!/usr/bin/env sh\nexit 7\n").unwrap();
    std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npost_add = \"scripts/post_add.sh\"\n",
    )
    .unwrap();

    limb()
        .current_dir(&repo)
        .args(["add", "feat-p"])
        .assert()
        .success()
        .stderr(contains("post_add"));

    limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .stdout(contains("feat-p"));
}

#[test]
fn doctor_reports_missing_projects_root() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let repo = tmp.path().join("repo");
    std::fs::create_dir_all(&repo).unwrap();
    init_repo(&repo);
    let cfg_path = tmp.path().join("config.toml");
    let cfg = format!(
        "[projects]\nroots = ['{}']\n",
        tmp.path().join("does-not-exist").to_string_lossy()
    );
    std::fs::write(&cfg_path, cfg).unwrap();

    limb()
        .current_dir(&repo)
        .args(["--config", cfg_path.to_str().unwrap(), "doctor"])
        .assert()
        .failure()
        .stderr(contains("does not exist"))
        .stderr(contains("try:"));
}

#[test]
fn doctor_json_emits_summary_for_clean_repo() {
    let (_tmp, repo) = sandbox();
    let out = limb()
        .current_dir(&repo)
        .args(["--json", "doctor"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
    assert_eq!(parsed["ok"], true);
    assert_eq!(parsed["summary"]["problems"], 0);
    assert!(parsed["items"].is_array());
}

#[test]
fn doctor_json_includes_problem_for_missing_root() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let repo = tmp.path().join("repo");
    std::fs::create_dir_all(&repo).unwrap();
    init_repo(&repo);
    let cfg_path = tmp.path().join("config.toml");
    let cfg = format!(
        "[projects]\nroots = ['{}']\n",
        tmp.path().join("does-not-exist").to_string_lossy()
    );
    std::fs::write(&cfg_path, cfg).unwrap();

    let out = limb()
        .current_dir(&repo)
        .args(["--json", "--config", cfg_path.to_str().unwrap(), "doctor"])
        .assert()
        .failure()
        .get_output()
        .stdout
        .clone();
    let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
    assert_eq!(parsed["ok"], false);
    assert!(parsed["summary"]["problems"].as_u64().unwrap() >= 1);
    let items = parsed["items"].as_array().expect("items is array");
    assert!(items.iter().any(|i| {
        i["severity"] == "problem" && i["message"].as_str().unwrap().contains("does not exist")
    }));
}

#[test]
fn doctor_json_no_text_interleaving() {
    let (_tmp, repo) = sandbox();
    let out = limb()
        .current_dir(&repo)
        .args(["--json", "doctor"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let s = std::str::from_utf8(&out).expect("utf8");
    let trimmed = s.trim();
    assert!(
        trimmed.starts_with('{') && trimmed.ends_with('}'),
        "stdout must be a single json object, got: {trimmed:?}"
    );
}

#[cfg(unix)]
#[test]
fn hook_parent_dir_traversal_rejected() {
    let (_tmp, repo) = sandbox();
    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npre_add = \"../escape.sh\"\n",
    )
    .unwrap();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-esc"])
        .assert()
        .failure()
        .stderr(contains("'..'"));
}

#[test]
fn lock_then_unlock_roundtrip() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-lk"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["lock", "feat-lk", "--reason", "demo"])
        .assert()
        .success()
        .stderr(contains("locked"));
    limb()
        .current_dir(&repo)
        .args(["unlock", "feat-lk"])
        .assert()
        .success()
        .stderr(contains("unlocked"));
}

#[test]
fn repair_succeeds_on_healthy_repo() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .arg("repair")
        .assert()
        .success()
        .stderr(contains("repaired"));
}

#[test]
fn prune_dry_run_is_noop() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["prune", "--dry-run"])
        .assert()
        .success();
}

#[test]
fn rename_moves_worktree() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-old"])
        .assert()
        .success();
    let parent = repo.parent().unwrap();
    assert!(parent.join("feat-old").exists());
    limb()
        .current_dir(&repo)
        .args(["rename", "feat-old", "feat-new"])
        .assert()
        .success()
        .stderr(contains("feat-new"));
    assert!(!parent.join("feat-old").exists(), "old dir should be gone");
    assert!(parent.join("feat-new").exists(), "new dir should exist");
    let out = limb()
        .current_dir(&repo)
        .args(["--json", "list"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
    let names: Vec<String> = json
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|row| row["name"].as_str().map(String::from))
        .collect();
    assert!(names.contains(&"feat-new".to_string()));
    assert!(!names.contains(&"feat-old".to_string()));
}

#[test]
fn rename_rejects_same_name() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-same"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["rename", "feat-same", "feat-same"])
        .assert()
        .failure()
        .stderr(contains("identical"));
}

#[test]
fn rename_dry_run_preserves_original() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-dr"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["rename", "feat-dr", "feat-new", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("would rename"));
    let out = limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    assert!(String::from_utf8_lossy(&out).contains("feat-dr"));
}

#[test]
fn clean_reports_no_stale_when_clean() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["clean", "--yes"])
        .assert()
        .success()
        .stderr(contains("no stale"));
}

#[test]
fn clean_dry_run_is_safe() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["clean", "--dry-run"])
        .assert()
        .success();
}

#[test]
fn migrate_on_worktree_errors() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .arg("add")
        .arg("wt")
        .assert()
        .success();
    let wt = repo.parent().unwrap().join("wt");
    limb()
        .current_dir(&wt)
        .arg("migrate")
        .assert()
        .failure()
        .stderr(contains("does not look like a plain git clone"));
}

#[test]
fn migrate_dry_run_preserves_repo() {
    let (_tmp, repo) = sandbox();
    let git_dir_before = repo.join(".git").exists();
    limb()
        .current_dir(&repo)
        .args(["migrate", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("dry run"));
    assert_eq!(repo.join(".git").exists(), git_dir_before);
    assert!(!repo.join(".bare").exists());
}

// Windows holds file handles open after rename and rejects renaming a dir
// that is any process's cwd; integration-testing the success path is
// flaky there. The refuse-path migrate tests still run on Windows.
#[cfg(not(windows))]
#[test]
fn migrate_succeeds_on_clean_clone() {
    let (_tmp, repo) = sandbox();
    let parent = repo.parent().unwrap().to_path_buf();
    let repo_name = repo.file_name().unwrap().to_str().unwrap().to_string();
    limb()
        .current_dir(&parent)
        .args(["migrate", repo.to_str().unwrap()])
        .assert()
        .success()
        .stderr(contains("migrated:"));
    let new_root = parent.join(&repo_name);
    assert!(new_root.join(".bare").is_dir(), ".bare/ exists at new root");
    assert!(new_root.join("main").is_dir(), "main worktree dir exists");
    assert!(
        !new_root.join(".git").exists(),
        ".git is gone from new root"
    );
    let leftover = parent.join(format!(".{repo_name}-limb-old"));
    assert!(!leftover.exists(), "backup dir cleaned on success");
    let staging = parent.join(format!(".{repo_name}-limb-migrate"));
    assert!(!staging.exists(), "staging dir cleaned on success");
}

#[test]
fn migrate_refuses_on_dirty_tree() {
    let (_tmp, repo) = sandbox();
    std::fs::write(repo.join("dirty.txt"), b"unstaged change").unwrap();
    limb()
        .current_dir(&repo)
        .arg("migrate")
        .assert()
        .failure()
        .stderr(contains("uncommitted"));
    assert!(repo.join(".git").is_dir(), "original repo intact");
    assert!(!repo.join(".bare").exists(), "no .bare created on refusal");
}

#[test]
fn pick_help_works() {
    let tmp = tempfile::tempdir().expect("tempdir");
    limb()
        .current_dir(tmp.path())
        .args(["pick", "--help"])
        .assert()
        .success()
        .stdout(contains("picker"));
}

#[test]
fn pick_outside_repo_fails() {
    let tmp = tempfile::tempdir().expect("tempdir");
    limb()
        .current_dir(tmp.path())
        .arg("pick")
        .assert()
        .failure();
}

#[cfg(unix)]
#[test]
fn mark_cd_writes_marker_with_tmux_pane() {
    let target = tempfile::tempdir().expect("target");
    let marker_dir = tempfile::tempdir().expect("marker dir");
    let pane = "%limb_test99";

    limb()
        .env("TMUX_PANE", pane)
        .env("TMPDIR", marker_dir.path())
        .args(["mark-cd", target.path().to_str().unwrap()])
        .assert()
        .success();

    let marker = marker_dir.path().join(format!("limb-pending-cd-{pane}"));
    let contents = std::fs::read_to_string(&marker).expect("read marker");
    let canon = target.path().canonicalize().expect("canonicalize");
    assert_eq!(contents.trim(), canon.to_string_lossy());
}

#[test]
fn mark_cd_noop_without_tmux_pane() {
    let target = tempfile::tempdir().expect("target");
    let marker_dir = tempfile::tempdir().expect("marker dir");

    limb()
        .env_remove("TMUX_PANE")
        .env("TMPDIR", marker_dir.path())
        .args(["mark-cd", target.path().to_str().unwrap()])
        .assert()
        .success();

    let mut entries = std::fs::read_dir(marker_dir.path()).expect("read marker dir");
    assert!(entries.next().is_none(), "no marker should be written");
}

#[test]
fn suggests_similar_name_on_miss() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feature"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["cd", "feautre"])
        .assert()
        .failure()
        .stderr(contains("did you mean 'feature'"));
}

#[test]
fn add_detach_creates_detached_head() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "scratch", "--detach"])
        .assert()
        .success();
    let out = limb()
        .current_dir(&repo)
        .args(["--json", "list"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let trees: serde_json::Value = serde_json::from_slice(&out).expect("json");
    let scratch = trees
        .as_array()
        .unwrap()
        .iter()
        .find(|t| t["name"] == "scratch")
        .expect("scratch worktree");
    assert!(
        scratch.get("branch").is_none_or(serde_json::Value::is_null),
        "detached worktree should have no branch, got: {scratch}"
    );
}

#[test]
fn add_orphan_creates_orphan_branch() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "docs-only", "--orphan"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .stdout(contains("docs-only"));
}

#[test]
fn add_lock_locks_worktree_after_creation() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "holding", "--lock", "--reason", "wip"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .stdout(contains("holding"))
        .stdout(contains("(locked)"));
}

#[test]
fn reason_without_lock_is_rejected() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "x", "--reason", "foo"])
        .assert()
        .failure()
        .stderr(contains("--lock"));
}

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

    let (_tmp, repo) = sandbox();
    std::fs::create_dir_all(repo.join("hooks")).expect("mkdir hooks");
    let hook_path = repo.join("hooks/post_add.sh");
    let marker = repo.join(".hook-ran");
    let script = format!(
        "#!/usr/bin/env bash\ntouch {}\necho $LIMB_WORKTREE_NAME > {}.name\n",
        marker.display(),
        marker.display()
    );
    std::fs::write(&hook_path, script).expect("write hook");
    std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))
        .expect("chmod hook");

    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npost_add = \"hooks/post_add.sh\"\n",
    )
    .expect("write .limb.toml");

    limb()
        .current_dir(&repo)
        .args(["add", "feat-hook"])
        .assert()
        .success();

    assert!(marker.exists(), "post_add hook should have created marker");
    let name_file = repo.join(".hook-ran.name");
    let contents = std::fs::read_to_string(&name_file).expect("read name marker");
    assert_eq!(contents.trim(), "feat-hook");
}

#[cfg(windows)]
#[test]
fn hooks_pre_add_runs_and_can_abort_windows() {
    let (_tmp, repo) = sandbox();
    let scripts = repo.join("scripts");
    std::fs::create_dir_all(&scripts).unwrap();
    let hook = scripts.join("pre_add.cmd");
    std::fs::write(&hook, "@echo off\r\nexit /b 1\r\n").unwrap();
    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npre_add = \"scripts/pre_add.cmd\"\n",
    )
    .unwrap();

    limb()
        .current_dir(&repo)
        .args(["add", "feat-blocked"])
        .assert()
        .failure()
        .stderr(contains("pre_add"));
}

#[cfg(windows)]
#[test]
fn hooks_post_add_failure_does_not_abort_worktree_windows() {
    let (_tmp, repo) = sandbox();
    let scripts = repo.join("scripts");
    std::fs::create_dir_all(&scripts).unwrap();
    let hook = scripts.join("post_add.cmd");
    std::fs::write(&hook, "@echo off\r\nexit /b 7\r\n").unwrap();
    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npost_add = \"scripts/post_add.cmd\"\n",
    )
    .unwrap();

    limb()
        .current_dir(&repo)
        .args(["add", "feat-best"])
        .assert()
        .success();

    limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .stdout(contains("feat-best"));
}

#[cfg(windows)]
#[test]
fn post_add_hook_runs_windows() {
    let (_tmp, repo) = sandbox();
    std::fs::create_dir_all(repo.join("hooks")).expect("mkdir hooks");
    let hook_path = repo.join("hooks/post_add.cmd");
    let marker = repo.join(".hook-ran");
    let name_marker = repo.join(".hook-ran.name");
    let script = format!(
        "@echo off\r\ntype nul > \"{marker}\"\r\n>\"{name_marker}\" echo %LIMB_WORKTREE_NAME%\r\n",
        marker = marker.display(),
        name_marker = name_marker.display(),
    );
    std::fs::write(&hook_path, script).expect("write hook");

    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npost_add = \"hooks/post_add.cmd\"\n",
    )
    .expect("write .limb.toml");

    limb()
        .current_dir(&repo)
        .args(["add", "feat-hook"])
        .assert()
        .success();

    assert!(marker.exists(), "post_add hook should have created marker");
    let contents = std::fs::read_to_string(&name_marker).expect("read name marker");
    assert_eq!(contents.trim(), "feat-hook");
}

#[test]
fn add_dry_run_does_not_create() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-pretend", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("would add"));
    let parent = repo.parent().unwrap();
    assert!(!parent.join("feat-pretend").exists());
}

#[test]
fn remove_dry_run_preserves_worktree() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-keep"])
        .assert()
        .success();
    let parent = repo.parent().unwrap();
    assert!(parent.join("feat-keep").exists());
    limb()
        .current_dir(&repo)
        .args(["remove", "feat-keep", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("would remove"));
    assert!(
        parent.join("feat-keep").exists(),
        "worktree should still exist after dry-run"
    );
}

#[test]
fn lock_dry_run_does_not_lock() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-lockdr"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["lock", "feat-lockdr", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("would lock"));
    let out = limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let stdout = String::from_utf8_lossy(&out);
    let line = stdout
        .lines()
        .find(|l| l.contains("feat-lockdr"))
        .expect("feat-lockdr should appear in list");
    assert!(
        !line.contains("(locked)"),
        "worktree should not be locked after dry-run"
    );
}

#[test]
fn unlock_dry_run_does_not_unlock() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-unlockdr"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["lock", "feat-unlockdr"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["unlock", "feat-unlockdr", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("would unlock"));
    let out = limb()
        .current_dir(&repo)
        .arg("list")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let stdout = String::from_utf8_lossy(&out);
    let line = stdout
        .lines()
        .find(|l| l.contains("feat-unlockdr"))
        .expect("feat-unlockdr should appear in list");
    assert!(
        line.contains("(locked)"),
        "worktree should still be locked after unlock dry-run"
    );
}

#[test]
fn update_dry_run_announces_plan() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["update", "--dry-run"])
        .assert()
        .success()
        .stderr(contains("would fetch"));
}

#[test]
fn update_all_dry_run_visits_each_repo_under_root() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("root");
    std::fs::create_dir_all(&root).unwrap();
    let repo_a = root.join("alpha");
    let repo_b = root.join("beta");
    std::fs::create_dir_all(&repo_a).unwrap();
    std::fs::create_dir_all(&repo_b).unwrap();
    init_repo(&repo_a);
    init_repo(&repo_b);
    let cfg_path = tmp.path().join("config.toml");
    let cfg = format!("[projects]\nroots = ['{}']\n", root.to_string_lossy());
    std::fs::write(&cfg_path, cfg).unwrap();

    limb()
        .current_dir(tmp.path())
        .args([
            "--config",
            cfg_path.to_str().unwrap(),
            "update",
            "--all",
            "--dry-run",
        ])
        .assert()
        .success()
        .stderr(contains("alpha"))
        .stderr(contains("beta"));
}

#[test]
fn update_all_json_emits_array() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("root");
    std::fs::create_dir_all(&root).unwrap();
    let repo_a = root.join("alpha");
    std::fs::create_dir_all(&repo_a).unwrap();
    init_repo(&repo_a);
    let cfg_path = tmp.path().join("config.toml");
    let cfg = format!("[projects]\nroots = ['{}']\n", root.to_string_lossy());
    std::fs::write(&cfg_path, cfg).unwrap();

    let out = limb()
        .current_dir(tmp.path())
        .args([
            "--config",
            cfg_path.to_str().unwrap(),
            "--json",
            "update",
            "--all",
            "--dry-run",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
    assert!(parsed.is_array(), "update --all --json must emit an array");
}

#[test]
fn add_track_requires_from() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-track", "--track"])
        .assert()
        .failure()
        .stderr(contains("--from"));
}

#[test]
fn add_no_track_requires_from() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-no-track", "--no-track"])
        .assert()
        .failure()
        .stderr(contains("--from"));
}

#[test]
fn add_track_and_no_track_conflict() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args([
            "add",
            "feat-conflict",
            "--from",
            "main",
            "--track",
            "--no-track",
        ])
        .assert()
        .failure()
        .stderr(contains("cannot be used"));
}

#[test]
fn add_with_track_creates_worktree() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-tracked", "--from", "main", "--track"])
        .assert()
        .success();
    let parent = repo.parent().unwrap();
    assert!(parent.join("feat-tracked").exists());
}

#[test]
fn repair_accepts_path_argument() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-rep"])
        .assert()
        .success();
    let parent = repo.parent().unwrap();
    let wt = parent.join("feat-rep");
    limb()
        .current_dir(&repo)
        .args(["repair", wt.to_str().unwrap()])
        .assert()
        .success()
        .stderr(contains("repaired"));
}

#[test]
fn list_verbose_shows_lock_reason() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-locked"])
        .assert()
        .success();
    limb()
        .current_dir(&repo)
        .args(["lock", "feat-locked", "--reason", "in use by build"])
        .assert()
        .success();
    let out = limb()
        .current_dir(&repo)
        .args(["list", "-v"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let stdout = String::from_utf8_lossy(&out);
    assert!(
        stdout.contains("locked: in use by build"),
        "list -v should show lock reason, got: {stdout}"
    );
}

#[test]
fn add_no_checkout_smoke() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-nc", "--no-checkout"])
        .assert()
        .success();
    let parent = repo.parent().unwrap();
    assert!(parent.join("feat-nc").exists());
}

#[test]
fn add_force_branch_requires_from() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-fb", "-B", "newbranch"])
        .assert()
        .failure()
        .stderr(contains("--from"));
}

#[test]
fn add_force_branch_overwrites_existing() {
    let (_tmp, repo) = sandbox();
    git(&repo, &["branch", "existing-feat"]);
    limb()
        .current_dir(&repo)
        .args(["add", "wt-fb", "-B", "existing-feat", "--from", "main"])
        .assert()
        .success();
    let parent = repo.parent().unwrap();
    assert!(parent.join("wt-fb").exists());
}

#[test]
fn add_guess_remote_requires_from() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-gr", "--guess-remote"])
        .assert()
        .failure()
        .stderr(contains("--from"));
}

#[test]
fn add_guess_remote_pair_conflict() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args([
            "add",
            "feat-gr",
            "--from",
            "main",
            "--guess-remote",
            "--no-guess-remote",
        ])
        .assert()
        .failure()
        .stderr(contains("cannot be used"));
}

#[test]
fn add_relative_paths_pair_conflict() {
    let (_tmp, repo) = sandbox();
    limb()
        .current_dir(&repo)
        .args(["add", "feat-rp", "--relative-paths", "--no-relative-paths"])
        .assert()
        .failure()
        .stderr(contains("cannot be used"));
}

#[test]
fn quiet_suppresses_git_progress() {
    let (_tmp, repo) = sandbox();
    let output = limb()
        .current_dir(&repo)
        .args(["--quiet", "add", "feat-q"])
        .assert()
        .success()
        .get_output()
        .clone();
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !stderr.contains("Preparing"),
        "git progress should be suppressed under --quiet, got: {stderr:?}"
    );
    assert!(
        !stderr.contains("✓"),
        "limb's success marker should be suppressed under --quiet, got: {stderr:?}"
    );
}

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

    let (_tmp, repo) = sandbox();
    std::fs::create_dir_all(repo.join("hooks")).expect("mkdir hooks");
    let hook_path = repo.join("hooks/post_add.sh");
    let marker = repo.join(".hook-ran");
    let script = format!("#!/usr/bin/env bash\ntouch {}\n", marker.display());
    std::fs::write(&hook_path, script).expect("write hook");
    std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))
        .expect("chmod hook");
    std::fs::write(
        repo.join(".limb.toml"),
        "[hooks]\npost_add = \"hooks/post_add.sh\"\n",
    )
    .expect("write .limb.toml");

    limb()
        .current_dir(&repo)
        .args(["add", "feat-no-hook", "--dry-run"])
        .assert()
        .success();

    assert!(!marker.exists(), "hook should not run in dry-run");
    let parent = repo.parent().unwrap();
    assert!(!parent.join("feat-no-hook").exists());
}
