use tempfile::TempDir;

fn git(dir: &std::path::Path, args: &[&str]) {
    std::process::Command::new("git")
        .arg("-c").arg("init.defaultBranch=main")
        .args(args)
        .current_dir(dir)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .status()
        .unwrap();
}

fn run_apm(dir: &std::path::Path, args: &[&str]) -> std::process::Output {
    let bin = env!("CARGO_BIN_EXE_apm");
    let out = std::process::Command::new(bin)
        .args(args)
        .current_dir(dir)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "apm {:?} failed:\nstdout: {}\nstderr: {}",
        args,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    out
}

/// Create a ticket via `apm new` and return `(id, branch)`.
fn create_ticket(dir: &std::path::Path, title: &str) -> (String, String) {
    let out = run_apm(dir, &["new", "--no-edit", "--no-aggressive", title]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    let line = stdout.lines().find(|l| l.starts_with("Created ticket")).unwrap();
    let id = line.split_whitespace().nth(2).unwrap().trim_end_matches(':').to_string();
    let branch = line
        .split("(branch: ").nth(1).unwrap()
        .trim_end_matches(')')
        .to_string();
    (id, branch)
}

fn init_repo() -> TempDir {
    let dir = tempfile::tempdir().unwrap();
    git(dir.path(), &["init", "-q", "-b", "main"]);
    git(dir.path(), &["config", "user.name", "test"]);
    git(dir.path(), &["config", "user.email", "test@test.com"]);
    let bin = env!("CARGO_BIN_EXE_apm");
    let out = std::process::Command::new(bin)
        .args(["init", "--no-claude", "--quiet"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    assert!(out.status.success(), "apm init failed: {}", String::from_utf8_lossy(&out.stderr));
    git(dir.path(), &["add", "."]);
    git(dir.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
    dir
}

#[test]
fn test_init_repo_helper() {
    let dir = init_repo();
    let p = dir.path();
    assert!(p.join(".apm/config.toml").exists());
    assert!(p.join(".apm/workflow.toml").exists());
    assert!(p.join("tickets").is_dir());
    let gitignore = std::fs::read_to_string(p.join(".gitignore")).unwrap();
    assert!(gitignore.contains(".apm/local.toml"), ".gitignore missing .apm/local.toml entry");
    assert!(apm_core::config::Config::load(p).is_ok());
    git(p, &["rev-parse", "HEAD"]);
}

fn setup() -> TempDir {
    init_repo()
}

/// Like setup() but configures the `merge` completion strategy for
/// `in_progress → implemented` so that depends_on is allowed when tickets
/// share the same target_branch (or both default to main).
fn setup_merge() -> TempDir {
    let dir = init_repo();

    // BYPASS: no apm command can change a workflow completion strategy post-init.
    // Change pr_or_epic_merge → merge so that depends_on is allowed for tickets
    // that share the same target_branch without belonging to an epic.
    let wf_path = dir.path().join(".apm/workflow.toml");
    let wf = std::fs::read_to_string(&wf_path).unwrap();
    assert!(
        wf.contains("completion = \"pr_or_epic_merge\""),
        "expected pr_or_epic_merge in workflow.toml — default template may have changed"
    );
    let patched = wf.replace(
        "completion = \"pr_or_epic_merge\"",
        "completion = \"merge\"",
    );
    std::fs::write(&wf_path, patched).unwrap();

    git(dir.path(), &["add", ".apm/workflow.toml"]);
    git(dir.path(), &["commit", "-m", "set merge completion"]);

    dir
}

/// Read a file's content from a git branch (does not touch the working tree).
fn branch_content(dir: &std::path::Path, branch: &str, path: &str) -> String {
    let out = std::process::Command::new("git")
        .args(["show", &format!("{branch}:{path}")])
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(out.status.success(), "git show {branch}:{path} failed: {}", String::from_utf8_lossy(&out.stderr));
    String::from_utf8(out.stdout).unwrap()
}

/// Check out a file from a ticket branch into the working tree so that
/// read-only commands (apm list, apm show, apm next) can see it.
fn sync_from_branch(dir: &std::path::Path, branch: &str, path: &str) {
    git(dir, &["checkout", branch, "--", path]);
}

/// Find the ticket branch whose slug matches (after any hex ID prefix).
fn find_ticket_branch(dir: &std::path::Path, slug: &str) -> String {
    let pattern = format!("ticket/*-{slug}");
    let out = std::process::Command::new("git")
        .args(["branch", "--list", &pattern])
        .current_dir(dir)
        .output()
        .unwrap();
    let stdout = String::from_utf8(out.stdout).unwrap();
    stdout
        .lines()
        .find(|l| !l.trim().is_empty())
        // strip "* " (current branch) and "+ " (branch in another worktree)
        .map(|l| l.trim().trim_start_matches(['*', '+']).trim_start_matches(' ').to_string())
        .unwrap_or_else(|| panic!("no branch found for slug: {slug}"))
}

/// Extract the ticket ID from a branch found by slug.
fn find_ticket_id(dir: &std::path::Path, slug: &str) -> String {
    let branch = find_ticket_branch(dir, slug);
    branch
        .strip_prefix("ticket/")
        .and_then(|s| s.split('-').next())
        .unwrap_or_else(|| panic!("bad branch: {branch}"))
        .to_string()
}

/// Derive the tickets/ relative path from a branch name.
fn ticket_rel_path(branch: &str) -> String {
    let suffix = branch.strip_prefix("ticket/").expect("not a ticket branch");
    format!("tickets/{suffix}.md")
}

/// Write a valid spec body to a ticket on its branch, without changing HEAD.
fn write_valid_spec_to_branch(dir: &std::path::Path, branch: &str, path: &str) {
    let existing = branch_content(dir, branch, path);
    let fm_end = existing.find("\n+++\n").expect("frontmatter close not found") + 5;
    let frontmatter = &existing[..fm_end];
    let body = "\n## Spec\n\n### Problem\n\nTest problem.\n\n### Acceptance criteria\n\n- [ ] One criterion\n\n### Out of scope\n\nNothing.\n\n### Approach\n\nDirect approach.\n\n## History\n\n| When | From | To | By |\n|------|------|----|-----|\n| 2026-01-01T00:00Z | — | new | test-agent |\n";
    let content = format!("{frontmatter}{body}");
    git(dir, &["checkout", branch]);
    std::fs::write(dir.join(path), &content).unwrap();
    git(dir, &["-c", "commit.gpgsign=false", "add", path]);
    git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "write spec"]);
    git(dir, &["checkout", "-"]);
}

// --- init ---

#[test]
fn init_creates_expected_files() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    apm_core::init::setup(p, None, None, None, None).unwrap();
    assert!(p.join("tickets").is_dir());
    assert!(p.join(".apm/config.toml").exists());
    assert!(p.join(".apm/workflow.toml").exists());
    assert!(p.join(".apm/ticket.toml").exists());
    assert!(p.join(".gitignore").exists());
    assert!(!p.join(".git/hooks/pre-push").exists());
    assert!(!p.join(".git/hooks/post-merge").exists());
}

#[test]
fn init_is_idempotent() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    apm_core::init::setup(p, None, None, None, None).unwrap();
    let toml_before = std::fs::read_to_string(p.join(".apm/config.toml")).unwrap();
    apm_core::init::setup(p, None, None, None, None).unwrap();
    let toml_after = std::fs::read_to_string(p.join(".apm/config.toml")).unwrap();
    assert_eq!(toml_before, toml_after);
}

#[test]
fn init_generated_config_has_all_workflow_states() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    apm_core::init::setup(p, None, None, None, None).unwrap();

    let toml = std::fs::read_to_string(p.join(".apm/workflow.toml")).unwrap();
    for state in &["new", "groomed", "question", "specd", "ammend", "in_design", "ready", "in_progress", "implemented", "merge_failed", "closed"] {
        assert!(toml.contains(&format!("\"{state}\"")), "missing state: {state}");
    }
    assert!(toml.contains("terminal"), "closed must be terminal");
    // Must parse without error.
    apm_core::config::Config::load(p).unwrap();
}

fn run_apm_with_home(dir: &std::path::Path, home: &std::path::Path, args: &[&str]) -> std::process::Output {
    let bin = env!("CARGO_BIN_EXE_apm");
    let out = std::process::Command::new(bin)
        .args(args)
        .current_dir(dir)
        .env("HOME", home)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "apm {:?} failed:\nstdout: {}\nstderr: {}",
        args,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    out
}

fn init_git_repo(p: &std::path::Path) {
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    apm_core::init::setup(p, None, None, None, None).unwrap();
}

#[test]
fn init_yes_creates_settings_when_claude_dir_exists() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let settings_path = p.join(".claude/settings.json");
    assert!(settings_path.exists(), ".claude/settings.json was not created");
    let contents = std::fs::read_to_string(&settings_path).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(entries.contains(&"Bash(apm show*)"), "Bash(apm show*) missing");
    assert!(entries.contains(&"Bash(apm state *)"), "Bash(apm state *) missing");
    assert!(entries.contains(&"Bash(apm spec *)"), "Bash(apm spec *) missing");
    assert!(entries.contains(&"Bash(apm instructions*)"), "Bash(apm instructions*) missing");
}

#[test]
fn init_yes_merges_without_duplicating_existing_entries() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();
    // Pre-seed one entry
    let existing = serde_json::json!({
        "permissions": { "allow": ["Bash(apm show*)"] }
    });
    std::fs::write(p.join(".claude/settings.json"), serde_json::to_string_pretty(&existing).unwrap()).unwrap();

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let contents = std::fs::read_to_string(p.join(".claude/settings.json")).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    let count = entries.iter().filter(|&&e| e == "Bash(apm show*)").count();
    assert_eq!(count, 1, "Bash(apm show*) duplicated: found {} times", count);
    assert!(entries.contains(&"Bash(apm state *)"), "Bash(apm state *) missing");
}

#[test]
fn init_yes_no_op_when_claude_dir_absent() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    // No .claude/ directory

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    assert!(!p.join(".claude").exists(), ".claude/ must not be created");
    assert!(!p.join(".claude/settings.json").exists(), "settings.json must not be created");
}

#[test]
fn init_non_tty_creates_settings_without_yes_flag() {
    // run_apm spawns a subprocess whose stdin is a pipe (not a TTY), so
    // is_tty == false and yes is implied automatically.
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();

    run_apm_with_home(p, home.path(), &["init", "--quiet"]);

    let settings_path = p.join(".claude/settings.json");
    assert!(settings_path.exists(), ".claude/settings.json was not created on non-TTY stdin");
    let contents = std::fs::read_to_string(&settings_path).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    assert!(!allow.is_empty(), "allow list must be populated");
}

#[test]
fn init_yes_updates_user_settings() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let user_settings = home.path().join(".claude/settings.json");
    assert!(user_settings.exists(), "~/.claude/settings.json was not created");
    let contents = std::fs::read_to_string(&user_settings).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(entries.contains(&"Bash(git -C*)"), "Bash(git -C*) missing from user settings");
    assert!(entries.contains(&"Bash(apm state *)"), "Bash(apm state *) missing from user settings");
    assert!(entries.contains(&"Bash(apm instructions*)"), "Bash(apm instructions*) missing from user settings");
}

#[test]
fn init_yes_prints_updated_message() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();

    let out = run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("Updated .claude/settings.json"), "missing project settings update message: {stdout}");
    assert!(stdout.contains("Updated ~/.claude/settings.json"), "missing user settings update message: {stdout}");
}

#[test]
fn init_no_claude_with_yes_suppresses_writes() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();

    run_apm_with_home(p, home.path(), &["init", "--no-claude", "--yes", "--quiet"]);

    assert!(!p.join(".claude/settings.json").exists(), ".claude/settings.json must not be written with --no-claude");
}

#[test]
fn init_yes_includes_edit_write_and_baseline() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let contents = std::fs::read_to_string(p.join(".claude/settings.json")).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(entries.contains(&"Edit"), "Edit missing");
    assert!(entries.contains(&"Write"), "Write missing");
    assert!(entries.contains(&"Read"), "Read missing");
    assert!(entries.contains(&"Glob"), "Glob missing");
    assert!(entries.contains(&"Grep"), "Grep missing");
    assert!(entries.contains(&"Bash(git -C *)"), "Bash(git -C *) missing");
    assert!(entries.contains(&"Bash(ls *)"), "Bash(ls *) missing");
    assert!(entries.contains(&"Bash(grep *)"), "Bash(grep *) missing");
    assert!(entries.contains(&"Bash(find *)"), "Bash(find *) missing");
    assert!(entries.contains(&"Bash(cat *)"), "Bash(cat *) missing");
    assert!(entries.contains(&"Bash(sed *)"), "Bash(sed *) missing");
    assert!(entries.contains(&"Bash(awk *)"), "Bash(awk *) missing");
    assert!(entries.contains(&"Bash(mv *)"), "Bash(mv *) missing");
    assert!(entries.contains(&"Bash(cp *)"), "Bash(cp *) missing");
    assert!(entries.contains(&"Bash(rm /tmp/*)"), "Bash(rm /tmp/*) missing");
    assert!(entries.contains(&"Bash(mkdir -p /tmp/*)"), "Bash(mkdir -p /tmp/*) missing");
    assert!(entries.contains(&"Bash(echo *)"), "Bash(echo *) missing");
    assert!(entries.contains(&"Bash(test *)"), "Bash(test *) missing");
    assert!(entries.contains(&"Bash(true)"), "Bash(true) missing");
    assert!(entries.contains(&"Bash(false)"), "Bash(false) missing");
}

#[test]
fn init_yes_adds_cargo_for_rust_project() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();
    std::fs::write(p.join("Cargo.toml"), "[package]\nname = \"test\"\n").unwrap();

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let contents = std::fs::read_to_string(p.join(".claude/settings.json")).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(entries.contains(&"Bash(cargo *)"), "Bash(cargo *) missing for Rust project");
    assert!(entries.contains(&"Edit"), "Edit missing");
    assert!(entries.contains(&"Bash(git -C *)"), "Bash(git -C *) missing");
}

#[test]
fn init_yes_adds_npm_for_node_project() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();
    std::fs::write(p.join("package.json"), "{\"name\": \"test\"}\n").unwrap();

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let contents = std::fs::read_to_string(p.join(".claude/settings.json")).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(entries.contains(&"Bash(npm *)"), "Bash(npm *) missing for Node project");
    assert!(entries.contains(&"Bash(npx *)"), "Bash(npx *) missing for Node project");
}

#[test]
fn init_yes_adds_python3_for_python_project() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();
    std::fs::write(p.join("requirements.txt"), "requests\n").unwrap();

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let contents = std::fs::read_to_string(p.join(".claude/settings.json")).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(entries.contains(&"Bash(python3 *)"), "Bash(python3 *) missing for Python project (requirements.txt)");
}

#[test]
fn init_yes_user_settings_include_all_toolchains() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    // No Cargo.toml — user settings should still have all toolchain entries
    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let user_settings = home.path().join(".claude/settings.json");
    assert!(user_settings.exists(), "~/.claude/settings.json was not created");
    let contents = std::fs::read_to_string(&user_settings).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(entries.contains(&"Edit"), "Edit missing from user settings");
    assert!(entries.contains(&"Write"), "Write missing from user settings");
    assert!(entries.contains(&"Read"), "Read missing from user settings");
    assert!(entries.contains(&"Glob"), "Glob missing from user settings");
    assert!(entries.contains(&"Grep"), "Grep missing from user settings");
    assert!(entries.contains(&"Bash(cargo *)"), "Bash(cargo *) missing from user settings");
    assert!(entries.contains(&"Bash(npm *)"), "Bash(npm *) missing from user settings");
    assert!(entries.contains(&"Bash(npx *)"), "Bash(npx *) missing from user settings");
    assert!(entries.contains(&"Bash(python3 *)"), "Bash(python3 *) missing from user settings");
    assert!(entries.contains(&"Bash(git -C *)"), "Bash(git -C *) missing from user settings");
}

#[test]
fn init_yes_no_cargo_detected_when_no_cargo_toml() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let p = dir.path();
    init_git_repo(p);
    std::fs::create_dir_all(p.join(".claude")).unwrap();
    // No Cargo.toml present

    run_apm_with_home(p, home.path(), &["init", "--yes", "--quiet"]);

    let contents = std::fs::read_to_string(p.join(".claude/settings.json")).unwrap();
    let val: serde_json::Value = serde_json::from_str(&contents).unwrap();
    let allow = val.pointer("/permissions/allow")
        .and_then(|v| v.as_array())
        .expect("permissions.allow must be an array");
    let entries: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
    assert!(!entries.contains(&"Bash(cargo *)"), "Bash(cargo *) must not appear without Cargo.toml");
}

#[test]
fn list_excludes_terminal_tickets_by_default() {
    let dir = setup();
apm::cmd::new::run(dir.path(), "Open ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    apm::cmd::new::run(dir.path(), "Closed ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let closed_id = find_ticket_id(dir.path(), "closed-ticket");
    apm::cmd::state::run(dir.path(), &closed_id, "closed".into(), false, false).unwrap();

    // Verify indirectly through the filter logic in the library.
    let config = apm_core::config::Config::load(dir.path()).unwrap();
    let tickets = apm_core::ticket::load_all_from_git(dir.path(), &config.tickets.dir).unwrap();
    let terminal: std::collections::HashSet<&str> = config.workflow.states.iter()
        .filter(|s| s.terminal)
        .map(|s| s.id.as_str())
        .collect();
    let visible: Vec<_> = tickets.iter()
        .filter(|t| !terminal.contains(t.frontmatter.state.as_str()))
        .collect();
    assert_eq!(visible.len(), 1, "only the open ticket should be visible");
    assert_eq!(visible[0].frontmatter.title, "Open ticket");

    let all: Vec<_> = tickets.iter().collect();
    assert_eq!(all.len(), 2, "--all should include the closed ticket");
}

// --- new ---

#[test]
fn new_creates_ticket_file() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "My first ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    // File lives on the ticket branch, not in the working tree.
    let branch = find_ticket_branch(dir.path(), "my-first-ticket");
    let rel_path = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel_path);
    assert!(!content.is_empty());
}

#[test]
fn new_ticket_has_correct_frontmatter() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Hello World".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "hello-world");
    let rel_path = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel_path);
    assert!(content.contains("title = \"Hello World\""));
    assert!(content.contains("state = \"new\""));
    assert!(content.contains(&format!("branch = \"{branch}\"")));
}

#[test]
fn new_uses_local_toml_username_as_author() {
    let dir = setup();
    let apm_dir = dir.path().join(".apm");
    std::fs::create_dir_all(&apm_dir).unwrap();
    std::fs::write(apm_dir.join("local.toml"), "username = \"carol\"\n").unwrap();
    apm::cmd::new::run(dir.path(), "My Ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "my-ticket");
    let rel_path = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel_path);
    assert!(content.contains("author = \"carol\""), "author should come from local.toml: {content}");
}

#[test]
fn new_uses_apm_when_no_local_toml() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Unnamed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "unnamed");
    let rel_path = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel_path);
    assert!(content.contains("author = \"unassigned\""), "author should be unassigned without local.toml: {content}");
}

#[test]
fn new_ticket_does_not_write_agent_field() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "No Agent".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "no-agent");
    let rel_path = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel_path);
    assert!(!content.contains("agent ="), "agent field must not appear in new tickets: {content}");
}

#[test]
fn new_increments_ids() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "First".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    apm::cmd::new::run(dir.path(), "Second".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id1 = find_ticket_id(dir.path(), "first");
    let id2 = find_ticket_id(dir.path(), "second");
    assert_ne!(id1, id2, "ticket IDs must be unique");
    assert_eq!(id1.len(), 8, "ID must be 8 hex chars");
    assert_eq!(id2.len(), 8, "ID must be 8 hex chars");
    assert!(id1.chars().all(|c| c.is_ascii_hexdigit()), "ID must be hex: {id1}");
    assert!(id2.chars().all(|c| c.is_ascii_hexdigit()), "ID must be hex: {id2}");
}

#[test]
fn new_editor_flow_does_not_change_head() {
    let dir = init_repo();
    let p = dir.path();

    // Record the branch before running `apm new`.
    let before = std::process::Command::new("git")
        .args(["branch", "--show-current"])
        .current_dir(p)
        .output()
        .unwrap();
    let branch_before = String::from_utf8(before.stdout).unwrap().trim().to_string();
    assert_eq!(branch_before, "main");

    // Run `apm new` without --no-edit; use `true` as the editor (exits 0, no-op).
    let bin = env!("CARGO_BIN_EXE_apm");
    let out = std::process::Command::new(bin)
        .args(["new", "--no-aggressive", "My ticket"])
        .current_dir(p)
        .env("EDITOR", "true")
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "apm new failed:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );

    // HEAD must still be on main.
    let after = std::process::Command::new("git")
        .args(["branch", "--show-current"])
        .current_dir(p)
        .output()
        .unwrap();
    let branch_after = String::from_utf8(after.stdout).unwrap().trim().to_string();
    assert_eq!(branch_after, "main", "HEAD moved during editor session");

    // A ticket/ branch with a 'write spec' commit must exist.
    let ticket_branch = find_ticket_branch(p, "my-ticket");
    let log = std::process::Command::new("git")
        .args(["log", "--pretty=%s", &ticket_branch])
        .current_dir(p)
        .output()
        .unwrap();
    let log_text = String::from_utf8(log.stdout).unwrap();
    assert!(
        log_text.lines().any(|l| l == "write spec"),
        "expected a 'write spec' commit on {ticket_branch}:\n{log_text}",
    );
}

// --- list ---

#[test]
fn list_shows_all_tickets() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Alpha".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b1 = find_ticket_branch(dir.path(), "alpha");
    sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
    apm::cmd::new::run(dir.path(), "Beta".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b2 = find_ticket_branch(dir.path(), "beta");
    sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));
    apm::cmd::list::run(dir.path(), None, false, false, None, false, true, None, None).unwrap();
}

#[test]
fn list_state_filter() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Alpha".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b1 = find_ticket_branch(dir.path(), "alpha");
    let alpha_id = find_ticket_id(dir.path(), "alpha");
    sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
    apm::cmd::new::run(dir.path(), "Beta".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b2 = find_ticket_branch(dir.path(), "beta");
    sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));
    write_valid_spec_to_branch(dir.path(), &b1, &ticket_rel_path(&b1));
    // new → specd is not a valid transition in the production workflow; use force.
    apm::cmd::state::run(dir.path(), &alpha_id, "specd".into(), false, true).unwrap();
    // Sync the updated ticket from its branch so apm list can see the new state.
    sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
    apm::cmd::list::run(dir.path(), Some("specd".into()), false, false, None, false, true, None, None).unwrap();
}

#[test]
fn list_mine_filter() {
    let dir = setup();
    // Write a .apm/local.toml with a username.
    let apm_dir = dir.path().join(".apm");
    std::fs::create_dir_all(&apm_dir).unwrap();
    std::fs::write(apm_dir.join("local.toml"), "username = \"testuser\"\n").unwrap();

    // Create one ticket authored by "testuser" and one by the default ("apm").
    apm::cmd::new::run(dir.path(), "Mine".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b1 = find_ticket_branch(dir.path(), "mine");
    sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));

    // Remove local.toml so the next ticket gets the fallback author.
    std::fs::remove_file(apm_dir.join("local.toml")).unwrap();
    apm::cmd::new::run(dir.path(), "Theirs".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b2 = find_ticket_branch(dir.path(), "theirs");
    sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));

    // Restore local.toml so --mine resolves to "testuser".
    std::fs::write(apm_dir.join("local.toml"), "username = \"testuser\"\n").unwrap();

    // --mine should show only the first ticket.
    apm::cmd::list::run(dir.path(), None, false, false, None, true, true, None, None).unwrap();
}

// --- show ---

#[test]
fn show_existing_ticket() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Show me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(dir.path(), "show-me");
    apm::cmd::show::run(dir.path(), &id, false, false).unwrap();
}

#[test]
fn show_missing_ticket_errors() {
    let dir = setup();
    assert!(apm::cmd::show::run(dir.path(), "99", false, false).is_err());
}

#[test]
fn show_displays_epic_target_branch_depends_on_when_set() {
    let dir = setup();
    let p = dir.path();

    // BYPASS: apm new --epic sets target_branch to the epic branch but does not support
    // pre-seeding depends_on with synthetic ticket IDs (validation would reject unknown IDs);
    // the test needs all three fields (epic, target_branch, depends_on) set simultaneously.
    let ticket_content = "+++\nid = \"aabb1122\"\ntitle = \"Rich ticket\"\nstate = \"new\"\nbranch = \"ticket/aabb1122-rich-ticket\"\nepic = \"epic001\"\ntarget_branch = \"epic/epic001-user-auth\"\ndepends_on = [\"ccdd3344\", \"eeff5566\"]\n+++\n\n## Spec\n\n### Problem\n\nTest.\n\n### Acceptance criteria\n\n- [ ] One\n\n### Out of scope\n\nN/A.\n\n### Approach\n\nDirect.\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n";
    let ticket_dir = p.join("tickets");
    std::fs::create_dir_all(&ticket_dir).unwrap();
    let ticket_path = ticket_dir.join("aabb1122-rich-ticket.md");
    std::fs::write(&ticket_path, ticket_content).unwrap();
    git(p, &["checkout", "-b", "ticket/aabb1122-rich-ticket"]);
    git(p, &["-c", "commit.gpgsign=false", "add", "tickets/aabb1122-rich-ticket.md"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add rich ticket"]);
    git(p, &["checkout", "-"]);
    let _ = std::fs::remove_file(&ticket_path);

    let bin = env!("CARGO_BIN_EXE_apm");
    let out = std::process::Command::new(bin)
        .args(["show", "aabb1122"])
        .current_dir(p)
        .output()
        .unwrap();
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(out.status.success(), "apm show failed: {}", String::from_utf8_lossy(&out.stderr));
    assert!(stdout.contains("epic:"), "expected epic: line in:\n{stdout}");
    assert!(stdout.contains("epic001"), "expected epic id in:\n{stdout}");
    assert!(stdout.contains("target_branch:"), "expected target_branch: line in:\n{stdout}");
    assert!(stdout.contains("epic/epic001-user-auth"), "expected target_branch value in:\n{stdout}");
    assert!(stdout.contains("depends_on:"), "expected depends_on: line in:\n{stdout}");
    assert!(stdout.contains("ccdd3344"), "expected depends_on value in:\n{stdout}");
}

#[test]
fn show_omits_epic_target_branch_depends_on_when_absent() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Plain ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(dir.path(), "plain-ticket");

    let bin = env!("CARGO_BIN_EXE_apm");
    let out = std::process::Command::new(bin)
        .args(["show", &id])
        .current_dir(dir.path())
        .output()
        .unwrap();
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(out.status.success(), "apm show failed: {}", String::from_utf8_lossy(&out.stderr));
    assert!(!stdout.contains("epic:"), "unexpected epic: line in:\n{stdout}");
    assert!(!stdout.contains("target_branch:"), "unexpected target_branch: line in:\n{stdout}");
    assert!(!stdout.contains("depends_on:"), "unexpected depends_on: line in:\n{stdout}");
}

// --- state ---

#[test]
fn state_transition_updates_file() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Transition test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "transition-test");
    let id = find_ticket_id(dir.path(), "transition-test");
    let rel = ticket_rel_path(&branch);
    write_valid_spec_to_branch(dir.path(), &branch, &rel);
    // new → specd is not a valid transition in the production workflow; use force.
    apm::cmd::state::run(dir.path(), &id, "specd".into(), false, true).unwrap();
    // Read the updated state from the ticket branch (not the working tree).
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("state = \"specd\""));
}

#[test]
fn state_transition_appends_history_row() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "History test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "history-test");
    let id = find_ticket_id(dir.path(), "history-test");
    let rel = ticket_rel_path(&branch);
    write_valid_spec_to_branch(dir.path(), &branch, &rel);
    // new → specd is not a valid transition in the production workflow; use force.
    apm::cmd::state::run(dir.path(), &id, "specd".into(), false, true).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("| new | specd |"));
}

#[test]
fn state_ammend_inserts_amendment_section() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Ammend test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "ammend-test");
    let id = find_ticket_id(dir.path(), "ammend-test");
    let rel = ticket_rel_path(&branch);
    // new → ammend is not a valid transition in the production workflow; use force.
    apm::cmd::state::run(dir.path(), &id, "ammend".into(), false, true).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("### Amendment requests"));
}

// --- set ---

#[test]
fn set_priority_updates_frontmatter() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Set test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "set-test");
    let id = find_ticket_id(dir.path(), "set-test");
    let rel = ticket_rel_path(&branch);
    apm::cmd::set::run(dir.path(), &id, "priority".into(), "7".into(), true).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("priority = 7"));
}

#[test]
fn set_depends_on_single_id() {
    let dir = setup_merge();
    apm::cmd::new::run(dir.path(), "Dep ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id = find_ticket_id(dir.path(), "dep-ticket");
    apm::cmd::new::run(dir.path(), "Dep test single".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "dep-test-single");
    let id = find_ticket_id(dir.path(), "dep-test-single");
    let rel = ticket_rel_path(&branch);
    apm::cmd::set::run(dir.path(), &id, "depends_on".into(), dep_id.clone(), true).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains(&dep_id), "dep id missing in content");
}

#[test]
fn set_depends_on_comma_separated() {
    let dir = setup_merge();
    apm::cmd::new::run(dir.path(), "Dep alpha".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id1 = find_ticket_id(dir.path(), "dep-alpha");
    apm::cmd::new::run(dir.path(), "Dep beta".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id2 = find_ticket_id(dir.path(), "dep-beta");
    apm::cmd::new::run(dir.path(), "Dep test multi".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "dep-test-multi");
    let id = find_ticket_id(dir.path(), "dep-test-multi");
    let rel = ticket_rel_path(&branch);
    let val = format!("{dep_id1},{dep_id2}");
    apm::cmd::set::run(dir.path(), &id, "depends_on".into(), val, true).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains(&dep_id1), "first dep id missing");
    assert!(content.contains(&dep_id2), "second dep id missing");
}

#[test]
fn set_depends_on_clear() {
    let dir = setup_merge();
    apm::cmd::new::run(dir.path(), "Dep ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id = find_ticket_id(dir.path(), "dep-ticket");
    apm::cmd::new::run(dir.path(), "Dep test clear".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "dep-test-clear");
    let id = find_ticket_id(dir.path(), "dep-test-clear");
    let rel = ticket_rel_path(&branch);
    apm::cmd::set::run(dir.path(), &id, "depends_on".into(), dep_id, true).unwrap();
    apm::cmd::set::run(dir.path(), &id, "depends_on".into(), "-".into(), true).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(!content.contains("depends_on"));
}

#[test]
fn set_depends_on_trims_whitespace() {
    let dir = setup_merge();
    apm::cmd::new::run(dir.path(), "Dep one".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id1 = find_ticket_id(dir.path(), "dep-one");
    apm::cmd::new::run(dir.path(), "Dep two".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id2 = find_ticket_id(dir.path(), "dep-two");
    apm::cmd::new::run(dir.path(), "Dep test trim".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "dep-test-trim");
    let id = find_ticket_id(dir.path(), "dep-test-trim");
    let rel = ticket_rel_path(&branch);
    let val = format!(" {dep_id1} , {dep_id2} ");
    apm::cmd::set::run(dir.path(), &id, "depends_on".into(), val, true).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains(&format!("\"{dep_id1}\"")), "first dep id missing");
    assert!(content.contains(&format!("\"{dep_id2}\"")), "second dep id missing");
}

// --- next ---

#[test]
fn next_returns_highest_priority() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Low priority".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b1 = find_ticket_branch(dir.path(), "low-priority");
    sync_from_branch(dir.path(), &b1, &ticket_rel_path(&b1));
    apm::cmd::new::run(dir.path(), "High priority".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b2 = find_ticket_branch(dir.path(), "high-priority");
    let high_id = find_ticket_id(dir.path(), "high-priority");
    apm::cmd::set::run(dir.path(), &high_id, "priority".into(), "10".into(), true).unwrap();
    sync_from_branch(dir.path(), &b2, &ticket_rel_path(&b2));
    apm::cmd::next::run(dir.path(), false, true).unwrap();
}

#[test]
fn next_json_is_valid() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Json test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let b = find_ticket_branch(dir.path(), "json-test");
    sync_from_branch(dir.path(), &b, &ticket_rel_path(&b));
    apm::cmd::next::run(dir.path(), true, true).unwrap();
}

#[test]
fn next_null_when_no_actionable() {
    let dir = setup();
    apm::cmd::next::run(dir.path(), true, true).unwrap();
}

// --- branch ---

#[test]
fn new_ticket_creates_branch() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Branch test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    // Branch should exist locally after apm new.
    let branch = find_ticket_branch(dir.path(), "branch-test");
    assert!(branch.starts_with("ticket/"), "expected ticket/ branch, got: {branch}");
    assert!(branch.ends_with("-branch-test"), "expected slug in branch: {branch}");
}

#[test]
fn new_ticket_sets_branch_in_frontmatter() {
    let dir = setup();
    apm::cmd::new::run(dir.path(), "Frontmatter branch".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "frontmatter-branch");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains(&format!("branch = \"{branch}\"")));
}

#[test]
fn init_config_has_default_branch_and_parses() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "trunk"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    apm_core::init::setup(p, None, None, None, None).unwrap();

    let toml = std::fs::read_to_string(p.join(".apm/config.toml")).unwrap();
    assert!(toml.contains("default_branch = \"trunk\""), "default_branch not written: {toml}");

    let config = apm_core::config::Config::load(p).unwrap();
    assert_eq!(config.project.default_branch, "trunk");
}

#[test]
fn config_default_branch_defaults_to_main_when_absent() {
    // A config without default_branch should deserialize with "main" as default.
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/config.toml"), "[project]\nname = \"test\"\n").unwrap();
    let config = apm_core::config::Config::load(p).unwrap();
    assert_eq!(config.project.default_branch, "main");
}

// --- sync bulk close ---

fn setup_with_close_workflow() -> TempDir {
    init_repo()
}

fn write_ticket_to_branch(dir: &std::path::Path, state: &str, title: &str) -> (String, String) {
    // apm new always sets owner = resolved_identity. Temporarily configure a test
    // identity so we can clear the owner immediately after creation, leaving the
    // ticket with no owner field (matching the pre-migration raw TOML behaviour).
    let local_toml = dir.join(".apm/local.toml");
    let saved = if local_toml.exists() { Some(std::fs::read_to_string(&local_toml).unwrap()) } else { None };
    std::fs::create_dir_all(dir.join(".apm")).unwrap();
    std::fs::write(&local_toml, "username = \"test\"\n").unwrap();
    let (id, branch) = create_ticket(dir, title);
    run_apm(dir, &["set", &id, "owner", "-", "--no-aggressive"]);
    match saved {
        Some(content) => std::fs::write(&local_toml, content).unwrap(),
        None => { let _ = std::fs::remove_file(&local_toml); }
    }
    if state == "implemented" {
        run_apm(dir, &["spec", &id, "--section", "Acceptance criteria", "--set", "- [x] Done", "--no-aggressive"]);
    }
    run_apm(dir, &["state", &id, state, "--force", "--no-aggressive"]);
    (id, branch)
}

#[test]
fn sync_closes_implemented_ticket_on_merged_branch() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (_id, branch) = write_ticket_to_branch(p, "implemented", "my ticket");
    // Merge the branch into main so sync detects it.
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();

    let content = branch_content(p, "main", &ticket_rel_path(&branch));
    assert!(content.contains("state = \"closed\""), "ticket should be closed on main: {content}");
}

#[test]
fn sync_closes_implemented_ticket_with_no_branch() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    // BYPASS: apm new always creates a ticket branch; this test simulates a stale ticket
    // whose branch was already deleted and the ticket exists only on main — that state
    // cannot be produced by the CLI.
    let path = "tickets/0001-stale.md";
    let content = "+++\nid = 1\ntitle = \"stale\"\nstate = \"implemented\"\nbranch = \"ticket/0001-stale\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
    std::fs::write(p.join(path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add stale ticket"]);
    // No ticket branch exists

    apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();

    let updated = std::fs::read_to_string(p.join(path)).unwrap();
    assert!(updated.contains("state = \"closed\""), "stale ticket should be closed: {updated}");
}

#[test]
fn sync_no_close_when_nothing_to_close() {
    let dir = setup_with_close_workflow();
    let p = dir.path();
    // No tickets at all
    let log_before = branch_content(p, "main", ".apm/config.toml"); // just to get a ref point
    apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();
    // main should have no new commits (same HEAD)
    let head = std::process::Command::new("git")
        .args(["log", "--oneline", "-1"])
        .current_dir(p)
        .output()
        .unwrap();
    let head_msg = String::from_utf8(head.stdout).unwrap();
    assert!(!head_msg.contains("apm sync: close"), "no close commit expected: {head_msg}");
    drop(log_before);
}

#[test]
fn sync_closes_multiple_tickets_on_merged_branches() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (_id1, alpha_branch) = write_ticket_to_branch(p, "implemented", "alpha");
    let (_id2, beta_branch) = write_ticket_to_branch(p, "implemented", "beta");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &alpha_branch, "--no-edit"]);
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &beta_branch, "--no-edit"]);

    apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();

    let alpha = branch_content(p, "main", &ticket_rel_path(&alpha_branch));
    let beta = branch_content(p, "main", &ticket_rel_path(&beta_branch));
    assert!(alpha.contains("state = \"closed\""), "alpha should be closed on main: {alpha}");
    assert!(beta.contains("state = \"closed\""), "beta should be closed on main: {beta}");
}

// --- sync handler (server path) ---

#[test]
fn sync_handler_closes_merged_ticket() {
    // Regression test for the server sync_handler calling detect+apply.
    // Mirrors the logic added to apm-server's sync_handler.
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (_id, branch) = write_ticket_to_branch(p, "implemented", "server sync");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    assert_eq!(candidates.close.len(), 1, "should detect one close candidate");
    let aggressive = config.sync.aggressive;
    apm_core::sync::apply(p, &config, &candidates, "apm-ui", aggressive).unwrap();

    let content = branch_content(p, "main", &ticket_rel_path(&branch));
    assert!(content.contains("state = \"closed\""), "ticket should be closed: {content}");
}

#[test]
fn sync_handler_no_close_returns_zero() {
    // A sync that closes no tickets: detect returns empty candidates.
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    assert_eq!(candidates.close.len(), 0, "no candidates when no merged tickets");
}

// --- take ---

// ── apm spec ────────────────────────────────────────────────────────────────

fn write_spec_ticket(dir: &std::path::Path, problem: &str, approach: &str) -> (String, String) {
    let (id, branch) = create_ticket(dir, "spec test");
    run_apm(dir, &["spec", &id, "--section", "Problem", "--set", problem, "--no-aggressive"]);
    run_apm(dir, &["spec", &id, "--section", "Acceptance criteria", "--set", "- [ ] criterion one", "--no-aggressive"]);
    run_apm(dir, &["spec", &id, "--section", "Out of scope", "--set", "nothing", "--no-aggressive"]);
    run_apm(dir, &["spec", &id, "--section", "Approach", "--set", approach, "--no-aggressive"]);
    run_apm(dir, &["state", &id, "in_progress", "--force", "--no-aggressive"]);
    (id, branch)
}

#[test]
fn spec_prints_all_sections() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    // Should succeed and not error
    apm::cmd::spec::run(p, &id, None, None, None, false, None, None, None, None, true).unwrap();
}

#[test]
fn spec_prints_single_section() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "the problem text", "the approach");
    apm::cmd::spec::run(p, &id, Some("Problem".into()), None, None, false, None, None, None, None, true).unwrap();
}

#[test]
fn spec_set_section_commits() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_spec_ticket(p, "old problem", "old approach");
    apm::cmd::spec::run(p, &id, Some("Problem".into()), Some("new problem text".into()), None, false, None, None, None, None, true).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("new problem text"), "updated problem not found: {content}");
}

#[test]
fn spec_check_passes_full_ticket() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    apm::cmd::spec::run(p, &id, None, None, None, true, None, None, None, None, true).unwrap();
}

#[test]
fn spec_unknown_section_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    let result = apm::cmd::spec::run(p, &id, Some("NonExistent".into()), None, None, false, None, None, None, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("unknown section"));
}

#[test]
fn spec_nonexistent_ticket_errors() {
    let dir = setup();
    let p = dir.path();
    let result = apm::cmd::spec::run(p, "999", None, None, None, false, None, None, None, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("no ticket matches"));
}

#[test]
fn spec_set_without_section_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    let result = apm::cmd::spec::run(p, &id, None, Some("some value".into()), None, false, None, None, None, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("--set requires --section"));
}

#[test]
fn spec_set_hyphen_value() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_spec_ticket(p, "old problem", "old approach");
    apm::cmd::spec::run(p, &id, Some("Problem".into()), Some("- [ ] Fix the thing".into()), None, false, None, None, None, None, true).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("- [ ] Fix the thing"), "hyphen value not stored: {content}");
}

#[test]
fn spec_set_file_reads_content() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_spec_ticket(p, "old problem", "old approach");
    let tmp = tempfile::NamedTempFile::new().unwrap();
    std::fs::write(tmp.path(), "content from file").unwrap();
    apm::cmd::spec::run(p, &id, Some("Problem".into()), None, Some(tmp.path().to_string_lossy().into_owned()), false, None, None, None, None, true).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("content from file"), "file content not stored: {content}");
}

#[test]
fn spec_set_file_nonexistent_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    let result = apm::cmd::spec::run(p, &id, Some("Problem".into()), None, Some("/nonexistent/path/to/file.txt".into()), false, None, None, None, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("--set-file"));
}

#[test]
fn spec_set_file_without_section_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    let result = apm::cmd::spec::run(p, &id, None, None, Some("/some/file.txt".into()), false, None, None, None, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("--set-file requires --section"));
}

fn write_ticket_with_amendment_requests(dir: &std::path::Path) -> (String, String) {
    let (id, branch) = create_ticket(dir, "spec test");
    run_apm(dir, &["spec", &id, "--section", "Problem", "--set", "Test.", "--no-aggressive"]);
    run_apm(dir, &["spec", &id, "--section", "Acceptance criteria", "--set", "- [ ] criterion one", "--no-aggressive"]);
    run_apm(dir, &["spec", &id, "--section", "Out of scope", "--set", "nothing", "--no-aggressive"]);
    run_apm(dir, &["spec", &id, "--section", "Approach", "--set", "Direct.", "--no-aggressive"]);
    run_apm(dir, &["state", &id, "specd", "--force", "--no-aggressive"]);
    run_apm(dir, &["spec", &id, "--section", "Amendment requests", "--set", "- [ ] Add error handling\n- [ ] Fix the bug", "--no-aggressive"]);
    run_apm(dir, &["state", &id, "ammend", "--no-aggressive"]);
    (id, branch)
}

#[test]
fn spec_mark_checks_off_item_in_amendment_requests() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_ticket_with_amendment_requests(p);
    apm::cmd::spec::run(
        p,
        &id,
        Some("Amendment requests".into()),
        None,
        None,
        false,
        Some("Add error handling".into()),
        None,
        None,
        None,
        true,
    ).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("- [x] Add error handling"), "item not checked: {content}");
    assert!(content.contains("- [ ] Fix the bug"), "other item should remain unchecked: {content}");
}

#[test]
fn spec_mark_no_match_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_ticket_with_amendment_requests(p);
    let result = apm::cmd::spec::run(
        p,
        &id,
        Some("Amendment requests".into()),
        None,
        None,
        false,
        Some("nonexistent item".into()),
        None,
        None,
        None,
        true,
    );
    assert!(result.is_err());
    let msg = format!("{}", result.unwrap_err());
    assert!(msg.contains("no unchecked item"), "unexpected error: {msg}");
}

#[test]
fn spec_mark_ambiguous_errors() {
    let dir = setup();
    let p = dir.path();
    // BYPASS: the ambiguity test requires two amendment items both containing "error"
    // ("Add error handling", "Fix the error"); write_ticket_with_amendment_requests uses
    // "Fix the bug" (no "error") which cannot trigger the ambiguous-match path.
    // "error" matches both "Add error handling" and "Fix the error"
    let branch = "ticket/0001-spec-test";
    let filename = "0001-spec-test.md";
    let path = format!("tickets/{filename}");
    let content = "+++\nid = 1\ntitle = \"spec test\"\nstate = \"ammend\"\nbranch = \"ticket/0001-spec-test\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n### Problem\n\nTest.\n\n### Acceptance criteria\n\n- [ ] one\n\n### Out of scope\n\nnothing\n\n### Approach\n\nDirect.\n\n### Amendment requests\n\n- [ ] Add error handling\n- [ ] Fix the error\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
    git(p, &["checkout", "-b", branch]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    std::fs::write(p.join(&path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: ambiguous"]);
    git(p, &["checkout", "main"]);

    let result = apm::cmd::spec::run(
        p,
        "1",
        Some("Amendment requests".into()),
        None,
        None,
        false,
        Some("error".into()),
        None,
        None,
        None,
        true,
    );
    assert!(result.is_err());
    let msg = format!("{}", result.unwrap_err());
    assert!(msg.contains("ambiguous"), "unexpected error: {msg}");
}

#[test]
fn spec_mark_without_section_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_ticket_with_amendment_requests(p);
    let result = apm::cmd::spec::run(p, &id, None, None, None, false, Some("Add error handling".into()), None, None, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("--mark requires --section"));
}

#[test]
fn spec_mark_case_insensitive() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_ticket_with_amendment_requests(p);
    apm::cmd::spec::run(
        p,
        &id,
        Some("Amendment requests".into()),
        None,
        None,
        false,
        Some("ADD ERROR".into()),
        None,
        None,
        None,
        true,
    ).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("- [x] Add error handling"), "item not checked: {content}");
}

// ── apm spec --append / --append-file / --add-task ───────────────────────────

#[test]
fn spec_append_without_section_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    let result = apm::cmd::spec::run(p, &id, None, None, None, false, None, Some("extra".into()), None, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("--append requires --section"));
}

#[test]
fn spec_append_file_without_section_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    let result = apm::cmd::spec::run(p, &id, None, None, None, false, None, None, Some("/some/file.txt".into()), None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("--append-file requires --section"));
}

#[test]
fn spec_add_task_without_section_errors() {
    let dir = setup();
    let p = dir.path();
    let (id, _branch) = write_spec_ticket(p, "a problem", "an approach");
    let result = apm::cmd::spec::run(p, &id, None, None, None, false, None, None, None, Some("new task".into()), true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("--add-task requires --section"));
}

#[test]
fn spec_append_adds_to_existing_section() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_spec_ticket(p, "original problem", "an approach");
    apm::cmd::spec::run(
        p, &id,
        Some("Problem".into()),
        None, None, false, None,
        Some("extra line".into()),
        None, None, true,
    ).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("original problem"), "original text missing: {content}");
    assert!(content.contains("extra line"), "appended text missing: {content}");
}

#[test]
fn spec_append_creates_absent_section() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_spec_ticket(p, "a problem", "an approach");
    apm::cmd::spec::run(
        p, &id,
        Some("Open questions".into()),
        None, None, false, None,
        Some("Why does this happen?".into()),
        None, None, true,
    ).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("Why does this happen?"), "new section content missing: {content}");
}

#[test]
fn spec_append_file_reads_and_appends() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_spec_ticket(p, "original", "an approach");
    let tmp = tempfile::NamedTempFile::new().unwrap();
    std::fs::write(tmp.path(), "appended from file").unwrap();
    apm::cmd::spec::run(
        p, &id,
        Some("Problem".into()),
        None, None, false, None, None,
        Some(tmp.path().to_string_lossy().into_owned()),
        None, true,
    ).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("original"), "original text missing: {content}");
    assert!(content.contains("appended from file"), "file content missing: {content}");
}

#[test]
fn spec_add_task_appends_checkbox() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_ticket_with_amendment_requests(p);
    apm::cmd::spec::run(
        p, &id,
        Some("Amendment requests".into()),
        None, None, false, None, None, None,
        Some("Handle edge case".into()),
        true,
    ).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("- [ ] Handle edge case"), "new task missing: {content}");
    assert!(content.contains("- [ ] Add error handling"), "existing task missing: {content}");
}

#[test]
fn spec_add_task_creates_absent_section() {
    let dir = setup();
    let p = dir.path();
    let (id, branch) = write_spec_ticket(p, "a problem", "an approach");
    // "Amendment requests" is tasks-typed but absent from the write_spec_ticket fixture
    apm::cmd::spec::run(
        p, &id,
        Some("Amendment requests".into()),
        None, None, false, None, None, None,
        Some("What is the scope?".into()),
        true,
    ).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("- [ ] What is the scope?"), "new task missing: {content}");
}

// ── apm close ────────────────────────────────────────────────────────────────

#[test]
fn close_transitions_from_any_state() {
    let dir = setup();
    let p = dir.path();
    apm::cmd::new::run(p, "Close me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "close-me");
    let id = find_ticket_id(p, "close-me");
    let rel = ticket_rel_path(&branch);
    // Ticket is in "new" state — no transition to "closed" is defined.
    apm::cmd::close::run(p, &id, None, true).unwrap();
    let content = branch_content(p, &branch, &rel);
    assert!(content.contains("state = \"closed\""), "state not updated: {content}");
    assert!(content.contains("| new | closed |"), "history row missing: {content}");
}

#[test]
fn close_with_reason_appends_to_history() {
    let dir = setup();
    let p = dir.path();
    apm::cmd::new::run(p, "Close reason".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "close-reason");
    let id = find_ticket_id(p, "close-reason");
    let rel = ticket_rel_path(&branch);
    apm::cmd::close::run(p, &id, Some("superseded by #42".into()), true).unwrap();
    let content = branch_content(p, &branch, &rel);
    assert!(content.contains("state = \"closed\""), "state not updated: {content}");
    assert!(content.contains("superseded by #42"), "reason missing: {content}");
}

#[test]
fn close_already_closed_is_error() {
    let dir = setup();
    let p = dir.path();
    apm::cmd::new::run(p, "Already closed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "already-closed");
    apm::cmd::close::run(p, &id, None, true).unwrap();
    let result = apm::cmd::close::run(p, &id, None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("already closed"));
}

#[test]
fn close_nonexistent_ticket_is_error() {
    let dir = setup();
    let p = dir.path();
    let result = apm::cmd::close::run(p, "999", None, true);
    assert!(result.is_err());
    assert!(format!("{}", result.unwrap_err()).contains("no ticket matches"));
}

#[test]
fn validate_does_not_flag_closed_state() {
    let dir = setup();
    let p = dir.path();
    apm::cmd::new::run(p, "Validate closed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "validate-closed");
    apm::cmd::close::run(p, &id, None, true).unwrap();
    // apm validate should not flag the closed ticket as having an unknown state.
    // The test config is minimal and may produce config warnings (e.g. missing transitions),
    // but there must be zero ticket-level errors.
    let result = apm::cmd::validate::run(p, false, false, false, true, false);
    if let Err(e) = &result {
        let msg = e.to_string();
        assert!(
            msg.contains("0 ticket errors"),
            "validate flagged a ticket-level error (possibly unknown state): {msg}"
        );
    }
}

#[test]
fn state_to_closed_bypasses_transition_rules() {
    let dir = setup();
    let p = dir.path();
    apm::cmd::new::run(p, "State closed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "state-closed");
    let id = find_ticket_id(p, "state-closed");
    let rel = ticket_rel_path(&branch);
    // "new" state has no outgoing transitions to "closed" in the test config,
    // but "closed" is a mandatory terminal state so it should still work.
    apm::cmd::state::run(p, &id, "closed".into(), false, false).unwrap();
    let content = branch_content(p, &branch, &rel);
    assert!(content.contains("state = \"closed\""), "state not updated: {content}");
}

// ── aggressive fetch/push ──────────────────────────────────────────────────────

/// Build a minimal apm.toml with sync.aggressive = true.
fn setup_aggressive() -> TempDir {
    init_repo()
}

/// Commands with aggressive=true but no remote must not abort — fetch/push
/// failures are warnings only.
#[test]
fn aggressive_no_remote_does_not_abort_next() {
    let dir = setup_aggressive();
    let p = dir.path();
    apm::cmd::new::run(p, "Aggressive next".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
    // No remote configured — fetch_all will fail; must not propagate as error.
    apm::cmd::next::run(p, false, false).unwrap();
}

#[test]
fn aggressive_no_remote_does_not_abort_list() {
    let dir = setup_aggressive();
    let p = dir.path();
    apm::cmd::new::run(p, "Aggressive list".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
    apm::cmd::list::run(p, None, false, false, None, false, false, None, None).unwrap();
}

#[test]
fn aggressive_no_remote_does_not_abort_close() {
    let dir = setup_aggressive();
    let p = dir.path();
    apm::cmd::new::run(p, "Aggressive close".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "aggressive-close");
    // No remote: fetch and push will warn but not abort.
    apm::cmd::close::run(p, &id, None, false).unwrap();
    let branch = find_ticket_branch(p, "aggressive-close");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(p, &branch, &rel);
    assert!(content.contains("state = \"closed\""), "ticket not closed: {content}");
}

/// --no-aggressive suppresses fetch/push even when config has aggressive = true.
#[test]
fn no_aggressive_flag_suppresses_fetch_on_next() {
    let dir = setup_aggressive();
    let p = dir.path();
    apm::cmd::new::run(p, "No agg next".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
    // --no-aggressive = true means fetch is skipped entirely (no warning printed,
    // no error). We verify the command still succeeds.
    apm::cmd::next::run(p, false, true).unwrap();
}

#[test]
fn no_aggressive_flag_suppresses_fetch_on_spec() {
    let dir = setup_aggressive();
    let p = dir.path();
    apm::cmd::new::run(p, "No agg spec".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "no-agg-spec");
    // no_aggressive=true: fetch and push are skipped.
    apm::cmd::spec::run(
        p,
        &id,
        Some("Problem".into()),
        Some("test content".into()),
        None,
        false,
        None,
        None,
        None,
        None,
        true,
    ).unwrap();
}

#[test]
fn no_aggressive_flag_suppresses_fetch_on_set() {
    let dir = setup_aggressive();
    let p = dir.path();
    apm::cmd::new::run(p, "No agg set".into(), true, false, None, None, false, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "no-agg-set");
    apm::cmd::set::run(p, &id, "priority".into(), "5".into(), true).unwrap();
    let branch = find_ticket_branch(p, "no-agg-set");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(p, &branch, &rel);
    assert!(content.contains("priority = 5"), "priority not set: {content}");
}

// ── apm start --next ─────────────────────────────────────────────────────────

/// Setup that puts the worktrees dir inside the temp dir to avoid parallel-test collisions.
fn setup_with_local_worktrees() -> TempDir {
    let dir = init_repo();
    let p = dir.path();

    let config_path = p.join(".apm/config.toml");
    let config = std::fs::read_to_string(&config_path).unwrap();
    let config = config.replace("default = \"claude/coder\"", "default = \"mock-happy/coder\"");
    std::fs::write(&config_path, config).unwrap();

    let workflow_path = p.join(".apm/workflow.toml");
    let workflow = std::fs::read_to_string(&workflow_path).unwrap();
    let workflow = workflow.replace("\"claude/", "\"mock-happy/");
    std::fs::write(&workflow_path, workflow).unwrap();

    git(p, &["add", ".apm/config.toml", ".apm/workflow.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add workers config"]);
    dir
}

#[test]
fn start_next_no_tickets_prints_message() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    apm::cmd::start::run_next(p, true, false, false).unwrap();
}

#[test]
fn start_next_claims_highest_priority_ticket() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
    let (_id, branch) = write_ticket_with_owner(p, "ready", "alpha", "test-agent");
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    apm::cmd::start::run_next(p, true, false, false).unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress: {content}");
    assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}

#[test]
fn start_next_with_instructions_includes_text_in_output() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    // Write a state with instructions pointing to a file
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[workers]
default = "claude/coder"

[worktrees]
dir = ".apm--worktrees"

[agents]
max_concurrent = 3

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "ready"
label = "Ready"

  [[workflow.states.transitions]]
  to      = "in_progress"
  trigger = "command:start"

[[workflow.states]]
id    = "in_progress"
label = "In Progress"

[[workflow.states]]
id    = "closed"
label = "Closed"
terminal = true
"#).unwrap();
    std::fs::write(p.join("worker-instructions.txt"), "WORKER INSTRUCTIONS CONTENT").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", ".apm/config.toml", "worker-instructions.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add instructions"]);

    let (_id, _branch) = write_ticket_to_branch(p, "ready", "work");
    std::env::set_var("APM_AGENT_NAME", "test-agent");

    // Capture stdout
    apm::cmd::start::run_next(p, true, false, false).unwrap();
    // If we get here without error, the instructions file was found and processed.
    // The test verifies no panic or error on a valid instructions path.
}

#[test]
fn start_next_clears_focus_section_from_ticket() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();

    // BYPASS: focus_section has no apm set subcommand; must write the TOML directly.
    // Write ticket with focus_section set
    let branch = "ticket/0001-focused";
    let filename = "0001-focused.md";
    let path = format!("tickets/{filename}");
    let content = "+++\nid = 1\ntitle = \"focused\"\nstate = \"ready\"\nbranch = \"ticket/0001-focused\"\nowner = \"test-agent\"\nfocus_section = \"Approach\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
    let branch_exists = std::process::Command::new("git")
        .args(["rev-parse", "--verify", branch])
        .current_dir(p)
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !branch_exists {
        git(p, &["checkout", "-b", branch]);
    }
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    std::fs::write(p.join(&path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: focused"]);
    git(p, &["checkout", "main"]);

    std::env::set_var("APM_AGENT_NAME", "test-agent");
    apm::cmd::start::run_next(p, true, false, false).unwrap();

    let after = branch_content(p, branch, &path);
    assert!(!after.contains("focus_section"), "focus_section should be cleared: {after}");
}

#[test]
fn start_next_claims_new_ticket_when_no_ready_tickets() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();

    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);

    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[workers]
default = "claude/coder"

[worktrees]
dir = "worktrees"

[agents]
max_concurrent = 3

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "new"
label = "New"

  [[workflow.states.transitions]]
  to      = "in_design"
  trigger = "command:start"

[[workflow.states]]
id             = "in_design"
label          = "In Design"
worker_profile = "claude/coder"

  [[workflow.states.transitions]]
  to      = "closed"
  trigger = "manual"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#,
    )
    .unwrap();

    std::fs::write(p.join(".gitignore"), "/worktrees/\n").unwrap();
    git(p, &["add", ".apm/config.toml", ".gitignore"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();

    let (_id, branch) = write_ticket_with_owner(p, "new", "spec me", "test-agent");
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    apm::cmd::start::run_next(p, true, false, false).unwrap();

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"in_design\""), "ticket should be in_design: {content}");
    assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}

// ── apm start --spawn ────────────────────────────────────────────────────────

/// Poll until the process with the given PID has exited and been reaped.
fn wait_for_pid(pid: u32) {
    loop {
        let running = std::process::Command::new("kill")
            .args(["-0", &pid.to_string()])
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if !running { break; }
        std::thread::sleep(std::time::Duration::from_millis(50));
    }
}

#[test]
fn start_spawn_sets_agent_to_worker_pid() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    let (id, branch) = write_ticket_to_branch(p, "ready", "alpha");

    std::env::set_var("APM_AGENT_NAME", "delegator-agent");
    let out = apm_core::start::run(p, &id, true, true, false, "delegator-agent").unwrap();
    let pid = out.worker_pid.unwrap();
    wait_for_pid(pid);

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"implemented\""), "ticket should be implemented after spawn: {content}");
    assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}

#[test]
fn start_non_spawn_keeps_agent_name() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    let (id, branch) = write_ticket_to_branch(p, "ready", "alpha");

    std::env::set_var("APM_AGENT_NAME", "delegator-agent");
    apm::cmd::start::run(p, &id, true, false, false, "delegator-agent").unwrap();

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress: {content}");
    assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}

#[test]
fn start_next_spawn_sets_agent_to_worker_pid() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/local.toml"), "username = \"delegator-agent\"\n").unwrap();
    let (_id, branch) = write_ticket_with_owner(p, "ready", "alpha", "delegator-agent");

    std::env::set_var("APM_AGENT_NAME", "delegator-agent");
    let out = apm_core::start::run_next(p, true, true, false).unwrap();
    let pid = out.worker_pid.unwrap();
    wait_for_pid(pid);

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"implemented\""), "ticket should be implemented after spawn: {content}");
    assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}

// ── apm start: owner guard ───────────────────────────────────────────────────

fn write_ticket_with_owner(dir: &std::path::Path, state: &str, title: &str, owner: &str) -> (String, String) {
    // apm new sets owner = resolved_identity. Configure the identity to match the
    // desired owner so the ticket is created with the correct owner field, then
    // restore the previous local.toml state (apm set owner requires a matching identity,
    // which is unavailable in most test setups).
    let local_toml = dir.join(".apm/local.toml");
    let saved = if local_toml.exists() { Some(std::fs::read_to_string(&local_toml).unwrap()) } else { None };
    std::fs::create_dir_all(dir.join(".apm")).unwrap();
    std::fs::write(&local_toml, format!("username = \"{owner}\"\n")).unwrap();
    let (id, branch) = create_ticket(dir, title);
    match saved {
        Some(content) => std::fs::write(&local_toml, content).unwrap(),
        None => { let _ = std::fs::remove_file(&local_toml); }
    }
    if state != "new" {
        run_apm(dir, &["state", &id, state, "--force", "--no-aggressive"]);
    }
    (id, branch)
}

#[test]
fn start_does_not_set_owner_when_unowned() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    let (id, branch) = write_ticket_to_branch(p, "ready", "alpha");

    std::env::set_var("APM_AGENT_NAME", "alice");
    apm::cmd::start::run(p, &id, true, false, false, "alice").unwrap();

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(!content.contains("owner ="), "owner should not be set by apm start when unowned: {content}");
}

#[test]
fn start_does_not_modify_owner_when_already_owned() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    let (id, branch) = write_ticket_with_owner(p, "ready", "alpha", "alice");

    std::env::set_var("APM_AGENT_NAME", "bob");
    apm::cmd::start::run(p, &id, true, false, false, "bob").unwrap();

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("owner = \"alice\""), "owner should stay alice, not be overwritten by bob: {content}");
    assert!(!content.contains("owner = \"bob\""), "bob must not become owner: {content}");
}

// ── apm state in_design: owner unchanged ─────────────────────────────────────

#[test]
fn in_design_does_not_set_owner_when_unowned() {
    let dir = setup_for_prompt_dispatch();
    let p = dir.path();
    let (id, branch) = write_ticket_to_branch(p, "groomed", "spec me");

    std::env::set_var("APM_AGENT_NAME", "alice");
    apm::cmd::state::run(p, &id, "in_design".into(), true, false).unwrap();

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(!content.contains("owner ="), "owner should not be set when transitioning to in_design unowned: {content}");
}

#[test]
fn in_design_does_not_overwrite_different_owner() {
    let dir = setup_for_prompt_dispatch();
    let p = dir.path();
    let (id, branch) = write_ticket_with_owner(p, "groomed", "spec me", "alice");

    std::env::set_var("APM_AGENT_NAME", "bob");
    apm::cmd::state::run(p, &id, "in_design".into(), true, false).unwrap();

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("owner = \"alice\""), "owner should stay alice when bob transitions to in_design: {content}");
    assert!(!content.contains("owner = \"bob\""), "bob must not become owner: {content}");
}

// ── system prompt dispatch ───────────────────────────────────────────────────

/// Sets up a repo using the real production workflow (via init_repo()) with a
/// mock worker injected so that apm start --spawn exercises dispatch paths.
fn setup_for_prompt_dispatch() -> TempDir {
    let dir = init_repo();
    let p = dir.path();

    let config_path = p.join(".apm/config.toml");
    let cfg = std::fs::read_to_string(&config_path).unwrap();
    let cfg = cfg.replace("default = \"claude/coder\"", "default = \"mock-happy/coder\"");
    std::fs::write(&config_path, cfg).unwrap();

    let workflow_path = p.join(".apm/workflow.toml");
    let workflow = std::fs::read_to_string(&workflow_path).unwrap();
    let workflow = workflow.replace("\"claude/", "\"mock-happy/");
    std::fs::write(&workflow_path, workflow).unwrap();

    dir
}

#[test]
fn spawn_new_ticket_transitions_to_in_design() {
    let dir = setup_for_prompt_dispatch();
    let p = dir.path();
    std::fs::write(p.join(".apm/apm.spec-writer.md"), "SPEC WRITER PROMPT").unwrap();
    let (id, branch) = write_ticket_to_branch(p, "groomed", "spec me");

    std::env::set_var("APM_AGENT_NAME", "test-agent");
    let out = apm_core::start::run(p, &id, true, true, false, "test-agent").unwrap();
    let pid = out.worker_pid.unwrap();
    wait_for_pid(pid);

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"specd\""), "new ticket should transition to specd: {content}");
}

#[test]
fn ammend_to_groomed_succeeds() {
    let dir = init_repo();
    let p = dir.path();
    let (id, branch) = write_ticket_to_branch(p, "ammend", "needs revision");
    let rel = ticket_rel_path(&branch);

    apm::cmd::state::run(p, &id, "groomed".into(), false, false).unwrap();
    let content = branch_content(p, &branch, &rel);
    assert!(
        content.contains("state = \"groomed\""),
        "expected groomed state:\n{content}"
    );
}

#[test]
fn spawn_ready_ticket_transitions_to_in_progress() {
    let dir = setup_for_prompt_dispatch();
    let p = dir.path();
    std::fs::write(p.join(".apm/apm.worker.md"), "WORKER PROMPT").unwrap();
    let (id, branch) = write_ticket_to_branch(p, "ready", "implement me");

    std::env::set_var("APM_AGENT_NAME", "test-agent");
    let out = apm_core::start::run(p, &id, true, true, false, "test-agent").unwrap();
    let pid = out.worker_pid.unwrap();
    wait_for_pid(pid);

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"implemented\""), "ready ticket should transition to implemented: {content}");
}

#[test]
fn start_next_spawn_new_ticket_transitions_correctly() {
    let dir = setup_for_prompt_dispatch();
    let p = dir.path();
    std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
    std::fs::write(p.join(".apm/apm.spec-writer.md"), "SPEC WRITER PROMPT").unwrap();
    let (_id, branch) = write_ticket_with_owner(p, "groomed", "spec me", "test-agent");

    std::env::set_var("APM_AGENT_NAME", "test-agent");
    let out = apm_core::start::run_next(p, true, true, false).unwrap();
    let pid = out.worker_pid.unwrap();
    wait_for_pid(pid);

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"specd\""), "run_next on new ticket should go to specd: {content}");
}

#[test]
fn start_next_spawn_ready_ticket_transitions_correctly() {
    let dir = setup_for_prompt_dispatch();
    let p = dir.path();
    std::fs::write(p.join(".apm/local.toml"), "username = \"test-agent\"\n").unwrap();
    std::fs::write(p.join(".apm/apm.worker.md"), "WORKER PROMPT").unwrap();
    let (_id, branch) = write_ticket_with_owner(p, "ready", "implement me", "test-agent");

    std::env::set_var("APM_AGENT_NAME", "test-agent");
    let out = apm_core::start::run_next(p, true, true, false).unwrap();
    let pid = out.worker_pid.unwrap();
    wait_for_pid(pid);

    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"implemented\""), "run_next on ready ticket should go to implemented: {content}");
}

// ── apm work ─────────────────────────────────────────────────────────────────

#[test]
fn work_dry_run_lists_actionable_tickets() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    let (_id1, _branch1) = write_ticket_to_branch(p, "ready", "alpha");
    let (_id2, _branch2) = write_ticket_to_branch(p, "ready", "beta");
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    // dry-run should succeed without touching worktrees or spawning anything
    apm::cmd::work::run(p, false, true, false, 30, None).unwrap();
}

#[test]
fn work_dry_run_no_tickets() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    apm::cmd::work::run(p, false, true, false, 30, None).unwrap();
}

// --- sync direct close ---

#[test]
fn sync_closes_implemented_ticket_with_merged_branch_in_one_run() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    // Write a ticket in `implemented` state on its ticket branch.
    let (_id, branch) = write_ticket_to_branch(p, "implemented", "impl ticket");

    // Merge the ticket branch into main so it appears as merged.
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    // Run sync with auto_close — ticket should be closed in a single run.
    apm::cmd::sync::run(p, true, true, true, true, false, false).unwrap();

    // The ticket branch should now have state = "closed".
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"closed\""), "ticket should be closed in one sync run: {content}");
}

// --- context-section ---

#[test]
fn context_section_approach_places_text_under_approach() {
    let dir = setup();
    apm::cmd::new::run(
        dir.path(),
        "Section test".into(),
        true,
        false,
        Some("my approach text".into()),
        Some("Approach".into()),
        true,
        vec![],
        vec![],
        None,
        vec![],
    ).unwrap();
    let branch = find_ticket_branch(dir.path(), "section-test");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("### Approach\n\nmy approach text\n\n"), "expected context under ### Approach");
    // Problem section must not contain the custom context text — production template
    // may pre-populate it with placeholder text, so we only verify the context
    // went to the right section, not that Problem is empty.
    assert!(!content.contains("### Problem\n\nmy approach text"), "context must not bleed into Problem section");
}

#[test]
fn context_section_defaults_to_problem_without_config() {
    let dir = setup();
    apm::cmd::new::run(
        dir.path(),
        "Default section test".into(),
        true,
        false,
        Some("default context".into()),
        None,
        true,
        vec![],
        vec![],
        None,
        vec![],
    ).unwrap();
    let branch = find_ticket_branch(dir.path(), "default-section-test");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("### Problem\n\ndefault context\n\n"), "expected context under ### Problem");
}

#[test]
fn context_section_without_context_is_error() {
    let dir = setup();
    let result = apm::cmd::new::run(
        dir.path(),
        "Error test".into(),
        true,
        false,
        None,
        Some("Approach".into()),
        true,
        vec![],
        vec![],
        None,
        vec![],
    );
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("--context-section requires --context"));
}

#[test]
fn context_section_unknown_section_is_error() {
    let dir = setup();
    let result = apm::cmd::new::run(
        dir.path(),
        "Bad section test".into(),
        true,
        false,
        Some("some text".into()),
        Some("Nonexistent".into()),
        true,
        vec![],
        vec![],
        None,
        vec![],
    );
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("not found in ticket body template"));
}

#[test]
fn context_section_from_transition_config() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/config.toml"), r#"[project]
name = "test"

[tickets]
dir = "tickets"

[agents]
max_concurrent = 3

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "new"
label = "New"

[[workflow.states.transitions]]
to              = "in_design"
context_section = "Approach"
"#).unwrap();
    git(p, &["add", ".apm/config.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    apm::cmd::new::run(
        p,
        "Transition context test".into(),
        true,
        false,
        Some("transition driven context".into()),
        None,
        true,
        vec![],
        vec![],
        None,
        vec![],
    ).unwrap();
    let branch = find_ticket_branch(p, "transition-context-test");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(p, &branch, &rel);
    assert!(content.contains("### Approach\n\ntransition driven context\n\n"), "expected context under ### Approach from transition config");
}

// --- --section/--set pre-population ---

#[test]
fn new_section_set_prepopulates_multiple_sections() {
    let dir = setup();
    apm::cmd::new::run(
        dir.path(),
        "Pre-populated ticket".into(),
        true,
        false,
        None,
        None,
        true,
        vec!["Problem".into(), "Approach".into()],
        vec!["Something is broken".into(), "Fix it with a hammer".into()],
        None,
        vec![],
    ).unwrap();
    let branch = find_ticket_branch(dir.path(), "pre-populated-ticket");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("### Problem\n\nSomething is broken\n"), "Problem section should be pre-populated");
    assert!(content.contains("### Approach\n\nFix it with a hammer\n"), "Approach section should be pre-populated");
    // Only one commit on the ticket branch above main (no intermediate empty-section commit)
    let log = std::process::Command::new("git")
        .args(["log", "--oneline", &branch, "^main"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    let log_str = String::from_utf8(log.stdout).unwrap();
    assert_eq!(log_str.lines().count(), 1, "ticket branch should have exactly one commit above main");
}

#[test]
fn new_section_set_mismatched_counts_is_error() {
    let dir = setup();
    let result = apm::cmd::new::run(
        dir.path(),
        "Mismatch test".into(),
        true,
        false,
        None,
        None,
        true,
        vec!["Problem".into(), "Approach".into()],
        vec!["Only one set".into()],
        None,
        vec![],
    );
    assert!(result.is_err());
    let msg = result.unwrap_err().to_string();
    assert!(msg.contains("--section") && msg.contains("--set"), "error should mention both flags");
}

#[test]
fn new_set_without_section_is_error() {
    let dir = setup();
    let result = apm::cmd::new::run(
        dir.path(),
        "Set only test".into(),
        true,
        false,
        None,
        None,
        true,
        vec![],
        vec!["Orphaned value".into()],
        None,
        vec![],
    );
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("--set requires --section"));
}

#[test]
fn new_section_unknown_name_is_error() {
    let dir = setup();
    let result = apm::cmd::new::run(
        dir.path(),
        "Unknown section test".into(),
        true,
        false,
        None,
        None,
        true,
        vec!["Nonexistent".into()],
        vec!["some text".into()],
        None,
        vec![],
    );
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("unknown section"));
}

// --- --epic / --depends-on ---

fn setup_with_epic() -> (tempfile::TempDir, String) {
    let dir = init_repo();
    let p = dir.path();
    let epic_id = "ab12cd34";
    let epic_branch = format!("epic/{epic_id}-my-epic");
    // BYPASS: apm epic new requires a remote origin; create epic branch directly via git
    git(p, &["checkout", "-b", &epic_branch]);
    std::fs::write(p.join("tickets/EPIC.md"), "# my-epic\n").unwrap();
    git(p, &["add", "tickets/EPIC.md"]);
    git(p, &["commit", "-m", &format!("epic({epic_id}): create my-epic")]);
    git(p, &["checkout", "main"]);
    (dir, epic_id.to_string())
}

#[test]
fn new_epic_sets_frontmatter_fields() {
    let (dir, epic_id) = setup_with_epic();
    apm::cmd::new::run(
        dir.path(),
        "Epic child ticket".into(),
        true,
        false,
        None,
        None,
        true,
        vec![],
        vec![],
        Some(epic_id.clone()),
        vec![],
    ).unwrap();
    let branch = find_ticket_branch(dir.path(), "epic-child-ticket");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains(&format!("epic = \"{epic_id}\"")), "epic field missing in frontmatter");
    let expected_target = format!("epic/{epic_id}-my-epic");
    assert!(content.contains(&format!("target_branch = \"{expected_target}\"")), "target_branch missing");
}

#[test]
fn new_epic_branch_created_from_epic_tip() {
    let (dir, epic_id) = setup_with_epic();
    // Get the SHA of the epic branch tip before creating the ticket
    let epic_branch = format!("epic/{epic_id}-my-epic");
    let epic_tip = std::process::Command::new("git")
        .args(["rev-parse", &epic_branch])
        .current_dir(dir.path())
        .output()
        .unwrap();
    let epic_tip_sha = String::from_utf8(epic_tip.stdout).unwrap().trim().to_string();

    apm::cmd::new::run(
        dir.path(),
        "Branched ticket".into(),
        true,
        false,
        None,
        None,
        true,
        vec![],
        vec![],
        Some(epic_id.clone()),
        vec![],
    ).unwrap();

    let ticket_branch = find_ticket_branch(dir.path(), "branched-ticket");
    // The parent of the ticket creation commit should be the epic branch tip.
    // The ticket branch has exactly one commit above the epic tip, so ticket^1 = epic tip.
    let parent = std::process::Command::new("git")
        .args(["rev-parse", &format!("{ticket_branch}^1")])
        .current_dir(dir.path())
        .output()
        .unwrap();
    let parent_sha = String::from_utf8(parent.stdout).unwrap().trim().to_string();
    assert_eq!(parent_sha, epic_tip_sha, "ticket branch should be created from the epic branch tip");
}

#[test]
fn new_epic_bad_id_is_error() {
    let dir = setup();
    let result = apm::cmd::new::run(
        dir.path(),
        "Orphan ticket".into(),
        true,
        false,
        None,
        None,
        true,
        vec![],
        vec![],
        Some("deadbeef".into()),
        vec![],
    );
    assert!(result.is_err());
    assert!(
        result.unwrap_err().to_string().contains("No epic branch found for id 'deadbeef'"),
        "error message should mention the bad id"
    );
}

#[test]
fn new_depends_on_sets_frontmatter() {
    let dir = setup_merge();
    apm::cmd::new::run(dir.path(), "Dep one".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id1 = find_ticket_id(dir.path(), "dep-one");
    apm::cmd::new::run(dir.path(), "Dep two".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id2 = find_ticket_id(dir.path(), "dep-two");
    apm::cmd::new::run(
        dir.path(),
        "Dependent ticket".into(),
        true,
        false,
        None,
        None,
        true,
        vec![],
        vec![],
        None,
        vec![dep_id1.clone(), dep_id2.clone()],
    ).unwrap();
    let branch = find_ticket_branch(dir.path(), "dependent-ticket");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("depends_on"), "depends_on field missing");
    assert!(content.contains(&dep_id1), "first dep missing");
    assert!(content.contains(&dep_id2), "second dep missing");
    assert!(!content.contains("epic ="), "epic should be absent");
    assert!(!content.contains("target_branch ="), "target_branch should be absent");
}

#[test]
fn new_depends_on_comma_separated() {
    let dir = setup_merge();
    apm::cmd::new::run(dir.path(), "Dep one".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id1 = find_ticket_id(dir.path(), "dep-one");
    apm::cmd::new::run(dir.path(), "Dep two".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let dep_id2 = find_ticket_id(dir.path(), "dep-two");
    let combined = format!("{dep_id1},{dep_id2}");
    apm::cmd::new::run(
        dir.path(),
        "Comma deps ticket".into(),
        true,
        false,
        None,
        None,
        true,
        vec![],
        vec![],
        None,
        vec![combined],
    ).unwrap();
    let branch = find_ticket_branch(dir.path(), "comma-deps-ticket");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains(&dep_id1), "first dep missing");
    assert!(content.contains(&dep_id2), "second dep missing");
}

#[test]
fn new_without_epic_flags_is_unchanged() {
    let dir = setup();
    apm::cmd::new::run(
        dir.path(),
        "Plain ticket".into(),
        true,
        false,
        None,
        None,
        true,
        vec![],
        vec![],
        None,
        vec![],
    ).unwrap();
    let branch = find_ticket_branch(dir.path(), "plain-ticket");
    let rel = ticket_rel_path(&branch);
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(!content.contains("epic ="), "epic should not be present");
    assert!(!content.contains("target_branch ="), "target_branch should not be present");
    assert!(!content.contains("depends_on"), "depends_on should not be present");
    // Ticket branch should be rooted from main (parent of the ticket commit is main's tip)
    let main_tip = std::process::Command::new("git")
        .args(["rev-parse", "main"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    let main_sha = String::from_utf8(main_tip.stdout).unwrap().trim().to_string();
    let parent = std::process::Command::new("git")
        .args(["rev-parse", &format!("{branch}^1")])
        .current_dir(dir.path())
        .output()
        .unwrap();
    let parent_sha = String::from_utf8(parent.stdout).unwrap().trim().to_string();
    assert_eq!(parent_sha, main_sha, "ticket branch should be rooted from main");
}

#[test]
fn new_body_scaffold_from_ticket_sections_config() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/config.toml"), r#"[project]
name = "test"

[tickets]
dir = "tickets"

[agents]
max_concurrent = 3

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "new"
label = "New"

[[ticket.sections]]
name        = "Summary"
type        = "free"
placeholder = "What does this do?"

[[ticket.sections]]
name     = "Tasks"
type     = "tasks"
required = true

[[ticket.sections]]
name = "Notes"
type = "free"
"#).unwrap();
    git(p, &["add", ".apm/config.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();

    apm::cmd::new::run(p, "Scaffold test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();

    let scaffold_branch = find_ticket_branch(p, "scaffold-test");
    let scaffold_rel = ticket_rel_path(&scaffold_branch);
    let content = branch_content(p, &scaffold_branch, &scaffold_rel);
    assert!(content.contains("### Summary\n\nWhat does this do?\n\n"), "placeholder should appear");
    assert!(content.contains("### Tasks\n\n\n\n"), "empty section should appear");
    assert!(content.contains("### Notes\n\n\n\n"), "Notes section should appear");
    assert!(!content.contains("### Problem\n"), "hardcoded Problem should not appear");
    assert!(!content.contains("### Acceptance criteria\n"), "hardcoded AC should not appear");
}

// --- validate ---

#[test]
fn validate_config_missing_instructions_and_bad_context_section() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();

    // Init a minimal git repo.
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);

    // Config with a transition context_section that doesn't exist in ticket.sections
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[workers]
default = "claude/coder"

[[ticket.sections]]
name = "Problem"
type = "free"

[[workflow.states]]
id    = "new"
label = "New"

[[workflow.states.transitions]]
to              = "closed"
context_section = "NonExistentSection"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#,
    )
    .unwrap();

    let config = apm_core::config::Config::load(p).unwrap();
    let errors = apm::cmd::validate::validate_config(&config, p);

    assert_eq!(errors.len(), 1, "expected exactly 1 error, got: {errors:?}");

    let has_bad_section = errors.iter().any(|e| {
        e.contains("context_section") && e.contains("NonExistentSection")
    });
    assert!(has_bad_section, "expected context_section mismatch error in {errors:?}");
}

// --- on_failure fix tests ---

fn setup_on_failure_fix_project(
    on_failure: Option<&str>,
    declare_merge_failed: bool,
) -> tempfile::TempDir {
    let dir = init_repo();
    let p = dir.path();
    let wf_path = p.join(".apm/workflow.toml");
    let mut content = std::fs::read_to_string(&wf_path).unwrap();

    if on_failure.is_none() {
        // BYPASS: no apm command removes on_failure from a workflow transition
        content = content
            .lines()
            .filter(|l| !l.trim().starts_with("on_failure"))
            .collect::<Vec<_>>()
            .join("\n");
    }

    if !declare_merge_failed {
        // BYPASS: no apm command removes a workflow state
        let segments: Vec<&str> = content.split("[[workflow.states]]").collect();
        let filtered: Vec<&str> = segments
            .iter()
            .copied()
            .filter(|seg| {
                !seg.lines()
                    .any(|l| l.trim().starts_with("id") && l.contains("merge_failed"))
            })
            .collect();
        content = filtered.join("[[workflow.states]]");
    }

    std::fs::write(&wf_path, &content).unwrap();
    dir
}

#[test]
fn test_fix_adds_field_only() {
    // on_failure absent, merge_failed state IS declared.
    let dir = setup_on_failure_fix_project(None, true);
    let p = dir.path();

    // validate fails before fix.
    assert!(
        apm::cmd::validate::run(p, false, false, true, true, false).is_err(),
        "expected validate to fail before fix"
    );

    // --fix repairs the workflow.toml (may still report errors from before the fix).
    let _ = apm::cmd::validate::run(p, true, false, true, true, false);

    let wf_content = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
    assert!(
        wf_content.contains("on_failure = \"merge_failed\""),
        "expected on_failure field added to workflow.toml:\n{wf_content}"
    );

    // Subsequent validate passes.
    apm::cmd::validate::run(p, false, false, true, true, false).unwrap();
}

#[test]
fn test_fix_adds_state_only() {
    // on_failure present, merge_failed state NOT declared.
    let dir = setup_on_failure_fix_project(Some("merge_failed"), false);
    let p = dir.path();

    // validate fails before fix.
    assert!(
        apm::cmd::validate::run(p, false, false, true, true, false).is_err(),
        "expected validate to fail before fix"
    );

    // --fix repairs the workflow.toml.
    let _ = apm::cmd::validate::run(p, true, false, true, true, false);

    let wf_content = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
    assert!(
        wf_content.contains("merge_failed"),
        "expected merge_failed state appended to workflow.toml:\n{wf_content}"
    );
    assert!(
        wf_content.contains("on_failure = \"merge_failed\""),
        "on_failure field should still be present:\n{wf_content}"
    );

    // Subsequent validate passes.
    apm::cmd::validate::run(p, false, false, true, true, false).unwrap();
}

#[test]
fn test_fix_adds_both_atomically() {
    // Both on_failure field and merge_failed state are absent.
    let dir = setup_on_failure_fix_project(None, false);
    let p = dir.path();

    // validate fails before fix.
    assert!(
        apm::cmd::validate::run(p, false, false, true, true, false).is_err(),
        "expected validate to fail before fix"
    );

    // A SINGLE --fix adds both field and state.
    let _ = apm::cmd::validate::run(p, true, false, true, true, false);

    let wf_content = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();
    assert!(
        wf_content.contains("on_failure = \"merge_failed\""),
        "expected on_failure field added:\n{wf_content}"
    );
    assert!(
        wf_content.contains("merge_failed"),
        "expected merge_failed state appended:\n{wf_content}"
    );

    // Subsequent validate passes without needing another --fix.
    apm::cmd::validate::run(p, false, false, true, true, false).unwrap();
}

#[test]
fn test_fix_is_idempotent() {
    // Start with both absent; first --fix repairs; second --fix makes no further changes.
    let dir = setup_on_failure_fix_project(None, false);
    let p = dir.path();

    // First fix: repairs the workflow.
    let _ = apm::cmd::validate::run(p, true, false, true, true, false);
    let after_first = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();

    // Second fix on the already-fixed project: no changes.
    apm::cmd::validate::run(p, true, false, true, true, false).unwrap();
    let after_second = std::fs::read_to_string(p.join(".apm").join("workflow.toml")).unwrap();

    assert_eq!(
        after_first, after_second,
        "workflow.toml should be identical after two --fix runs"
    );
}

// --- review ---

#[test]
fn review_ammend_normalises_plain_bullets_to_checkboxes() {
    let dir = setup();
    let p = dir.path();

    apm::cmd::new::run(p, "Review checkbox test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();

    let branch = find_ticket_branch(p, "review-checkbox-test");
    let ticket_path = ticket_rel_path(&branch);
    let id = find_ticket_id(p, "review-checkbox-test");

    // Write a spec with a ### Amendment requests section containing plain bullets.
    let existing = branch_content(p, &branch, &ticket_path);
    let fm_end = existing.find("\n+++\n").expect("frontmatter close not found") + 5;
    let frontmatter = &existing[..fm_end];
    let body = "\n## Spec\n\n### Problem\n\nTest.\n\n### Acceptance criteria\n\n- [ ] AC one\n\n### Out of scope\n\nNothing.\n\n### Approach\n\nDirect.\n\n### Amendment requests\n\n- plain item\n- [ ] already a checkbox\n- [x] already checked\n\n## History\n\n| When | From | To | By |\n|------|------|----|-----|\n| 2026-01-01T00:00Z | — | new | test-agent |\n";
    let content = format!("{frontmatter}{body}");
    git(p, &["checkout", &branch]);
    std::fs::write(p.join(&ticket_path), &content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &ticket_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add amendment requests"]);
    git(p, &["checkout", "-"]);

    // Advance to specd first (new → specd not valid in production workflow; use force),
    // so that ammend is a reachable transition for review.
    apm::cmd::state::run(p, &id, "specd".into(), false, true).unwrap();

    // Use a no-op editor so the spec is not modified interactively.
    std::env::set_var("EDITOR", "true");
    apm::cmd::review::run(p, &id, Some("ammend".to_string()), true).unwrap();
    std::env::remove_var("EDITOR");

    let committed = branch_content(p, &branch, &ticket_path);
    assert!(committed.contains("- [ ] plain item"), "plain bullet should be converted to checkbox");
    assert!(!committed.contains("\n- plain item\n"), "plain bullet should no longer appear as-is");
    assert!(committed.contains("- [ ] already a checkbox"), "existing checkbox should be unchanged");
    assert!(committed.contains("- [x] already checked"), "checked item should be unchanged");
}

// ── apm clean ────────────────────────────────────────────────────────────────

/// Write a closed ticket to a branch. Returns the branch name and rel_path.
fn write_closed_ticket(dir: &std::path::Path, slug: &str) -> (String, String) {
    let (id, branch) = create_ticket(dir, slug);
    run_apm(dir, &["state", &id, "closed", "--no-aggressive"]);
    let rel_path = ticket_rel_path(&branch);
    (branch, rel_path)
}

/// Merge a branch into main via --no-ff.
fn merge_into_main(dir: &std::path::Path, branch: &str) {
    git(dir, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch, "-m", &format!("Merge {branch}")]);
}

fn branch_exists(dir: &std::path::Path, branch: &str) -> bool {
    std::process::Command::new("git")
        .args(["rev-parse", "--verify", branch])
        .current_dir(dir)
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

#[test]
fn clean_default_does_not_remove_local_branch() {
    // apm clean (without --branches) must leave the local branch intact.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "done");
    merge_into_main(p, &branch);

    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT have been removed without --branches");
}

#[test]
fn clean_branches_flag_removes_local_branch() {
    // apm clean --branches removes the local branch.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "branches-flag");
    merge_into_main(p, &branch);

    apm::cmd::clean::run(p, false, false, false, true, None, false, false).unwrap();

    assert!(!branch_exists(p, &branch), "branch should have been removed with --branches");
}

#[test]
fn clean_dry_run_includes_state_in_output() {
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "dry");
    merge_into_main(p, &branch);

    // dry_run=true should not actually delete anything
    apm::cmd::clean::run(p, true, false, false, false, None, false, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT have been removed in dry-run");
}

#[test]
fn clean_skips_ticket_not_on_main() {
    // Ticket branch is "merged" via -s ours (tip reachable from main),
    // but the ticket file is NOT present on main — should warn and skip.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "ghost");

    // -s ours: makes branch tip reachable from main without bringing content
    git(p, &["-c", "commit.gpgsign=false", "merge", "-s", "ours", &branch, "-m", "ours merge"]);

    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT have been removed — ticket not on main");
}

#[test]
fn clean_proceeds_despite_state_mismatch_between_branch_and_main() {
    // Ticket is closed on branch and merged into main. Then main's copy
    // gets updated to a different state (simulating a buggy sync). Clean should
    // trust the branch state and proceed — branch is authoritative.
    let dir = setup();
    let p = dir.path();
    let (branch, rel_path) = write_closed_ticket(p, "mismatch");
    merge_into_main(p, &branch);

    // Overwrite the ticket on main to a different state
    let main_content = "+++\nid = 1\ntitle = \"mismatch\"\nstate = \"new\"\nbranch = \"ticket/0001-mismatch\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
    std::fs::write(p.join(&rel_path), main_content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "update ticket state on main"]);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["clean", "--branches"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(!branch_exists(p, &branch), "branch should have been removed — branch state is authoritative");
}

#[test]
fn clean_treats_closed_as_terminal_without_config_entry() {
    // Config has no "closed" state entry at all, but clean should still treat it as terminal.
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    // Config with no terminal states defined
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[agents]
max_concurrent = 1

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "new"
label = "New"
"#,
    ).unwrap();
    git(p, &["add", ".apm/config.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();

    // BYPASS: the workflow only defines "new"; apm state "closed" would fail with
    // "unknown state". Write the closed ticket directly to test that clean treats
    // state="closed" as terminal even without a config entry for it.
    let branch = {
        let b = "ticket/nt000001-no-terminal-config";
        let path = "tickets/nt000001-no-terminal-config.md";
        let content = "+++\nid = \"nt000001\"\ntitle = \"no terminal config\"\nstate = \"closed\"\nbranch = \"ticket/nt000001-no-terminal-config\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
        git(p, &["checkout", "-b", b]);
        std::fs::write(p.join(path), content).unwrap();
        git(p, &["-c", "commit.gpgsign=false", "add", path]);
        git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: nt000001"]);
        git(p, &["checkout", "main"]);
        b.to_string()
    };
    merge_into_main(p, &branch);

    apm::cmd::clean::run(p, false, false, false, true, None, false, false).unwrap();

    assert!(!branch_exists(p, &branch), "closed should be treated as terminal even without config entry");
}

#[test]
fn clean_skips_local_tip_ahead_of_remote() {
    // Set up a bare remote, clone from it, merge a closed ticket, then make an
    // extra local commit so local tip ≠ remote tip. Clean should skip.
    let bare = tempfile::tempdir().unwrap();
    let bp = bare.path();
    git(bp, &["init", "--bare", "-q"]);

    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["clone", &bp.to_string_lossy(), "."]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);

    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[workers]
default = "claude/coder"

[agents]
max_concurrent = 1

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "new"
label = "New"

[[workflow.states.transitions]]
to      = "closed"
trigger = "manual"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#,
    ).unwrap();
    git(p, &["add", ".apm/config.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
    git(p, &["push", "origin", "main"]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();

    // Create closed ticket branch, merge into main, push both
    let (branch, _) = write_closed_ticket(p, "diverged");
    git(p, &["push", "origin", &branch]);
    merge_into_main(p, &branch);
    git(p, &["push", "origin", "main"]);

    // Now make an extra commit on the ticket branch (without pushing)
    git(p, &["checkout", &branch]);
    std::fs::write(p.join("tickets/0001-diverged.md"), "extra change").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "tickets/0001-diverged.md"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "extra local commit"]);
    git(p, &["checkout", "main"]);

    // Local tip ≠ remote tip → should skip
    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT have been removed — local tip ahead of remote");
}

#[test]
fn clean_auto_removes_known_temp_files() {
    // Known temp files are removed automatically (no --untracked needed);
    // branch is NOT removed without --branches.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "tempfiles");
    merge_into_main(p, &branch);

    // Create a linked worktree for the branch
    let wt_path = p.join("worktrees").join("ticket-0001-tempfiles");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Drop a known temp file into the worktree
    std::fs::write(wt_path.join("pr-body.md"), "pr body content").unwrap();

    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT have been removed without --branches");
    assert!(!wt_path.exists(), "worktree should have been removed");
}

#[test]
fn clean_skips_modified_tracked_files() {
    let dir = setup();
    let p = dir.path();
    let (branch, rel_path) = write_closed_ticket(p, "modtracked");
    merge_into_main(p, &branch);

    // Create a linked worktree for the branch
    let wt_path = p.join("worktrees").join("ticket-0001-modtracked");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Modify a tracked file without committing
    std::fs::write(wt_path.join(&rel_path), "modified content").unwrap();

    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT have been removed — modified tracked file");
    assert!(wt_path.exists(), "worktree should NOT have been removed");
}

#[test]
fn clean_dry_run_diagnoses_dirty_worktree() {
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "drydiagnose");
    merge_into_main(p, &branch);

    // Create a linked worktree for the branch
    let wt_path = p.join("worktrees").join("ticket-0001-drydiagnose");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Drop a known temp file into the worktree
    let temp_file = wt_path.join("pr-body.md");
    std::fs::write(&temp_file, "pr body").unwrap();

    apm::cmd::clean::run(p, true, false, false, false, None, false, false).unwrap();

    // dry-run: nothing removed
    assert!(branch_exists(p, &branch), "branch should NOT have been removed in dry-run");
    assert!(wt_path.exists(), "worktree should NOT have been removed in dry-run");
    assert!(temp_file.exists(), "temp file should NOT have been removed in dry-run");
}

#[test]
fn clean_untracked_flag_removes_other_untracked_files() {
    // --untracked removes untracked non-temp files before worktree removal.
    // Branch is NOT removed without --branches.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "otheruntracked");
    merge_into_main(p, &branch);

    // Create a linked worktree for the branch
    let wt_path = p.join("worktrees").join("ticket-0001-otheruntracked");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Drop an unrecognised untracked file into the worktree
    std::fs::write(wt_path.join("notes.txt"), "my notes").unwrap();

    apm::cmd::clean::run(p, false, false, false, false, None, true, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT have been removed without --branches");
    assert!(!wt_path.exists(), "worktree should have been removed with --untracked");
}

#[test]
fn clean_warns_about_untracked_without_flag() {
    // Without --untracked, a worktree with untracked non-temp files is skipped with a warning.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "warn-untracked");
    merge_into_main(p, &branch);

    let wt_path = p.join("worktrees").join("ticket-0001-warn-untracked");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
    std::fs::write(wt_path.join("notes.txt"), "my notes").unwrap();

    // Without --untracked: worktree should stay in place.
    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    assert!(wt_path.exists(), "worktree should NOT be removed without --untracked");
    assert!(branch_exists(p, &branch), "branch should NOT be removed without --branches");
}

// ── apm clean --force ─────────────────────────────────────────────────────────

#[test]
fn clean_force_removes_unmerged_branch() {
    // Closed ticket whose branch was never merged into main.
    // Normal clean skips it; --force should remove it after confirmation.
    let dir = setup();
    let p = dir.path();
    let (branch, rel_path) = write_closed_ticket(p, "force-unmerged");

    // BYPASS: the test requires the closed ticket to appear on main WITHOUT a git merge
    // (simulating direct-close without PR); apm state closed creates the branch commit only,
    // so the main-branch copy must be written directly for state_from_branch to return Some("closed").
    let closed_content = format!(
        "+++\nid = 1\ntitle = \"force-unmerged\"\nstate = \"closed\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
    );
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    std::fs::write(p.join(&rel_path), &closed_content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add closed ticket to main"]);

    // Normal clean should skip (not merged via git merge).
    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
    assert!(branch_exists(p, &branch), "normal clean should skip unmerged branch");

    // Force clean with --branches and confirmation removes the local branch.
    use std::io::Write as _;
    let mut input = tempfile::NamedTempFile::new().unwrap();
    writeln!(input, "y").unwrap();
    input.flush().unwrap();
    let input_file = std::fs::File::open(input.path()).unwrap();
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["clean", "--force", "--branches"])
        .current_dir(p)
        .stdin(std::process::Stdio::from(input_file))
        .output()
        .unwrap();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(!branch_exists(p, &branch), "branch should have been removed by --force --branches clean");
}

#[test]
fn clean_force_removes_diverged_worktree() {
    // Closed ticket whose local branch tip is ahead of origin and worktree is dirty.
    // Normal clean skips it; --force should remove worktree and branch after confirmation.
    let bare = tempfile::tempdir().unwrap();
    let bp = bare.path();
    git(bp, &["init", "--bare", "-q"]);

    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["clone", &bp.to_string_lossy(), "."]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);

    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[workers]
default = "claude/coder"

[agents]
max_concurrent = 1

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "new"
label = "New"

[[workflow.states.transitions]]
to      = "closed"
trigger = "manual"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#,
    ).unwrap();
    git(p, &["add", ".apm/config.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init", "--allow-empty"]);
    git(p, &["push", "origin", "main"]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();

    // Create closed ticket, merge into main, push both to remote.
    let (branch, _) = write_closed_ticket(p, "force-diverged");
    git(p, &["push", "origin", &branch]);
    merge_into_main(p, &branch);
    git(p, &["push", "origin", "main"]);

    // Add an extra (unpushed) commit to the ticket branch so local tip diverges.
    // Write a non-ticket file so the ticket frontmatter stays intact.
    git(p, &["checkout", &branch]);
    std::fs::write(p.join("scratch.txt"), "extra change").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "scratch.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "extra local commit"]);
    git(p, &["checkout", "main"]);

    // Create a worktree at the diverged tip and drop an untracked file into it.
    let wt_path = p.join("worktrees").join("ticket-0001-force-diverged");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);
    std::fs::write(wt_path.join("notes.txt"), "scratch notes").unwrap();

    // Normal clean should skip (diverged + dirty worktree, and not an ancestor of main).
    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();
    assert!(branch_exists(p, &branch), "normal clean should skip diverged+dirty ticket");
    assert!(wt_path.exists(), "worktree should NOT be removed by normal clean");

    // Force clean removes the worktree; --branches also removes the local branch.
    use std::io::Write as _;
    let mut input = tempfile::NamedTempFile::new().unwrap();
    writeln!(input, "y").unwrap();
    input.flush().unwrap();
    let input_file = std::fs::File::open(input.path()).unwrap();
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["clean", "--force", "--branches"])
        .current_dir(p)
        .stdin(std::process::Stdio::from(input_file))
        .output()
        .unwrap();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(!branch_exists(p, &branch), "branch should have been removed by --force --branches clean");
    assert!(!wt_path.exists(), "worktree should have been removed by --force clean");
}

#[test]
fn clean_force_still_skips_non_terminal() {
    // A ticket in a non-terminal state should be skipped even with --force.
    let dir = setup();
    let p = dir.path();
    let (_id, branch) = write_ticket_to_branch(p, "in_progress", "in progress");

    // No candidates (non-terminal) → no prompts needed; call library directly.
    apm::cmd::clean::run(p, false, false, true, false, None, false, false).unwrap();

    assert!(
        branch_exists(p, &branch),
        "non-terminal ticket should NOT be removed by --force clean"
    );
}

#[test]
fn clean_force_dry_run_shows_unmerged() {
    // --force --dry-run should print "would remove" for unmerged branches without touching anything.
    let dir = setup();
    let p = dir.path();
    let (branch, rel_path) = write_closed_ticket(p, "force-dryrun");

    // BYPASS: same as clean_force_removes_unmerged_branch — the closed ticket must exist
    // on main without a git merge so state_from_branch returns Some("closed") while the
    // branch remains unmerged; cannot be achieved via apm CLI alone.
    let closed_content = format!(
        "+++\nid = 1\ntitle = \"force-dryrun\"\nstate = \"closed\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
    );
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    std::fs::write(p.join(&rel_path), &closed_content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add closed ticket to main"]);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["clean", "--force", "--branches", "--dry-run"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("would remove"),
        "expected 'would remove' in output: {stdout}"
    );
    assert!(branch_exists(p, &branch), "dry-run should not remove the branch");
}

#[test]
fn clean_force_skips_modified_tracked() {
    // A ticket whose worktree has modified tracked files should be skipped even with --force.
    let dir = setup();
    let p = dir.path();
    let (branch, rel_path) = write_closed_ticket(p, "force-modtracked");
    merge_into_main(p, &branch);

    let wt_path = p.join("worktrees").join("ticket-0001-force-modtracked");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Modify a tracked file without committing.
    std::fs::write(wt_path.join(&rel_path), "modified content").unwrap();

    // Force clean: modified tracked files must never be auto-removed.
    apm::cmd::clean::run(p, false, false, true, false, None, false, false).unwrap();

    assert!(branch_exists(p, &branch), "branch should NOT be removed — modified tracked file");
    assert!(wt_path.exists(), "worktree should NOT be removed — modified tracked file");
}

// --- resolve_caller_name fallback ---

#[test]
fn start_without_apm_agent_name_uses_fallback() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    let (id, branch) = write_ticket_to_branch(p, "ready", "fallback");
    // Use a pre-resolved name to avoid env var manipulation in a concurrent test context.
    // The important thing is that run() accepts an explicit agent_name and stores it.
    apm::cmd::start::run(p, &id, true, false, false, "ci-agent").unwrap();
    let content = branch_content(p, &branch, &ticket_rel_path(&branch));
    assert!(content.contains("state = \"in_progress\""), "ticket should be in_progress: {content}");
    assert!(!content.contains("agent ="), "agent field must not be written: {content}");
}

// ---------------------------------------------------------------------------
// workers
// ---------------------------------------------------------------------------

fn setup_with_worktrees() -> TempDir {
    let dir = init_repo();
    let p = dir.path();

    // BYPASS: production workflow has explicit new → groomed → ... transitions that
    // block the workers tests' direct new → ready transition; override with a minimal
    // workflow that has no transitions on "new" (any transition is then allowed)
    std::fs::write(p.join(".apm/workflow.toml"), "[workflow]\n\n[[workflow.states]]\nid    = \"new\"\nlabel = \"New\"\n\n[[workflow.states]]\nid    = \"ready\"\nlabel = \"Ready\"\n\n[[workflow.states]]\nid    = \"in_progress\"\nlabel = \"In Progress\"\n\n[[workflow.states]]\nid       = \"closed\"\nlabel    = \"Closed\"\nterminal = true\n\n[workflow.prioritization]\npriority_weight = 10.0\neffort_weight   = -2.0\nrisk_weight     = -1.0\n").unwrap();
    git(p, &["add", ".apm/workflow.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "simplify workflow for worktree tests"]);
    dir
}

#[test]
fn workers_no_worktrees_returns_ok() {
    let dir = setup();
    let p = dir.path();
    // No ticket worktrees, so workers list should succeed (and print nothing).
    apm::cmd::workers::run(p, None, None).unwrap();
}

#[test]
fn workers_kill_no_pid_file_errors() {
    let dir = setup_with_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");

    apm::cmd::new::run(p, "kill test ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "kill-test-ticket");
    apm::cmd::state::run(p, &id, "ready".into(), true, false).unwrap();

    // Provision a worktree without writing a pid file.
    apm_core::worktree::ensure_worktree(p, &p.join("worktrees"), &find_ticket_branch(p, "kill-test-ticket")).unwrap();

    // --kill should return an error since there is no pid file.
    let result = apm::cmd::workers::run(p, None, Some(&id));
    assert!(result.is_err(), "expected error when no pid file present");
    let msg = format!("{:#}", result.unwrap_err());
    assert!(
        msg.contains("not running") || msg.contains(".apm-worker.pid"),
        "unexpected error message: {msg}"
    );
}

#[test]
fn workers_stale_pid_file_detected() {
    let dir = setup_with_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");

    apm::cmd::new::run(p, "stale pid ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "stale-pid-ticket");
    apm::cmd::state::run(p, &id, "ready".into(), true, false).unwrap();
    let branch = find_ticket_branch(p, "stale-pid-ticket");
    apm_core::worktree::ensure_worktree(p, &p.join("worktrees"), &branch).unwrap();
    let wt_name = branch.replace('/', "-");
    let wt_path = p.join("worktrees").join(&wt_name);
    std::fs::create_dir_all(&wt_path).unwrap();

    // Write a stale pid file (PID 99999999 is almost certainly not running).
    let pid_file = wt_path.join(".apm-worker.pid");
    std::fs::write(
        &pid_file,
        r#"{"pid":99999999,"ticket_id":"XXXX","started_at":"2026-01-01T00:00:00+00:00"}"#,
    )
    .unwrap();

    // workers list should succeed (stale entry is shown as "crashed", not an error).
    apm::cmd::workers::run(p, None, None).unwrap();
}

#[test]
fn workers_kill_stale_pid_errors() {
    let dir = setup_with_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");

    apm::cmd::new::run(p, "kill stale ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "kill-stale-ticket");
    apm::cmd::state::run(p, &id, "ready".into(), true, false).unwrap();
    let branch = find_ticket_branch(p, "kill-stale-ticket");
    apm_core::worktree::ensure_worktree(p, &p.join("worktrees"), &branch).unwrap();
    let wt_name = branch.replace('/', "-");
    let wt_dir = p.join("worktrees").join(&wt_name);
    std::fs::create_dir_all(&wt_dir).unwrap();
    let pid_file = wt_dir.join(".apm-worker.pid");
    std::fs::write(
        &pid_file,
        r#"{"pid":99999999,"ticket_id":"XXXX","started_at":"2026-01-01T00:00:00+00:00"}"#,
    )
    .unwrap();

    // --kill on a stale pid should return an error.
    let result = apm::cmd::workers::run(p, None, Some(&id));
    let err_msg = match &result {
        Err(e) => format!("{e:#}"),
        Ok(_) => String::new(),
    };
    assert!(result.is_err(), "expected error when pid is stale");
    assert!(
        err_msg.contains("not running"),
        "unexpected error (expected 'not running'): {err_msg}"
    );
    // The stale pid file should be cleaned up.  Check via git's recorded path
    // to handle macOS symlink resolution (/var -> /private/var).
    let real_wt = apm_core::worktree::find_worktree_for_branch(p, &branch)
        .expect("worktree must still be registered");
    assert!(
        !real_wt.join(".apm-worker.pid").exists(),
        "stale pid file should be removed on failed kill"
    );
}

fn setup_with_strict_transitions() -> TempDir {
    let dir = init_repo();
    // BYPASS: no apm command can replace the workflow post-init.
    // The production default has no new → in_progress transition (new only goes to groomed
    // or closed); this 5-state restricted workflow is intentionally minimal to isolate
    // --force bypass behaviour without the full spec/review lifecycle.
    std::fs::write(dir.path().join(".apm/workflow.toml"), r#"[workflow]

[[workflow.states]]
id    = "new"
label = "New"

  [[workflow.states.transitions]]
  to      = "in_progress"
  trigger = "manual"

[[workflow.states]]
id    = "specd"
label = "Specd"

[[workflow.states]]
id    = "in_progress"
label = "In Progress"

  [[workflow.states.transitions]]
  to      = "implemented"
  trigger = "manual"

[[workflow.states]]
id    = "implemented"
label = "Implemented"

  [[workflow.states.transitions]]
  to      = "closed"
  trigger = "manual"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#).unwrap();
    git(dir.path(), &["add", ".apm/workflow.toml"]);
    git(dir.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "strict transitions workflow"]);
    dir
}

#[test]
fn state_force_bypasses_transition_rules() {
    let dir = setup_with_strict_transitions();
    let p = dir.path();

    apm::cmd::new::run(p, "force test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "force-test");

    // Advance to in_progress (new → in_progress is valid).
    apm::cmd::state::run(p, &id, "in_progress".into(), true, false).unwrap();

    // Without --force, going back to new from in_progress should fail (not in allowed transitions).
    let result = apm::cmd::state::run(p, &id, "new".into(), true, false);
    assert!(result.is_err(), "expected transition rejection without --force");

    // With --force, the same transition must succeed.
    apm::cmd::state::run(p, &id, "new".into(), true, true).unwrap();

    // Verify the ticket is back in "new".
    let tickets = apm_core::ticket::load_all_from_git(p, std::path::Path::new("tickets")).unwrap();
    let t = tickets.iter().find(|t| t.frontmatter.id == id).unwrap();
    assert_eq!(t.frontmatter.state, "new");
}

#[test]
fn state_force_implemented_from_in_progress() {
    let dir = setup_with_strict_transitions();
    let p = dir.path();

    apm::cmd::new::run(p, "force progress".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "force-progress");

    // Advance to in_progress.
    apm::cmd::state::run(p, &id, "in_progress".into(), true, false).unwrap();

    // Without --force, going to new from in_progress should fail.
    let result = apm::cmd::state::run(p, &id, "new".into(), true, false);
    assert!(result.is_err(), "expected transition rejection without --force");

    // With --force, new is reachable from in_progress.
    apm::cmd::state::run(p, &id, "new".into(), true, true).unwrap();

    let tickets = apm_core::ticket::load_all_from_git(p, std::path::Path::new("tickets")).unwrap();
    let t = tickets.iter().find(|t| t.frontmatter.id == id).unwrap();
    assert_eq!(t.frontmatter.state, "new");
}

#[test]
fn state_force_still_rejects_unknown_state() {
    let dir = setup();
    let p = dir.path();

    apm::cmd::new::run(p, "force unknown".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "force-unknown");

    // --force does not allow transitioning to a state that doesn't exist in config.
    let result = apm::cmd::state::run(p, &id, "nonexistent_state".into(), true, true);
    assert!(result.is_err(), "expected error for unknown state even with --force");
}

#[test]
fn state_force_does_not_skip_doc_validation() {
    let dir = setup();
    let p = dir.path();

    apm::cmd::new::run(p, "force doc valid".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let id = find_ticket_id(p, "force-doc-valid");

    // Transitioning to "specd" without a valid spec should still fail even with --force.
    let result = apm::cmd::state::run(p, &id, "specd".into(), true, true);
    assert!(result.is_err(), "expected spec validation to still fail with --force");
}

// --- squash-merge detection ---

/// Set up a bare remote + local clone initialised with real `apm init`.
/// Returns (bare_dir, local_dir). Both TempDirs must be kept alive.
fn init_remote_repo() -> (TempDir, TempDir) {
    let bare = tempfile::tempdir().unwrap();
    let bp = bare.path();
    git(bp, &["init", "--bare", "-q"]);

    let local = tempfile::tempdir().unwrap();
    let p = local.path();
    git(p, &["clone", "-q", &bp.to_string_lossy(), "."]);
    git(p, &["config", "user.name", "test"]);
    git(p, &["config", "user.email", "test@test.com"]);

    let bin = env!("CARGO_BIN_EXE_apm");
    let out = std::process::Command::new(bin)
        .args(["init", "--no-claude", "--quiet"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(out.status.success(), "apm init failed: {}", String::from_utf8_lossy(&out.stderr));

    git(p, &["add", "."]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
    git(p, &["push", "origin", "main"]);

    (bare, local)
}

fn setup_squash_remote() -> (TempDir, TempDir) {
    init_remote_repo()
}

/// Write an "implemented" ticket to a branch.
fn write_implemented_ticket(dir: &std::path::Path) {
    let (id, _branch) = create_ticket(dir, "Squash test");
    run_apm(dir, &["spec", &id, "--section", "Acceptance criteria", "--set", "- [x] Done", "--no-aggressive"]);
    run_apm(dir, &["state", &id, "implemented", "--force", "--no-aggressive"]);
}

/// Squash-merge `branch` into main: `git merge --squash` + `git commit`.
fn squash_merge_into_main(dir: &std::path::Path, branch: &str) {
    git(dir, &["-c", "commit.gpgsign=false", "merge", "--squash", branch]);
    git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("squash merge {branch}")]);
}

#[test]
fn sync_detect_squash_merged_branch_remote_ref_present() {
    let (_bare, local) = setup_squash_remote();
    let p = local.path();

    write_implemented_ticket(p);
    let branch = find_ticket_branch(p, "squash-test");
    git(p, &["push", "origin", &branch]);

    // Squash-merge into main locally and push main.
    squash_merge_into_main(p, &branch);
    git(p, &["push", "origin", "main"]);

    // Fetch so local has up-to-date origin/main.
    git(p, &["fetch", "--all", "--quiet"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        close_branches.contains(&branch.as_str()),
        "squash-merged ticket should appear in close candidates; got: {close_branches:?}"
    );
}

#[test]
fn sync_detect_squash_merged_branch_remote_ref_deleted() {
    let (_bare, local) = setup_squash_remote();
    let p = local.path();

    write_implemented_ticket(p);
    let branch = find_ticket_branch(p, "squash-test");
    git(p, &["push", "origin", &branch]);

    squash_merge_into_main(p, &branch);
    git(p, &["push", "origin", "main"]);

    // Delete the remote branch (GitHub does this automatically after merge).
    git(p, &["push", "origin", "--delete", &branch]);
    // Prune the deleted remote tracking ref.
    git(p, &["fetch", "--all", "--prune", "--quiet"]);

    // Local branch still exists; remote tracking ref is gone.
    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        close_branches.contains(&branch.as_str()),
        "squash-merged ticket with deleted remote ref should appear in close candidates; got: {close_branches:?}"
    );
}

#[test]
fn sync_detect_does_not_falsely_detect_unmerged_branch() {
    let (_bare, local) = setup_squash_remote();
    let p = local.path();

    write_implemented_ticket(p);
    let branch = find_ticket_branch(p, "squash-test");
    git(p, &["push", "origin", &branch]);

    // Do NOT merge into main — branch has commits not in main.
    git(p, &["fetch", "--all", "--quiet"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        !close_branches.contains(&branch.as_str()),
        "unmerged ticket should NOT appear in close candidates; got: {close_branches:?}"
    );
}

#[test]
fn sync_detect_regular_merge_still_detected() {
    let (_bare, local) = setup_squash_remote();
    let p = local.path();

    write_implemented_ticket(p);
    let branch = find_ticket_branch(p, "squash-test");
    git(p, &["push", "origin", &branch]);

    // Regular (non-squash) merge.
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);
    git(p, &["push", "origin", "main"]);
    git(p, &["fetch", "--all", "--quiet"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        close_branches.contains(&branch.as_str()),
        "regular-merged ticket should appear in close candidates; got: {close_branches:?}"
    );
}

#[test]
fn start_uses_target_branch_as_merge_source() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();

    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);

    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[worktrees]
dir = "worktrees"

[sync]
aggressive = false

[[workflow.states]]
id    = "ready"
label = "Ready"

[[workflow.states]]
id    = "in_progress"
label = "In Progress"
"#,
    )
    .unwrap();

    std::fs::create_dir_all(p.join("tickets")).unwrap();

    git(p, &["add", ".apm/config.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);

    // Create epic/e1-foo branch with a unique commit.
    git(p, &["checkout", "-b", "epic/e1-foo"]);
    std::fs::write(p.join("epic-marker.txt"), "epic content").unwrap();
    git(p, &["add", "epic-marker.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic unique commit"]);

    // Back to main.
    git(p, &["checkout", "main"]);

    // BYPASS: this test uses a minimal repo without full epic infrastructure; apm new --epic
    // requires an existing epic ticket branch and sets target_branch automatically, but here
    // target_branch must be set to a non-existent epic branch for the rebase-source test.
    let ticket_branch = "ticket/abc1-epic-task";
    git(p, &["checkout", "-b", ticket_branch]);
    let ticket_content = concat!(
        "+++\n",
        "id = \"abc1\"\n",
        "title = \"Epic task\"\n",
        "state = \"ready\"\n",
        "branch = \"ticket/abc1-epic-task\"\n",
        "target_branch = \"epic/e1-foo\"\n",
        "+++\n\n",
    );
    std::fs::write(p.join("tickets/abc1-epic-task.md"), ticket_content).unwrap();
    git(p, &["add", "tickets/abc1-epic-task.md"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add ticket"]);

    git(p, &["checkout", "main"]);

    apm::cmd::start::run(p, "abc1", true, false, false, "test-agent").unwrap();

    // The worktree should exist.
    let wt_path = p.join("worktrees").join("ticket-abc1-epic-task");
    assert!(wt_path.exists(), "worktree should be created at {}", wt_path.display());

    // The unique commit from epic/e1-foo should appear in the worktree history.
    let log = std::process::Command::new("git")
        .args(["log", "--oneline"])
        .current_dir(&wt_path)
        .output()
        .unwrap();
    let log_str = String::from_utf8(log.stdout).unwrap();
    assert!(
        log_str.contains("epic unique commit"),
        "epic branch commit should be in worktree history; got:\n{log_str}"
    );
}

// ── depends_on scheduling ─────────────────────────────────────────────────────

fn setup_with_satisfies_deps() -> TempDir {
    init_repo()
}

fn commit_ticket_to_branch(dir: &std::path::Path, branch: &str, path: &str, content: &str) {
    // Create branch from main, write file, commit, return to main.
    let main_exists = std::process::Command::new("git")
        .args(["rev-parse", "--verify", "main"])
        .current_dir(dir)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    let base = if main_exists { "main" } else { "HEAD" };

    // Create branch from base if it doesn't exist, else just check it out.
    let branch_exists = std::process::Command::new("git")
        .args(["rev-parse", "--verify", branch])
        .current_dir(dir)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if branch_exists {
        git(dir, &["checkout", branch]);
    } else {
        git(dir, &["checkout", "-b", branch, base]);
    }
    std::fs::create_dir_all(dir.join("tickets")).unwrap();
    std::fs::write(dir.join(path), content).unwrap();
    git(dir, &["-c", "commit.gpgsign=false", "add", path]);
    git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "add ticket"]);
    git(dir, &["checkout", "-"]);
}

#[test]
fn next_skips_dep_blocked_returns_unblocked() {
    use apm_core::{config::Config, ticket};

    let dir = setup_with_satisfies_deps();
    let p = dir.path();

    // Ticket A: ready, no deps — should be returned by apm next
    let content_a = "+++\nid = \"aaaa0001\"\ntitle = \"Ticket A\"\nstate = \"ready\"\nbranch = \"ticket/aaaa0001-ticket-a\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/aaaa0001-ticket-a", "tickets/aaaa0001-ticket-a.md", content_a);

    // Ticket B: ready, depends_on A (which is in "ready", not satisfies_deps)
    let content_b = "+++\nid = \"bbbb0001\"\ntitle = \"Ticket B\"\nstate = \"ready\"\nbranch = \"ticket/bbbb0001-ticket-b\"\ndepends_on = [\"aaaa0001\"]\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/bbbb0001-ticket-b", "tickets/bbbb0001-ticket-b.md", content_b);

    let config = Config::load(p).unwrap();
    let tickets = ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
    let actionable_owned = config.actionable_states_for("agent");
    let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
    let p_cfg = &config.workflow.prioritization;

    let next = ticket::pick_next(&tickets, &actionable, &[], p_cfg.priority_weight, p_cfg.effort_weight, p_cfg.risk_weight, &config, None, None);
    assert!(next.is_some(), "should find an actionable ticket");
    assert_eq!(next.unwrap().frontmatter.id, "aaaa0001", "dep-blocked ticket B should be skipped, A returned");
}

#[test]
fn next_returns_dep_blocked_after_dep_satisfies() {
    use apm_core::{config::Config, ticket};

    let dir = setup_with_satisfies_deps();
    let p = dir.path();

    // Ticket A: implemented (satisfies_deps = true)
    let content_a = "+++\nid = \"aaaa0002\"\ntitle = \"Ticket A\"\nstate = \"implemented\"\nbranch = \"ticket/aaaa0002-ticket-a\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/aaaa0002-ticket-a", "tickets/aaaa0002-ticket-a.md", content_a);

    // Ticket B: ready, depends_on A (implemented = satisfies_deps)
    let content_b = "+++\nid = \"bbbb0002\"\ntitle = \"Ticket B\"\nstate = \"ready\"\nbranch = \"ticket/bbbb0002-ticket-b\"\ndepends_on = [\"aaaa0002\"]\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/bbbb0002-ticket-b", "tickets/bbbb0002-ticket-b.md", content_b);

    let config = Config::load(p).unwrap();
    let tickets = ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
    let actionable_owned = config.actionable_states_for("agent");
    let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
    let p_cfg = &config.workflow.prioritization;

    let next = ticket::pick_next(&tickets, &actionable, &[], p_cfg.priority_weight, p_cfg.effort_weight, p_cfg.risk_weight, &config, None, None);
    assert!(next.is_some(), "should find an actionable ticket");
    assert_eq!(next.unwrap().frontmatter.id, "bbbb0002", "ticket B should be returned once dep A satisfies_deps");
}

#[test]
fn next_picks_low_priority_blocker_before_higher_raw_independent() {
    use apm_core::{config::Config, ticket};

    // A: priority 2, ready, no deps (blocks C)
    // B: priority 7, ready, no deps (independent)
    // C: priority 9, ready, depends_on A (dep not satisfied, so C won't be returned)
    // Expected: pick_next returns A (ep=9 > B's ep=7)
    let dir = setup_with_satisfies_deps();
    let p = dir.path();

    let content_a = "+++\nid = \"aaaa0003\"\ntitle = \"Ticket A\"\nstate = \"ready\"\npriority = 2\nbranch = \"ticket/aaaa0003-ticket-a\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/aaaa0003-ticket-a", "tickets/aaaa0003-ticket-a.md", content_a);

    let content_b = "+++\nid = \"bbbb0003\"\ntitle = \"Ticket B\"\nstate = \"ready\"\npriority = 7\nbranch = \"ticket/bbbb0003-ticket-b\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/bbbb0003-ticket-b", "tickets/bbbb0003-ticket-b.md", content_b);

    let content_c = "+++\nid = \"cccc0003\"\ntitle = \"Ticket C\"\nstate = \"ready\"\npriority = 9\nbranch = \"ticket/cccc0003-ticket-c\"\ndepends_on = [\"aaaa0003\"]\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/cccc0003-ticket-c", "tickets/cccc0003-ticket-c.md", content_c);

    let config = Config::load(p).unwrap();
    let tickets = ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
    let actionable_owned = config.actionable_states_for("agent");
    let actionable: Vec<&str> = actionable_owned.iter().map(|s| s.as_str()).collect();
    let p_cfg = &config.workflow.prioritization;

    let next = ticket::pick_next(&tickets, &actionable, &[], p_cfg.priority_weight, p_cfg.effort_weight, p_cfg.risk_weight, &config, None, None);
    assert!(next.is_some(), "should find an actionable ticket");
    // C is dep-blocked (A not satisfied), so the contest is A (ep=9) vs B (ep=7)
    assert_eq!(next.unwrap().frontmatter.id, "aaaa0003", "A (ep=9) should beat B (ep=7)");
}

// --- epic list ---

fn setup_epic_list() -> TempDir {
    init_repo()
}

/// Create a bare local branch (simulates an epic branch).
fn create_epic_branch(dir: &std::path::Path, branch: &str) {
    // BYPASS: apm epic new requires a remote origin; create epic branch directly via git
    git(dir, &["checkout", "-b", branch]);
    // Write a placeholder file so the branch has a commit.
    std::fs::create_dir_all(dir.join("tickets")).unwrap();
    std::fs::write(dir.join("tickets/EPIC.md"), format!("# {branch}\n")).unwrap();
    git(dir, &["-c", "commit.gpgsign=false", "add", "tickets/EPIC.md"]);
    git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", "create epic"]);
    git(dir, &["checkout", "-"]);
}

#[test]
fn epic_list_no_epics_exits_zero_no_output() {
    let dir = setup_epic_list();
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "list"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    assert!(out.status.success(), "exit status: {}", out.status);
    assert!(out.stdout.is_empty(), "expected no output, got: {}", String::from_utf8_lossy(&out.stdout));
}

#[test]
fn epic_list_shows_epics_with_derived_state_and_counts() {
    let dir = setup_epic_list();
    let p = dir.path();

    // Epic 1: "ab12cd34-user-authentication"
    let epic1_id = "ab12cd34";
    let epic1_branch = format!("epic/{epic1_id}-user-authentication");
    create_epic_branch(p, &epic1_branch);

    // Epic 2: "ef567890-billing-overhaul" — no tickets (empty)
    let epic2_id = "ef567890";
    let epic2_branch = format!("epic/{epic2_id}-billing-overhaul");
    create_epic_branch(p, &epic2_branch);

    // Ticket for epic 1: state = ready (actionable by agent → "active")
    let t1 = format!(
        "+++\nid = \"t1000001\"\ntitle = \"Auth ticket\"\nstate = \"ready\"\nbranch = \"ticket/t1000001-auth-ticket\"\nepic = \"{epic1_id}\"\n+++\n\nbody\n"
    );
    commit_ticket_to_branch(p, "ticket/t1000001-auth-ticket", "tickets/t1000001-auth-ticket.md", &t1);

    // Second ticket for epic 1: state = implemented
    let t2 = format!(
        "+++\nid = \"t1000002\"\ntitle = \"Auth impl\"\nstate = \"implemented\"\nbranch = \"ticket/t1000002-auth-impl\"\nepic = \"{epic1_id}\"\n+++\n\nbody\n"
    );
    commit_ticket_to_branch(p, "ticket/t1000002-auth-impl", "tickets/t1000002-auth-impl.md", &t2);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "list"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));

    let stdout = String::from_utf8_lossy(&out.stdout);
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(lines.len(), 2, "expected 2 lines, got:\n{stdout}");

    // Epic 1: in_progress (has ticket with neither satisfies_deps nor terminal)
    assert!(lines[0].contains(epic1_id), "line 0 should contain epic1 id: {}", lines[0]);
    assert!(lines[0].contains("in_progress"), "line 0 should be in_progress: {}", lines[0]);
    assert!(lines[0].contains("User Authentication"), "line 0 should have title: {}", lines[0]);
    assert!(lines[0].contains("1 ready"), "line 0 should show 1 ready: {}", lines[0]);
    assert!(lines[0].contains("1 implemented"), "line 0 should show 1 implemented: {}", lines[0]);

    // Epic 2: empty (no tickets)
    assert!(lines[1].contains(epic2_id), "line 1 should contain epic2 id: {}", lines[1]);
    assert!(lines[1].contains("empty"), "line 1 should be empty: {}", lines[1]);
    assert!(lines[1].contains("Billing Overhaul"), "line 1 should have title: {}", lines[1]);
}

// --- epic show ---

fn setup_epic_show() -> tempfile::TempDir {
    init_repo()
}

#[test]
fn epic_show_displays_header_and_ticket_table() {
    let dir = setup_epic_show();
    let p = dir.path();

    let epic_id = "ab12cd34";
    let epic_branch = format!("epic/{epic_id}-user-auth");
    create_epic_branch(p, &epic_branch);

    // Ticket 1: belongs to epic, ready state
    let t1 = format!(
        "+++\nid = \"t2000001\"\ntitle = \"Implement login\"\nstate = \"ready\"\nbranch = \"ticket/t2000001-impl-login\"\nepic = \"{epic_id}\"\nagent = \"alice\"\n+++\n\nbody\n"
    );
    commit_ticket_to_branch(p, "ticket/t2000001-impl-login", "tickets/t2000001-impl-login.md", &t1);

    // Ticket 2: belongs to epic, implemented state, with depends_on
    let t2 = format!(
        "+++\nid = \"t2000002\"\ntitle = \"Add OAuth\"\nstate = \"implemented\"\nbranch = \"ticket/t2000002-add-oauth\"\nepic = \"{epic_id}\"\ndepends_on = [\"t2000001\"]\n+++\n\nbody\n"
    );
    commit_ticket_to_branch(p, "ticket/t2000002-add-oauth", "tickets/t2000002-add-oauth.md", &t2);

    // Ticket 3: does NOT belong to epic
    let t3 = "+++\nid = \"t2000003\"\ntitle = \"Unrelated\"\nstate = \"ready\"\nbranch = \"ticket/t2000003-unrelated\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/t2000003-unrelated", "tickets/t2000003-unrelated.md", t3);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "show", epic_id])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "exit: {}\nstderr: {}",
        out.status,
        String::from_utf8_lossy(&out.stderr)
    );

    let stdout = String::from_utf8_lossy(&out.stdout);
    // Header block
    assert!(stdout.contains("User Auth"), "should contain title: {stdout}");
    assert!(stdout.contains(&epic_branch), "should contain branch: {stdout}");
    assert!(stdout.contains("in_progress"), "should contain derived state: {stdout}");
    // Ticket table rows
    assert!(stdout.contains("t2000001"), "should contain ticket1 id: {stdout}");
    assert!(stdout.contains("t2000002"), "should contain ticket2 id: {stdout}");
    assert!(stdout.contains("t2000001"), "ticket2 depends_on should show t2000001: {stdout}");
    // Unrelated ticket must NOT appear
    assert!(!stdout.contains("t2000003"), "unrelated ticket must not appear: {stdout}");
    assert!(!stdout.contains("Unrelated"), "unrelated ticket title must not appear: {stdout}");
}

#[test]
fn epic_show_prefix_resolves_correctly() {
    let dir = setup_epic_show();
    let p = dir.path();

    let epic_id = "ab12cd34";
    let epic_branch = format!("epic/{epic_id}-user-auth");
    create_epic_branch(p, &epic_branch);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "show", "ab12"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "exit: {}\nstderr: {}",
        out.status,
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains(&epic_branch), "should resolve via prefix: {stdout}");
}

#[test]
fn epic_show_no_match_exits_nonzero() {
    let dir = setup_epic_show();
    let p = dir.path();

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "show", "zzzzzzz"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(!out.status.success(), "expected non-zero exit");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("zzzzzzz"), "error should mention the prefix: {stderr}");
}

#[test]
fn epic_show_ambiguous_prefix_exits_nonzero() {
    let dir = setup_epic_show();
    let p = dir.path();

    // Create two epics with the same prefix "aa"
    create_epic_branch(p, "epic/aa000001-first");
    create_epic_branch(p, "epic/aa000002-second");

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "show", "aa"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(!out.status.success(), "expected non-zero exit for ambiguous prefix");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("ambiguous"), "error should say ambiguous: {stderr}");
}

#[test]
fn epic_show_no_tickets_prints_no_tickets() {
    let dir = setup_epic_show();
    let p = dir.path();

    let epic_id = "cc112233";
    create_epic_branch(p, &format!("epic/{epic_id}-empty-epic"));

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "show", epic_id])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(out.status.success(), "exit: {}", out.status);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("(no tickets)"), "should print no tickets message: {stdout}");
    assert!(stdout.contains("empty"), "derived state should be empty: {stdout}");
}

// --- apm work --epic ---

fn write_ticket_with_epic(dir: &std::path::Path, state: &str, title: &str, epic: Option<&str>) -> (String, String) {
    let (id, branch) = if let Some(e) = epic {
        let out = run_apm(dir, &["new", "--no-edit", "--no-aggressive", "--epic", e, title]);
        let stdout = String::from_utf8_lossy(&out.stdout);
        let line = stdout.lines().find(|l| l.starts_with("Created ticket")).unwrap();
        let id = line.split_whitespace().nth(2).unwrap().trim_end_matches(':').to_string();
        let branch = line.split("(branch: ").nth(1).unwrap().trim_end_matches(')').to_string();
        (id, branch)
    } else {
        create_ticket(dir, title)
    };
    run_apm(dir, &["state", &id, state, "--force", "--no-aggressive"]);
    (id, branch)
}

#[test]
fn work_dry_run_epic_filter_shows_only_epic_ticket() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    create_epic_branch(p, "epic/ab12cd34-work-epic");
    let (_id1, _branch1) = write_ticket_with_epic(p, "ready", "epic ticket", Some("ab12cd34"));
    let (_id2, _branch2) = write_ticket_with_epic(p, "ready", "free ticket", None);

    // Capture stdout
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["work", "--dry-run", "--epic", "ab12cd34"])
        .current_dir(p)
        .env("APM_AGENT_NAME", "test-agent")
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
    assert!(stdout.contains("epic ticket"), "should show epic ticket: {stdout}");
    assert!(!stdout.contains("free ticket"), "should not show free ticket: {stdout}");
}

#[test]
fn work_dry_run_epic_filter_no_candidates() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    let (_id, _branch) = write_ticket_with_epic(p, "ready", "free ticket", None);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["work", "--dry-run", "--epic", "ab12cd34"])
        .current_dir(p)
        .env("APM_AGENT_NAME", "test-agent")
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
    assert!(stdout.contains("no actionable tickets"), "should show no candidates: {stdout}");
}

#[test]
fn work_dry_run_no_flag_shows_epic_ticket() {
    let dir = setup_with_local_worktrees();
    let p = dir.path();
    std::env::set_var("APM_AGENT_NAME", "test-agent");
    create_epic_branch(p, "epic/ab12cd34-work-epic");
    let (_id, _branch) = write_ticket_with_epic(p, "ready", "epic ticket", Some("ab12cd34"));

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["work", "--dry-run"])
        .current_dir(p)
        .env("APM_AGENT_NAME", "test-agent")
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(out.status.success(), "exit: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
    assert!(stdout.contains("epic ticket"), "should show epic ticket without filter: {stdout}");
}

// --- pr_or_epic_merge completion strategy ---

fn setup_pr_or_epic_merge_remote() -> (TempDir, TempDir) {
    init_remote_repo()
}

fn write_in_progress_ticket(dir: &std::path::Path, title: &str) -> (String, String) {
    let (id, branch) = create_ticket(dir, title);
    run_apm(dir, &["spec", &id, "--section", "Acceptance criteria", "--set", "- [x] Done", "--no-aggressive"]);
    run_apm(dir, &["state", &id, "in_progress", "--force", "--no-aggressive"]);
    (id, branch)
}

#[test]
fn pr_or_epic_merge_with_target_branch_merges_into_target() {
    let (_bare, local) = setup_pr_or_epic_merge_remote();
    let p = local.path();

    // Create epic branch and push to origin.
    git(p, &["checkout", "-b", "epic/test"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic init", "--allow-empty"]);
    git(p, &["push", "origin", "epic/test"]);
    git(p, &["checkout", "main"]);

    // Write an in_progress ticket with target_branch set.
    // BYPASS: target_branch field has no CLI setter independent of epic setup; the test
    // needs a specific target_branch value (epic/test) without creating a real epic ticket.
    let branch = "ticket/aa000001-merge-test";
    {
        let path = "tickets/aa000001-merge-test.md";
        let content = "+++\nid = \"aa000001\"\ntitle = \"Test ticket\"\nstate = \"in_progress\"\nbranch = \"ticket/aa000001-merge-test\"\ntarget_branch = \"epic/test\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
        git(p, &["checkout", "-b", branch]);
        std::fs::create_dir_all(p.join("tickets")).unwrap();
        std::fs::write(p.join(path), content).unwrap();
        git(p, &["-c", "commit.gpgsign=false", "add", path]);
        git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: aa000001"]);
        git(p, &["checkout", "main"]);
    }
    git(p, &["push", "origin", branch]);

    // Check out epic/test so merge_into_default merges into it.
    git(p, &["checkout", "epic/test"]);

    let result = apm_core::state::transition(p, "aa000001", "implemented".into(), true, false);
    assert!(result.is_ok(), "merge path should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());

    // Verify epic/test now has a merge commit referencing the ticket branch.
    let log = std::process::Command::new("git")
        .args(["log", "--oneline", "epic/test"])
        .current_dir(p)
        .output()
        .unwrap();
    let log_str = String::from_utf8_lossy(&log.stdout);
    assert!(log_str.lines().count() > 1, "epic/test should have additional commits after merge: {log_str}");
}

#[test]
fn pr_or_epic_merge_without_target_branch_attempts_pr() {
    let (_bare, local) = setup_pr_or_epic_merge_remote();
    let p = local.path();

    // Write an in_progress ticket without target_branch.
    let (id, branch) = write_in_progress_ticket(p, "pr test");
    git(p, &["push", "origin", &branch]);

    // Root stays on main — no target_branch → PR path.
    let result = apm_core::state::transition(p, &id, "implemented".into(), true, false);

    // Push succeeds (bare remote available); gh fails → Err returned.
    assert!(result.is_err(), "PR path should fail (gh not available against local bare repo)");

    // Confirm the ticket branch was pushed before gh was attempted.
    let remote_refs = std::process::Command::new("git")
        .args(["ls-remote", "origin", &branch])
        .current_dir(p)
        .output()
        .unwrap();
    let refs = String::from_utf8_lossy(&remote_refs.stdout);
    assert!(!refs.trim().is_empty(), "ticket branch should have been pushed before gh was called: {refs}");
}

#[test]
fn agents_list_shows_claude_builtin() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();

    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);

    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[[workflow.states]]
id    = "new"
label = "New"

[[workflow.states.transitions]]
to      = "closed"
trigger = "manual"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#,
    )
    .unwrap();

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["agents", "list"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(out.status.success(), "expected exit 0, got: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("claude"), "expected claude in output: {stdout}");
    assert!(stdout.contains("built-in"), "expected built-in in output: {stdout}");
}

fn setup_with_server_url(url: &str) -> TempDir {
    let dir = init_repo();
    let p = dir.path();
    // BYPASS: no apm command configures server.url — append directly to config
    let config_path = p.join(".apm/config.toml");
    let existing = std::fs::read_to_string(&config_path).unwrap();
    std::fs::write(&config_path, format!("{existing}\n[server]\nurl = \"{url}\"\n")).unwrap();
    dir
}

#[test]
fn register_prints_otp_from_server() {
    let mut server = mockito::Server::new();
    let mock = server
        .mock("POST", "/api/auth/otp")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"otp":"ABCD1234"}"#)
        .create();
    let dir = setup_with_server_url(&server.url());
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["register", "alice"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    mock.assert();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ABCD1234");
}

#[test]
fn sessions_empty_response_prints_no_active() {
    let mut server = mockito::Server::new();
    let mock = server
        .mock("GET", "/api/auth/sessions")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body("[]")
        .create();
    let dir = setup_with_server_url(&server.url());
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["sessions"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    mock.assert();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(String::from_utf8_lossy(&out.stdout).contains("No active sessions."));
}

#[test]
fn sessions_with_data_prints_table() {
    let mut server = mockito::Server::new();
    let mock = server
        .mock("GET", "/api/auth/sessions")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"[{"username":"alice","device_hint":"MacBook","last_seen":"2026-04-01T14:32:00Z","expires_at":"2026-04-08T14:32:00Z"}]"#)
        .create();
    let dir = setup_with_server_url(&server.url());
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["sessions"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    mock.assert();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("alice"), "missing username: {stdout}");
    assert!(stdout.contains("MacBook"), "missing device: {stdout}");
    assert!(stdout.contains("USERNAME"), "missing header: {stdout}");
}

#[test]
fn revoke_user_prints_count() {
    let mut server = mockito::Server::new();
    let mock = server
        .mock("DELETE", "/api/auth/sessions")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"revoked":2}"#)
        .create();
    let dir = setup_with_server_url(&server.url());
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["revoke", "alice"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    mock.assert();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(String::from_utf8_lossy(&out.stdout).contains("Revoked 2 session(s)."));
}

#[test]
fn revoke_user_zero_prints_no_sessions() {
    let mut server = mockito::Server::new();
    let mock = server
        .mock("DELETE", "/api/auth/sessions")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"revoked":0}"#)
        .create();
    let dir = setup_with_server_url(&server.url());
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["revoke", "alice"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    mock.assert();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(String::from_utf8_lossy(&out.stdout).contains("No sessions found for alice."));
}

#[test]
fn revoke_all_sends_all_flag() {
    let mut server = mockito::Server::new();
    let body_json = serde_json::json!({"username": null, "device": null, "all": true});
    let mock = server
        .mock("DELETE", "/api/auth/sessions")
        .match_body(mockito::Matcher::Json(body_json))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"revoked":5}"#)
        .create();
    let dir = setup_with_server_url(&server.url());
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["revoke", "--all"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    mock.assert();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(String::from_utf8_lossy(&out.stdout).contains("Revoked 5 session(s)."));
}

#[test]
fn revoke_with_device_hint() {
    let mut server = mockito::Server::new();
    let mock = server
        .mock("DELETE", "/api/auth/sessions")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(r#"{"revoked":1}"#)
        .create();
    let dir = setup_with_server_url(&server.url());
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["revoke", "alice", "--device", "MacBook"])
        .current_dir(dir.path())
        .output()
        .unwrap();
    mock.assert();
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(String::from_utf8_lossy(&out.stdout).contains("Revoked 1 session(s)."));
}

#[test]
fn assign_sets_owner_field() {
    let dir = setup();
    let apm_dir = dir.path().join(".apm");
    std::fs::create_dir_all(&apm_dir).unwrap();
    std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
    apm::cmd::new::run(dir.path(), "Assign test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "assign-test");
    let id = find_ticket_id(dir.path(), "assign-test");
    let rel = ticket_rel_path(&branch);
    apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("owner = \"alice\""));
}

#[test]
fn assign_clears_owner_field() {
    let dir = setup();
    let apm_dir = dir.path().join(".apm");
    std::fs::create_dir_all(&apm_dir).unwrap();
    std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
    apm::cmd::new::run(dir.path(), "Assign clear test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "assign-clear-test");
    let id = find_ticket_id(dir.path(), "assign-clear-test");
    let rel = ticket_rel_path(&branch);
    apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
    apm::cmd::assign::run(dir.path(), &id, "-", true, false).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(!content.contains("owner ="));
}

#[test]
fn assign_unknown_id_errors() {
    let dir = setup();
    let result = apm::cmd::assign::run(dir.path(), "9999", "alice", true, false);
    assert!(result.is_err());
}

#[test]
fn assign_force_succeeds_when_not_owner() {
    let dir = setup();
    let apm_dir = dir.path().join(".apm");
    std::fs::create_dir_all(&apm_dir).unwrap();
    // Current user is alice
    std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
    apm::cmd::new::run(dir.path(), "Force assign test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "force-assign-test");
    let id = find_ticket_id(dir.path(), "force-assign-test");
    let rel = ticket_rel_path(&branch);
    // Assign to alice first
    apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
    // Now force-reassign to bob (alice is the current user, bob is the target)
    apm::cmd::assign::run_inner(dir.path(), &id, "bob", true, true, Some(true)).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("owner = \"bob\""));
}

#[test]
fn assign_force_aborts_on_deny() {
    let dir = setup();
    let apm_dir = dir.path().join(".apm");
    std::fs::create_dir_all(&apm_dir).unwrap();
    std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
    apm::cmd::new::run(dir.path(), "Force deny test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "force-deny-test");
    let id = find_ticket_id(dir.path(), "force-deny-test");
    let rel = ticket_rel_path(&branch);
    apm::cmd::assign::run(dir.path(), &id, "alice", true, false).unwrap();
    // Force with deny: should return Ok(()) but leave owner as alice
    let result = apm::cmd::assign::run_inner(dir.path(), &id, "bob", true, true, Some(false));
    assert!(result.is_ok());
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("owner = \"alice\""));
}

#[test]
fn assign_force_skips_prompt_when_no_owner() {
    let dir = setup();
    let apm_dir = dir.path().join(".apm");
    std::fs::create_dir_all(&apm_dir).unwrap();
    std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
    apm::cmd::new::run(dir.path(), "Force no owner test".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(dir.path(), "force-no-owner-test");
    let id = find_ticket_id(dir.path(), "force-no-owner-test");
    let rel = ticket_rel_path(&branch);
    // No owner set — force-assign should succeed without prompting
    apm::cmd::assign::run_inner(dir.path(), &id, "alice", true, true, Some(true)).unwrap();
    let content = branch_content(dir.path(), &branch, &rel);
    assert!(content.contains("owner = \"alice\""));
}

// --- archive ---

fn setup_with_archive_dir() -> TempDir {
    init_repo()
}

#[test]
fn archive_no_archive_dir_errors() {
    let dir = setup();
    let p = dir.path();
    // Production config always sets archive_dir; remove it to test the error path.
    let config_path = p.join(".apm/config.toml");
    let config_content = std::fs::read_to_string(&config_path).unwrap();
    let patched: String = config_content.lines()
        .filter(|l| !l.contains("archive_dir"))
        .collect::<Vec<_>>()
        .join("\n") + "\n";
    std::fs::write(&config_path, patched).unwrap();
    let result = apm::cmd::archive::run(p, false, None);
    assert!(result.is_err());
    let msg = format!("{}", result.unwrap_err());
    assert!(msg.contains("archive_dir is not set"), "unexpected error: {msg}");
}

#[test]
fn archive_moves_closed_ticket_to_archive_dir() {
    let dir = setup_with_archive_dir();
    let p = dir.path();

    // Create and close a ticket (close merges to main).
    apm::cmd::new::run(p, "Archive me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "archive-me");
    let id = find_ticket_id(p, "archive-me");
    apm::cmd::close::run(p, &id, None, true).unwrap();

    // Verify the ticket file is on main before archive.
    let rel = ticket_rel_path(&branch);
    let files_before = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
    assert!(files_before.iter().any(|f| f == &rel), "ticket not on main before archive");

    // Run archive.
    apm::cmd::archive::run(p, false, None).unwrap();

    // Ticket file should be gone from tickets/ on main.
    let files_after = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap_or_default();
    assert!(!files_after.iter().any(|f| f == &rel), "ticket still in tickets/ after archive");

    // Ticket file should exist in archive/tickets/ on main.
    let filename = std::path::Path::new(&rel).file_name().unwrap().to_str().unwrap();
    let archive_path = format!("archive/tickets/{filename}");
    let archive_files = apm_core::git::list_files_on_branch(p, "main", "archive/tickets").unwrap();
    assert!(archive_files.iter().any(|f| f == &archive_path), "ticket not in archive/tickets/ after archive");
}

#[test]
fn archive_show_finds_ticket_in_archive_after_branch_deleted() {
    let dir = setup_with_archive_dir();
    let p = dir.path();

    // Create and close a ticket.
    apm::cmd::new::run(p, "Show archived".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "show-archived");
    let id = find_ticket_id(p, "show-archived");
    apm::cmd::close::run(p, &id, None, true).unwrap();

    // Archive it.
    apm::cmd::archive::run(p, false, None).unwrap();

    // Delete the ticket branch (simulate apm clean --branches).
    git(p, &["branch", "-D", &branch]);

    // apm show should still succeed via the archive fallback.
    apm::cmd::show::run(p, &id, true, false).unwrap();
}

#[test]
fn archive_dry_run_does_not_move_files() {
    let dir = setup_with_archive_dir();
    let p = dir.path();

    apm::cmd::new::run(p, "Dry run me".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "dry-run-me");
    let id = find_ticket_id(p, "dry-run-me");
    apm::cmd::close::run(p, &id, None, true).unwrap();

    // Dry run should not move anything.
    apm::cmd::archive::run(p, true, None).unwrap();

    // Ticket file should still be in tickets/ on main.
    let rel = ticket_rel_path(&branch);
    let files = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
    assert!(files.iter().any(|f| f == &rel), "dry-run should not move ticket file");

    // archive/tickets/ should not exist.
    let archive_files = apm_core::git::list_files_on_branch(p, "main", "archive/tickets").unwrap_or_default();
    assert!(archive_files.is_empty(), "dry-run should not create archive dir");
}

#[test]
fn archive_skips_non_terminal_tickets_with_warning() {
    let dir = setup_with_archive_dir();
    let p = dir.path();

    // Create a ticket in "new" state (non-terminal).
    apm::cmd::new::run(p, "Non terminal".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "non-terminal");

    // Put the ticket file on main manually (without closing) so it appears in tickets/ on main.
    let rel = ticket_rel_path(&branch);
    let content = branch_content(p, &branch, &rel);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    std::fs::write(p.join(&rel), &content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &rel]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "put non-terminal ticket on main"]);

    // archive should not move it.
    apm::cmd::archive::run(p, false, None).unwrap();

    let files = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
    assert!(files.iter().any(|f| f == &rel), "non-terminal ticket should remain in tickets/");
}

#[test]
fn archive_older_than_skips_recent_ticket() {
    let dir = setup_with_archive_dir();
    let p = dir.path();

    apm::cmd::new::run(p, "Recent ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "recent-ticket");
    let id = find_ticket_id(p, "recent-ticket");
    apm::cmd::close::run(p, &id, None, true).unwrap();

    // Use a 30-day threshold — a ticket created now is newer than 30 days ago, so skip it.
    apm::cmd::archive::run(p, false, Some("30d".into())).unwrap();

    let rel = ticket_rel_path(&branch);
    let files = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap();
    assert!(files.iter().any(|f| f == &rel), "recent ticket should not be archived with --older-than 0d");
}

#[test]
fn archive_falls_back_to_ticket_branch_for_stale_main() {
    let dir = setup_with_archive_dir();
    let p = dir.path();

    // Create a ticket (state = "new", branch field set in frontmatter).
    apm::cmd::new::run(p, "Epic ticket".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "epic-ticket");
    let rel = ticket_rel_path(&branch);

    // Read original content from the ticket branch (state = "new").
    let orig = branch_content(p, &branch, &rel);

    // Put a stale "in_progress" (non-terminal) version on main.
    let stale = orig.replace("state = \"new\"", "state = \"in_progress\"");
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    std::fs::write(p.join(&rel), &stale).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &rel]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "stale non-terminal on main"]);

    // Commit a "closed" (terminal) version to the ticket branch.
    let closed = orig.replace("state = \"new\"", "state = \"closed\"");
    apm_core::git::commit_to_branch(p, &branch, &rel, &closed, "close ticket").unwrap();

    // Dry-run archive: ticket should appear in dry_run_moves with no warnings.
    let config = apm_core::config::Config::load(p).unwrap();
    let out = apm_core::archive::archive(p, &config, true, None).unwrap();

    let filename = std::path::Path::new(&rel).file_name().unwrap().to_str().unwrap().to_string();
    let archive_path = format!("archive/tickets/{filename}");
    assert!(
        out.dry_run_moves.iter().any(|(old, new)| old == &rel && new == &archive_path),
        "expected ticket in dry_run_moves; got: {:?}", out.dry_run_moves
    );
    assert!(out.warnings.is_empty(), "expected no warnings; got: {:?}", out.warnings);
}

// --- merge completion strategy: push to origin after merge ---

fn setup_merge_strategy_remote() -> (TempDir, TempDir) {
    let (bare, local) = init_remote_repo();
    let p = local.path();

    // BYPASS: no apm CLI command exists to override completion strategy on a
    // specific transition; edit .apm/workflow.toml directly to change
    // pr_or_epic_merge → merge on in_progress → implemented.
    // on_failure = "merge_failed" is kept — completion = "merge" also requires it.
    let wf_path = p.join(".apm/workflow.toml");
    let wf = std::fs::read_to_string(&wf_path).unwrap();
    let patched = wf.replace(r#"completion = "pr_or_epic_merge""#, r#"completion = "merge""#);
    std::fs::write(&wf_path, &patched).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", ".apm/workflow.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "override: use merge completion strategy"]);
    git(p, &["push", "origin", "main"]);

    (bare, local)
}

fn remote_ref_sha(dir: &std::path::Path, refname: &str) -> String {
    let out = std::process::Command::new("git")
        .args(["ls-remote", "origin", refname])
        .current_dir(dir)
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout)
        .split_whitespace()
        .next()
        .unwrap_or("")
        .to_string()
}

fn local_ref_sha(dir: &std::path::Path, refname: &str) -> String {
    let out = std::process::Command::new("git")
        .args(["rev-parse", refname])
        .current_dir(dir)
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

#[test]
fn merge_strategy_merges_locally_without_push() {
    let (_bare, local) = setup_merge_strategy_remote();
    let p = local.path();

    // BYPASS: setup_merge_strategy_remote patches workflow to completion="merge" which
    // requires [git_host]; apm new rejects this invalid config, so we write the
    // in_progress ticket directly to bypass CLI validation.
    let id = {
        let branch = "ticket/cafe0001-merge-push-test";
        let rel = "tickets/cafe0001-merge-push-test.md";
        let content = "+++\nid = \"cafe0001\"\ntitle = \"Merge push test\"\nstate = \"in_progress\"\nbranch = \"ticket/cafe0001-merge-push-test\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
        apm_core::git::commit_to_branch(p, branch, rel, content, "add in_progress ticket").unwrap();
        "cafe0001".to_string()
    };

    let main_before = local_ref_sha(p, "main");

    let result = apm_core::state::transition(p, &id, "implemented".into(), true, false);
    assert!(result.is_ok(), "merge strategy should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());

    let main_after = local_ref_sha(p, "main");
    assert_ne!(main_before, main_after, "local main should advance after merge");

    let remote_sha = remote_ref_sha(p, "main");
    assert_eq!(main_before, remote_sha, "origin/main should NOT advance — merge to default branch is local only");
}

#[test]
fn pr_or_epic_merge_with_target_branch_pushes_target_to_origin() {
    let (_bare, local) = setup_pr_or_epic_merge_remote();
    let p = local.path();

    git(p, &["checkout", "-b", "epic/push-test"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic init", "--allow-empty"]);
    git(p, &["push", "origin", "epic/push-test"]);
    git(p, &["checkout", "main"]);

    // BYPASS: target_branch field has no CLI setter independent of epic setup; the test
    // needs a specific target_branch value (epic/push-test) without creating a real epic ticket.
    let branch = "ticket/dd000004-epic-push-test";
    {
        let path = "tickets/dd000004-epic-push-test.md";
        let content = "+++\nid = \"dd000004\"\ntitle = \"Test ticket\"\nstate = \"in_progress\"\nbranch = \"ticket/dd000004-epic-push-test\"\ntarget_branch = \"epic/push-test\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
        git(p, &["checkout", "-b", branch]);
        std::fs::create_dir_all(p.join("tickets")).unwrap();
        std::fs::write(p.join(path), content).unwrap();
        git(p, &["-c", "commit.gpgsign=false", "add", path]);
        git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: dd000004"]);
        git(p, &["checkout", "main"]);
    }
    git(p, &["push", "origin", branch]);

    git(p, &["checkout", "epic/push-test"]);

    let result = apm_core::state::transition(p, "dd000004", "implemented".into(), true, false);
    assert!(result.is_ok(), "pr_or_epic_merge with target should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());

    let local_sha = local_ref_sha(p, "epic/push-test");
    let remote_sha = remote_ref_sha(p, "epic/push-test");
    assert!(!local_sha.is_empty(), "local epic/push-test should exist");
    assert_eq!(local_sha, remote_sha, "origin/epic/push-test should match local after push");
}

// --- epic set max_workers (now an error) ---

#[test]
fn epic_set_max_workers_is_now_unknown_field() {
    let (dir, epic_id) = setup_with_epic();
    let result = apm::cmd::epic::run_set(dir.path(), &epic_id, "max_workers", "2");
    assert!(result.is_err(), "expected error: max_workers is no longer a valid field");
}

#[test]
fn epic_set_unknown_field_is_error() {
    let (dir, epic_id) = setup_with_epic();
    let result = apm::cmd::epic::run_set(dir.path(), &epic_id, "unknown_field", "5");
    assert!(result.is_err(), "expected error for unknown field");
}

#[test]
fn epic_set_nonexistent_epic_exits_nonzero() {
    let dir = setup();
    // No epic branch exists, so any ID should fail.
    // run_set calls std::process::exit(1) for nonexistent epic, so we test via subprocess.
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "set", "deadbeef", "owner", "alice"])
        .current_dir(dir.path())
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .output()
        .unwrap();
    assert!(!out.status.success(), "expected non-zero exit for nonexistent epic");
}

#[test]
fn agents_max_workers_per_epic_defaults_to_one() {
    let dir = setup();
    let config = apm_core::config::Config::load(dir.path()).unwrap();
    assert_eq!(config.agents.max_workers_per_epic, 1);
}

// --- epic set owner (bulk) ---

fn write_ticket_in_epic(
    dir: &std::path::Path,
    state: &str,
    title: &str,
    owner: &str,
    epic_id: &str,
) -> (String, String) {
    let out = run_apm(dir, &["new", "--no-edit", "--no-aggressive", "--epic", epic_id, title]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    let line = stdout.lines().find(|l| l.starts_with("Created ticket")).unwrap();
    let id = line.split_whitespace().nth(2).unwrap().trim_end_matches(':').to_string();
    let branch = line.split("(branch: ").nth(1).unwrap().trim_end_matches(')').to_string();
    run_apm(dir, &["set", &id, "owner", owner, "--no-aggressive"]);
    run_apm(dir, &["state", &id, state, "--force", "--no-aggressive"]);
    (id, branch)
}

fn setup_with_epic_for_owner_tests() -> (tempfile::TempDir, String) {
    let (dir, epic_id) = setup_with_epic();
    let p = dir.path();
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
    (dir, epic_id)
}

#[test]
fn epic_bulk_owner_change_succeeds() {
    let (dir, epic_id) = setup_with_epic_for_owner_tests();
    let p = dir.path();

    let (_id1, branch1) = write_ticket_in_epic(p, "ready", "Alpha", "alice", &epic_id);
    let (_id2, branch2) = write_ticket_in_epic(p, "ready", "Beta", "alice", &epic_id);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "set", &epic_id, "owner", "bob"])
        .current_dir(p)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(out.status.success(), "expected success; stderr: {stderr}");
    assert!(stdout.contains("updated 2 ticket(s), skipped 0"), "expected summary in output:\n{stdout}");

    let content1 = branch_content(p, &branch1, &ticket_rel_path(&branch1));
    assert!(content1.contains("owner = \"bob\""), "expected owner = bob in alpha:\n{content1}");
    let content2 = branch_content(p, &branch2, &ticket_rel_path(&branch2));
    assert!(content2.contains("owner = \"bob\""), "expected owner = bob in beta:\n{content2}");
}

#[test]
fn epic_bulk_owner_change_skips_closed() {
    let (dir, epic_id) = setup_with_epic_for_owner_tests();
    let p = dir.path();

    let (_id1, branch_open) = write_ticket_in_epic(p, "ready", "Open ticket", "alice", &epic_id);
    let (_id2, branch_closed) = write_ticket_in_epic(p, "closed", "Closed ticket", "alice", &epic_id);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "set", &epic_id, "owner", "bob"])
        .current_dir(p)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(out.status.success(), "expected success; stderr: {stderr}");
    assert!(stdout.contains("updated 1 ticket(s), skipped 1"), "expected summary in output:\n{stdout}");

    let content_open = branch_content(p, &branch_open, &ticket_rel_path(&branch_open));
    assert!(content_open.contains("owner = \"bob\""), "expected owner = bob in open ticket:\n{content_open}");

    let content_closed = branch_content(p, &branch_closed, &ticket_rel_path(&branch_closed));
    assert!(content_closed.contains("owner = \"alice\""), "expected owner = alice in closed ticket:\n{content_closed}");
}

#[test]
fn epic_bulk_owner_change_blocked_non_owner() {
    let (dir, epic_id) = setup_with_epic_for_owner_tests();
    let p = dir.path();

    let (_id1, branch1) = write_ticket_in_epic(p, "ready", "Mine", "alice", &epic_id);
    let (_id2, branch2) = write_ticket_in_epic(p, "ready", "Theirs", "carol", &epic_id);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["epic", "set", &epic_id, "owner", "bob"])
        .current_dir(p)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .output()
        .unwrap();
    assert!(!out.status.success(), "expected failure when a ticket is not owned by current user");

    // Neither ticket should have been modified
    let content1 = branch_content(p, &branch1, &ticket_rel_path(&branch1));
    assert!(content1.contains("owner = \"alice\""), "alice's ticket should be unchanged:\n{content1}");
    let content2 = branch_content(p, &branch2, &ticket_rel_path(&branch2));
    assert!(content2.contains("owner = \"carol\""), "carol's ticket should be unchanged:\n{content2}");
}

// --- build_dependency_bundle ---

#[test]
fn build_dependency_bundle_with_direct_and_transitive_deps() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    // BYPASS: build_dependency_bundle requires tickets with stable, known IDs
    // (trans001, dir0001a, etc.) that the test references by name; apm new generates
    // random hex IDs, and depends_on validation rejects IDs that don't exist yet,
    // making it impossible to construct this dependency graph via the CLI.
    // Transitive dependency (closed, no depends_on).
    let trans_ticket = concat!(
        "+++\nid = \"trans001\"\ntitle = \"Base Library\"\n",
        "state = \"closed\"\n",
        "branch = \"ticket/trans001-base-library\"\n",
        "+++\n\n",
        "## Spec\n\n### Problem\n\nProvide low-level helpers.\n\n",
        "### Acceptance criteria\n\n- [x] Done\n\n",
        "### Out of scope\n\nNothing.\n\n",
        "### Approach\n\nWrite the helpers.\n\n",
        "## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
    );
    apm_core::git::commit_to_branch(
        p,
        "ticket/trans001-base-library",
        "tickets/trans001-base-library.md",
        trans_ticket,
        "add transitive dep",
    )
    .unwrap();

    // Direct dependency that is already closed.
    let direct_closed = concat!(
        "+++\nid = \"dir0001a\"\ntitle = \"Auth Module\"\n",
        "state = \"closed\"\n",
        "branch = \"ticket/dir0001a-auth-module\"\n",
        "depends_on = [\"trans001\"]\n",
        "+++\n\n",
        "## Spec\n\n### Problem\n\nImplement authentication.\n\n",
        "### Acceptance criteria\n\n- [x] Done\n\n",
        "### Out of scope\n\nOAuth only later.\n\n",
        "### Approach\n\nUse JWT tokens with a helper from Base Library.\n\n",
        "## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
    );
    apm_core::git::commit_to_branch(
        p,
        "ticket/dir0001a-auth-module",
        "tickets/dir0001a-auth-module.md",
        direct_closed,
        "add auth module initial",
    )
    .unwrap();
    // A second commit on the closed dep branch (different content) so the log range
    // has at least one entry relative to main.
    let direct_closed_v2 = concat!(
        "+++\nid = \"dir0001a\"\ntitle = \"Auth Module\"\n",
        "state = \"closed\"\n",
        "branch = \"ticket/dir0001a-auth-module\"\n",
        "depends_on = [\"trans001\"]\n",
        "+++\n\n",
        "## Spec\n\n### Problem\n\nImplement authentication.\n\n",
        "### Acceptance criteria\n\n- [x] Done\n\n",
        "### Out of scope\n\nOAuth only later.\n\n",
        "### Approach\n\nUse JWT tokens with a helper from Base Library.\n\n",
        "## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
        "| 2026-01-01T00:00Z | ready | closed | test |\n",
    );
    apm_core::git::commit_to_branch(
        p,
        "ticket/dir0001a-auth-module",
        "tickets/dir0001a-auth-module.md",
        direct_closed_v2,
        "auth: implement JWT signing",
    )
    .unwrap();

    // Direct dependency that is still open (in_progress).
    let direct_open = concat!(
        "+++\nid = \"dir0002b\"\ntitle = \"Session Store\"\n",
        "state = \"in_progress\"\n",
        "branch = \"ticket/dir0002b-session-store\"\n",
        "+++\n\n",
        "## Spec\n\n### Problem\n\nPersist user sessions.\n\n",
        "### Acceptance criteria\n\n- [ ] Store sessions in Redis.\n\n",
        "### Out of scope\n\nNothing.\n\n",
        "### Approach\n\nRedis-backed session store with TTL.\n\n",
        "## History\n\n| When | From | To | By |\n|------|------|----|----|\n",
    );
    apm_core::git::commit_to_branch(
        p,
        "ticket/dir0002b-session-store",
        "tickets/dir0002b-session-store.md",
        direct_open,
        "add session store ticket",
    )
    .unwrap();

    let dep_ids = vec!["dir0001a".to_string(), "dir0002b".to_string()];
    let bundle = apm_core::context::build_dependency_bundle(p, &dep_ids, &config);

    // Bundle header.
    assert!(bundle.contains("Dependency Context Bundle"), "header missing:\n{bundle}");

    // Direct closed dep: title, approach, no warning.
    assert!(bundle.contains("Auth Module"), "closed dep title missing:\n{bundle}");
    assert!(bundle.contains("JWT tokens"), "closed dep approach missing:\n{bundle}");
    assert!(!bundle.contains("Auth Module\n**State:** closed ⚠️"), "closed dep must not have warning:\n{bundle}");

    // Direct open dep: title, approach, warning.
    assert!(bundle.contains("Session Store"), "open dep title missing:\n{bundle}");
    assert!(bundle.contains("Redis-backed"), "open dep approach missing:\n{bundle}");
    assert!(bundle.contains("may still change"), "open dep must have warning:\n{bundle}");

    // Transitive dep listed under closed dep.
    assert!(bundle.contains("Base Library"), "transitive dep title missing:\n{bundle}");
    assert!(bundle.contains("low-level helpers"), "transitive dep problem one-liner missing:\n{bundle}");

    // Bundle footer.
    assert!(bundle.ends_with("***\n"), "bundle must end with separator:\n{bundle}");
}

#[test]
fn build_dependency_bundle_empty_when_no_deps() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let bundle = apm_core::context::build_dependency_bundle(p, &[], &config);
    assert!(bundle.is_empty(), "expected empty bundle for no deps, got:\n{bundle}");
}

// --- sync mid-merge guard ---

#[test]
fn sync_bails_on_mid_merge_state() {
    let dir = setup();
    let p = dir.path();

    // Simulate a mid-merge state by creating .git/MERGE_HEAD.
    std::fs::write(p.join(".git/MERGE_HEAD"), "0000000000000000000000000000000000000000").unwrap();

    // sync should return Ok(()) without performing any work.
    // Use offline=true so no fetch is attempted regardless.
    let result = apm::cmd::sync::run(p, true, true, true, true, false, false);
    assert!(result.is_ok(), "sync should return Ok on mid-merge: {result:?}");

    // Verify the MERGE_HEAD file is still intact (sync did not touch git state).
    assert!(p.join(".git/MERGE_HEAD").exists(), "MERGE_HEAD should still exist after bailing");
}

// --- sync_default_branch: main-branch state-machine tests ---
//
// Each test exercises one row of the Equal/Behind/Ahead/Diverged/NoRemote matrix
// described in sync_default_branch's block comment.
//
// Setup pattern: bare origin + local clone (created by setup_sync_repo).
// "shared.txt" is included in the initial commit so tests can dirty it without
// touching apm.toml (which Config::load reads on every sync call).

/// Create a bare origin repo and a local clone bootstrapped with apm.toml
/// (via setup()) plus "shared.txt".  Both TempDirs must stay alive for the
/// duration of each test.
fn setup_sync_repo() -> (TempDir, TempDir) {
    // BYPASS: no apm command creates a bare origin repo; bare-init is infrastructure only
    let origin = tempfile::tempdir().unwrap();
    std::process::Command::new("git")
        .args(["init", "--bare", "-q", "-b", "main"])
        .current_dir(origin.path())
        .status()
        .unwrap();

    let local = init_repo();
    let l = local.path();

    // A plain text file that tests can dirty without affecting Config::load.
    std::fs::write(l.join("shared.txt"), "version-1").unwrap();
    git(l, &["add", "shared.txt"]);
    git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "add shared.txt"]);

    // Wire up origin and push so local and origin start in sync.
    git(l, &["remote", "add", "origin", &origin.path().to_string_lossy()]);
    git(l, &["push", "-u", "origin", "main"]);

    (origin, local)
}

/// Push a new commit to origin via a disposable temp clone.
/// Modifies `file` to `content` and pushes main.
fn push_to_origin(origin_path: &std::path::Path, file: &str, content: &str) {
    let tmp = tempfile::tempdir().unwrap();
    let t = tmp.path();
    std::process::Command::new("git")
        .args(["clone", "-q", &origin_path.to_string_lossy(), "."])
        .current_dir(t)
        .status()
        .unwrap();
    git(t, &["config", "user.email", "test@test.com"]);
    git(t, &["config", "user.name", "test"]);
    std::fs::write(t.join(file), content).unwrap();
    git(t, &["add", file]);
    git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "origin-commit"]);
    git(t, &["push", "origin", "main"]);
    // tmp drops here; origin already has the push.
}

/// Read a ref's SHA from a repository (works on bare and non-bare).
fn rev_parse(dir: &std::path::Path, refname: &str) -> String {
    let out = std::process::Command::new("git")
        .args(["rev-parse", refname])
        .current_dir(dir)
        .output()
        .unwrap();
    String::from_utf8(out.stdout).unwrap().trim().to_string()
}

// Equal → no-op: local and origin are at the same commit.
#[test]
fn sync_main_equal_noop() {
    let (_origin, local) = setup_sync_repo();
    let l = local.path();

    let sha_before = rev_parse(l, "main");
    apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();
    let sha_after = rev_parse(l, "main");

    assert_eq!(sha_before, sha_after, "equal: main SHA must not change");
}

// Behind + clean working tree → fast-forward: local main advances to origin/main.
#[test]
fn sync_main_behind_ff_clean() {
    let (origin, local) = setup_sync_repo();
    let l = local.path();

    // Origin gets a new commit; local is now behind.
    push_to_origin(origin.path(), "shared.txt", "version-2");

    apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();

    let local_sha = rev_parse(l, "main");
    let origin_sha = rev_parse(origin.path(), "main");
    assert_eq!(
        local_sha, origin_sha,
        "behind FF clean: local main must match origin/main after fast-forward"
    );
}

// Behind + dirty overlap → guidance printed, working tree left untouched.
#[test]
fn sync_main_behind_ff_blocked_by_dirty_overlap() {
    let (origin, local) = setup_sync_repo();
    let l = local.path();

    // Origin modifies shared.txt (making local behind).
    push_to_origin(origin.path(), "shared.txt", "version-2");

    let sha_before = rev_parse(l, "main");

    // Local also modifies shared.txt without staging — creates dirty overlap.
    std::fs::write(l.join("shared.txt"), "local-dirty").unwrap();

    apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();

    // main SHA must be unchanged — FF was blocked.
    let sha_after = rev_parse(l, "main");
    assert_eq!(sha_before, sha_after, "behind FF blocked: local main SHA must be unchanged");

    // The local modification must still be present in the working tree.
    let content = std::fs::read_to_string(l.join("shared.txt")).unwrap();
    assert_eq!(content, "local-dirty", "behind FF blocked: working tree must be untouched");
}

// Ahead → info line printed, origin NOT pushed.
#[test]
fn sync_main_ahead_prints_info_no_push() {
    let (origin, local) = setup_sync_repo();
    let l = local.path();

    let origin_sha_before = rev_parse(origin.path(), "main");

    // Local-only commit — not pushed to origin.
    std::fs::write(l.join("local-only.txt"), "local").unwrap();
    git(l, &["add", "local-only.txt"]);
    git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-ahead commit"]);

    apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();

    // Origin must be unchanged — sync must never push.
    let origin_sha_after = rev_parse(origin.path(), "main");
    assert_eq!(
        origin_sha_before, origin_sha_after,
        "ahead: sync must not push to origin"
    );
}

// Diverged → guidance printed, local main SHA left unchanged.
#[test]
fn sync_main_diverged_prints_guidance() {
    let (origin, local) = setup_sync_repo();
    let l = local.path();

    // Origin gets a commit that local won't have.
    push_to_origin(origin.path(), "shared.txt", "version-origin");

    // Local also gets its own commit on top of the old base → diverged.
    std::fs::write(l.join("local-only.txt"), "local").unwrap();
    git(l, &["add", "local-only.txt"]);
    git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-diverge commit"]);

    let local_sha_before = rev_parse(l, "main");

    apm::cmd::sync::run(l, false, true, true, true, false, false).unwrap();

    let local_sha_after = rev_parse(l, "main");
    assert_eq!(
        local_sha_before, local_sha_after,
        "diverged: local main SHA must be unchanged"
    );
}

// NoRemote → sync completes without error; no crash, no output about main.
#[test]
fn sync_main_no_remote_skips() {
    // setup() produces a repo with no origin configured.
    // fetch_if_aggressive: git fetch --all returns 0 with no remotes (no-op).
    // sync_default_branch: classify_branch can't resolve origin/main → NoRemote → silent skip.
    let local = setup();
    let result = apm::cmd::sync::run(local.path(), false, true, true, true, false, false);
    assert!(result.is_ok(), "no remote: sync must return Ok, got: {result:?}");
}

// ── sync_non_checked_out_refs integration tests ───────────────────────────────
//
// Each test uses a bare origin and a local clone. Tests call sync_non_checked_out_refs
// directly (not via apm::cmd::sync::run) to avoid requiring apm.toml in all repos.
// The helper functions from the main-branch sync tests above are reused where possible.

/// Create a bare origin and a local clone; push one commit on the given branch to origin.
/// Returns (origin_dir, local_dir, origin_sha).
fn setup_branch_in_origin(branch: &str) -> (TempDir, TempDir, String) {
    // BYPASS: no apm command creates a bare origin repo; bare-init is infrastructure only
    let origin = tempfile::tempdir().unwrap();
    std::process::Command::new("git")
        .args(["init", "--bare", "-q", "-b", "main"])
        .current_dir(origin.path())
        .status()
        .unwrap();

    // init_repo() runs real `apm init`, makes an initial commit, and returns the TempDir
    let local = init_repo();
    git(local.path(), &["remote", "add", "origin", &origin.path().to_string_lossy()]);
    git(local.path(), &["push", "origin", "HEAD:main"]);

    // BYPASS: no apm command pushes a branch into a bare origin; disposable clone is the only option
    let tmp = tempfile::tempdir().unwrap();
    let t = tmp.path();
    std::process::Command::new("git")
        .args(["clone", "-q", &origin.path().to_string_lossy(), "."])
        .current_dir(t)
        .status()
        .unwrap();
    git(t, &["config", "user.email", "test@test.com"]);
    git(t, &["config", "user.name", "test"]);
    git(t, &["checkout", "-b", branch]);
    std::fs::write(t.join("branch.txt"), "v1").unwrap();
    git(t, &["add", "branch.txt"]);
    git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "branch commit"]);
    git(t, &["push", "origin", branch]);

    let sha = rev_parse(origin.path(), branch);

    // Local fetches so origin/* tracking refs are present.
    apm_core::git::fetch_all(local.path()).unwrap();

    (origin, local, sha)
}

// Equal → no ref change; local and origin are at the same SHA.
#[test]
fn sync_ticket_ref_equal_noop() {
    let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-equal");
    let l = local.path();

    // Create the local ref pointing at the same SHA as origin.
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/ticket/abc-equal", &origin_sha])
        .current_dir(l)
        .status()
        .unwrap();

    let sha_before = rev_parse(l, "refs/heads/ticket/abc-equal");
    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);
    let sha_after = rev_parse(l, "refs/heads/ticket/abc-equal");

    assert_eq!(sha_before, sha_after, "equal: local ref must be unchanged");
    assert!(w.is_empty(), "equal: no warnings expected");
    drop(origin);
}

// Behind → local ref fast-forwarded to match origin.
#[test]
fn sync_ticket_ref_behind_ff() {
    let (origin, local, _sha_v1) = setup_branch_in_origin("ticket/abc-behind");
    let l = local.path();
    let o = origin.path();

    // Local starts at the initial main commit (before the branch commit).
    let main_sha = rev_parse(l, "main");
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/ticket/abc-behind", &main_sha])
        .current_dir(l)
        .status()
        .unwrap();

    // Origin adds another commit on the branch → local is now two commits behind.
    let tmp = tempfile::tempdir().unwrap();
    let t = tmp.path();
    std::process::Command::new("git")
        .args(["clone", "-q", &o.to_string_lossy(), "."])
        .current_dir(t)
        .status()
        .unwrap();
    git(t, &["config", "user.email", "test@test.com"]);
    git(t, &["config", "user.name", "test"]);
    git(t, &["checkout", "ticket/abc-behind"]);
    std::fs::write(t.join("extra.txt"), "v2").unwrap();
    git(t, &["add", "extra.txt"]);
    git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "second commit"]);
    git(t, &["push", "origin", "ticket/abc-behind"]);

    apm_core::git::fetch_all(l).unwrap();

    let origin_sha = rev_parse(o, "ticket/abc-behind");
    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    let local_sha = rev_parse(l, "refs/heads/ticket/abc-behind");
    assert_eq!(local_sha, origin_sha, "behind: local ref must be fast-forwarded to origin SHA");
}

// Ahead → local ref is preserved (data-loss regression test for the old unconditional update-ref).
#[test]
fn sync_ticket_ref_ahead_preserves_local_commits() {
    let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-ahead");
    let l = local.path();

    // Create local ref at origin SHA, then add a local-only commit on top.
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/ticket/abc-ahead", &origin_sha])
        .current_dir(l)
        .status()
        .unwrap();
    // Commit a file using a temp worktree checkout.
    std::process::Command::new("git")
        .args(["checkout", "-b", "__tmp_ahead", "ticket/abc-ahead"])
        .current_dir(l)
        .status()
        .unwrap();
    std::fs::write(l.join("local-only.txt"), "local").unwrap();
    git(l, &["add", "local-only.txt"]);
    git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-only commit"]);
    // Move ticket/abc-ahead to point at the new commit; delete temp branch.
    let local_sha = rev_parse(l, "__tmp_ahead");
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/ticket/abc-ahead", &local_sha])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["checkout", "main"])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["branch", "-D", "__tmp_ahead"])
        .current_dir(l)
        .status()
        .unwrap();

    assert_ne!(local_sha, origin_sha, "pre-condition: local must have an extra commit");

    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    // CRITICAL: local ref must NOT have been rewound to origin SHA.
    let sha_after = rev_parse(l, "refs/heads/ticket/abc-ahead");
    assert_eq!(
        sha_after, local_sha,
        "ahead: local ref must NOT be overwritten — data-loss regression"
    );
    assert!(!w.is_empty(), "ahead: an info line should be emitted");
    drop(origin);
}

// Diverged → local ref preserved; warning emitted.
#[test]
fn sync_ticket_ref_diverged_preserves_local_commits() {
    let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-diverged");
    let l = local.path();
    let o = origin.path();

    // Local branch starts at the origin SHA; then gets a local-only commit.
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/ticket/abc-diverged", &origin_sha])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["checkout", "-b", "__tmp_div", "ticket/abc-diverged"])
        .current_dir(l)
        .status()
        .unwrap();
    std::fs::write(l.join("local-div.txt"), "local").unwrap();
    git(l, &["add", "local-div.txt"]);
    git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local diverge commit"]);
    let local_sha = rev_parse(l, "__tmp_div");
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/ticket/abc-diverged", &local_sha])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["checkout", "main"])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["branch", "-D", "__tmp_div"])
        .current_dir(l)
        .status()
        .unwrap();

    // Origin also gets a new commit on the branch (different from local).
    let tmp = tempfile::tempdir().unwrap();
    let t = tmp.path();
    std::process::Command::new("git")
        .args(["clone", "-q", &o.to_string_lossy(), "."])
        .current_dir(t)
        .status()
        .unwrap();
    git(t, &["config", "user.email", "test@test.com"]);
    git(t, &["config", "user.name", "test"]);
    git(t, &["checkout", "ticket/abc-diverged"]);
    std::fs::write(t.join("origin-div.txt"), "origin").unwrap();
    git(t, &["add", "origin-div.txt"]);
    git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "origin diverge commit"]);
    git(t, &["push", "origin", "ticket/abc-diverged"]);

    apm_core::git::fetch_all(l).unwrap();

    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    let sha_after = rev_parse(l, "refs/heads/ticket/abc-diverged");
    assert_eq!(
        sha_after, local_sha,
        "diverged: local ref must be preserved — data-loss regression"
    );
    assert!(!w.is_empty(), "diverged: a warning must be emitted");
}

// RemoteOnly → local ref is created at origin SHA (no prior local ref).
#[test]
fn sync_ticket_ref_remote_only_creates_local() {
    let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-remote-only");
    let l = local.path();

    // Confirm no local ref exists yet.
    let exists = std::process::Command::new("git")
        .args(["rev-parse", "--verify", "refs/heads/ticket/abc-remote-only"])
        .current_dir(l)
        .status()
        .unwrap()
        .success();
    assert!(!exists, "pre-condition: local ref must not exist");

    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    let local_sha = rev_parse(l, "refs/heads/ticket/abc-remote-only");
    assert_eq!(local_sha, origin_sha, "remote-only: local ref must be created at origin SHA");
    drop(origin);
}

// LocalOnly → local ref left untouched; no warning, no push.
#[test]
fn sync_ticket_ref_local_only_untouched() {
    let (_origin, local, _) = setup_branch_in_origin("ticket/abc-seed");
    let l = local.path();

    // Create a local-only branch with no origin counterpart.
    std::process::Command::new("git")
        .args(["checkout", "-b", "ticket/local-only", "main"])
        .current_dir(l)
        .status()
        .unwrap();
    std::fs::write(l.join("local.txt"), "data").unwrap();
    git(l, &["add", "local.txt"]);
    git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local-only commit"]);
    let local_sha = rev_parse(l, "ticket/local-only");
    std::process::Command::new("git")
        .args(["checkout", "main"])
        .current_dir(l)
        .status()
        .unwrap();

    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    // Local ref must be unchanged; no warning about it.
    let sha_after = rev_parse(l, "refs/heads/ticket/local-only");
    assert_eq!(sha_after, local_sha, "local-only: local ref must not be changed");
    // No warning about the local-only branch (no spam).
    assert!(
        w.iter().all(|m| !m.contains("ticket/local-only")),
        "local-only: no warning message expected for local-only branch"
    );
}

// epic/* behind → fast-forwarded (covers epic/* namespace).
#[test]
fn sync_epic_ref_behind_ff() {
    let (origin, local, _sha_v1) = setup_branch_in_origin("epic/abcd1234-my-epic");
    let l = local.path();
    let o = origin.path();

    // Local starts at main SHA (behind the epic branch).
    let main_sha = rev_parse(l, "main");
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/epic/abcd1234-my-epic", &main_sha])
        .current_dir(l)
        .status()
        .unwrap();

    // Origin adds another commit on the epic branch.
    let tmp = tempfile::tempdir().unwrap();
    let t = tmp.path();
    std::process::Command::new("git")
        .args(["clone", "-q", &o.to_string_lossy(), "."])
        .current_dir(t)
        .status()
        .unwrap();
    git(t, &["config", "user.email", "test@test.com"]);
    git(t, &["config", "user.name", "test"]);
    git(t, &["checkout", "epic/abcd1234-my-epic"]);
    std::fs::write(t.join("extra.txt"), "v2").unwrap();
    git(t, &["add", "extra.txt"]);
    git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "epic second commit"]);
    git(t, &["push", "origin", "epic/abcd1234-my-epic"]);

    apm_core::git::fetch_all(l).unwrap();

    let origin_sha = rev_parse(o, "epic/abcd1234-my-epic");
    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    let local_sha = rev_parse(l, "refs/heads/epic/abcd1234-my-epic");
    assert_eq!(
        local_sha, origin_sha,
        "epic behind: local ref must be fast-forwarded to origin SHA"
    );
}

// epic/* ahead → local ref preserved (data-loss regression for epic/* namespace).
#[test]
fn sync_epic_ref_ahead_preserves_local_commits() {
    let (origin, local, origin_sha) = setup_branch_in_origin("epic/efgh5678-my-epic");
    let l = local.path();

    // Set local ref to origin SHA, then add a local-only commit.
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/epic/efgh5678-my-epic", &origin_sha])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["checkout", "-b", "__tmp_epic_ahead", "epic/efgh5678-my-epic"])
        .current_dir(l)
        .status()
        .unwrap();
    std::fs::write(l.join("local-epic.txt"), "local").unwrap();
    git(l, &["add", "local-epic.txt"]);
    git(l, &["-c", "commit.gpgsign=false", "commit", "-m", "local epic commit"]);
    let local_sha = rev_parse(l, "__tmp_epic_ahead");
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/epic/efgh5678-my-epic", &local_sha])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["checkout", "main"])
        .current_dir(l)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["branch", "-D", "__tmp_epic_ahead"])
        .current_dir(l)
        .status()
        .unwrap();

    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    let sha_after = rev_parse(l, "refs/heads/epic/efgh5678-my-epic");
    assert_eq!(
        sha_after, local_sha,
        "epic ahead: local ref must NOT be overwritten — data-loss regression"
    );
    assert!(!w.is_empty(), "epic ahead: an info line must be emitted");
    drop(origin);
}

// Checked-out ticket branch is skipped.
#[test]
fn sync_checked_out_ticket_skipped() {
    let (origin, local, origin_sha) = setup_branch_in_origin("ticket/abc-checked-out");
    let l = local.path();
    let o = origin.path();

    // Create the local ref at origin SHA.
    std::process::Command::new("git")
        .args(["update-ref", "refs/heads/ticket/abc-checked-out", &origin_sha])
        .current_dir(l)
        .status()
        .unwrap();

    // Add a new commit to origin so local would be behind (if sync were to run).
    let tmp = tempfile::tempdir().unwrap();
    let t = tmp.path();
    std::process::Command::new("git")
        .args(["clone", "-q", &o.to_string_lossy(), "."])
        .current_dir(t)
        .status()
        .unwrap();
    git(t, &["config", "user.email", "test@test.com"]);
    git(t, &["config", "user.name", "test"]);
    git(t, &["checkout", "ticket/abc-checked-out"]);
    std::fs::write(t.join("extra.txt"), "origin-extra").unwrap();
    git(t, &["add", "extra.txt"]);
    git(t, &["-c", "commit.gpgsign=false", "commit", "-m", "origin extra"]);
    git(t, &["push", "origin", "ticket/abc-checked-out"]);

    apm_core::git::fetch_all(l).unwrap();

    // Check out the ticket branch in a worktree so it's in the checked-out set.
    let wt = tempfile::tempdir().unwrap();
    std::process::Command::new("git")
        .args(["worktree", "add", wt.path().to_str().unwrap(), "ticket/abc-checked-out"])
        .current_dir(l)
        .status()
        .unwrap();

    let sha_before = rev_parse(l, "refs/heads/ticket/abc-checked-out");

    let mut w = Vec::new();
    apm_core::git::sync_non_checked_out_refs(l, &mut w);

    let sha_after = rev_parse(l, "refs/heads/ticket/abc-checked-out");
    assert_eq!(
        sha_before, sha_after,
        "checked-out: branch ref must not be updated while checked out in worktree"
    );

    // Clean up the worktree.
    let _ = std::process::Command::new("git")
        .args(["worktree", "remove", "--force", wt.path().to_str().unwrap()])
        .current_dir(l)
        .status();
    drop(origin);
}

// ── move_ticket ───────────────────────────────────────────────────────────────

/// Add a file commit to a branch using a temporary git worktree, then remove the worktree.
/// The branch is not checked out when the function returns.
fn add_commit_to_branch_via_worktree(
    dir: &std::path::Path,
    branch: &str,
    filename: &str,
    content: &str,
) {
    let wt = tempfile::tempdir().unwrap();
    std::process::Command::new("git")
        .args(["worktree", "add", wt.path().to_str().unwrap(), branch])
        .current_dir(dir)
        .status()
        .unwrap();
    std::fs::write(wt.path().join(filename), content).unwrap();
    git(wt.path(), &["add", filename]);
    git(wt.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "extra commit"]);
    std::process::Command::new("git")
        .args(["worktree", "remove", wt.path().to_str().unwrap()])
        .current_dir(dir)
        .status()
        .unwrap();
}

fn git_merge_base(dir: &std::path::Path, ref1: &str, ref2: &str) -> String {
    let out = std::process::Command::new("git")
        .args(["merge-base", ref1, ref2])
        .current_dir(dir)
        .output()
        .unwrap();
    String::from_utf8(out.stdout).unwrap().trim().to_string()
}

fn make_epic_branch(p: &std::path::Path, title: &str, id: &str) -> (String, String) {
    let slug = title.to_lowercase().replace(' ', "-");
    let branch = format!("epic/{id}-{slug}");
    apm_core::git::commit_to_branch(p, &branch, "tickets/EPIC.md", &format!("# {title}\n"), "create epic").unwrap();
    (id.to_string(), branch)
}

#[test]
fn move_standalone_ticket_into_epic() {
    let dir = setup();
    let p = dir.path();

    let config = apm_core::config::Config::load(p).unwrap();

    // Create an epic branch.
    let (epic_id, epic_branch) = make_epic_branch(p, "test epic", "ee000001");

    // Create a standalone ticket (no epic).
    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "standalone ticket".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        None,
        None,
        None,
        None,
        &mut warnings,
    )
    .unwrap();

    let ticket_id = ticket.frontmatter.id.clone();
    let ticket_branch = ticket.frontmatter.branch.clone().unwrap();

    // Add an extra commit to the ticket branch so there's something to replay.
    add_commit_to_branch_via_worktree(p, &ticket_branch, "extra.txt", "extra");

    // Record the epic branch tip before moving.
    let epic_tip_before = rev_parse(p, &epic_branch);

    // Move the ticket into the epic.
    let msg = apm_core::ticket::move_to_epic(p, &config, &ticket_id, Some(&epic_id)).unwrap();
    assert!(msg.contains(&ticket_id), "message should mention ticket id: {msg}");
    assert!(msg.contains(&epic_id), "message should mention epic id: {msg}");

    // Read the updated ticket from its branch.
    let rel_path = format!("tickets/{}.md", ticket_branch.strip_prefix("ticket/").unwrap());
    let content = branch_content(p, &ticket_branch, &rel_path);
    let updated = apm_core::ticket::Ticket::parse(std::path::Path::new(&rel_path), &content).unwrap();

    // AC #1: epic and target_branch frontmatter updated.
    assert_eq!(
        updated.frontmatter.epic.as_deref(),
        Some(epic_id.as_str()),
        "epic field should be set"
    );
    assert_eq!(
        updated.frontmatter.target_branch.as_deref(),
        Some(epic_branch.as_str()),
        "target_branch should be set to epic branch"
    );

    // AC #2: ticket branch is now forked from the epic branch tip.
    let merge_base = git_merge_base(p, &ticket_branch, &epic_branch);
    assert_eq!(
        merge_base, epic_tip_before,
        "merge-base of ticket and epic should equal the epic tip at move time"
    );

    // AC #4: epic show lists the ticket.
    let all_tickets = apm_core::ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
    let epic_tickets: Vec<_> = all_tickets
        .iter()
        .filter(|t| t.frontmatter.epic.as_deref() == Some(&epic_id))
        .collect();
    assert_eq!(epic_tickets.len(), 1, "epic should list the ticket");
    assert_eq!(epic_tickets[0].frontmatter.id, ticket_id);

    // AC #5: history row is present.
    assert!(
        updated.body.contains("move:"),
        "history should contain move record"
    );
}

#[test]
fn move_commits_replayed_on_new_base() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let (epic_id, epic_branch) = make_epic_branch(p, "replay epic", "ee000002");

    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "replay ticket".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        None,
        None,
        None,
        None,
        &mut warnings,
    )
    .unwrap();

    let ticket_branch = ticket.frontmatter.branch.clone().unwrap();

    // Add TWO extra commits to verify replay order.
    add_commit_to_branch_via_worktree(p, &ticket_branch, "first.txt", "first");
    add_commit_to_branch_via_worktree(p, &ticket_branch, "second.txt", "second");

    let epic_tip = rev_parse(p, &epic_branch);

    apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id)).unwrap();

    // AC #3: commits replayed — both files should exist on the rebased ticket branch.
    let first_content = branch_content(p, &ticket_branch, "first.txt");
    assert_eq!(first_content.trim(), "first");
    let second_content = branch_content(p, &ticket_branch, "second.txt");
    assert_eq!(second_content.trim(), "second");

    // Branch should sit on top of the epic.
    let merge_base = git_merge_base(p, &ticket_branch, &epic_branch);
    assert_eq!(merge_base, epic_tip, "replayed commits must sit on top of epic");
}

#[test]
fn move_ticket_out_of_epic() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let (epic_id, epic_branch) = make_epic_branch(p, "out epic", "ee000003");

    // Create ticket directly in the epic.
    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "epic ticket".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        Some(epic_id.clone()),
        Some(epic_branch.clone()),
        None,
        Some(epic_branch.clone()),
        &mut warnings,
    )
    .unwrap();

    let ticket_id = ticket.frontmatter.id.clone();
    let ticket_branch = ticket.frontmatter.branch.clone().unwrap();

    // Record main tip before the move.
    let main_tip = rev_parse(p, "main");

    // Move out: target = None (sentinel "-").
    let msg =
        apm_core::ticket::move_to_epic(p, &config, &ticket_id, None).unwrap();
    assert!(msg.contains("out of epic") || msg.contains("main"), "message: {msg}");

    // AC #6: epic and target_branch cleared.
    let rel_path = format!("tickets/{}.md", ticket_branch.strip_prefix("ticket/").unwrap());
    let content = branch_content(p, &ticket_branch, &rel_path);
    let updated =
        apm_core::ticket::Ticket::parse(std::path::Path::new(&rel_path), &content).unwrap();
    assert!(updated.frontmatter.epic.is_none(), "epic should be cleared");
    assert!(
        updated.frontmatter.target_branch.is_none(),
        "target_branch should be cleared"
    );

    // AC #7: epic show no longer lists the ticket.
    let all_tickets = apm_core::ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
    let epic_tickets: Vec<_> = all_tickets
        .iter()
        .filter(|t| t.frontmatter.epic.as_deref() == Some(&epic_id))
        .collect();
    assert!(epic_tickets.is_empty(), "epic should no longer list the ticket");

    // Ticket branch should be rebased onto main.
    let merge_base = git_merge_base(p, &ticket_branch, "main");
    assert_eq!(merge_base, main_tip, "ticket should be rebased onto main");
}

#[test]
fn move_between_epics() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let (epic1_id, epic1_branch) = make_epic_branch(p, "first epic", "ee000004");
    let (epic2_id, epic2_branch) = make_epic_branch(p, "second epic", "ee000005");

    // Create ticket in epic1.
    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "migrating ticket".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        Some(epic1_id.clone()),
        Some(epic1_branch.clone()),
        None,
        Some(epic1_branch.clone()),
        &mut warnings,
    )
    .unwrap();

    let ticket_id = ticket.frontmatter.id.clone();
    let ticket_branch = ticket.frontmatter.branch.clone().unwrap();

    let epic2_tip = rev_parse(p, &epic2_branch);

    // AC #8: move from epic1 to epic2.
    apm_core::ticket::move_to_epic(p, &config, &ticket_id, Some(&epic2_id)).unwrap();

    let rel_path = format!("tickets/{}.md", ticket_branch.strip_prefix("ticket/").unwrap());
    let content = branch_content(p, &ticket_branch, &rel_path);
    let updated =
        apm_core::ticket::Ticket::parse(std::path::Path::new(&rel_path), &content).unwrap();

    assert_eq!(
        updated.frontmatter.epic.as_deref(),
        Some(epic2_id.as_str()),
        "epic should now be epic2"
    );
    assert_eq!(
        updated.frontmatter.target_branch.as_deref(),
        Some(epic2_branch.as_str())
    );

    // No longer listed under epic1.
    let all_tickets = apm_core::ticket::load_all_from_git(p, &config.tickets.dir).unwrap();
    let in_epic1: Vec<_> = all_tickets
        .iter()
        .filter(|t| t.frontmatter.epic.as_deref() == Some(&epic1_id))
        .collect();
    assert!(in_epic1.is_empty(), "should not be in epic1 anymore");

    // Listed under epic2.
    let in_epic2: Vec<_> = all_tickets
        .iter()
        .filter(|t| t.frontmatter.epic.as_deref() == Some(&epic2_id))
        .collect();
    assert_eq!(in_epic2.len(), 1, "should be in epic2");

    // Branch topology.
    let merge_base = git_merge_base(p, &ticket_branch, &epic2_branch);
    assert_eq!(merge_base, epic2_tip);
}

#[test]
fn move_already_in_same_epic_is_noop() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let (epic_id, epic_branch) = make_epic_branch(p, "same epic", "ee000006");

    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "already here".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        Some(epic_id.clone()),
        Some(epic_branch.clone()),
        None,
        Some(epic_branch.clone()),
        &mut warnings,
    )
    .unwrap();

    let tip_before = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());

    // AC #9: moving into the same epic is a no-op.
    let msg =
        apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id))
            .unwrap();
    assert!(
        msg.contains("already") || msg.contains("nothing to do"),
        "expected no-op message: {msg}"
    );

    let tip_after = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());
    assert_eq!(tip_before, tip_after, "branch ref must not change on no-op");
}

#[test]
fn move_ticket_not_in_epic_to_main_is_noop() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "no epic ticket".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        None,
        None,
        None,
        None,
        &mut warnings,
    )
    .unwrap();

    let tip_before = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());

    // AC #10: moving a ticket with no epic to main is a no-op.
    let msg =
        apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, None).unwrap();
    assert!(
        msg.contains("not in any epic") || msg.contains("nothing to do"),
        "expected no-op message: {msg}"
    );

    let tip_after = rev_parse(p, &ticket.frontmatter.branch.clone().unwrap());
    assert_eq!(tip_before, tip_after, "branch ref must not change on no-op");
}

#[test]
fn move_terminal_ticket_fails() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let (epic_id, _epic_branch) = make_epic_branch(p, "t epic", "ee000007");

    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "to close".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        None,
        None,
        None,
        None,
        &mut warnings,
    )
    .unwrap();

    // Close the ticket.
    apm_core::ticket::close(p, &config, &ticket.frontmatter.id, None, "test", false).unwrap();

    // AC #11: move of a closed ticket must fail.
    let err =
        apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id))
            .unwrap_err();
    assert!(
        err.to_string().contains("terminal"),
        "error should mention terminal state: {err}"
    );
}

#[test]
fn move_nonexistent_epic_fails() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "nomatch ticket".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        None,
        None,
        None,
        None,
        &mut warnings,
    )
    .unwrap();

    // AC #12: nonexistent epic must fail with a clear error.
    let err =
        apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some("deadbeef"))
            .unwrap_err();
    assert!(
        err.to_string().contains("no epic"),
        "error should mention epic not found: {err}"
    );
}

#[test]
fn move_rebase_conflict_fails_cleanly() {
    let dir = setup();
    let p = dir.path();
    let config = apm_core::config::Config::load(p).unwrap();

    // Create an epic branch, then add a commit to it that conflicts with the ticket.
    let (epic_id, epic_branch) = make_epic_branch(p, "conflict epic", "ee000008");

    // Add a commit to the epic branch that modifies "shared.txt".
    add_commit_to_branch_via_worktree(p, &epic_branch, "shared.txt", "from epic\n");

    // Create a standalone ticket from main, then add a conflicting commit.
    let mut warnings = vec![];
    let ticket = apm_core::ticket::create(
        p,
        &config,
        "conflict ticket".into(),
        "test".into(),
        "test".into(),
        None,
        None,
        false,
        vec![],
        None,
        None,
        None,
        None,
        &mut warnings,
    )
    .unwrap();
    let ticket_branch = ticket.frontmatter.branch.clone().unwrap();

    // Add "shared.txt" with different content on the ticket branch.
    add_commit_to_branch_via_worktree(p, &ticket_branch, "shared.txt", "from ticket\n");

    // Record ticket branch tip before attempted move.
    let tip_before = rev_parse(p, &ticket_branch);

    // AC #13: rebase conflict must fail with a clear error and leave repo clean.
    let err =
        apm_core::ticket::move_to_epic(p, &config, &ticket.frontmatter.id, Some(&epic_id))
            .unwrap_err();
    assert!(
        err.to_string().contains("rebase") || err.to_string().contains("conflict"),
        "error should describe the rebase failure: {err}"
    );

    // The branch ref must be restored to its pre-rebase state.
    let tip_after = rev_parse(p, &ticket_branch);
    assert_eq!(
        tip_before, tip_after,
        "branch ref must be restored after failed rebase"
    );

    // The repo must not be in a mid-rebase state.
    assert!(
        apm_core::git::detect_mid_merge_state(p).is_none(),
        "repo should be clean after abort"
    );
}

// --- merge_failed ---

fn setup_with_merge_workflow() -> TempDir {
    let dir = init_repo();
    // BYPASS: no apm command can add a new → implemented transition with completion = "merge"
    // post-init. The production default has no such path; a direct new → implemented route
    // is required so tests can trigger merge-failure without the full spec/worktree lifecycle.
    std::fs::write(dir.path().join(".apm/workflow.toml"), r#"[workflow]

[[workflow.states]]
id    = "new"
label = "New"

  [[workflow.states.transitions]]
  to         = "implemented"
  trigger    = "manual"
  completion = "merge"
  on_failure = "merge_failed"

[[workflow.states]]
id    = "implemented"
label = "Implemented"

  [[workflow.states.transitions]]
  to      = "in_progress"
  trigger = "manual"

[[workflow.states]]
id    = "merge_failed"
label = "Merge failed"

  [[workflow.states.transitions]]
  to      = "implemented"
  trigger = "manual"

  [[workflow.states.transitions]]
  to      = "in_progress"
  trigger = "manual"

[[workflow.states]]
id    = "in_progress"
label = "In Progress"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#).unwrap();
    git(dir.path(), &["add", ".apm/workflow.toml"]);
    git(dir.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "merge workflow"]);
    dir
}

#[test]
fn merge_failure_transitions_ticket_to_merge_failed() {
    let dir = setup_with_merge_workflow();
    let p = dir.path();

    // Create a ticket (starts in "new" state).
    apm::cmd::new::run(p, "Merge conflict".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "merge-conflict");
    let id = find_ticket_id(p, "merge-conflict");
    let rel = ticket_rel_path(&branch);

    // Add a file to the ticket branch that will conflict with main.
    git(p, &["checkout", &branch]);
    std::fs::write(p.join("conflict.txt"), "branch content\n").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "conflict.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add file on branch"]);
    git(p, &["checkout", "main"]);

    // Add a conflicting file to main.
    std::fs::write(p.join("conflict.txt"), "main content\n").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "conflict.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add conflicting file on main"]);

    // Transition new → implemented triggers a merge that fails.
    apm::cmd::state::run(p, &id, "implemented".into(), false, false).unwrap();

    // Ticket on its branch must now be in merge_failed state.
    let content = branch_content(p, &branch, &rel);
    assert!(
        content.contains("state = \"merge_failed\""),
        "expected merge_failed state:\n{content}"
    );
    assert!(
        content.contains("### Merge notes"),
        "expected Merge notes section:\n{content}"
    );
    assert!(
        content.contains("| implemented | merge_failed |"),
        "expected history row:\n{content}"
    );
}

#[test]
fn merge_failed_to_implemented_does_not_trigger_another_merge() {
    let dir = setup_with_merge_workflow();
    let p = dir.path();

    // Create ticket and force it to merge_failed state directly.
    apm::cmd::new::run(p, "Already failed".into(), true, false, None, None, true, vec![], vec![], None, vec![]).unwrap();
    let branch = find_ticket_branch(p, "already-failed");
    let id = find_ticket_id(p, "already-failed");
    let rel = ticket_rel_path(&branch);

    // Write merge_failed state directly to the branch.
    let existing = branch_content(p, &branch, &rel);
    let updated = existing.replace("state = \"new\"", "state = \"merge_failed\"");
    git(p, &["checkout", &branch]);
    std::fs::write(p.join(&rel), &updated).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &rel]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "set merge_failed"]);
    git(p, &["checkout", "main"]);

    // Transition merge_failed → implemented must succeed without attempting a merge.
    apm::cmd::state::run(p, &id, "implemented".into(), false, false).unwrap();
    let content = branch_content(p, &branch, &rel);
    assert!(
        content.contains("state = \"implemented\""),
        "expected implemented state:\n{content}"
    );
}

#[test]
fn merge_failed_to_in_progress_rejected() {
    let dir = init_repo();
    let p = dir.path();
    let (id, branch) = write_ticket_to_branch(p, "merge_failed", "retry rejected");
    let rel = ticket_rel_path(&branch);

    let result = apm::cmd::state::run(p, &id, "in_progress".into(), false, false);
    assert!(result.is_err(), "expected merge_failed → in_progress to be rejected");
    let msg = result.unwrap_err().to_string();
    assert!(
        msg.contains("no transition"),
        "expected 'no transition' in error message: {msg}"
    );
    // State must not have changed.
    let content = branch_content(p, &branch, &rel);
    assert!(content.contains("state = \"merge_failed\""), "state should remain merge_failed:\n{content}");
}

#[test]
fn merge_failed_to_implemented_with_epic_merge_succeeds() {
    let (_bare, local) = init_remote_repo();
    let p = local.path();

    // Create epic branch and push to origin.
    git(p, &["checkout", "-b", "epic/mf-succeed"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic init", "--allow-empty"]);
    git(p, &["push", "origin", "epic/mf-succeed"]);
    git(p, &["checkout", "main"]);

    // Create a ticket with merge_failed state and target_branch pointing to the epic.
    // BYPASS: target_branch has no CLI setter; write the ticket directly.
    let branch = "ticket/bb000001-mf-succeed";
    let path = "tickets/bb000001-mf-succeed.md";
    {
        let content = "+++\nid = \"bb000001\"\ntitle = \"MF succeed\"\nstate = \"merge_failed\"\nbranch = \"ticket/bb000001-mf-succeed\"\ntarget_branch = \"epic/mf-succeed\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
        git(p, &["checkout", "-b", branch]);
        std::fs::create_dir_all(p.join("tickets")).unwrap();
        std::fs::write(p.join(path), content).unwrap();
        git(p, &["-c", "commit.gpgsign=false", "add", path]);
        git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: bb000001 merge_failed"]);
        git(p, &["checkout", "main"]);
    }
    git(p, &["push", "origin", branch]);

    // Check out the epic branch so merge_into_default uses it directly.
    git(p, &["checkout", "epic/mf-succeed"]);

    let result = apm_core::state::transition(p, "bb000001", "implemented".into(), true, false);
    assert!(
        result.is_ok(),
        "merge_failed → implemented should succeed: {}",
        result.err().map(|e| e.to_string()).unwrap_or_default()
    );

    // Ticket branch must now be in implemented state.
    let content = branch_content(p, branch, path);
    assert!(
        content.contains("state = \"implemented\""),
        "expected implemented state on ticket branch:\n{content}"
    );
    assert!(
        content.contains("| merge_failed | implemented |"),
        "expected history row:\n{content}"
    );

    // Epic branch must have a new merge commit containing the ticket branch tip.
    let log = std::process::Command::new("git")
        .args(["log", "--oneline", "epic/mf-succeed"])
        .current_dir(p)
        .output()
        .unwrap();
    let log_str = String::from_utf8_lossy(&log.stdout);
    assert!(
        log_str.lines().count() > 1,
        "epic branch should have additional commits after merge:\n{log_str}"
    );
}

#[test]
fn merge_failed_to_implemented_with_epic_merge_fails() {
    let (_bare, local) = init_remote_repo();
    let p = local.path();

    // Create epic branch with a file that will conflict with the ticket branch.
    git(p, &["checkout", "-b", "epic/mf-fail"]);
    std::fs::write(p.join("conflict.txt"), "epic content\n").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "conflict.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "epic: add conflict file"]);
    git(p, &["push", "origin", "epic/mf-fail"]);
    git(p, &["checkout", "main"]);

    // Create a ticket with merge_failed state, target_branch, and a conflicting file.
    // BYPASS: target_branch has no CLI setter; write the ticket directly.
    let branch = "ticket/cc000001-mf-fail";
    let path = "tickets/cc000001-mf-fail.md";
    {
        let content = "+++\nid = \"cc000001\"\ntitle = \"MF fail\"\nstate = \"merge_failed\"\nbranch = \"ticket/cc000001-mf-fail\"\ntarget_branch = \"epic/mf-fail\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
        git(p, &["checkout", "-b", branch]);
        std::fs::create_dir_all(p.join("tickets")).unwrap();
        std::fs::write(p.join(path), content).unwrap();
        std::fs::write(p.join("conflict.txt"), "branch content\n").unwrap();
        git(p, &["-c", "commit.gpgsign=false", "add", path]);
        git(p, &["-c", "commit.gpgsign=false", "add", "conflict.txt"]);
        git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: cc000001 with conflict"]);
        git(p, &["checkout", "main"]);
    }
    git(p, &["push", "origin", branch]);

    // Capture the epic branch tip before the transition attempt.
    let epic_tip_before = String::from_utf8(
        std::process::Command::new("git")
            .args(["rev-parse", "epic/mf-fail"])
            .current_dir(p)
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap()
    .trim()
    .to_string();

    // Check out the epic branch so merge_into_default uses it directly.
    git(p, &["checkout", "epic/mf-fail"]);

    // Transition merge_failed → implemented triggers a merge that fails;
    // the on_failure path transitions back to merge_failed, returning Ok.
    let result = apm_core::state::transition(p, "cc000001", "implemented".into(), true, false);
    assert!(
        result.is_ok(),
        "on_failure path should return Ok with merge_failed state: {}",
        result.err().map(|e| e.to_string()).unwrap_or_default()
    );
    let out = result.unwrap();
    assert_eq!(out.new_state, "merge_failed", "final state should be merge_failed");

    // Ticket branch must end in merge_failed state with both history rows.
    let content = branch_content(p, branch, path);
    assert!(
        content.contains("state = \"merge_failed\""),
        "expected final state merge_failed:\n{content}"
    );
    assert!(
        content.contains("| merge_failed | implemented |"),
        "expected history row for merge_failed → implemented:\n{content}"
    );
    assert!(
        content.contains("| implemented | merge_failed |"),
        "expected history row for implemented → merge_failed (on_failure):\n{content}"
    );

    // Epic branch must not have advanced.
    let epic_tip_after = String::from_utf8(
        std::process::Command::new("git")
            .args(["rev-parse", "epic/mf-fail"])
            .current_dir(p)
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap()
    .trim()
    .to_string();
    assert_eq!(
        epic_tip_before, epic_tip_after,
        "epic branch should not have advanced after merge failure"
    );
}

// ---------------------------------------------------------------------------
// apm clean — in-repo worktree safety
// ---------------------------------------------------------------------------

#[test]
fn clean_refuses_when_cwd_inside_worktree() {
    // apm clean invoked from inside a ticket worktree must refuse with a
    // non-zero exit and a message containing the expected phrase.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "cwd-guard");
    merge_into_main(p, &branch);

    let wt_path = p.join("worktrees").join("ticket-0001-cwd-guard");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .arg("clean")
        .current_dir(&wt_path)
        .output()
        .unwrap();

    assert!(
        !out.status.success(),
        "apm clean from inside a worktree must exit non-zero"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("refusing to remove worktree containing the current working directory"),
        "stderr must contain refusal message, got: {stderr}"
    );
}

#[test]
fn clean_prunable_worktree_cleans_registry() {
    // When a registered worktree's path no longer exists on disk, apm clean
    // must prune the dangling registry entry without attempting to delete files.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "prunable");
    merge_into_main(p, &branch);

    let wt_path = p.join("worktrees").join("ticket-0001-prunable");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Delete the worktree directory without using git worktree remove.
    // This leaves a dangling registry entry in .git/worktrees/.
    std::fs::remove_dir_all(&wt_path).unwrap();
    assert!(!wt_path.exists(), "worktree dir must be gone before running clean");

    // Verify the registry entry still exists.
    let list_before = std::process::Command::new("git")
        .args(["worktree", "list", "--porcelain"])
        .current_dir(p)
        .output()
        .unwrap();
    let list_str = String::from_utf8_lossy(&list_before.stdout);
    assert!(
        list_str.contains("ticket-0001-prunable"),
        "registry entry must exist before clean: {list_str}"
    );

    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    // After clean, the dangling entry must be gone.
    let list_after = std::process::Command::new("git")
        .args(["worktree", "list", "--porcelain"])
        .current_dir(p)
        .output()
        .unwrap();
    let list_after_str = String::from_utf8_lossy(&list_after.stdout);
    assert!(
        !list_after_str.contains("ticket-0001-prunable"),
        "dangling registry entry must be pruned after clean: {list_after_str}"
    );
}

#[test]
fn clean_untracked_does_not_affect_worktrees_dir_debris() {
    // apm clean --untracked must not walk into or affect debris files placed
    // directly under <repo>/worktrees/ (outside any registered worktree).
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "debris");
    merge_into_main(p, &branch);

    let wt_path = p.join("worktrees").join("ticket-0001-debris");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Place a debris file directly under <repo>/worktrees/, outside any registered worktree.
    let debris = p.join("worktrees").join("stray-debris.txt");
    std::fs::write(&debris, "should not be touched").unwrap();

    apm::cmd::clean::run(p, false, false, false, false, None, true, false).unwrap();

    assert!(
        debris.exists(),
        "debris file outside any registered worktree must not be touched by --untracked"
    );
}

#[test]
fn clean_removes_closed_ticket_worktree_in_repo_layout() {
    // Happy path under the new in-repo layout: a closed ticket with a
    // provisioned worktree at <repo>/worktrees/<branch> must be cleaned via
    // git worktree remove (not rm -rf), and both the dir and the registry
    // entry must be gone after clean.
    let dir = setup();
    let p = dir.path();
    let (branch, _) = write_closed_ticket(p, "happy");
    merge_into_main(p, &branch);

    let wt_path = p.join("worktrees").join("ticket-0001-happy");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &wt_path.to_string_lossy(), &branch]);

    // Sanity: worktree exists on disk and in registry before clean.
    assert!(wt_path.is_dir(), "worktree dir must exist before clean");
    let list_before = std::process::Command::new("git")
        .args(["worktree", "list", "--porcelain"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(
        String::from_utf8_lossy(&list_before.stdout).contains("ticket-0001-happy"),
        "worktree must be registered before clean"
    );

    apm::cmd::clean::run(p, false, false, false, false, None, false, false).unwrap();

    assert!(
        !wt_path.exists(),
        "worktree dir must be removed by clean: {}",
        wt_path.display()
    );
    let list_after = std::process::Command::new("git")
        .args(["worktree", "list", "--porcelain"])
        .current_dir(p)
        .output()
        .unwrap();
    assert!(
        !String::from_utf8_lossy(&list_after.stdout).contains("ticket-0001-happy"),
        "registry entry must be gone after clean"
    );
}

#[test]
fn external_layout_worktrees_provisioned_at_sibling_path() {
    // Existing repos with `dir = "../<name>--worktrees"` must keep working
    // unchanged. provision_worktree must place the worktree at the
    // configured external sibling, not inside the repo.
    //
    // Build the repo as a subdirectory of a controlled tempdir so the `..`
    // resolution lands inside our tempdir (not the shared OS temp parent).
    let outer = tempfile::tempdir().unwrap();
    let p = outer.path().join("repo");
    std::fs::create_dir_all(&p).unwrap();

    git(&p, &["init", "-q", "-b", "main"]);
    git(&p, &["config", "user.email", "test@test.com"]);
    git(&p, &["config", "user.name", "test"]);

    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"

[tickets]
dir = "tickets"

[agents]
max_concurrent = 1

[worktrees]
dir = "../external-worktrees"

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id    = "new"
label = "New"

[[ticket.sections]]
name     = "Problem"
type     = "free"
required = true

[[ticket.sections]]
name     = "Acceptance criteria"
type     = "tasks"
required = true

[[ticket.sections]]
name     = "Out of scope"
type     = "free"
required = true

[[ticket.sections]]
name     = "Approach"
type     = "free"
required = true
"#,
    )
    .unwrap();

    git(&p, &["add", ".apm/config.toml"]);
    git(&p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
    git(&p, &["branch", "ticket/ext-test"]);

    let cfg = apm_core::config::Config::load(&p).unwrap();
    let mut warnings: Vec<String> = Vec::new();
    let wt = apm_core::worktree::provision_worktree(&p, &cfg, "ticket/ext-test", &mut warnings)
        .unwrap();

    let expected = outer.path().join("external-worktrees").join("ticket-ext-test");
    assert_eq!(
        wt.canonicalize().unwrap(),
        expected.canonicalize().unwrap(),
        "external worktree must be at sibling path: wt={} expected={}",
        wt.display(),
        expected.display()
    );
    assert!(wt.is_dir(), "external worktree dir must exist on disk");
    assert!(
        !wt.starts_with(&p),
        "external worktree must not be inside the repo: wt={}",
        wt.display()
    );
}

#[test]
fn clean_older_than_filters_by_updated_at() {
    // --older-than filters candidates by ticket frontmatter updated_at:
    // tickets last updated before the threshold are cleaned, recent ones
    // are kept. Tickets with no updated_at are conservatively kept.
    let dir = setup();
    let p = dir.path();

    // BYPASS: write_closed_ticket sets updated_at = now (via apm new); this test
    // requires updated_at = 2026-01-01 (before the 2026-03-01 threshold) and there
    // is no CLI command to set a historical updated_at.
    let old_branch = {
        let b = "ticket/old001-old";
        let path = "tickets/old001-old.md";
        let content = "+++\nid = \"old001\"\ntitle = \"old\"\nstate = \"closed\"\nbranch = \"ticket/old001-old\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|";
        git(p, &["checkout", "-b", b]);
        std::fs::create_dir_all(p.join("tickets")).unwrap();
        std::fs::write(p.join(path), content).unwrap();
        git(p, &["-c", "commit.gpgsign=false", "add", path]);
        git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: old"]);
        git(p, &["checkout", "main"]);
        b.to_string()
    };
    merge_into_main(p, &old_branch);

    // Recent ticket: updated_at = 2026-04-01, written inline.
    let recent_branch = "ticket/0002-recent";
    let recent_filename = "0002-recent.md";
    let recent_content = format!(
        "+++\nid = 2\ntitle = \"recent\"\nstate = \"closed\"\nbranch = \"{recent_branch}\"\ncreated_at = \"2026-04-01T00:00:00Z\"\nupdated_at = \"2026-04-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
    );
    git(p, &["checkout", "-b", recent_branch]);
    std::fs::write(p.join("tickets").join(recent_filename), &recent_content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &format!("tickets/{recent_filename}")]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket(2): close"]);
    git(p, &["checkout", "main"]);
    merge_into_main(p, recent_branch);

    // Provision worktrees for both.
    let old_wt = p.join("worktrees").join("ticket-0001-old");
    let recent_wt = p.join("worktrees").join("ticket-0002-recent");
    std::fs::create_dir_all(p.join("worktrees")).unwrap();
    git(p, &["worktree", "add", &old_wt.to_string_lossy(), &old_branch]);
    git(p, &["worktree", "add", &recent_wt.to_string_lossy(), recent_branch]);

    // Threshold between the two dates: only the old ticket should match.
    apm::cmd::clean::run(p, false, false, false, false, Some("2026-03-01".to_string()), false, false).unwrap();

    assert!(!old_wt.exists(), "old worktree should be cleaned (updated_at < threshold)");
    assert!(recent_wt.exists(), "recent worktree should be kept (updated_at >= threshold)");
}

// --- apm help commands ---

fn apm_help_commands(dir: &std::path::Path) -> std::process::Output {
    std::process::Command::new(env!("CARGO_BIN_EXE_apm"))
        .args(["help", "commands"])
        .current_dir(dir)
        .output()
        .unwrap()
}

#[test]
fn help_commands_exits_zero() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    assert!(
        out.status.success(),
        "apm help commands failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
fn help_commands_includes_visible_top_level_commands() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();
    for cmd in &["agents", "archive", "assign", "clean", "close", "epic",
                 "help", "init", "list", "move", "new", "next",
                 "refresh-epic", "register", "review", "revoke",
                 "sessions", "set", "show", "spec", "start", "state",
                 "sync", "validate", "version", "work",
                 "workers", "worktrees"] {
        assert!(stdout.contains(cmd), "missing command '{}' in:\n{}", cmd, stdout);
    }
}

#[test]
fn help_commands_excludes_hidden_hook() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(
        !stdout.contains("_hook"),
        "hidden command '_hook' appeared in:\n{stdout}"
    );
}

#[test]
fn help_commands_alphabetical_order() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();

    // Check a few pairs that must appear in alphabetical order.
    let agents_pos = stdout.find("\nagents\n").expect("agents not found");
    let archive_pos = stdout.find("\narchive\n").expect("archive not found");
    let list_pos = stdout.find("\nlist\n").expect("list not found");
    let worktrees_pos = stdout.find("\nworktrees\n").expect("worktrees not found");

    assert!(agents_pos < archive_pos, "agents must precede archive");
    assert!(archive_pos < list_pos, "archive must precede list");
    assert!(list_pos < worktrees_pos, "list must precede worktrees");
}

#[test]
fn help_commands_shows_epic_subcommands_in_order() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();

    for sub in &["epic new", "epic close", "epic list", "epic show", "epic set"] {
        assert!(stdout.contains(sub), "missing subcommand '{}' in:\n{}", sub, stdout);
    }

    // Subcommands must appear in declaration order (new, close, list, show, set).
    let new_pos   = stdout.find("epic new").unwrap();
    let close_pos = stdout.find("epic close").unwrap();
    let list_pos  = stdout.find("epic list").unwrap();
    let show_pos  = stdout.find("epic show").unwrap();
    let set_pos   = stdout.find("epic set").unwrap();
    assert!(new_pos < close_pos, "epic new must precede epic close");
    assert!(close_pos < list_pos, "epic close must precede epic list");
    assert!(list_pos < show_pos, "epic list must precede epic show");
    assert!(show_pos < set_pos, "epic show must precede epic set");
}

#[test]
fn help_commands_no_help_or_version_flags() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(!stdout.contains("--help"), "--help flag appeared in output");
    assert!(!stdout.contains("--version"), "--version flag appeared in output");
}

#[test]
fn help_commands_no_ansi_codes() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(
        !stdout.contains('\x1b'),
        "ANSI escape code found in output"
    );
}

#[test]
fn help_commands_lines_within_100_chars() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();
    for line in stdout.lines() {
        assert!(
            line.len() <= 100,
            "line exceeds 100 chars ({} chars): {:?}",
            line.len(),
            line
        );
    }
}

#[test]
fn help_commands_shows_defaults() {
    let dir = setup();
    let out = apm_help_commands(dir.path());
    let stdout = String::from_utf8(out.stdout).unwrap();
    // The --interval flag on `work` has default_value = "30".
    assert!(
        stdout.contains("(default: 30)"),
        "default annotation not found in:\n{stdout}"
    );
    // Default must appear only once (not duplicated).
    let count = stdout.matches("(default: 30)").count();
    assert_eq!(count, 1, "default annotation appeared {} times (expected 1)", count);
}

// --- pre-merge leak detection ---

/// Local-only repo with the `merge` completion strategy for `in_progress →
/// implemented`.  "implemented" is intentionally NOT terminal so that the
/// completion strategy is actually loaded from the transition config (the
/// `target_is_terminal` guard in state.rs skips the completion path when the
/// target state is terminal).
fn setup_merge_local() -> TempDir {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    git(p, &["config", "user.email", "test@test.com"]);
    git(p, &["config", "user.name", "test"]);
    // BYPASS: minimal two-state workflow; apm init installs the full default workflow which is not suitable for leak-detection tests
    std::fs::create_dir_all(p.join(".apm")).unwrap();
    std::fs::write(
        p.join(".apm/config.toml"),
        r#"[project]
name = "test"
default_branch = "main"

[tickets]
dir = "tickets"

[[workflow.states]]
id    = "in_progress"
label = "In Progress"

  [[workflow.states.transitions]]
  to         = "implemented"
  trigger    = "manual"
  completion = "merge"

[[workflow.states]]
id    = "implemented"
label = "Implemented"
"#,
    )
    .unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", ".apm/config.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    dir
}

/// Write a minimal in_progress ticket file directly on the given branch and
/// return to `main`.  The ticket has all AC boxes checked so the AC guard
/// passes and only the leak check can block the transition.
fn write_leak_test_ticket(dir: &std::path::Path, id: &str, branch: &str) {
    let rel = format!("tickets/{}.md", branch.strip_prefix("ticket/").unwrap_or(branch));
    let content = format!(
        "+++\nid = \"{id}\"\ntitle = \"Leak test\"\nstate = \"in_progress\"\nbranch = \"{branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
    );
    let exists = std::process::Command::new("git")
        .args(["rev-parse", "--verify", branch])
        .current_dir(dir)
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !exists {
        git(dir, &["checkout", "-b", branch]);
    } else {
        git(dir, &["checkout", branch]);
    }
    std::fs::create_dir_all(dir.join("tickets")).unwrap();
    std::fs::write(dir.join(&rel), &content).unwrap();
    git(dir, &["-c", "commit.gpgsign=false", "add", &rel]);
    git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("ticket: {id}")]);
    git(dir, &["checkout", "main"]);
}

#[test]
fn state_implemented_refuses_when_main_dirty_overlap() {
    let dir = setup_merge_local();
    let p = dir.path();

    // Commit src/foo.rs on main.
    std::fs::create_dir_all(p.join("src")).unwrap();
    std::fs::write(p.join("src/foo.rs"), "original").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add foo"]);

    // Create ticket branch that modifies src/foo.rs.
    let id = "ab000001";
    let branch = "ticket/ab000001-leak-overlap";
    git(p, &["checkout", "-b", branch]);
    std::fs::write(p.join("src/foo.rs"), "ticket change").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: change foo"]);
    git(p, &["checkout", "main"]);

    // Write the in_progress ticket file on the ticket branch.
    write_leak_test_ticket(p, id, branch);

    // Simulate a leaked edit: modify src/foo.rs on main without committing.
    std::fs::write(p.join("src/foo.rs"), "leaked edit").unwrap();

    let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
    assert!(result.is_err(), "should refuse with leak diagnostic; got Ok");
    let err_msg = result.err().unwrap().to_string();
    assert!(err_msg.contains("src/foo.rs"), "error should name leaked file: {err_msg}");
    assert!(err_msg.contains("ab000001"), "error should include ticket id: {err_msg}");

    // Ticket state on the branch must still be in_progress — no transition committed.
    let ticket_rel = format!("tickets/{}.md", branch.strip_prefix("ticket/").unwrap());
    let content = branch_content(p, branch, &ticket_rel);
    assert!(content.contains("state = \"in_progress\""), "ticket state must remain in_progress: {content}");
}

#[test]
fn state_implemented_proceeds_when_main_clean() {
    let dir = setup_merge_local();
    let p = dir.path();

    // Commit src/foo.rs on main.
    std::fs::create_dir_all(p.join("src")).unwrap();
    std::fs::write(p.join("src/foo.rs"), "original").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add foo"]);

    // Create ticket branch that modifies src/foo.rs.
    let id = "ab000002";
    let branch = "ticket/ab000002-clean-main";
    git(p, &["checkout", "-b", branch]);
    std::fs::write(p.join("src/foo.rs"), "ticket change").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: change foo"]);
    git(p, &["checkout", "main"]);

    write_leak_test_ticket(p, id, branch);

    // Main is clean — no leaked edit.
    let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
    assert!(result.is_ok(), "clean main should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());
}

#[test]
fn state_implemented_proceeds_when_dirty_no_overlap() {
    let dir = setup_merge_local();
    let p = dir.path();

    // Commit both src/foo.rs and src/bar.rs on main.
    std::fs::create_dir_all(p.join("src")).unwrap();
    std::fs::write(p.join("src/foo.rs"), "original").unwrap();
    std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs", "src/bar.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add foo and bar"]);

    // Ticket branch modifies only src/foo.rs.
    let id = "ab000003";
    let branch = "ticket/ab000003-no-overlap";
    git(p, &["checkout", "-b", branch]);
    std::fs::write(p.join("src/foo.rs"), "ticket change").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "src/foo.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: change foo"]);
    git(p, &["checkout", "main"]);

    write_leak_test_ticket(p, id, branch);

    // Main has src/bar.rs dirty — no overlap with ticket branch.
    std::fs::write(p.join("src/bar.rs"), "dirty bar").unwrap();

    let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
    assert!(result.is_ok(), "no overlap should succeed: {}", result.err().map(|e| e.to_string()).unwrap_or_default());
}

#[test]
fn state_implemented_refuses_when_main_has_untracked_overlap() {
    let dir = setup_merge_local();
    let p = dir.path();

    // Initial commit so there is at least one file on main.
    std::fs::write(p.join("existing.rs"), "base").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "existing.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "base"]);

    // Ticket branch adds src/new.rs as a brand-new file.
    let id = "ab000004";
    let branch = "ticket/ab000004-untracked-overlap";
    git(p, &["checkout", "-b", branch]);
    std::fs::create_dir_all(p.join("src")).unwrap();
    std::fs::write(p.join("src/new.rs"), "new file").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "src/new.rs"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "ticket: add new file"]);
    git(p, &["checkout", "main"]);

    write_leak_test_ticket(p, id, branch);

    // Leak: src/new.rs dropped untracked on main (worker wrote it to the wrong worktree).
    std::fs::create_dir_all(p.join("src")).unwrap();
    std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();

    let result = apm_core::state::transition(p, id, "implemented".into(), true, false);
    assert!(result.is_err(), "untracked overlap should be detected: got Ok");
    let err_msg = result.err().unwrap().to_string();
    assert!(err_msg.contains("src/new.rs"), "error should name the leaked file: {err_msg}");
}

// ── local_stale / staleness classification ─────────────────────────────────

/// Push a new commit to the bare origin from a second clone, simulating
/// another machine updating the ticket branch.
// Reserved for local_stale staleness classification tests.
#[allow(dead_code)]
fn push_remote_state_update(bare_path: &std::path::Path, branch: &str, rel_path: &str, new_content: &str) {
    let remote2 = tempfile::tempdir().unwrap();
    let r2 = remote2.path();
    std::process::Command::new("git")
        .args(["clone", "-q", &bare_path.to_string_lossy(), "."])
        .current_dir(r2)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["config", "user.name", "test"])
        .current_dir(r2)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(r2)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["checkout", branch])
        .current_dir(r2)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .status()
        .unwrap();
    let full_path = r2.join(rel_path);
    if let Some(parent) = full_path.parent() {
        std::fs::create_dir_all(parent).unwrap();
    }
    std::fs::write(&full_path, new_content).unwrap();
    std::process::Command::new("git")
        .args(["-c", "commit.gpgsign=false", "add", rel_path])
        .current_dir(r2)
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["-c", "commit.gpgsign=false", "commit", "-m", "remote state update"])
        .current_dir(r2)
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .status()
        .unwrap();
    std::process::Command::new("git")
        .args(["push", "origin", branch])
        .current_dir(r2)
        .status()
        .unwrap();
}

/// `load_all_from_git_classified` marks a ticket as `local_stale` when the
/// local branch is strictly behind origin.
#[test]
fn load_all_from_git_classified_marks_stale_when_local_behind() {
    let (_bare, local) = init_remote_repo();
    let p = local.path();

    // Create a ticket and push the branch.
    let (id, _) = create_ticket(p, "Stale classification test");
    let branch = find_ticket_branch(p, "stale-classification-test");
    git(p, &["push", "origin", &branch]);

    // Get the rel path.
    let rel_path = ticket_rel_path(&branch);

    // Read original content.
    let orig = branch_content(p, &branch, &rel_path);

    // Simulate remote machine pushing a new commit.
    let bare_path = std::process::Command::new("git")
        .args(["remote", "get-url", "origin"])
        .current_dir(p)
        .output()
        .unwrap();
    let bare_url = String::from_utf8_lossy(&bare_path.stdout).trim().to_string();

    let r2 = tempfile::tempdir().unwrap();
    std::process::Command::new("git")
        .args(["clone", "-q", &bare_url, "."])
        .current_dir(r2.path())
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .status()
        .unwrap();
    git(r2.path(), &["config", "user.name", "test"]);
    git(r2.path(), &["config", "user.email", "test@test.com"]);
    git(r2.path(), &["checkout", &branch]);
    // Write a modified version (e.g. state changed to in_progress).
    let new_content = orig.replace("state = \"new\"", "state = \"in_progress\"");
    std::fs::write(r2.path().join(&rel_path), &new_content).unwrap();
    git(r2.path(), &["-c", "commit.gpgsign=false", "add", &rel_path]);
    git(r2.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "remote: state update"]);
    git(r2.path(), &["push", "origin", &branch]);

    // Fetch in local so origin/ticket/* is ahead but local is stale.
    git(p, &["fetch", "--all", "--quiet"]);

    // load_all_from_git_classified should mark local_stale = true.
    let config = apm_core::config::Config::load(p).unwrap();
    let tickets = apm_core::ticket::load_all_from_git_classified(p, &config.tickets.dir).unwrap();
    let t = tickets.iter().find(|t| t.frontmatter.id.starts_with(&id[..8])).unwrap();
    assert!(t.local_stale, "ticket should be local_stale; state={}", t.frontmatter.state);
    assert!(!t.local_diverged, "ticket should not be local_diverged");
    // Content should reflect the new remote state.
    assert_eq!(
        t.frontmatter.state, "in_progress",
        "classified load should show origin state; got state={}", t.frontmatter.state
    );
}

/// `apm list` (aggressive on, with remote) shows `*` prefix for stale tickets.
#[test]
fn apm_list_shows_asterisk_for_stale_tickets() {
    let (_bare, local) = init_remote_repo();
    let p = local.path();

    let (id, _) = create_ticket(p, "Stale list test");
    let branch = find_ticket_branch(p, "stale-list-test");
    let rel_path = ticket_rel_path(&branch);
    git(p, &["push", "origin", &branch]);

    // Read original content to produce a modified version.
    let orig = branch_content(p, &branch, &rel_path);
    let new_content = orig.replace("state = \"new\"", "state = \"in_progress\"");

    // Simulate remote update.
    let bare_url = String::from_utf8_lossy(
        &std::process::Command::new("git")
            .args(["remote", "get-url", "origin"])
            .current_dir(p)
            .output()
            .unwrap()
            .stdout
    ).trim().to_string();
    let r2 = tempfile::tempdir().unwrap();
    std::process::Command::new("git")
        .args(["clone", "-q", &bare_url, "."])
        .current_dir(r2.path())
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .status()
        .unwrap();
    git(r2.path(), &["config", "user.name", "test"]);
    git(r2.path(), &["config", "user.email", "test@test.com"]);
    git(r2.path(), &["checkout", &branch]);
    std::fs::write(r2.path().join(&rel_path), &new_content).unwrap();
    git(r2.path(), &["-c", "commit.gpgsign=false", "add", &rel_path]);
    git(r2.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "remote: state update"]);
    git(r2.path(), &["push", "origin", &branch]);

    // Fetch so local is behind.
    git(p, &["fetch", "--all", "--quiet"]);

    // apm list should succeed and contain "*<id>" in output.
    let short_id = &id[..8];
    let out = run_apm(p, &["list", "--all"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains(&format!("*{short_id}")),
        "expected '*{short_id}' in list output; got:\n{stdout}"
    );
    assert!(
        stdout.contains("apm sync"),
        "expected 'apm sync' suggestion in list output; got:\n{stdout}"
    );
}

/// `apm list --no-aggressive` does NOT show `*` prefix even when local is stale.
#[test]
fn apm_list_no_aggressive_does_not_classify() {
    let (_bare, local) = init_remote_repo();
    let p = local.path();

    let (id, _) = create_ticket(p, "No aggressive stale");
    let branch = find_ticket_branch(p, "no-aggressive-stale");
    let rel_path = ticket_rel_path(&branch);
    git(p, &["push", "origin", &branch]);

    let orig = branch_content(p, &branch, &rel_path);
    let new_content = orig.replace("state = \"new\"", "state = \"in_progress\"");

    let bare_url = String::from_utf8_lossy(
        &std::process::Command::new("git")
            .args(["remote", "get-url", "origin"])
            .current_dir(p)
            .output()
            .unwrap()
            .stdout
    ).trim().to_string();
    let r2 = tempfile::tempdir().unwrap();
    std::process::Command::new("git")
        .args(["clone", "-q", &bare_url, "."])
        .current_dir(r2.path())
        .env("GIT_AUTHOR_NAME", "test")
        .env("GIT_AUTHOR_EMAIL", "test@test.com")
        .env("GIT_COMMITTER_NAME", "test")
        .env("GIT_COMMITTER_EMAIL", "test@test.com")
        .status()
        .unwrap();
    git(r2.path(), &["config", "user.name", "test"]);
    git(r2.path(), &["config", "user.email", "test@test.com"]);
    git(r2.path(), &["checkout", &branch]);
    std::fs::write(r2.path().join(&rel_path), &new_content).unwrap();
    git(r2.path(), &["-c", "commit.gpgsign=false", "add", &rel_path]);
    git(r2.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "remote: state update"]);
    git(r2.path(), &["push", "origin", &branch]);

    // --no-aggressive skips both fetch and classification.
    let out = run_apm(p, &["list", "--no-aggressive", "--all"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    let short_id = &id[..8];
    assert!(
        !stdout.contains(&format!("*{short_id}")),
        "no-aggressive should NOT show '*' prefix; got:\n{stdout}"
    );
}

/// A ticket whose local ref is ahead of origin still shows the local
/// (unpushed) state — not origin's older state.
#[test]
fn load_all_from_git_classified_local_ahead_shows_local_state() {
    let (_bare, local) = init_remote_repo();
    let p = local.path();

    let (id, _) = create_ticket(p, "Ahead local state");
    let branch = find_ticket_branch(p, "ahead-local-state");
    let rel_path = ticket_rel_path(&branch);
    git(p, &["push", "origin", &branch]);

    // Make a local commit (new state, not pushed).
    let orig = branch_content(p, &branch, &rel_path);
    let ahead_content = orig.replace("state = \"new\"", "state = \"in_progress\"");
    git(p, &["checkout", &branch]);
    std::fs::write(p.join(&rel_path), &ahead_content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", &rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "local: state update (unpushed)"]);
    git(p, &["checkout", "main"]);

    // Fetch (no new remote commits, so origin is behind local).
    git(p, &["fetch", "--all", "--quiet"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let tickets = apm_core::ticket::load_all_from_git_classified(p, &config.tickets.dir).unwrap();
    let t = tickets.iter().find(|t| t.frontmatter.id.starts_with(&id[..8])).unwrap();
    assert!(!t.local_stale, "local-ahead ticket must NOT be marked local_stale");
    assert_eq!(
        t.frontmatter.state, "in_progress",
        "should show local (unpushed) state; got: {}", t.frontmatter.state
    );
}

#[test]
fn sync_detect_case4_regular_merge_into_target_branch() {
    let dir = init_repo();
    let p = dir.path();

    // Create epic/foo branch from main.
    git(p, &["checkout", "-b", "epic/foo"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "epic/foo init"]);
    git(p, &["checkout", "main"]);

    // Create ticket branch with target_branch = "epic/foo" and state = "implemented".
    let branch = "ticket/ab12cd34-target-branch-regular";
    let rel_path = "tickets/ab12cd34-target-branch-regular.md";
    git(p, &["checkout", "-b", branch]);
    let content = "+++\nid = \"ab12cd34\"\ntitle = \"Target branch regular\"\nstate = \"implemented\"\nbranch = \"ticket/ab12cd34-target-branch-regular\"\ntarget_branch = \"epic/foo\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n";
    std::fs::write(p.join(rel_path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "implement ticket"]);
    git(p, &["checkout", "main"]);

    // Merge ticket branch into epic/foo with --no-ff.
    git(p, &["checkout", "epic/foo"]);
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch, "--no-edit"]);
    git(p, &["checkout", "main"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();

    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        close_branches.contains(&branch),
        "regular-merged ticket should be in close candidates; got: {close_branches:?}"
    );
    let close_reasons: Vec<&str> = candidates.close.iter()
        .filter(|c| c.ticket.frontmatter.branch.as_deref() == Some(branch))
        .map(|c| c.reason)
        .collect();
    assert_eq!(
        close_reasons, ["branch merged into target"],
        "close reason mismatch; got: {close_reasons:?}"
    );
    assert!(
        candidates.hints.is_empty(),
        "hints should be empty for auto-closed ticket; got: {:?}", candidates.hints
    );
}

#[test]
fn sync_detect_case4_squash_merge_into_target_branch() {
    let dir = init_repo();
    let p = dir.path();

    // Create epic/bar branch from main.
    git(p, &["checkout", "-b", "epic/bar"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "epic/bar init"]);
    git(p, &["checkout", "main"]);

    // Create ticket branch with target_branch = "epic/bar" and state = "implemented".
    let branch = "ticket/cd56ef78-target-branch-squash";
    let rel_path = "tickets/cd56ef78-target-branch-squash.md";
    git(p, &["checkout", "-b", branch]);
    let content = "+++\nid = \"cd56ef78\"\ntitle = \"Target branch squash\"\nstate = \"implemented\"\nbranch = \"ticket/cd56ef78-target-branch-squash\"\ntarget_branch = \"epic/bar\"\n+++\n\n## Spec\n\n### Acceptance criteria\n\n- [x] Done\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n";
    std::fs::write(p.join(rel_path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "implement ticket"]);
    git(p, &["checkout", "main"]);

    // Squash-merge ticket branch into epic/bar.
    git(p, &["checkout", "epic/bar"]);
    git(p, &["-c", "commit.gpgsign=false", "merge", "--squash", branch]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("squash {branch}")]);
    git(p, &["checkout", "main"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();

    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        close_branches.contains(&branch),
        "squash-merged ticket should be in close candidates; got: {close_branches:?}"
    );
    let close_reasons: Vec<&str> = candidates.close.iter()
        .filter(|c| c.ticket.frontmatter.branch.as_deref() == Some(branch))
        .map(|c| c.reason)
        .collect();
    assert_eq!(
        close_reasons, ["branch merged into target"],
        "close reason mismatch; got: {close_reasons:?}"
    );
    assert!(
        candidates.hints.is_empty(),
        "hints should be empty for auto-closed ticket; got: {:?}", candidates.hints
    );
}

#[test]
fn sync_detect_skips_pre_impl_ticket_with_fork_in_main() {
    // Reproduces the bug: a side-note ticket (state = "new") whose branch forks
    // from an epic that was --no-ff merged into main must NOT appear in close
    // candidates, even though content_merged_into_main returns a false positive.
    let dir = init_repo();
    let p = dir.path();

    // Checkout epic/test from main; add an allow-empty commit E1; return to main.
    git(p, &["checkout", "-b", "epic/test"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "epic/test init"]);
    git(p, &["checkout", "main"]);

    // Branch ticket/aa11bb22-side-note from epic/test (at E1).
    let branch = "ticket/aa11bb22-side-note";
    let rel_path = "tickets/aa11bb22-side-note.md";
    git(p, &["checkout", "epic/test"]);
    git(p, &["checkout", "-b", branch]);
    let content = "+++\nid = \"aa11bb22\"\ntitle = \"side note\"\nstate = \"new\"\nbranch = \"ticket/aa11bb22-side-note\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n";
    std::fs::write(p.join(rel_path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add side-note ticket"]);
    git(p, &["checkout", "main"]);

    // Merge epic/test (not the ticket branch) into main with --no-ff.
    // This makes E1 reachable from main via main's non-first parent only,
    // which triggers the content_merged_into_main false positive.
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", "epic/test", "--no-edit"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();

    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        !close_branches.contains(&branch),
        "pre-impl ticket should NOT appear in close candidates; got: {close_branches:?}"
    );
    assert!(
        candidates.hints.is_empty(),
        "hints should be empty for pre-impl ticket; got: {:?}", candidates.hints
    );
}

#[test]
fn sync_detect_implemented_ticket_still_closed_after_pre_impl_filter() {
    // Regression: an implemented ticket whose branch is --no-ff merged into main
    // must still appear in close candidates (Case 1) after the PRE_IMPL filter.
    let dir = init_repo();
    let p = dir.path();

    let (_id, branch) = write_ticket_to_branch(p, "implemented", "regression-test");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();

    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        close_branches.contains(&branch.as_str()),
        "implemented ticket should still appear in close candidates; got: {close_branches:?}"
    );
}

#[test]
fn sync_detect_skips_non_merge_completed_ticket_on_merged_branch() {
    // A ticket in `ready` state (not a merge-completed state) whose branch is
    // merged into main must NOT appear in close candidates.
    let dir = init_repo();
    let p = dir.path();

    let config = apm_core::config::Config::load(p).unwrap();
    assert!(
        !config.implementation_state_ids().contains("ready"),
        "`ready` must not be in implementation_state_ids for this test to be valid"
    );

    let (_id, branch) = write_ticket_to_branch(p, "ready", "ready-ticket");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    let candidates = apm_core::sync::detect(p, &config).unwrap();

    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        !close_branches.contains(&branch.as_str()),
        "ready-state ticket should NOT appear in close candidates; got: {close_branches:?}"
    );
    assert!(
        candidates.hints.is_empty(),
        "hints should be empty for non-merge-completed ticket; got: {:?}", candidates.hints
    );
}

#[test]
fn sync_detect_uses_config_derived_merge_completed_state() {
    // A custom workflow with a renamed merge-completion state (`shipped`) must
    // close tickets in `shipped` and skip tickets in `ready`.
    let dir = init_repo();
    let p = dir.path();

    // Replace default workflow with a custom one where `shipped` is the merge-completion state.
    let custom_workflow = r#"[workflow]

[[workflow.states]]
id    = "ready"
label = "Ready"

[[workflow.states]]
id    = "in_progress"
label = "In Progress"

  [[workflow.states.transitions]]
  to         = "shipped"
  trigger    = "manual"
  completion = "merge"

[[workflow.states]]
id    = "shipped"
label = "Shipped"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#;
    std::fs::write(p.join(".apm/workflow.toml"), custom_workflow).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", ".apm/workflow.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "use custom shipped workflow"]);

    let config = apm_core::config::Config::load(p).unwrap();
    assert_eq!(
        config.implementation_state_ids(),
        ["shipped".to_string()].into_iter().collect(),
        "custom workflow should report `shipped` as the implementation state"
    );

    // Ticket A: state = "shipped" — should be a close candidate.
    let branch_a = "ticket/aa11bb22-shipped";
    let rel_path_a = "tickets/aa11bb22-shipped.md";
    git(p, &["checkout", "-b", branch_a]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    let content_a = "+++\nid = \"aa11bb22\"\ntitle = \"shipped ticket\"\nstate = \"shipped\"\nbranch = \"ticket/aa11bb22-shipped\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n";
    std::fs::write(p.join(rel_path_a), content_a).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", rel_path_a]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add shipped ticket"]);
    git(p, &["checkout", "main"]);

    // Ticket B: state = "ready" — must NOT be a close candidate.
    let branch_b = "ticket/bb22cc33-ready";
    let rel_path_b = "tickets/bb22cc33-ready.md";
    git(p, &["checkout", "-b", branch_b]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    let content_b = "+++\nid = \"bb22cc33\"\ntitle = \"ready ticket\"\nstate = \"ready\"\nbranch = \"ticket/bb22cc33-ready\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n";
    std::fs::write(p.join(rel_path_b), content_b).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", rel_path_b]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add ready ticket"]);
    git(p, &["checkout", "main"]);

    // Merge both branches into main.
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch_a, "--no-edit"]);
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch_b, "--no-edit"]);

    let candidates = apm_core::sync::detect(p, &config).unwrap();

    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        close_branches.contains(&branch_a),
        "shipped ticket should appear in close candidates; got: {close_branches:?}"
    );
    assert!(
        !close_branches.contains(&branch_b),
        "ready ticket should NOT appear in close candidates; got: {close_branches:?}"
    );
}

#[test]
fn sync_detect_no_candidate_for_terminal_merge_completed_ticket() {
    // A workflow where a merging-completion transition targets a terminal state:
    // tickets in that state must NOT produce close candidates or hints — they are
    // already terminal and need no further transition.
    let dir = init_repo();
    let p = dir.path();

    // Replace default workflow with one where `done` is both terminal and the
    // target of a merge completion transition.
    let custom_workflow = r#"[workflow]

[[workflow.states]]
id    = "in_progress"
label = "In Progress"

  [[workflow.states.transitions]]
  to         = "done"
  trigger    = "manual"
  completion = "merge"

[[workflow.states]]
id       = "done"
label    = "Done"
terminal = true

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#;
    std::fs::write(p.join(".apm/workflow.toml"), custom_workflow).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", ".apm/workflow.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "workflow with terminal merge-completion state"]);

    let config = apm_core::config::Config::load(p).unwrap();
    assert!(
        config.implementation_state_ids().contains("done"),
        "`done` must be in implementation_state_ids"
    );
    assert!(
        config.terminal_state_ids().contains("done"),
        "`done` must be in terminal_state_ids"
    );

    // Create a ticket in `done` state and merge its branch into main.
    let branch = "ticket/cc33dd44-done";
    let rel_path = "tickets/cc33dd44-done.md";
    git(p, &["checkout", "-b", branch]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    let content = "+++\nid = \"cc33dd44\"\ntitle = \"done ticket\"\nstate = \"done\"\nbranch = \"ticket/cc33dd44-done\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n";
    std::fs::write(p.join(rel_path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add done ticket"]);
    git(p, &["checkout", "main"]);
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch, "--no-edit"]);

    let candidates = apm_core::sync::detect(p, &config).unwrap();

    let close_branches: Vec<&str> = candidates.close.iter()
        .map(|c| c.ticket.frontmatter.branch.as_deref().unwrap_or(""))
        .collect();
    assert!(
        !close_branches.contains(&branch),
        "terminal merge-completed ticket should NOT appear in close candidates; got: {close_branches:?}"
    );
    assert!(
        candidates.hints.is_empty(),
        "no hints should be emitted for terminal merge-completed ticket; got: {:?}", candidates.hints
    );
}

#[test]
fn sync_eligibility_invariant_to_state_order() {
    // Build a repo with a simple custom workflow and a merged implemented ticket.
    // Run detect twice: once with states in normal order, once with them reversed.
    // The set of close candidates must be identical.
    let dir = init_repo();
    let p = dir.path();

    let workflow_v1 = r#"[workflow]

[[workflow.states]]
id    = "ready"
label = "Ready"

  [[workflow.states.transitions]]
  to      = "in_progress"
  trigger = "command:start"

[[workflow.states]]
id             = "in_progress"
label          = "In Progress"
worker_profile = "claude/coder"

  [[workflow.states.transitions]]
  to         = "implemented"
  trigger    = "manual"
  completion = "pr_or_epic_merge"

[[workflow.states]]
id    = "implemented"
label = "Implemented"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#;
    std::fs::write(p.join(".apm/workflow.toml"), workflow_v1).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", ".apm/workflow.toml"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "simple workflow"]);

    // Create a ticket that went through in_progress before reaching implemented.
    let branch = "ticket/aa11bb22-order-test";
    let rel_path = "tickets/aa11bb22-order-test.md";
    git(p, &["checkout", "-b", branch]);
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    let content = "+++\nid = \"aa11bb22\"\ntitle = \"order test\"\nstate = \"implemented\"\nbranch = \"ticket/aa11bb22-order-test\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|  \n| 2026-01-01T00:00Z | — | new | test |\n| 2026-01-02T00:00Z | new | in_progress | test |\n| 2026-01-03T00:00Z | in_progress | implemented | test |\n";
    std::fs::write(p.join(rel_path), content).unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", rel_path]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "add impl ticket"]);
    git(p, &["checkout", "main"]);
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", branch, "--no-edit"]);

    let config1 = apm_core::config::Config::load(p).unwrap();
    let candidates1 = apm_core::sync::detect(p, &config1).unwrap();
    let close1: std::collections::HashSet<String> = candidates1.close.iter()
        .map(|c| c.ticket.frontmatter.id.clone())
        .collect();

    // Same workflow with states in reversed order — transitions are unchanged.
    let workflow_v2 = r#"[workflow]

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true

[[workflow.states]]
id    = "implemented"
label = "Implemented"

[[workflow.states]]
id             = "in_progress"
label          = "In Progress"
worker_profile = "claude/coder"

  [[workflow.states.transitions]]
  to         = "implemented"
  trigger    = "manual"
  completion = "pr_or_epic_merge"

[[workflow.states]]
id    = "ready"
label = "Ready"

  [[workflow.states.transitions]]
  to      = "in_progress"
  trigger = "command:start"
"#;
    // Write the shuffled workflow to disk (no commit needed — Config::load reads from disk).
    std::fs::write(p.join(".apm/workflow.toml"), workflow_v2).unwrap();

    let config2 = apm_core::config::Config::load(p).unwrap();
    assert_eq!(
        config1.implementation_state_ids(),
        config2.implementation_state_ids(),
        "implementation_state_ids must be invariant to state list order"
    );

    let candidates2 = apm_core::sync::detect(p, &config2).unwrap();
    let close2: std::collections::HashSet<String> = candidates2.close.iter()
        .map(|c| c.ticket.frontmatter.id.clone())
        .collect();

    assert_eq!(
        close1, close2,
        "close candidates must be identical regardless of workflow state order; v1={close1:?} v2={close2:?}"
    );
    assert!(
        close1.contains("aa11bb22"),
        "the implemented ticket should appear in close candidates; got: {close1:?}"
    );
}

// --- close path: fix for AC df03566b ---

/// Verify that commit_to_branch commits only the target file even when
/// unrelated changes are already staged in the index.
#[test]
fn commit_to_branch_excludes_pre_staged_files() {
    let dir = init_repo();
    let p = dir.path();

    // Write and stage an unrelated file before calling commit_to_branch.
    std::fs::write(p.join("other.txt"), "unrelated").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "other.txt"]);

    // Commit a ticket file to the current branch (main) via the library.
    let rel_path = "tickets/aabbccdd-test.md";
    std::fs::create_dir_all(p.join("tickets")).unwrap();
    let content = "+++\nid = \"aabbccdd\"\ntitle = \"test\"\nstate = \"new\"\nbranch = \"ticket/aabbccdd-test\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n";
    apm_core::git::commit_to_branch(p, "main", rel_path, content, "test commit").unwrap();

    // The HEAD commit must contain only the ticket file, not other.txt.
    let show_out = std::process::Command::new("git")
        .args(["show", "--name-only", "--format=", "HEAD"])
        .current_dir(p)
        .output()
        .unwrap();
    let changed = String::from_utf8(show_out.stdout).unwrap();
    let files: Vec<&str> = changed.lines().filter(|l| !l.is_empty()).collect();
    assert!(files.contains(&rel_path), "commit must include ticket file: {files:?}");
    assert!(!files.contains(&"other.txt"), "commit must not include pre-staged other.txt: {files:?}");

    // other.txt must still be staged (not swept into the commit).
    let staged_out = std::process::Command::new("git")
        .args(["diff", "--cached", "--name-only"])
        .current_dir(p)
        .output()
        .unwrap();
    let staged = String::from_utf8(staged_out.stdout).unwrap();
    assert!(staged.trim().contains("other.txt"), "other.txt must remain staged: {staged:?}");
}

/// Closing a main-scoped ticket (no target_branch) writes state=closed to both
/// the ticket branch and main without performing a working-tree merge.
#[test]
fn close_main_scoped_writes_to_target() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (id, branch) = write_ticket_to_branch(p, "implemented", "close main scoped");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    let config = apm_core::config::Config::load(p).unwrap();
    apm_core::ticket::close(p, &config, &id, None, "test", false).unwrap();

    let rel = ticket_rel_path(&branch);
    let on_ticket = branch_content(p, &branch, &rel);
    let on_main = branch_content(p, "main", &rel);
    assert!(on_ticket.contains("state = \"closed\""), "ticket branch must be closed: {on_ticket}");
    assert!(on_main.contains("state = \"closed\""), "main must be closed: {on_main}");

    // No working-tree side-effects: no ticket files must be modified in the working tree.
    let status_out = std::process::Command::new("git")
        .args(["status", "--porcelain", "tickets/"])
        .current_dir(p)
        .output()
        .unwrap();
    let status = String::from_utf8(status_out.stdout).unwrap();
    assert!(status.trim().is_empty(), "ticket files must not be dirty after close: {status}");
}

/// Closing an epic-scoped ticket writes state=closed to the epic branch only;
/// main is not touched.
#[test]
fn close_epic_scoped_writes_to_epic_not_main() {
    let dir = init_repo();
    let p = dir.path();

    let epic_branch = "epic/aa111111-my-epic";

    // Create the epic branch from main.
    git(p, &["checkout", "-b", epic_branch]);
    git(p, &["checkout", "main"]);

    // Write the ticket directly with target_branch pointing to the epic.
    let ticket_id = "bb222222";
    let ticket_slug = format!("{ticket_id}-epic-scoped");
    let ticket_branch = format!("ticket/{ticket_slug}");
    let rel = format!("tickets/{ticket_slug}.md");
    let content = format!(
        "+++\nid = \"{ticket_id}\"\ntitle = \"epic scoped\"\nstate = \"implemented\"\nbranch = \"{ticket_branch}\"\ntarget_branch = \"{epic_branch}\"\ncreated_at = \"2026-01-01T00:00:00Z\"\nupdated_at = \"2026-01-01T00:00:00Z\"\n+++\n\n## Spec\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
    );

    // Write the ticket to both its own branch and the epic branch.
    apm_core::git::commit_to_branch(p, &ticket_branch, &rel, &content, "add ticket").unwrap();
    apm_core::git::commit_to_branch(p, epic_branch, &rel, &content, "merge ticket to epic").unwrap();

    let config = apm_core::config::Config::load(p).unwrap();
    apm_core::ticket::close(p, &config, ticket_id, None, "test", false).unwrap();

    // The epic branch must show state=closed.
    let on_epic = branch_content(p, epic_branch, &rel);
    assert!(on_epic.contains("state = \"closed\""), "epic branch must be closed: {on_epic}");

    // Main must not have the ticket file (it was never merged there).
    let files_on_main = apm_core::git::list_files_on_branch(p, "main", "tickets").unwrap_or_default();
    assert!(
        !files_on_main.contains(&rel),
        "main must not contain the epic-scoped ticket: {files_on_main:?}"
    );
}

/// state::transition to "closed" writes state=closed to both the ticket branch
/// and the effective target branch (same resolution used by ticket::close).
#[test]
fn state_transition_closed_writes_to_target() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (id, branch) = write_ticket_to_branch(p, "implemented", "state close target");
    // Merge into main so main has the implemented view.
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    let out = apm_core::state::transition(p, &id, "closed".into(), true, false).unwrap();
    assert!(
        out.warnings.iter().all(|w| !w.contains("error")),
        "unexpected errors: {:?}", out.warnings
    );

    let rel = ticket_rel_path(&branch);
    let on_ticket = branch_content(p, &branch, &rel);
    let on_main = branch_content(p, "main", &rel);
    assert!(on_ticket.contains("state = \"closed\""), "ticket branch: {on_ticket}");
    assert!(on_main.contains("state = \"closed\""), "main: {on_main}");
}

/// apm sync auto-close succeeds even when the main worktree has an uncommitted
/// file (the old merge-based path would fail or warn in this situation).
#[test]
fn sync_close_succeeds_with_dirty_main_worktree() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (id, branch) = write_ticket_to_branch(p, "implemented", "dirty worktree close");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    // Dirty the main worktree — uncommitted file.
    std::fs::write(p.join("dirty.txt"), "uncommitted").unwrap();

    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    assert!(
        candidates.close.iter().any(|c| c.ticket.frontmatter.id == id),
        "ticket should be a close candidate"
    );

    // Apply must succeed without error even though the worktree is dirty.
    apm_core::sync::apply(p, &config, &candidates, "test", false).unwrap();

    let rel = ticket_rel_path(&branch);
    let on_main = branch_content(p, "main", &rel);
    assert!(on_main.contains("state = \"closed\""), "main must show closed: {on_main}");
}

/// After a main-scoped ticket is closed (state=closed written to main) and its
/// ticket branch is deleted, sync must not re-offer it as a close candidate
/// (Case 2 must not re-fire for already-closed tickets).
#[test]
fn no_case2_redetection_after_close() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (id, branch) = write_ticket_to_branch(p, "implemented", "no redetect");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    // Close the ticket — this now writes state=closed to main as well as the ticket branch.
    let config = apm_core::config::Config::load(p).unwrap();
    let candidates = apm_core::sync::detect(p, &config).unwrap();
    assert!(candidates.close.iter().any(|c| c.ticket.frontmatter.id == id));
    apm_core::sync::apply(p, &config, &candidates, "test", false).unwrap();

    // Delete the ticket branch (simulates post-cleanup state).
    git(p, &["branch", "-D", &branch]);

    // Detect again — the ticket must NOT reappear (main now shows "closed").
    let candidates2 = apm_core::sync::detect(p, &config).unwrap();
    assert!(
        !candidates2.close.iter().any(|c| c.ticket.frontmatter.id == id),
        "already-closed ticket must not reappear after branch deletion: {:?}",
        candidates2.close.iter().map(|c| &c.ticket.frontmatter.id).collect::<Vec<_>>()
    );
}

/// Calling close on an already-closed ticket returns an error and makes no
/// additional commits on any branch.
#[test]
fn close_already_closed_returns_error() {
    let dir = setup_with_close_workflow();
    let p = dir.path();

    let (id, branch) = write_ticket_to_branch(p, "implemented", "already closed guard");
    git(p, &["-c", "commit.gpgsign=false", "merge", "--no-ff", &branch, "--no-edit"]);

    let config = apm_core::config::Config::load(p).unwrap();
    apm_core::ticket::close(p, &config, &id, None, "test", false).unwrap();

    let ticket_tip = rev_parse(p, &branch);
    let main_tip = rev_parse(p, "main");

    // Second call must fail with an "already closed" error.
    let err = apm_core::ticket::close(p, &config, &id, None, "test", false).unwrap_err();
    assert!(
        err.to_string().contains("already closed"),
        "error must mention already closed: {err}"
    );

    // No additional commits must have been made.
    assert_eq!(rev_parse(p, &branch), ticket_tip, "ticket branch must not change");
    assert_eq!(rev_parse(p, "main"), main_tip, "main must not change");
}

// --- recovery guidance ---

/// Init a repo with a workflow that has merge_failed agent-actionable (via
/// command:start transition) so that apm next can surface it.
fn setup_for_recovery() -> TempDir {
    let dir = init_repo();
    // Use pr_or_epic_merge (no git_host required).  Add transitions out of
    // implemented so the workflow passes apm validate.
    std::fs::write(dir.path().join(".apm/workflow.toml"), r#"[workflow]

[[workflow.states]]
id    = "ready"
label = "Ready"

  [[workflow.states.transitions]]
  to      = "in_progress"
  trigger = "command:start"

[[workflow.states]]
id             = "in_progress"
label          = "In Progress"
worker_profile = "claude/coder"

  [[workflow.states.transitions]]
  to         = "implemented"
  trigger    = "manual"
  completion = "pr_or_epic_merge"
  on_failure = "merge_failed"

[[workflow.states]]
id    = "implemented"
label = "Implemented"

  [[workflow.states.transitions]]
  to      = "closed"
  trigger = "manual"

[[workflow.states]]
id    = "merge_failed"
label = "Merge failed"

  [[workflow.states.transitions]]
  to      = "implemented"
  trigger = "manual"

  [[workflow.states.transitions]]
  to      = "merge_retry"
  trigger = "command:start"

[[workflow.states]]
id             = "merge_retry"
label          = "Merge retry"
worker_profile = "claude/coder"

  [[workflow.states.transitions]]
  to         = "implemented"
  trigger    = "manual"
  completion = "pr_or_epic_merge"
  on_failure = "merge_failed"

[[workflow.states]]
id       = "closed"
label    = "Closed"
terminal = true
"#).unwrap();
    git(dir.path(), &["add", ".apm/workflow.toml"]);
    git(dir.path(), &["-c", "commit.gpgsign=false", "commit", "-m", "recovery workflow"]);
    // Update validate stamp so mutating commands are not blocked.
    run_apm(dir.path(), &["validate"]);
    dir
}

/// Overwrite the ticket file on its branch to change from_state to to_state.
fn force_ticket_state(dir: &std::path::Path, branch: &str, rel: &str, from_state: &str, to_state: &str) {
    let existing = branch_content(dir, branch, rel);
    let updated = existing.replace(
        &format!("state = \"{from_state}\""),
        &format!("state = \"{to_state}\""),
    );
    git(dir, &["checkout", branch]);
    std::fs::write(dir.join(rel), &updated).unwrap();
    git(dir, &["-c", "commit.gpgsign=false", "add", rel]);
    git(dir, &["-c", "commit.gpgsign=false", "commit", "-m", &format!("set {to_state}")]);
    git(dir, &["checkout", "-"]);
}

#[test]
fn recovery_show_displays_block_for_merge_failed() {
    let dir = setup_for_recovery();
    let p = dir.path();
    let (id, branch) = create_ticket(p, "Recovery show test");
    let rel = ticket_rel_path(&branch);
    force_ticket_state(p, &branch, &rel, "new", "merge_failed");

    let out = run_apm(p, &["show", &id, "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("Recovery options:"),
        "expected 'Recovery options:' in show output:\n{stdout}"
    );
    assert!(
        stdout.contains(&format!("apm state {id} implemented")),
        "expected 'apm state {id} implemented' in show output:\n{stdout}"
    );
}

#[test]
fn recovery_list_shows_summary_for_merge_failed() {
    let dir = setup_for_recovery();
    let p = dir.path();
    let (_, branch) = create_ticket(p, "Recovery list test");
    let rel = ticket_rel_path(&branch);
    force_ticket_state(p, &branch, &rel, "new", "merge_failed");

    let out = run_apm(p, &["list", "--state", "merge_failed", "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("Recovery:"),
        "expected 'Recovery:' in list output:\n{stdout}"
    );
}

#[test]
fn recovery_next_shows_options_for_merge_failed() {
    let dir = setup_for_recovery();
    let p = dir.path();
    let (id, branch) = create_ticket(p, "Recovery next test");
    let rel = ticket_rel_path(&branch);
    force_ticket_state(p, &branch, &rel, "new", "merge_failed");
    run_apm(p, &["set", &id, "priority", "10", "--no-aggressive"]);

    let out = run_apm(p, &["next", "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("Recovery options:"),
        "expected 'Recovery options:' in next output:\n{stdout}"
    );
}

#[test]
fn recovery_show_no_block_for_in_progress() {
    let dir = setup_for_recovery();
    let p = dir.path();
    let (id, branch) = create_ticket(p, "No recovery show");
    let rel = ticket_rel_path(&branch);
    force_ticket_state(p, &branch, &rel, "new", "in_progress");

    let out = run_apm(p, &["show", &id, "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("Recovery options:"),
        "unexpected 'Recovery options:' in show output for in_progress:\n{stdout}"
    );
}

#[test]
fn recovery_list_no_summary_for_in_progress() {
    let dir = setup_for_recovery();
    let p = dir.path();
    let (_, branch) = create_ticket(p, "No recovery list");
    let rel = ticket_rel_path(&branch);
    force_ticket_state(p, &branch, &rel, "new", "in_progress");

    let out = run_apm(p, &["list", "--state", "in_progress", "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("Recovery:"),
        "unexpected 'Recovery:' in list output for in_progress:\n{stdout}"
    );
}

#[test]
fn recovery_next_no_options_for_in_progress() {
    let dir = setup_for_recovery();
    let p = dir.path();
    let (id, branch) = create_ticket(p, "No recovery next");
    let rel = ticket_rel_path(&branch);
    force_ticket_state(p, &branch, &rel, "new", "in_progress");
    run_apm(p, &["set", &id, "priority", "10", "--no-aggressive"]);

    // in_progress has no actionable = ["agent"] so apm next returns no ticket;
    // stdout must not contain "Recovery options:".
    let out = run_apm(p, &["next", "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("Recovery options:"),
        "unexpected 'Recovery options:' in next output for in_progress:\n{stdout}"
    );
}

/// `apm list` shows `↓` on stale-epic ticket rows and nothing on fresh/main-scoped rows.
#[test]
fn apm_list_epic_stale_marker() {
    let dir = init_repo();
    let p = dir.path();

    // Create stale epic: branch before advancing main.
    git(p, &["checkout", "-b", "epic/aa000001-stale-epic"]);
    git(p, &["checkout", "main"]);

    // Advance main so the epic branch is now behind.
    std::fs::write(p.join("advance.txt"), "advance").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "advance.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "advance main"]);

    // Create fresh epic: branch from updated main.
    git(p, &["checkout", "-b", "epic/bb000002-fresh-epic"]);
    git(p, &["checkout", "main"]);

    // Ticket on stale epic.
    let t_stale = "+++\nid = \"cc000001\"\ntitle = \"Stale epic ticket\"\nstate = \"ready\"\nbranch = \"ticket/cc000001-stale-epic-ticket\"\ntarget_branch = \"epic/aa000001-stale-epic\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/cc000001-stale-epic-ticket", "tickets/cc000001-stale-epic-ticket.md", t_stale);

    // Ticket on fresh epic.
    let t_fresh = "+++\nid = \"dd000001\"\ntitle = \"Fresh epic ticket\"\nstate = \"ready\"\nbranch = \"ticket/dd000001-fresh-epic-ticket\"\ntarget_branch = \"epic/bb000002-fresh-epic\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/dd000001-fresh-epic-ticket", "tickets/dd000001-fresh-epic-ticket.md", t_fresh);

    // Main-scoped ticket.
    let t_main = "+++\nid = \"ee000001\"\ntitle = \"Main scoped ticket\"\nstate = \"ready\"\nbranch = \"ticket/ee000001-main-scoped-ticket\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/ee000001-main-scoped-ticket", "tickets/ee000001-main-scoped-ticket.md", t_main);

    let out = run_apm(p, &["list", "--all", "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        stdout.lines().any(|l| l.contains("cc000001") && l.contains('\u{2193}')),
        "stale epic ticket must show ↓; got:\n{stdout}"
    );
    assert!(
        stdout.lines().any(|l| l.contains("dd000001") && !l.contains('\u{2193}')),
        "fresh epic ticket must not show ↓; got:\n{stdout}"
    );
    assert!(
        stdout.lines().any(|l| l.contains("ee000001") && !l.contains('\u{2193}')),
        "main-scoped ticket must not show ↓; got:\n{stdout}"
    );
    assert!(
        !stdout.contains("epics:"),
        "output must not contain 'epics:' footer; got:\n{stdout}"
    );
}

/// When two tickets share the same stale epic, both rows show `↓`.
#[test]
fn apm_list_shared_epic_stale_marker() {
    let dir = init_repo();
    let p = dir.path();

    // Create stale epic: branch before advancing main.
    git(p, &["checkout", "-b", "epic/aa000001-stale-epic"]);
    git(p, &["checkout", "main"]);

    // Advance main so the epic branch is now behind.
    std::fs::write(p.join("advance.txt"), "advance").unwrap();
    git(p, &["-c", "commit.gpgsign=false", "add", "advance.txt"]);
    git(p, &["-c", "commit.gpgsign=false", "commit", "-m", "advance main"]);

    // Two tickets both on the stale epic.
    let t1 = "+++\nid = \"ff000001\"\ntitle = \"Stale epic ticket 1\"\nstate = \"ready\"\nbranch = \"ticket/ff000001-stale-1\"\ntarget_branch = \"epic/aa000001-stale-epic\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/ff000001-stale-1", "tickets/ff000001-stale-1.md", t1);

    let t2 = "+++\nid = \"gg000001\"\ntitle = \"Stale epic ticket 2\"\nstate = \"ready\"\nbranch = \"ticket/gg000001-stale-2\"\ntarget_branch = \"epic/aa000001-stale-epic\"\n+++\n\nbody\n";
    commit_ticket_to_branch(p, "ticket/gg000001-stale-2", "tickets/gg000001-stale-2.md", t2);

    let out = run_apm(p, &["list", "--all", "--no-aggressive"]);
    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        stdout.lines().any(|l| l.contains("ff000001") && l.contains('\u{2193}')),
        "first stale ticket must show ↓; got:\n{stdout}"
    );
    assert!(
        stdout.lines().any(|l| l.contains("gg000001") && l.contains('\u{2193}')),
        "second stale ticket must show ↓; got:\n{stdout}"
    );
    assert!(
        !stdout.contains("epics:"),
        "output must not contain 'epics:' footer; got:\n{stdout}"
    );
}
