//! Regression tests for pipeline advance (bugs 295, 519, 529, 645, 668).
use super::super::super::{AgentPool, composite_key};
use crate::agents::{AgentStatus, CompletionReport};
use crate::io::watcher::WatcherEvent;

// ── story 519: mergemaster pre-flight blocks when no commits ahead ──

/// Regression test for story 519: when the feature branch has zero commits
/// ahead of master, mergemaster must not spawn a Claude session.  A no-op
/// session spent $0.82 in the 2026-04-09 incident because the worktree was
/// reset to master before mergemaster ran.
#[tokio::test]
async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() {
    use std::process::Command;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Init a bare git repo on master with one empty commit.
    Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "--allow-empty", "-m", "init"])
        .current_dir(root)
        .output()
        .unwrap();

    // Create a feature branch that points at master HEAD (zero commits ahead).
    // This replicates the incident where the worktree was reset to master.
    Command::new("git")
        .args(["branch", "feature/story-9919_story_no_commits"])
        .current_dir(root)
        .output()
        .unwrap();

    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9919_story_no_commits",
        "2_current",
        "---\nname: Test\n---\n",
        crate::db::ItemMeta::named("Test"),
    );

    let pool = AgentPool::new_test(3001);
    let mut rx = pool.watcher_tx.subscribe();

    // Simulate coder completing with gates passed (qa: server → goes to merge).
    pool.run_pipeline_advance(
        "9919_story_no_commits",
        "coder-1",
        CompletionReport {
            summary: "done".to_string(),
            gates_passed: true,
            gate_output: String::new(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        None,
        false,
        None,
    )
    .await;

    // Story should still exist in the content store after moving to merge.
    assert!(
        crate::db::read_content(crate::db::ContentKey::Story("9919_story_no_commits")).is_some(),
        "story should remain in content store — not removed"
    );

    // A StoryBlocked event must be emitted by the background merge task.
    // The deterministic merge pipeline runs asynchronously, so poll with a
    // timeout instead of a non-blocking try_recv().
    let mut got_blocked = false;
    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
    while tokio::time::Instant::now() < deadline {
        while let Ok(evt) = rx.try_recv() {
            if let WatcherEvent::StoryBlocked { story_id, .. } = &evt
                && story_id == "9919_story_no_commits"
            {
                got_blocked = true;
            }
        }
        if got_blocked {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    }
    assert!(
        got_blocked,
        "StoryBlocked event must be sent when feature branch has no commits ahead of master"
    );

    // No mergemaster agent should have been started.
    let agents = pool.agents.try_lock().unwrap();
    let mergemaster_started = agents
        .values()
        .any(|a| a.agent_name.contains("mergemaster"));
    assert!(
        !mergemaster_started,
        "mergemaster agent must NOT be started when no commits ahead of master"
    );
}

// ── bug 295: pipeline advance picks up waiting QA stories ──────────

#[tokio::test]
async fn pipeline_advance_picks_up_waiting_qa_stories_after_completion() {
    use super::super::super::auto_assign::is_agent_free;
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk = root.join(".huskies");
    fs::create_dir_all(&sk).unwrap();

    // Configure a single QA agent.
    fs::write(
        sk.join("project.toml"),
        r#"
[[agent]]
name = "qa"
stage = "qa"
"#,
    )
    .unwrap();

    // Seed stories via CRDT (the only source of truth).
    crate::db::ensure_content_store();
    // Story 292 is in QA with QA agent running (will "complete" via
    // run_pipeline_advance below).  Story 293 is in QA with NO agent —
    // simulating the "stuck" state from bug 295.
    crate::db::write_item_with_content(
        "292_story_first",
        "3_qa",
        "---\nname: First\nqa: human\n---\n",
        crate::db::ItemMeta::named("First"),
    );
    crate::db::write_item_with_content(
        "293_story_second",
        "3_qa",
        "---\nname: Second\nqa: human\n---\n",
        crate::db::ItemMeta::named("Second"),
    );

    let pool = AgentPool::new_test(3001);
    // QA is currently running on story 292.
    pool.inject_test_agent("292_story_first", "qa", AgentStatus::Running);

    // Verify that 293 cannot get a QA agent right now (QA is busy).
    {
        let agents = pool.agents.try_lock().unwrap();
        assert!(
            !is_agent_free(&agents, "qa"),
            "qa should be busy on story 292"
        );
    }

    // Simulate QA completing on story 292: remove the agent from the pool
    // (as run_server_owned_completion does) then run pipeline advance.
    {
        let mut agents = pool.agents.try_lock().unwrap();
        agents.remove(&composite_key("292_story_first", "qa"));
    }

    pool.run_pipeline_advance(
        "292_story_first",
        "qa",
        CompletionReport {
            summary: "QA done".to_string(),
            gates_passed: true,
            gate_output: String::new(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        None,
        false,
        None,
    )
    .await;

    // After pipeline advance, auto_assign should have started QA on story 293.
    let agents = pool.agents.try_lock().unwrap();
    let qa_on_293 = agents.values().any(|a| {
        a.agent_name == "qa" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
    });
    assert!(
        qa_on_293,
        "auto_assign should have started qa for story 293 after 292's QA completed, \
         but no qa agent is pending/running. Pool: {:?}",
        agents
            .iter()
            .map(|(k, a)| format!("{k}: {} ({})", a.agent_name, a.status))
            .collect::<Vec<_>>()
    );
}

// ── bug 529: stale mergemaster advance for a done story is a no-op ──

/// Regression test for bug 529: when a stale mergemaster advance fires
/// after the story has already reached 5_done, the advance must be a
/// no-op — no post-merge tests, no notifications, no agent restarts.
#[tokio::test]
async fn stale_mergemaster_advance_for_done_story_is_noop() {
    use std::process::Command;

    // Initialise CRDT so read_typed works.
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Init a git repo so post-merge tests would pass if they ran.
    Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "--allow-empty", "-m", "init"])
        .current_dir(root)
        .output()
        .unwrap();

    // Seed the story in 5_done via the DB, which also writes to the CRDT.
    let story_id = "9929_story_zombie_merge";
    let content = "---\nname: Zombie Merge Test\n---\n";
    crate::db::write_content(crate::db::ContentKey::Story(story_id), content);
    crate::db::write_item_with_content(
        story_id,
        "5_done",
        content,
        crate::db::ItemMeta::named("Zombie Merge Test"),
    );

    let pool = AgentPool::new_test(3001);
    let mut rx = pool.watcher_tx.subscribe();

    // Simulate a stale mergemaster advance firing for the already-done story.
    pool.run_pipeline_advance(
        story_id,
        "mergemaster",
        CompletionReport {
            summary: "stale advance".to_string(),
            gates_passed: true,
            gate_output: String::new(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        None,
        false,
        None,
    )
    .await;

    // No agents should have been started.
    let agents = pool.agents.try_lock().unwrap();
    assert!(
        agents.is_empty(),
        "No agents should be started for a stale advance on a done story. \
         Pool: {:?}",
        agents.keys().collect::<Vec<_>>()
    );
    drop(agents);

    // No StoryBlocked or other events should have been emitted.
    let mut got_event = false;
    while let Ok(evt) = rx.try_recv() {
        // AgentStateChanged from auto_assign is acceptable only if the
        // advance didn't short-circuit.  Since we return early, no events.
        if matches!(evt, WatcherEvent::StoryBlocked { .. }) {
            got_event = true;
        }
    }
    assert!(
        !got_event,
        "No StoryBlocked event should be emitted for a stale advance"
    );

    // The story should still be in done (not moved elsewhere).
    if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id) {
        assert_eq!(
            item.stage.dir_name(),
            "done",
            "Story should remain in done after stale mergemaster advance"
        );
    }
}

// ── bug 645: work-survived check advances to QA instead of blocking ──

/// Integration test: when a coder agent fails gates but committed work
/// survives and compiles, the story advances to QA (not retry/block).
/// Simulates an agent that commits work and then dies mid-output.
#[tokio::test]
async fn work_survived_advances_to_qa_instead_of_blocking() {
    use std::fs;
    use std::process::Command;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Init a git repo with a minimal Cargo project.
    Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(root)
        .output()
        .unwrap();
    fs::write(
        root.join("Cargo.toml"),
        "[package]\nname = \"test_proj\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
    )
    .unwrap();
    fs::create_dir_all(root.join("src")).unwrap();
    fs::write(root.join("src/lib.rs"), "// empty\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "init"])
        .current_dir(root)
        .output()
        .unwrap();

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

    // Commit valid code on the feature branch.
    fs::write(wt_path.join("src/lib.rs"), "pub fn survived() {}\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(&wt_path)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add survived fn"])
        .current_dir(&wt_path)
        .output()
        .unwrap();

    // Set up the story in the content store.
    crate::db::ensure_content_store();
    crate::db::write_content(
        crate::db::ContentKey::Story("9945_story_survived"),
        "---\nname: Survived Test\n---\n",
    );
    crate::db::write_item_with_content(
        "9945_story_survived",
        "2_current",
        "---\nname: Survived Test\n---\n",
        crate::db::ItemMeta::named("Survived Test"),
    );

    // Simulate a passing run_tests call during the agent's session (bug 668):
    // the agent ran script/test, it passed, and the server captured the evidence.
    crate::db::write_content(
        crate::db::ContentKey::RunTestsOk("9945_story_survived"),
        "1",
    );

    let pool = AgentPool::new_test(3001);

    // Simulate coder failing gates (e.g. agent crashed, dirty worktree).
    pool.run_pipeline_advance(
        "9945_story_survived",
        "coder-1",
        CompletionReport {
            summary: "Agent crashed".to_string(),
            gates_passed: false,
            gate_output: "Worktree has uncommitted changes".to_string(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        Some(wt_path),
        false,
        None,
    )
    .await;

    // Story should have advanced — content store should reflect the move.
    // The work-survived check should have moved it to QA (or merge for
    // server qa mode), NOT incremented retry_count.
    let content = crate::db::read_content(crate::db::ContentKey::Story("9945_story_survived"))
        .expect("story should exist in content store");
    assert!(
        !content.contains("blocked"),
        "story should NOT be blocked when committed work survives: {content}"
    );
    assert!(
        !content.contains("retry_count"),
        "story should NOT have retry_count when work survived: {content}"
    );
}

/// Backwards-compat: agents that die WITHOUT committed work still get
/// the existing retry/block treatment.
#[tokio::test]
async fn no_committed_work_still_retries_and_blocks() {
    use std::fs;
    use std::process::Command;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Init a git repo (no Cargo project needed — cargo check will fail).
    Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "--allow-empty", "-m", "init"])
        .current_dir(root)
        .output()
        .unwrap();

    // Create a worktree with NO commits on the feature branch.
    let wt_path = tmp.path().join("wt");
    Command::new("git")
        .args([
            "worktree",
            "add",
            &wt_path.to_string_lossy(),
            "-b",
            "feature/story-9946_story_nowork",
        ])
        .current_dir(root)
        .output()
        .unwrap();

    // Set up the story with max_retries=1 so it blocks immediately.
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    crate::db::write_content(
        crate::db::ContentKey::Story("9946_story_nowork"),
        "---\nname: No Work Test\n---\n",
    );
    crate::db::write_item_with_content(
        "9946_story_nowork",
        "2_current",
        "---\nname: No Work Test\n---\n",
        crate::db::ItemMeta::named("No Work Test"),
    );

    // Write a project.toml with max_retries = 1.
    fs::create_dir_all(root.join(".huskies")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3001);
    let mut rx = pool.watcher_tx.subscribe();

    // Simulate coder failing gates with NO committed work on the worktree.
    pool.run_pipeline_advance(
        "9946_story_nowork",
        "coder-1",
        CompletionReport {
            summary: "Agent crashed".to_string(),
            gates_passed: false,
            gate_output: "Tests failed".to_string(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        Some(wt_path),
        false,
        None,
    )
    .await;

    // With no committed work and max_retries=1, the story should be blocked.
    let mut got_blocked = false;
    while let Ok(evt) = rx.try_recv() {
        if let WatcherEvent::StoryBlocked { story_id, .. } = &evt
            && story_id == "9946_story_nowork"
        {
            got_blocked = true;
            break;
        }
    }
    assert!(
        got_blocked,
        "Story with no committed work should be blocked after exceeding retry limit"
    );
}

// ── bug 668: pipeline must NOT advance when gates_passed=false and no test evidence ──

/// Path (a): gates_passed=false with committed work but NO captured run_tests
/// evidence → story stays in coding (retries), does NOT advance to QA/merge.
#[tokio::test]
async fn gates_failed_no_test_evidence_does_not_advance() {
    use std::fs;
    use std::process::Command;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Init a git repo with committed work on a feature branch.
    Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(root)
        .output()
        .unwrap();
    fs::write(
        root.join("Cargo.toml"),
        "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
    )
    .unwrap();
    fs::create_dir_all(root.join("src")).unwrap();
    fs::write(root.join("src/lib.rs"), "// empty\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "init"])
        .current_dir(root)
        .output()
        .unwrap();

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

    fs::write(wt_path.join("src/lib.rs"), "pub fn added() {}\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(&wt_path)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add fn"])
        .current_dir(&wt_path)
        .output()
        .unwrap();

    // Set up the story with max_retries=1 so we can observe the retry/block.
    crate::db::ensure_content_store();
    crate::db::write_content(
        crate::db::ContentKey::Story("9947_story_no_evidence"),
        "---\nname: No Evidence Test\n---\n",
    );
    crate::db::write_item_with_content(
        "9947_story_no_evidence",
        "2_current",
        "---\nname: No Evidence Test\n---\n",
        crate::db::ItemMeta::named("No Evidence Test"),
    );

    // Explicitly ensure no test evidence exists for this story.
    crate::db::delete_content(crate::db::ContentKey::RunTestsOk("9947_story_no_evidence"));

    fs::create_dir_all(root.join(".huskies")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3001);
    let mut rx = pool.watcher_tx.subscribe();

    // gates_passed=false, no run_tests evidence, but committed work exists.
    pool.run_pipeline_advance(
        "9947_story_no_evidence",
        "coder-1",
        CompletionReport {
            summary: "Gates failed".to_string(),
            gates_passed: false,
            gate_output: "Tests failed".to_string(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        Some(wt_path),
        false,
        None,
    )
    .await;

    // Story must NOT advance — it should be blocked (max_retries=1 means
    // first failure triggers block) rather than moving to QA/merge.
    let mut got_blocked = false;
    while let Ok(evt) = rx.try_recv() {
        if let WatcherEvent::StoryBlocked { story_id, .. } = &evt
            && story_id == "9947_story_no_evidence"
        {
            got_blocked = true;
            break;
        }
    }
    assert!(
        got_blocked,
        "gates_passed=false without run_tests evidence must NOT advance to QA/merge — \
         story should stay in coding (bug 668)"
    );
}

/// Path (b): gates_passed=false WITH captured run_tests evidence AND committed
/// work → advances to QA/merge (the legitimate bug-645 salvage case).
/// This is the case where the agent ran passing tests then crashed before server
/// gates could confirm results.
#[tokio::test]
async fn gates_failed_with_test_evidence_and_committed_work_advances() {
    use std::fs;
    use std::process::Command;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Init a git repo with committed work.
    Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(root)
        .output()
        .unwrap();
    fs::write(
        root.join("Cargo.toml"),
        "[package]\nname=\"t\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
    )
    .unwrap();
    fs::create_dir_all(root.join("src")).unwrap();
    fs::write(root.join("src/lib.rs"), "// empty\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "init"])
        .current_dir(root)
        .output()
        .unwrap();

    let wt_path = tmp.path().join("wt");
    Command::new("git")
        .args([
            "worktree",
            "add",
            &wt_path.to_string_lossy(),
            "-b",
            "feature/story-9948_story_with_evidence",
        ])
        .current_dir(root)
        .output()
        .unwrap();

    fs::write(wt_path.join("src/lib.rs"), "pub fn salvaged() {}\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(&wt_path)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add salvaged fn"])
        .current_dir(&wt_path)
        .output()
        .unwrap();

    crate::db::ensure_content_store();
    crate::db::write_content(
        crate::db::ContentKey::Story("9948_story_with_evidence"),
        "---\nname: With Evidence Test\n---\n",
    );
    crate::db::write_item_with_content(
        "9948_story_with_evidence",
        "2_current",
        "---\nname: With Evidence Test\n---\n",
        crate::db::ItemMeta::named("With Evidence Test"),
    );

    // Write the run_tests evidence — simulates the agent having called run_tests
    // MCP and getting a passing result before it crashed.
    crate::db::write_content(
        crate::db::ContentKey::RunTestsOk("9948_story_with_evidence"),
        "1",
    );

    let pool = AgentPool::new_test(3001);

    // gates_passed=false (agent crashed), but test evidence exists.
    pool.run_pipeline_advance(
        "9948_story_with_evidence",
        "coder-1",
        CompletionReport {
            summary: "Agent crashed".to_string(),
            gates_passed: false,
            gate_output: "PTY write assertion failed".to_string(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        Some(wt_path),
        false,
        None,
    )
    .await;

    // Story should advance (not blocked, no retry_count).
    let content = crate::db::read_content(crate::db::ContentKey::Story("9948_story_with_evidence"))
        .expect("story must exist in content store");
    assert!(
        !content.contains("blocked"),
        "story must NOT be blocked when test evidence exists and work committed: {content}"
    );
    assert!(
        !content.contains("retry_count"),
        "story must NOT have retry_count when salvaged via test evidence: {content}"
    );
    // Evidence must be consumed (cleared) after use.
    assert!(
        crate::db::read_content(crate::db::ContentKey::RunTestsOk(
            "9948_story_with_evidence"
        ))
        .is_none(),
        "run_tests evidence must be cleared after pipeline advance consumes it"
    );
}

// ── story 822: warm-resume coder on gate failure ──────────────────────────

/// Story 822 / AC 1 & 4: when a coder fails gates and a prior session ID is
/// provided, the pipeline re-spawns the coder so it can warm-resume the prior
/// conversation with the failure context injected — rather than starting from
/// scratch and re-reading the spec.
///
/// The test verifies:
/// - The coder is re-spawned (Pending/Running) rather than blocked.
/// - The retry counter is incremented (AC 3).
#[tokio::test]
async fn warm_resume_coder_on_gate_failure_with_session_id() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Set up a project with a fast no-op coder agent.
    fs::create_dir_all(root.join(".huskies")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        r#"
max_retries = 3

[[agent]]
name = "coder-1"
role = "Coder"
command = "echo"
args = ["noop"]
prompt = "test prompt"
stage = "coder"
"#,
    )
    .unwrap();

    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9950_story_warm_resume",
        "2_current",
        "---\nname: Warm Resume Test\n---\n",
        crate::db::ItemMeta::named("Warm Resume Test"),
    );

    let pool = AgentPool::new_test(3001);

    // Simulate a coder failing gates.  A prior session ID is provided to
    // trigger the warm-resume path (--resume <session_id>).
    pool.run_pipeline_advance(
        "9950_story_warm_resume",
        "coder-1",
        CompletionReport {
            summary: "Tests failed".to_string(),
            gates_passed: false,
            gate_output: "error[E0308]: mismatched types\n --> src/lib.rs:5:10".to_string(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        None,
        false,
        Some("prior-session-abc123".to_string()),
    )
    .await;

    // The coder must be re-spawned — Pending or Running.
    let agents = pool.agents.try_lock().unwrap();
    let coder_restarted = agents.values().any(|a| {
        a.agent_name == "coder-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
    });
    assert!(
        coder_restarted,
        "Coder must be re-spawned (warm-resumed) when gates fail and prior session ID provided. \
         Pool: {:?}",
        agents
            .iter()
            .map(|(k, a)| format!("{k}: {} ({})", a.agent_name, a.status))
            .collect::<Vec<_>>()
    );
    drop(agents);

    // Retry counter must have been incremented (AC 3) — checked via CRDT.
    let item =
        crate::crdt_state::read_item("9950_story_warm_resume").expect("story must be in CRDT");
    assert!(
        item.retry_count() > 0,
        "retry_count must be incremented after warm-resume: got {}",
        item.retry_count()
    );
}

// ── story 954: commit-only recovery respawn ───────────────────────────────

/// AC1+AC2: when a coder exits with `needs_commit_recovery=true` (uncommitted
/// work, zero commits), the pipeline issues a commit-only recovery respawn
/// WITHOUT consuming a retry_count slot.
#[tokio::test]
async fn commit_recovery_respawn_does_not_consume_retry_count() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    fs::create_dir_all(root.join(".huskies")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        r#"
max_retries = 3

[[agent]]
name = "coder-1"
role = "Coder"
command = "echo"
args = ["noop"]
prompt = "test"
stage = "coder"
"#,
    )
    .unwrap();

    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9954_story_recovery",
        "2_current",
        "---\nname: Recovery Test\n---\n",
        crate::db::ItemMeta::named("Recovery Test"),
    );
    // Ensure no stale recovery key exists.
    crate::db::delete_content(crate::db::ContentKey::CommitRecoveryPending(
        "9954_story_recovery",
    ));

    let pool = AgentPool::new_test(3001);

    pool.run_pipeline_advance(
        "9954_story_recovery",
        "coder-1",
        CompletionReport {
            summary: "exited".to_string(),
            gates_passed: false,
            gate_output: "Worktree has uncommitted changes".to_string(),
            needs_commit_recovery: true,
        },
        Some(root.to_path_buf()),
        None,
        false,
        None,
    )
    .await;

    // The recovery respawn must have been issued — coder-1 should be Pending/Running.
    let agents = pool.agents.try_lock().unwrap();
    let coder_restarted = agents.values().any(|a| {
        a.agent_name == "coder-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
    });
    assert!(
        coder_restarted,
        "Commit-recovery respawn must be issued when needs_commit_recovery=true. \
         Pool: {:?}",
        agents
            .iter()
            .map(|(k, a)| format!("{k}: {} ({})", a.agent_name, a.status))
            .collect::<Vec<_>>()
    );
    drop(agents);

    // retry_count must NOT have been incremented (AC 2).
    let item = crate::crdt_state::read_item("9954_story_recovery").expect("story must be in CRDT");
    assert_eq!(
        item.retry_count(),
        0,
        "retry_count must NOT be incremented for a commit-recovery respawn (AC 2): got {}",
        item.retry_count()
    );

    // The recovery key must be set so a second failure triggers a block.
    assert!(
        crate::db::read_content(crate::db::ContentKey::CommitRecoveryPending(
            "9954_story_recovery"
        ))
        .is_some(),
        "commit_recovery_pending key must be set after issuing recovery respawn"
    );
}

/// AC3: when consecutive commit-recovery respawns make NO file-edit progress
/// (worktree diff byte-identical across attempts), the story moves to `blocked`
/// after the no-progress cap is hit.  The agent gets unlimited respawns while
/// progress is being made, only stalling triggers escalation.
#[tokio::test]
async fn no_progress_commit_recovery_blocks_story_at_cap() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    fs::create_dir_all(root.join(".huskies")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        r#"
max_retries = 3

[[agent]]
name = "coder-1"
role = "Coder"
command = "echo"
args = ["noop"]
prompt = "test"
stage = "coder"
"#,
    )
    .unwrap();

    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9955_story_recovery2",
        "2_current",
        "---\nname: Recovery2 Test\n---\n",
        crate::db::ItemMeta::named("Recovery2 Test"),
    );

    // Simulate two previous consecutive no-progress respawns: counter=2 and a
    // fingerprint stored that matches what the current (worktree-less) attempt
    // will produce (None vs Some(stored) differ, but the path with stored=Some
    // and current=None enters the else branch where we increment the counter).
    crate::db::write_content(
        crate::db::ContentKey::CommitRecoveryPending("9955_story_recovery2"),
        "2",
    );
    crate::db::write_content(
        crate::db::ContentKey::CommitRecoveryDiffFingerprint("9955_story_recovery2"),
        "0",
    );

    let pool = AgentPool::new_test(3001);
    let mut rx = pool.watcher_tx.subscribe();

    pool.run_pipeline_advance(
        "9955_story_recovery2",
        "coder-1",
        CompletionReport {
            summary: "exited again".to_string(),
            gates_passed: false,
            gate_output: "Worktree has uncommitted changes".to_string(),
            needs_commit_recovery: true,
        },
        Some(root.to_path_buf()),
        None,
        false,
        None,
    )
    .await;

    // The story must be blocked once the cap is reached (counter 2 + 1 = 3).
    let mut got_blocked = false;
    let mut block_reason = String::new();
    while let Ok(evt) = rx.try_recv() {
        if let WatcherEvent::StoryBlocked { story_id, reason } = evt
            && story_id == "9955_story_recovery2"
        {
            got_blocked = true;
            block_reason = reason;
            break;
        }
    }
    assert!(
        got_blocked,
        "Story must be blocked after NO_PROGRESS_CAP consecutive no-progress respawns"
    );
    assert!(
        block_reason.contains("self-exits with no file or read progress"),
        "Block reason should describe the no-progress condition, got: {block_reason}"
    );

    // Both recovery keys must be cleared after blocking.
    assert!(
        crate::db::read_content(crate::db::ContentKey::CommitRecoveryPending(
            "9955_story_recovery2"
        ))
        .is_none(),
        "commit_recovery_pending key must be cleared after blocking"
    );
    assert!(
        crate::db::read_content(crate::db::ContentKey::CommitRecoveryDiffFingerprint(
            "9955_story_recovery2"
        ))
        .is_none(),
        "commit_recovery_diff_fingerprint key must be cleared after blocking"
    );

    // retry_count must NOT have been incremented (recovery never consumes a slot).
    let item = crate::crdt_state::read_item("9955_story_recovery2").expect("story must be in CRDT");
    assert_eq!(
        item.retry_count(),
        0,
        "retry_count must NOT be incremented during commit-recovery path: got {}",
        item.retry_count()
    );
}

/// Outer-cap path: even if the agent keeps making file-edit progress between
/// every commit-recovery respawn (different fingerprint each time), after
/// TOTAL_ATTEMPTS_CAP respawns without a commit the story must block.  This
/// catches the "flapping agent" pattern where the progress-aware counter
/// would never trigger because the diff keeps changing.
#[tokio::test]
async fn total_attempts_cap_blocks_flapping_agent_without_commit() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    fs::create_dir_all(root.join(".huskies")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        r#"
max_retries = 3

[[agent]]
name = "coder-1"
role = "Coder"
command = "echo"
args = ["noop"]
prompt = "test"
stage = "coder"
"#,
    )
    .unwrap();

    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9956_story_flapper",
        "2_current",
        "---\nname: Flapper Test\n---\n",
        crate::db::ItemMeta::named("Flapper Test"),
    );

    // Simulate 7 previous respawns (one short of the outer cap of 8).  The
    // no-progress counter is at 1 (the agent has been making progress every
    // attempt) but total attempts is at the threshold.
    crate::db::write_content(
        crate::db::ContentKey::CommitRecoveryTotalAttempts("9956_story_flapper"),
        "7",
    );
    crate::db::write_content(
        crate::db::ContentKey::CommitRecoveryPending("9956_story_flapper"),
        "1",
    );

    let pool = AgentPool::new_test(3001);
    let mut rx = pool.watcher_tx.subscribe();

    pool.run_pipeline_advance(
        "9956_story_flapper",
        "coder-1",
        CompletionReport {
            summary: "still no commit".to_string(),
            gates_passed: false,
            gate_output: "Worktree has uncommitted changes".to_string(),
            needs_commit_recovery: true,
        },
        Some(root.to_path_buf()),
        None,
        false,
        None,
    )
    .await;

    // Outer cap reached (7 + 1 = 8) → block.
    let mut got_blocked = false;
    let mut block_reason = String::new();
    while let Ok(evt) = rx.try_recv() {
        if let WatcherEvent::StoryBlocked { story_id, reason } = evt
            && story_id == "9956_story_flapper"
        {
            got_blocked = true;
            block_reason = reason;
            break;
        }
    }
    assert!(
        got_blocked,
        "Story must be blocked once total commit-recovery attempts hits the outer cap"
    );
    assert!(
        block_reason.contains("commit absent") && block_reason.contains("never committed"),
        "Block reason should describe the flapping pattern, got: {block_reason}"
    );

    // All three recovery keys must be cleared after blocking.
    assert!(
        crate::db::read_content(crate::db::ContentKey::CommitRecoveryTotalAttempts(
            "9956_story_flapper"
        ))
        .is_none(),
        "total_attempts key must be cleared after blocking"
    );
    assert!(
        crate::db::read_content(crate::db::ContentKey::CommitRecoveryPending(
            "9956_story_flapper"
        ))
        .is_none(),
        "commit_recovery_pending key must be cleared after blocking"
    );
}

// ── bug 953: bug-645 path must not advance when feature branch has zero commits ──

/// Regression test for bug 953: when a coder agent exits with gates_passed=false
/// but has captured passing test evidence AND the feature branch has ZERO commits
/// ahead of master, the bug-645 salvage path must NOT advance the story to
/// QA or Merge.  Zero commits = no actual work was done; treat as no-progress.
#[tokio::test]
async fn coder_completion_with_test_evidence_and_zero_commits_does_not_advance() {
    use std::fs;
    use std::process::Command;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    // Init a git repo with an initial commit on master.
    Command::new("git")
        .args(["init"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.email", "test@test.com"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(root)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "--allow-empty", "-m", "init"])
        .current_dir(root)
        .output()
        .unwrap();

    // Create a worktree on a feature branch that has ZERO commits ahead of master.
    let wt_path = tmp.path().join("wt");
    Command::new("git")
        .args([
            "worktree",
            "add",
            &wt_path.to_string_lossy(),
            "-b",
            "feature/story-9953_story_zero_commits",
        ])
        .current_dir(root)
        .output()
        .unwrap();

    // Seed the story in CRDT.
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9953_story_zero_commits",
        "2_current",
        "---\nname: Zero Commits Test\n---\n",
        crate::db::ItemMeta::named("Zero Commits Test"),
    );

    // Simulate the agent having called run_tests with a passing result (bug-645
    // evidence) — but the feature branch still has zero commits ahead of master.
    crate::db::write_content(
        crate::db::ContentKey::RunTestsOk("9953_story_zero_commits"),
        "1",
    );

    // Write a project.toml with max_retries=1 so the story blocks immediately,
    // giving us a clean assertion target (StoryBlocked event).
    fs::create_dir_all(root.join(".huskies")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        "max_retries = 1\n\n[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3001);
    let mut rx = pool.watcher_tx.subscribe();

    // Simulate coder completing with gates_passed=false (e.g. agent crashed).
    pool.run_pipeline_advance(
        "9953_story_zero_commits",
        "coder-1",
        crate::agents::CompletionReport {
            summary: "Agent crashed mid-output".to_string(),
            gates_passed: false,
            gate_output: "PTY write assertion failed".to_string(),
            needs_commit_recovery: false,
        },
        Some(root.to_path_buf()),
        Some(wt_path),
        false,
        None,
    )
    .await;

    // The story must NOT have advanced to QA or Merge — it should be blocked
    // (zero commits = no progress, so the bug-645 salvage path must not fire).
    let mut got_blocked = false;
    while let Ok(evt) = rx.try_recv() {
        if let WatcherEvent::StoryBlocked { story_id, .. } = &evt
            && story_id == "9953_story_zero_commits"
        {
            got_blocked = true;
            break;
        }
    }
    assert!(
        got_blocked,
        "Story with zero commits ahead of master must be blocked even when \
         test evidence exists — the bug-645 salvage path must require commits_ahead > 0"
    );

    // No QA or merge agent should have been started.
    let agents = pool.agents.try_lock().unwrap();
    let qa_or_merge_started = agents
        .values()
        .any(|a| a.agent_name.contains("qa") || a.agent_name.contains("merge"));
    assert!(
        !qa_or_merge_started,
        "No QA or merge agent must be started when feature branch has zero commits. \
         Pool: {:?}",
        agents
            .iter()
            .map(|(k, a)| format!("{k}: {}", a.agent_name))
            .collect::<Vec<_>>()
    );

    // Test evidence must have been consumed (cleared) by the advance handler.
    assert!(
        crate::db::read_content(crate::db::ContentKey::RunTestsOk("9953_story_zero_commits"))
            .is_none(),
        "run_tests evidence must be cleared after pipeline advance consumes it"
    );
}

// ── bug 1008: successful mergemaster exit must not re-spawn or block ──────────

/// AC4 regression (bug 1008): when `merge_agent_work` returns success with
/// `story_archived: true`, the spawn.rs exit handler must:
/// (a) not re-spawn the mergemaster,
/// (b) transition the story to done (already done by the merge runner before
///     writing `ContentKey::MergeSuccess` — verified via CRDT stage), and
/// (c) clear `MergeMasterSpawnCount` so a future re-entry starts fresh.
///
/// This test simulates the exit handler path: it seeds `ContentKey::MergeSuccess`
/// (as the merge runner would), seeds the story as Done in the CRDT (as
/// `move_story_to_done` would), then exercises the spawn.rs logic directly.
#[test]
fn successful_mergemaster_exit_does_not_respawn_or_block() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "9908_story_merge_success_1008";

    // Seed the story as Done in the CRDT (as move_story_to_done would have done).
    crate::db::write_item_with_content(
        story_id,
        "5_done",
        "---\nname: Merge Success Test\n---\n",
        crate::db::ItemMeta::named("Merge Success Test"),
    );

    // Simulate the merge runner writing MergeSuccess BEFORE the agent exited.
    crate::db::write_content(crate::db::ContentKey::MergeSuccess(story_id), "1");

    // Simulate a pre-existing spawn count (e.g. a previous transient exit).
    crate::db::write_content(crate::db::ContentKey::MergeMasterSpawnCount(story_id), "1");

    // Simulate the exit handler: read the DB key (as spawn.rs does).
    let merge_succeeded =
        crate::db::read_content(crate::db::ContentKey::MergeSuccess(story_id)).is_some();
    assert!(
        merge_succeeded,
        "MergeSuccess key must be present before the exit handler runs"
    );

    // Simulate what spawn.rs does on merge_succeeded=true.
    crate::db::delete_content(crate::db::ContentKey::MergeSuccess(story_id));
    crate::db::delete_content(crate::db::ContentKey::MergeMasterSpawnCount(story_id));

    // (a) No re-spawn: MergeSuccess key is gone (no reassign triggered).
    assert!(
        crate::db::read_content(crate::db::ContentKey::MergeSuccess(story_id)).is_none(),
        "(a) MergeSuccess key must be cleared after exit handler runs"
    );

    // (b) Story is still Done (not moved to blocked).
    if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id) {
        assert_eq!(
            item.stage.dir_name(),
            "done",
            "(b) Story must remain in done after successful mergemaster exit"
        );
    }

    // (c) Spawn count cleared so future re-entry starts fresh.
    assert!(
        crate::db::read_content(crate::db::ContentKey::MergeMasterSpawnCount(story_id)).is_none(),
        "(c) MergeMasterSpawnCount must be cleared after successful merge exit"
    );
}
