//! Tests for the merge pipeline module.

// `serial_test_lock` returns a `std::sync::MutexGuard<'static, ()>` that
// each test holds for its full body — including across `.await` points.
// Clippy normally warns about that because async-yielding while holding a
// sync mutex can deadlock arbitrary tasks; here it's exactly the goal:
// only one test in this module holds the guard at a time, the rest block
// on `lock()`, and the merge gate's flaky interleaving is eliminated.
#![allow(clippy::await_holding_lock)]

use super::super::super::AgentPool;
use super::time::{encode_server_start_time, server_start_time};
use crate::agents::merge::{MergeJob, MergeJobStatus};
use std::process::Command;
use std::sync::Arc;

/// Acquire a process-wide serial lock for the merge-pipeline tests.
///
/// These tests share a process-wide `server_start_time()` (a `OnceLock`
/// captured the first time anything in the merge subsystem calls it) and
/// touch the global merge-job CRDT log. Cargo runs them in parallel by
/// default, and the merge gate's Docker scheduler has caught at least one
/// interleaving where one test's `delete_merge_job` lands while another is
/// asserting the entry is still there.
///
/// Holding this mutex for each test serialises only the merge-pipeline
/// tests against each other — the rest of the suite stays parallel. The
/// guard is poison-tolerant so a panicking test doesn't lock out everything
/// that follows.
fn serial_test_lock() -> std::sync::MutexGuard<'static, ()> {
    use std::sync::Mutex;
    static LOCK: Mutex<()> = Mutex::new(());
    LOCK.lock().unwrap_or_else(|p| p.into_inner())
}

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

// ── bug 498: stale Running job blocks retry ───────────────────────────────

/// Regression test for bug 498: a Running merge job left behind by a killed
/// mergemaster must not block the next call to start_merge_agent_work.
///
/// Before the fix: start_merge_agent_work would return "Merge already in
/// progress" when a Running entry existed, even after the mergemaster died.
/// After the fix: the entry is cleared when the mergemaster exits, so a new
/// call succeeds.
#[tokio::test]
async fn stale_running_merge_job_is_cleared_and_retry_succeeds() {
    let _serial = serial_test_lock();
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();

    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    let pool = Arc::new(AgentPool::new_test(3001));

    // Inject a stale Running entry via CRDT, simulating a mergemaster that
    // died before the merge pipeline completed.  Use the current process PID
    // so the stale-lock sweep does NOT auto-remove it — this test verifies
    // the double-start guard path.
    crate::crdt_state::write_merge_job(
        "77_story_stale",
        "running",
        1.0,
        None,
        Some(&encode_server_start_time(server_start_time())),
    );

    // With a stale Running entry, start_merge_agent_work must be blocked.
    let blocked = pool.start_merge_agent_work(repo, "77_story_stale");
    assert!(
        blocked.is_err(),
        "start_merge_agent_work must be blocked while Running job exists"
    );
    let err_msg = blocked.unwrap_err();
    assert!(
        err_msg.contains("already in progress"),
        "unexpected error: {err_msg}"
    );

    // Simulate the mergemaster exit path: clear the stale Running entry.
    crate::crdt_state::delete_merge_job("77_story_stale");

    // After clearing, start_merge_agent_work must succeed (it will fail
    // the pipeline because there's no feature branch, but it must not be
    // blocked by "Merge already in progress").
    let result = pool.start_merge_agent_work(repo, "77_story_stale");
    assert!(
        result.is_ok(),
        "start_merge_agent_work must succeed after stale Running job is cleared; got: {result:?}"
    );

    // start_merge_agent_work spawns the actual pipeline as a background
    // tokio task and returns immediately. Wait for it to reach a terminal
    // state before the test ends: otherwise the task keeps running after
    // `_serial` is released and can still be touching CRDT state
    // (write_merge_job / delete_merge_job) while the next merge-pipeline
    // test has already called init_for_test(), corrupting that test's
    // thread-local state.
    loop {
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        if let Some(job) = pool.get_merge_status("77_story_stale")
            && !matches!(job.status, MergeJobStatus::Running)
        {
            break;
        }
    }
}

// ── story 852: periodic background reaper ────────────────────────────────

/// AC2: a Running merge_job whose `server_start` is older than the current
/// server's boot time must be deleted by `reap_stale_merge_jobs` without any
/// merge attempt being triggered.
#[tokio::test]
async fn reap_stale_merge_jobs_removes_old_running_entry_without_merge() {
    let _serial = serial_test_lock();
    crate::crdt_state::init_for_test();

    let pool = Arc::new(AgentPool::new_test(3001));

    // Inject a Running entry whose server_start predates the current server.
    crate::crdt_state::write_merge_job(
        "852_stale_reaper",
        "running",
        1.0,
        None,
        Some(&super::time::encode_server_start_time(0.0)), // older boot → stale
    );
    assert!(
        crate::crdt_state::read_merge_job("852_stale_reaper").is_some(),
        "stale entry must exist before reap"
    );

    // Reap: must remove the entry without triggering a merge pipeline.
    pool.reap_stale_merge_jobs();

    assert!(
        crate::crdt_state::read_merge_job("852_stale_reaper").is_none(),
        "reap_stale_merge_jobs must delete the stale Running entry"
    );

    // No agents must have been spawned (no merge was triggered).
    let agents = pool.agents.try_lock().unwrap();
    assert!(
        agents.is_empty(),
        "reap must not spawn any agents; got {} agent(s)",
        agents.len()
    );
}

/// Reaping a stale Running merge job must also fire a `WatcherEvent::WorkItem`
/// reassign so the auto-assign watcher loop re-triggers the merge on the
/// fresh boot.  Without this, a mid-merge server restart causes mergemaster
/// to see "Merge job disappeared", burn a retry, and eventually escalate to
/// MergeFailureFinal even though the merge never actually failed.
#[tokio::test]
async fn reap_stale_merge_jobs_emits_reassign_watcher_event() {
    let _serial = serial_test_lock();
    crate::crdt_state::init_for_test();

    use tokio::sync::broadcast;
    let (watcher_tx, mut rx) = broadcast::channel::<crate::io::watcher::WatcherEvent>(16);
    let pool = Arc::new(AgentPool::new(3001, watcher_tx));

    crate::crdt_state::write_merge_job(
        "853_stale_reapdispatch",
        "running",
        1.0,
        None,
        Some(&super::time::encode_server_start_time(0.0)),
    );

    pool.reap_stale_merge_jobs();

    // Drain the channel and look for a matching reassign event.
    let mut found = false;
    while let Ok(event) = rx.try_recv() {
        if let crate::io::watcher::WatcherEvent::WorkItem {
            stage,
            item_id,
            action,
            ..
        } = event
            && stage == "4_merge"
            && item_id == "853_stale_reapdispatch"
            && action == "reassign"
        {
            found = true;
            break;
        }
    }
    assert!(
        found,
        "reap_stale_merge_jobs must emit a 4_merge reassign event for the cleared job"
    );
}

// ── story 719: stale-lock recovery on new merge attempts ─────────────────

/// AC1/AC2/AC3: seeding merge_jobs with an entry whose PID is dead, then
/// triggering a new merge for a *different* story, must automatically remove
/// the stale entry (AC1/AC3) and log at INFO (AC2 — verified structurally
/// because the log path is exercised when the entry is removed).
#[cfg(unix)]
#[tokio::test]
async fn stale_merge_job_with_dead_pid_is_swept_on_new_merge_attempt() {
    let _serial = serial_test_lock();
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();

    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    let pool = Arc::new(AgentPool::new_test(3001));

    // Seed CRDT merge_jobs with a Running entry whose recorded server-start
    // time is older than the current server (legacy / previous instance).
    crate::crdt_state::write_merge_job(
        "719_stale_other",
        "running",
        1.0,
        None,
        Some(&encode_server_start_time(0.0)), // legacy/older boot — should be cleaned up
    );

    // Verify the entry is present before the sweep.
    assert!(
        crate::crdt_state::read_merge_job("719_stale_other").is_some(),
        "stale entry should exist before new merge attempt"
    );

    // Trigger a new merge for a *different* story.  The sweep runs at the
    // top of start_merge_agent_work and must remove the dead-PID entry.
    let _ = pool.start_merge_agent_work(repo, "719_trigger_story");

    // The stale entry must have been cleared.
    assert!(
        crate::crdt_state::read_merge_job("719_stale_other").is_none(),
        "stale entry with dead pid must be removed when a new merge attempt starts"
    );
}

// ── merge_agent_work tests ────────────────────────────────────────────────

/// Helper: start a merge and poll until terminal state.
async fn run_merge_to_completion(
    pool: &Arc<AgentPool>,
    repo: &std::path::Path,
    story_id: &str,
) -> MergeJob {
    pool.start_merge_agent_work(repo, story_id).unwrap();
    loop {
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        if let Some(job) = pool.get_merge_status(story_id)
            && !matches!(job.status, MergeJobStatus::Running)
        {
            return job;
        }
    }
}

#[tokio::test]
async fn merge_agent_work_returns_error_when_branch_not_found() {
    let _serial = serial_test_lock();
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "99_nonexistent").await;
    match &job.status {
        MergeJobStatus::Completed(report) => {
            assert!(
                !matches!(
                    report.result,
                    crate::agents::merge::MergeResult::Success { .. }
                ),
                "should fail when branch missing"
            );
        }
        MergeJobStatus::Failed(_) => {
            // Also acceptable — the pipeline errored out
        }
        MergeJobStatus::Running => {
            panic!("should not still be running");
        }
    }
}

#[tokio::test]
async fn merge_agent_work_succeeds_on_clean_branch() {
    let _serial = serial_test_lock();
    use std::fs;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Create a feature branch with a commit
    Command::new("git")
        .args(["checkout", "-b", "feature/story-23_test"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("feature.txt"), "feature content").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add feature"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Switch back to master (initial branch)
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Create the story file in 4_merge/ so we can test archival
    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    let story_file = merge_dir.join("23_test.md");
    fs::write(&story_file, "---\nname: Test\n---\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add story in merge"])
        .current_dir(repo)
        .output()
        .unwrap();

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "23_test").await;

    match &job.status {
        MergeJobStatus::Completed(report) => {
            assert!(
                !matches!(
                    report.result,
                    crate::agents::merge::MergeResult::Conflict { .. }
                ),
                "should have no conflicts"
            );
            let is_success = matches!(
                report.result,
                crate::agents::merge::MergeResult::Success { .. }
            );
            let is_gate_failure = matches!(
                report.result,
                crate::agents::merge::MergeResult::GateFailure { .. }
            );
            assert!(
                is_success || is_gate_failure || report.result.output().contains("Failed to run"),
                "report should be coherent: {report:?}"
            );
            if report.story_archived {
                let done = repo.join(".huskies/work/5_done/23_test.md");
                assert!(done.exists(), "done file should exist");
            }
        }
        MergeJobStatus::Failed(e) => {
            // Gate failures are acceptable in test env
            assert!(
                e.contains("Failed") || e.contains("failed"),
                "unexpected failure: {e}"
            );
        }
        MergeJobStatus::Running => panic!("should not still be running"),
    }
}

/// Regression test (story 1178, AC3): when the squash merge itself succeeds
/// but the CRDT state transition to Done fails (here: no CRDT entry exists
/// for the story, so `move_story_to_done` errors and `story_archived` is
/// false), the feature branch must NOT be deleted — otherwise a retry has
/// nothing to merge from.
#[tokio::test]
async fn merge_success_without_story_archived_keeps_feature_branch() {
    let _serial = serial_test_lock();
    use std::fs;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    let branch = "feature/story-1178_branch_kept";
    Command::new("git")
        .args(["checkout", "-b", branch])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("feature.txt"), "feature content").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add feature"])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();

    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    fs::write(
        merge_dir.join("1178_branch_kept.md"),
        "---\nname: Branch Kept Test\n---\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add story in merge"])
        .current_dir(repo)
        .output()
        .unwrap();

    let pool = Arc::new(AgentPool::new_test(3001));
    // Note: no CRDT entry is written for this story, so `move_story_to_done`
    // will fail with NotFound — `story_archived` will be false regardless of
    // git merge outcome. That is exactly the scenario this test protects.
    let job = run_merge_to_completion(&pool, repo, "1178_branch_kept").await;

    let MergeJobStatus::Completed(report) = &job.status else {
        panic!("expected a completed job, got: {:?}", job.status);
    };
    if matches!(
        report.result,
        crate::agents::merge::MergeResult::Success { .. }
    ) {
        assert!(
            !report.story_archived,
            "story_archived should be false: no CRDT entry exists for this story"
        );
        assert!(
            !report.worktree_cleaned_up,
            "worktree/branch must not be cleaned up when story_archived is false"
        );
        let branch_check = Command::new("git")
            .args(["rev-parse", "--verify", branch])
            .current_dir(repo)
            .output()
            .unwrap();
        assert!(
            branch_check.status.success(),
            "feature branch '{branch}' must still exist after a merge whose state \
             transition to Done failed, so a retry stays possible"
        );
    }
}

// ── quality gate ordering test ────────────────────────────────

/// Regression test for bug 142: quality gates must run BEFORE the fast-forward
/// to master so that broken code never lands on master.
#[cfg(unix)]
#[test]
fn quality_gates_run_before_fast_forward_to_master() {
    let _serial = serial_test_lock();
    use std::fs;
    use std::os::unix::fs::PermissionsExt;
    use tempfile::tempdir;

    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Add a failing script/test so quality gates will fail.
    let script_dir = repo.join("script");
    fs::create_dir_all(&script_dir).unwrap();
    let script_test = script_dir.join("test");
    fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap();
    let mut perms = fs::metadata(&script_test).unwrap().permissions();
    perms.set_mode(0o755);
    fs::set_permissions(&script_test, perms).unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add failing script/test"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Create a feature branch with a commit.
    Command::new("git")
        .args(["checkout", "-b", "feature/story-142_test"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("change.txt"), "feature change").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "feature work"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Switch back to master and record its HEAD.
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();
    let head_before = String::from_utf8(
        Command::new("git")
            .args(["rev-parse", "HEAD"])
            .current_dir(repo)
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap()
    .trim()
    .to_string();

    // Run the squash-merge.  The failing script/test makes quality gates
    // fail → fast-forward must NOT happen.
    let result =
        crate::agents::merge::run_squash_merge(repo, "feature/story-142_test", "142_test").unwrap();

    let head_after = String::from_utf8(
        Command::new("git")
            .args(["rev-parse", "HEAD"])
            .current_dir(repo)
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap()
    .trim()
    .to_string();

    // Gates must have failed (script/test exits 1) so master should be untouched.
    assert!(
        !matches!(result, crate::agents::merge::MergeResult::Success { .. }),
        "run_squash_merge must report failure when gates fail; got: {result:?}"
    );
    assert_eq!(
        head_before, head_after,
        "master HEAD must not advance when quality gates fail (bug 142)"
    );
}

#[tokio::test]
async fn merge_agent_work_conflict_does_not_break_master() {
    let _serial = serial_test_lock();
    use std::fs;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Create a file on master.
    fs::write(
        repo.join("code.rs"),
        "fn main() {\n    println!(\"hello\");\n}\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "initial code"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Feature branch: modify the same line differently.
    Command::new("git")
        .args(["checkout", "-b", "feature/story-42_story_foo"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(
        repo.join("code.rs"),
        "fn main() {\n    println!(\"hello\");\n    feature_fn();\n}\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "feature: add fn call"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Master: add different line at same location.
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(
        repo.join("code.rs"),
        "fn main() {\n    println!(\"hello\");\n    master_fn();\n}\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "master: add fn call"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Create story file in 4_merge.
    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    fs::write(merge_dir.join("42_story_foo.md"), "---\nname: Test\n---\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add story"])
        .current_dir(repo)
        .output()
        .unwrap();

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "42_story_foo").await;

    // Master should NEVER have conflict markers, regardless of merge outcome.
    let master_code = fs::read_to_string(repo.join("code.rs")).unwrap();
    assert!(
        !master_code.contains("<<<<<<<"),
        "master must never contain conflict markers:\n{master_code}"
    );
    assert!(
        !master_code.contains(">>>>>>>"),
        "master must never contain conflict markers:\n{master_code}"
    );

    // The report should accurately reflect what happened.
    match &job.status {
        MergeJobStatus::Completed(report) => {
            assert!(
                matches!(
                    report.result,
                    crate::agents::merge::MergeResult::Conflict { .. }
                ),
                "should report conflicts"
            );
        }
        MergeJobStatus::Failed(_) => {
            // Acceptable — merge aborted due to conflicts
        }
        MergeJobStatus::Running => panic!("should not still be running"),
    }
}

// ── bug 675: zero commits ahead must fail with "no commits to merge" ─────

/// Regression test for bug 675: when the feature branch has zero commits
/// ahead of master the pipeline must fail with a clear "no commits to merge"
/// error and the story must remain in `4_merge` (not advance to `5_done`).
#[tokio::test]
async fn merge_agent_work_zero_commits_ahead_stays_in_merge_stage() {
    let _serial = serial_test_lock();
    use std::fs;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Feature branch is created at the same commit as master — zero commits ahead.
    Command::new("git")
        .args(["checkout", "-b", "feature/story-675_zero_commits"])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Place the story file in 4_merge so we can verify it stays there.
    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    fs::write(
        merge_dir.join("675_zero_commits.md"),
        "---\nname: Zero commits test\n---\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "place story in 4_merge"])
        .current_dir(repo)
        .output()
        .unwrap();

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "675_zero_commits").await;

    // The job must have completed with success=false and no_commits=true.
    // Since story 986 the "no commits" path returns Ok(result) not Err, so the
    // job status is Completed (not Failed).
    match &job.status {
        MergeJobStatus::Completed(report) => {
            assert!(
                matches!(
                    report.result,
                    crate::agents::merge::MergeResult::NoCommits { .. }
                ),
                "merge must fail with NoCommits when feature branch is empty; got: {:?}",
                report.result
            );
            assert!(
                report.result.output().contains("no commits to merge"),
                "output must contain 'no commits to merge', got: {}",
                report.result.output()
            );
        }
        MergeJobStatus::Failed(e) => {
            panic!("expected Completed(success=false) status, got Failed: {e}");
        }
        MergeJobStatus::Running => panic!("should not still be running"),
    }

    // Story file must still be in 4_merge — NOT advanced to 5_done.
    assert!(
        merge_dir.join("675_zero_commits.md").exists(),
        "story file must remain in 4_merge when merge fails"
    );
    assert!(
        !repo
            .join(".huskies/work/5_done/675_zero_commits.md")
            .exists(),
        "story must NOT advance to 5_done when merge fails with no commits"
    );
}

// ── Story 757: deterministic server-side merge ────────────────────────────

/// AC5 (happy path): a clean feature branch with one commit ahead of master
/// must advance to `5_done/` automatically with no LLM agent involved.
/// The merge_failure field must NOT be written.
#[tokio::test]
async fn server_side_merge_happy_path_advances_to_done() {
    let _serial = serial_test_lock();
    use std::fs;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Feature branch: one commit ahead of master.
    Command::new("git")
        .args(["checkout", "-b", "feature/story-757a_happy"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("happy.txt"), "content\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add happy file"])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Place story in 4_merge.
    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    fs::write(
        merge_dir.join("757a_happy.md"),
        "---\nname: Happy path test\n---\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "place story in 4_merge"])
        .current_dir(repo)
        .output()
        .unwrap();

    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "757a_happy",
        "4_merge",
        "---\nname: Happy path test\n---\n",
        crate::db::ItemMeta::named("Happy path test"),
    );

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "757a_happy").await;

    // Verify the merge succeeded and story advanced to 5_done.
    match &job.status {
        MergeJobStatus::Completed(report) => {
            assert!(
                !matches!(
                    report.result,
                    crate::agents::merge::MergeResult::Conflict { .. }
                ),
                "clean branch should have no conflicts"
            );
            if matches!(
                report.result,
                crate::agents::merge::MergeResult::Success { .. }
            ) {
                // story_archived may or may not be true depending on gate env,
                // but merge_failure must NOT be in the content store.
                let content = crate::db::read_content(crate::db::ContentKey::Story("757a_happy"));
                if let Some(c) = content {
                    assert!(
                        !c.contains("merge_failure"),
                        "merge_failure must not be set on success: {c}"
                    );
                }
            } else {
                // Gate failure (no script/test) is acceptable in test env —
                // but merge_failure should be written.
                let content = crate::db::read_content(crate::db::ContentKey::Story("757a_happy"));
                if let Some(c) = content {
                    // merge_failure should be written for gate failures
                    assert!(
                        c.contains("merge_failure"),
                        "merge_failure must be set when gates fail: {c}"
                    );
                }
            }
        }
        MergeJobStatus::Failed(_) => {
            // Acceptable — "no commits to merge" or similar infra failure.
        }
        MergeJobStatus::Running => panic!("should not still be running"),
    }

    // Verify no LLM agent was spawned.
    let agents = pool.agents.try_lock().unwrap();
    assert!(
        agents.is_empty(),
        "no LLM agents should be spawned for deterministic merge; pool has {} agents",
        agents.len()
    );
}

/// AC5 (conflict path): when the feature branch conflicts with master,
/// `merge_failure` must be written to the story content and the story
/// must remain in `4_merge/`.
#[tokio::test]
async fn server_side_merge_conflict_sets_merge_failure() {
    let _serial = serial_test_lock();
    use std::fs;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Create a file on master.
    fs::write(repo.join("shared.rs"), "fn master() {}\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "master: add shared.rs"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Feature branch: modify the same file differently.
    Command::new("git")
        .args(["checkout", "-b", "feature/story-757b_conflict"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("shared.rs"), "fn feature() {}\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "feature: rewrite shared.rs"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Master: modify the same file differently.
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("shared.rs"), "fn master_v2() {}\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "master: update shared.rs"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Place story in 4_merge.
    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    fs::write(
        merge_dir.join("757b_conflict.md"),
        "---\nname: Conflict test\n---\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "place story in 4_merge"])
        .current_dir(repo)
        .output()
        .unwrap();

    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "757b_conflict",
        "4_merge",
        "---\nname: Conflict test\n---\n",
        crate::db::ItemMeta::named("Conflict test"),
    );

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "757b_conflict").await;

    // The merge must fail (conflict).
    let failed = matches!(
        &job.status,
        MergeJobStatus::Completed(r)
            if !matches!(r.result, crate::agents::merge::MergeResult::Success { .. })
    ) || matches!(&job.status, MergeJobStatus::Failed(_));
    assert!(
        failed,
        "conflicting branches must not succeed; status: {:?}",
        job.status
    );

    // Story 929: merge_failure detail is persisted on the MergeJob CRDT entry,
    // not the YAML body.
    let mj = crate::crdt_state::read_merge_job("757b_conflict").expect("merge job must be in CRDT");
    assert!(
        mj.error.is_some(),
        "MergeJob.error must be set on conflict: {mj:?}"
    );

    // Story must remain in 4_merge (not advanced to 5_done).
    assert!(
        !repo.join(".huskies/work/5_done/757b_conflict.md").exists(),
        "story must stay in 4_merge when conflict occurs"
    );
}

/// AC5 (gate-failure path): when the feature branch merges cleanly but
/// quality gates fail, `merge_failure` must be written and the story
/// must remain in `4_merge/`.
#[cfg(unix)]
#[tokio::test]
async fn server_side_merge_gate_failure_sets_merge_failure() {
    let _serial = serial_test_lock();
    use std::fs;
    use std::os::unix::fs::PermissionsExt;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Add a failing script/test so quality gates will always fail.
    let script_dir = repo.join("script");
    fs::create_dir_all(&script_dir).unwrap();
    let script_test = script_dir.join("test");
    fs::write(&script_test, "#!/usr/bin/env sh\nexit 1\n").unwrap();
    let mut perms = fs::metadata(&script_test).unwrap().permissions();
    perms.set_mode(0o755);
    fs::set_permissions(&script_test, perms).unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add failing gates"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Feature branch: one commit ahead of master.
    Command::new("git")
        .args(["checkout", "-b", "feature/story-757c_gates"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("feature_c.txt"), "content\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add feature"])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Place story in 4_merge.
    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    fs::write(
        merge_dir.join("757c_gates.md"),
        "---\nname: Gate failure test\n---\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "place story in 4_merge"])
        .current_dir(repo)
        .output()
        .unwrap();

    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "757c_gates",
        "4_merge",
        "---\nname: Gate failure test\n---\n",
        crate::db::ItemMeta::named("Gate failure test"),
    );

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "757c_gates").await;

    // The merge must report gate failure (not conflict).
    match &job.status {
        MergeJobStatus::Completed(report) => {
            assert!(
                !matches!(
                    report.result,
                    crate::agents::merge::MergeResult::Success { .. }
                ),
                "gates should have failed; report: {report:?}"
            );
            assert!(
                !matches!(
                    report.result,
                    crate::agents::merge::MergeResult::Conflict { .. }
                ),
                "should be a gate failure, not a conflict"
            );
        }
        MergeJobStatus::Failed(_) => {
            // Also acceptable.
        }
        MergeJobStatus::Running => panic!("should not still be running"),
    }

    // Story 929: merge_failure detail is persisted on the MergeJob CRDT
    // entry, not the YAML body.
    let mj = crate::crdt_state::read_merge_job("757c_gates").expect("merge job must be in CRDT");
    assert!(
        mj.error.is_some(),
        "MergeJob.error must be set on gate failure: {mj:?}"
    );

    // Story must remain in 4_merge.
    assert!(
        !repo.join(".huskies/work/5_done/757c_gates.md").exists(),
        "story must stay in 4_merge when gates fail"
    );
}

/// Non-regression test for bug 675: a feature branch with exactly one commit
/// ahead of master must continue to merge successfully (happy path).
#[tokio::test]
async fn merge_agent_work_one_commit_ahead_merges_successfully() {
    let _serial = serial_test_lock();
    use std::fs;
    use tempfile::tempdir;

    crate::crdt_state::init_for_test();
    let tmp = tempdir().unwrap();
    let repo = tmp.path();
    init_git_repo(repo);

    // Feature branch: one commit ahead of master.
    Command::new("git")
        .args(["checkout", "-b", "feature/story-675_one_commit"])
        .current_dir(repo)
        .output()
        .unwrap();
    fs::write(repo.join("feature_675.txt"), "feature content\n").unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "add feature file"])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["checkout", "master"])
        .current_dir(repo)
        .output()
        .unwrap();

    // Place the story file in 4_merge.
    let merge_dir = repo.join(".huskies/work/4_merge");
    fs::create_dir_all(&merge_dir).unwrap();
    fs::write(
        merge_dir.join("675_one_commit.md"),
        "---\nname: One commit test\n---\n",
    )
    .unwrap();
    Command::new("git")
        .args(["add", "."])
        .current_dir(repo)
        .output()
        .unwrap();
    Command::new("git")
        .args(["commit", "-m", "place story in 4_merge"])
        .current_dir(repo)
        .output()
        .unwrap();

    let pool = Arc::new(AgentPool::new_test(3001));
    let job = run_merge_to_completion(&pool, repo, "675_one_commit").await;

    // The merge must not fail with "no commits to merge".
    match &job.status {
        MergeJobStatus::Failed(e) => {
            assert!(
                !e.contains("no commits to merge"),
                "one-commit-ahead branch must NOT fail with 'no commits to merge': {e}"
            );
            // Gate failures (no script/test) are acceptable in test env.
        }
        MergeJobStatus::Completed(report) => {
            // Success or gate failure — both acceptable; the key invariant is
            // that we didn't fail with the zero-commits early-exit.
            let is_success = matches!(
                report.result,
                crate::agents::merge::MergeResult::Success { .. }
            );
            let is_gate_failure = matches!(
                report.result,
                crate::agents::merge::MergeResult::GateFailure { .. }
            );
            assert!(
                is_success || is_gate_failure,
                "unexpected result variant: {:?}",
                report.result
            );
        }
        MergeJobStatus::Running => panic!("should not still be running"),
    }
}
