//! Tests for the typed pipeline state machine.
use super::*;
use std::num::NonZeroU32;

fn nz(n: u32) -> NonZeroU32 {
    NonZeroU32::new(n).unwrap()
}
fn fb(name: &str) -> BranchName {
    BranchName(name.to_string())
}
fn sha(s: &str) -> GitSha {
    GitSha(s.to_string())
}
fn sid(s: &str) -> StoryId {
    StoryId(s.to_string())
}

#[test]
fn happy_path_backlog_through_archived() {
    let s = Stage::Backlog;
    let s = transition(s, PipelineEvent::DepsMet).unwrap();
    assert!(matches!(s, Stage::Coding { .. }));

    let s = transition(
        s,
        PipelineEvent::QaSkipped {
            feature_branch: fb("feature/story-1"),
            commits_ahead: nz(3),
        },
    )
    .unwrap();
    assert!(matches!(s, Stage::Merge { .. }));

    let s = transition(
        s,
        PipelineEvent::MergeSucceeded {
            merge_commit: sha("abc123"),
        },
    )
    .unwrap();
    assert!(matches!(s, Stage::Done { .. }));

    let s = transition(s, PipelineEvent::Accepted).unwrap();
    assert!(matches!(
        s,
        Stage::Archived {
            reason: ArchiveReason::Completed,
            ..
        }
    ));
}

#[test]
fn happy_path_with_qa() {
    let s = Stage::Coding {
        claim: None,
        plan: PlanState::Missing,
        retries: 0,
    };
    let s = transition(s, PipelineEvent::GatesStarted).unwrap();
    assert!(matches!(s, Stage::Qa));

    let s = transition(
        s,
        PipelineEvent::GatesPassed {
            feature_branch: fb("feature/story-2"),
            commits_ahead: nz(5),
        },
    )
    .unwrap();
    assert!(matches!(s, Stage::Merge { .. }));
}

#[test]
fn qa_retry_loop() {
    let s = Stage::Coding {
        claim: None,
        plan: PlanState::Missing,
        retries: 0,
    };
    let s = transition(s, PipelineEvent::GatesStarted).unwrap();
    assert!(matches!(s, Stage::Qa));

    let s = transition(
        s,
        PipelineEvent::GatesFailed {
            reason: "tests failed".into(),
        },
    )
    .unwrap();
    assert!(matches!(s, Stage::Coding { .. }));
}

// ── Bug 519: Merge with zero commits is unrepresentable ─────────────

#[test]
fn merge_with_zero_commits_is_unrepresentable() {
    assert!(NonZeroU32::new(0).is_none());
}

// ── Invalid transitions ─────────────────────────────────────────────

#[test]
fn cannot_jump_backlog_to_done() {
    let result = transition(Stage::Backlog, PipelineEvent::Accepted);
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn cannot_unblock_done_story() {
    let s = Stage::Done {
        merged_at: chrono::Utc::now(),
        merge_commit: sha("abc"),
    };
    let result = transition(s, PipelineEvent::Unblock);
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn cannot_unblock_review_held_story() {
    let s = Stage::Archived {
        archived_at: chrono::Utc::now(),
        reason: ArchiveReason::ReviewHeld {
            reason: "TBD".into(),
        },
    };
    let result = transition(s, PipelineEvent::Unblock);
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn cannot_merge_from_backlog() {
    let result = transition(
        Stage::Backlog,
        PipelineEvent::MergeSucceeded {
            merge_commit: sha("abc"),
        },
    );
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn cannot_start_gates_from_backlog() {
    let result = transition(Stage::Backlog, PipelineEvent::GatesStarted);
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn cannot_accept_from_coding() {
    let result = transition(
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        PipelineEvent::Accepted,
    );
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

// ── Block from any active stage ─────────────────────────────────────

#[test]
fn block_from_any_active_stage() {
    for s in [
        Stage::Backlog,
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        Stage::Qa,
    ] {
        let result = transition(
            s.clone(),
            PipelineEvent::Block {
                reason: "stuck".into(),
            },
        );
        assert!(matches!(result, Ok(Stage::Blocked { .. })));
    }

    let m = Stage::Merge {
        feature_branch: fb("f"),
        commits_ahead: nz(1),
        claim: None,
        retries: 0,
        server_start_time: None,
    };
    let result = transition(
        m,
        PipelineEvent::Block {
            reason: "stuck".into(),
        },
    );
    assert!(matches!(result, Ok(Stage::Blocked { .. })));
}

#[test]
fn unblock_returns_to_coding() {
    let s = Stage::Blocked {
        reason: "test".into(),
    };
    let result = transition(s, PipelineEvent::Unblock).unwrap();
    assert!(matches!(result, Stage::Coding { .. }));
}

#[test]
fn blocked_demote_returns_to_backlog() {
    // Stuck-story parking lane: `Blocked + Demote → Backlog` lets operators
    // move a blocked story back to the backlog without losing it to
    // Archived. Complements `Blocked + Unblock → Coding` which re-enters
    // active work.
    let s = Stage::Blocked {
        reason: "waiting on dep".into(),
    };
    let result = transition(s, PipelineEvent::Demote).unwrap();
    assert!(matches!(result, Stage::Backlog));
}

#[test]
fn cannot_demote_from_done() {
    // Sanity: Demote remains illegal from terminal/archived stages — the
    // new `Blocked + Demote → Backlog` rule must NOT broaden it further.
    let s = Stage::Done {
        merged_at: chrono::Utc::now(),
        merge_commit: sha("x"),
    };
    assert!(matches!(
        transition(s, PipelineEvent::Demote),
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn cannot_demote_from_upcoming() {
    let s = Stage::Upcoming;
    assert!(matches!(
        transition(s, PipelineEvent::Demote),
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn legacy_unblock_archived_blocked_returns_to_backlog() {
    let s = Stage::Archived {
        archived_at: chrono::Utc::now(),
        reason: ArchiveReason::Blocked {
            reason: "test".into(),
        },
    };
    let result = transition(s, PipelineEvent::Unblock).unwrap();
    assert!(matches!(result, Stage::Backlog));
}

// ── Abandon / supersede ─────────────────────────────────────────────

#[test]
fn abandon_from_any_active_or_done() {
    for s in [
        Stage::Backlog,
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        Stage::Qa,
        Stage::Done {
            merged_at: chrono::Utc::now(),
            merge_commit: sha("x"),
        },
    ] {
        let result = transition(s, PipelineEvent::Abandon);
        assert!(matches!(result, Ok(Stage::Abandoned { .. })));
    }
}

#[test]
fn supersede_from_any_active_or_done() {
    for s in [
        Stage::Backlog,
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        Stage::Qa,
        Stage::Done {
            merged_at: chrono::Utc::now(),
            merge_commit: sha("x"),
        },
    ] {
        let result = transition(
            s,
            PipelineEvent::Supersede {
                by: sid("999_story_new"),
            },
        );
        assert!(matches!(result, Ok(Stage::Superseded { .. })));
    }
}

// ── Review hold ─────────────────────────────────────────────────────

#[test]
fn review_hold_from_active_stages() {
    // Story 945: `ReviewHold` transitions to `Stage::ReviewHold { resume_to }`
    // with the resume_to set to the originating stage, replacing the legacy
    // boolean flag.
    for s in [
        Stage::Backlog,
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        Stage::Qa,
    ] {
        let result = transition(
            s.clone(),
            PipelineEvent::ReviewHold {
                reason: "review".into(),
            },
        );
        let resumed = match result {
            Ok(Stage::ReviewHold { resume_to, .. }) => *resume_to,
            other => panic!("ReviewHold should produce Stage::ReviewHold; got {other:?}"),
        };
        assert_eq!(
            resumed, s,
            "resume_to should preserve the originating stage"
        );
    }
}

// ── Merge failed final ──────────────────────────────────────────────

#[test]
fn merge_failed_final() {
    let s = Stage::Merge {
        feature_branch: fb("f"),
        commits_ahead: nz(1),
        claim: None,
        retries: 0,
        server_start_time: None,
    };
    let result = transition(
        s,
        PipelineEvent::MergeFailedFinal {
            reason: "conflicts".into(),
        },
    )
    .unwrap();
    assert!(matches!(
        result,
        Stage::Archived {
            reason: ArchiveReason::MergeFailed { .. },
            ..
        }
    ));
}

#[test]
fn merge_failed_only_from_merge() {
    let result = transition(
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        PipelineEvent::MergeFailedFinal {
            reason: "conflicts".into(),
        },
    );
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

// ── Execution state machine ─────────────────────────────────────────

#[test]
fn execution_happy_path() {
    let e = ExecutionState::Idle;
    let e = execution_transition(
        e,
        ExecutionEvent::SpawnRequested {
            agent: AgentName("coder-1".into()),
        },
    )
    .unwrap();
    assert!(matches!(e, ExecutionState::Pending { .. }));

    let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
    assert!(matches!(e, ExecutionState::Running { .. }));

    let e = execution_transition(e, ExecutionEvent::Heartbeat).unwrap();
    assert!(matches!(e, ExecutionState::Running { .. }));

    let e = execution_transition(e, ExecutionEvent::Exited { exit_code: 0 }).unwrap();
    assert!(matches!(e, ExecutionState::Completed { exit_code: 0, .. }));
}

#[test]
fn execution_rate_limit_then_resume() {
    let e = ExecutionState::Running {
        agent: AgentName("coder-1".into()),
        started_at: chrono::Utc::now(),
        last_heartbeat: chrono::Utc::now(),
    };
    let e = execution_transition(
        e,
        ExecutionEvent::HitRateLimit {
            resume_at: chrono::Utc::now() + chrono::Duration::minutes(5),
        },
    )
    .unwrap();
    assert!(matches!(e, ExecutionState::RateLimited { .. }));

    let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
    assert!(matches!(e, ExecutionState::Running { .. }));
}

#[test]
fn execution_stop_from_anywhere() {
    let e = ExecutionState::Running {
        agent: AgentName("coder-1".into()),
        started_at: chrono::Utc::now(),
        last_heartbeat: chrono::Utc::now(),
    };
    let e = execution_transition(e, ExecutionEvent::Stopped).unwrap();
    assert!(matches!(e, ExecutionState::Idle));
}

// ── Projection tests ────────────────────────────────────────────────

#[test]
fn bug_502_agent_not_in_stage() {
    // Bug 502 was caused by a coder agent being assigned to a story in
    // Merge stage. In the typed system, Stage has no `agent` field at all.
    // Agent assignment is per-node ExecutionState. This test documents that
    // the old failure mode is structurally impossible.
    let merge = Stage::Merge {
        feature_branch: BranchName("feature/story-1".into()),
        commits_ahead: NonZeroU32::new(3).unwrap(),
        claim: None,
        retries: 0,
        server_start_time: None,
    };
    // Stage::Merge has exactly two fields: feature_branch and commits_ahead.
    // There is no way to attach an agent name to it. The type system
    // prevents bug 502 by construction.
    assert!(matches!(merge, Stage::Merge { .. }));
}

// ── TransitionError Display ─────────────────────────────────────────

#[test]
fn transition_error_display() {
    let err = TransitionError::InvalidTransition {
        from_stage: "Backlog".into(),
        event: "Accepted".into(),
    };
    assert_eq!(err.to_string(), "invalid transition: Backlog + Accepted");
}

// ── Upcoming / Triage ──────────────────────────────────────────────

#[test]
fn triage_upcoming_to_backlog() {
    let s = Stage::Upcoming;
    let s = transition(s, PipelineEvent::Triage).unwrap();
    assert!(matches!(s, Stage::Backlog));
}

#[test]
fn cannot_triage_from_backlog() {
    let result = transition(Stage::Backlog, PipelineEvent::Triage);
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn abandon_from_upcoming() {
    let result = transition(Stage::Upcoming, PipelineEvent::Abandon).unwrap();
    assert!(matches!(result, Stage::Abandoned { .. }));
}

#[test]
fn supersede_from_upcoming() {
    let result = transition(
        Stage::Upcoming,
        PipelineEvent::Supersede {
            by: sid("999_story_new"),
        },
    )
    .unwrap();
    assert!(matches!(result, Stage::Superseded { .. }));
}

#[test]
fn cannot_deps_met_from_upcoming() {
    let result = transition(Stage::Upcoming, PipelineEvent::DepsMet);
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

// ── Reject ─────────────────────────────────────────────────────────

#[test]
fn reject_from_active_stages() {
    for s in [
        Stage::Backlog,
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        Stage::Qa,
    ] {
        let result = transition(
            s.clone(),
            PipelineEvent::Reject {
                reason: "not needed".into(),
            },
        );
        assert!(matches!(result, Ok(Stage::Rejected { .. })));
    }

    let m = Stage::Merge {
        feature_branch: fb("f"),
        commits_ahead: nz(1),
        claim: None,
        retries: 0,
        server_start_time: None,
    };
    let result = transition(
        m,
        PipelineEvent::Reject {
            reason: "not needed".into(),
        },
    );
    assert!(matches!(result, Ok(Stage::Rejected { .. })));
}

#[test]
fn cannot_reject_from_done() {
    let s = Stage::Done {
        merged_at: chrono::Utc::now(),
        merge_commit: sha("abc"),
    };
    let result = transition(
        s,
        PipelineEvent::Reject {
            reason: "too late".into(),
        },
    );
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

#[test]
fn cannot_reject_from_archived() {
    let s = Stage::Archived {
        archived_at: chrono::Utc::now(),
        reason: ArchiveReason::Completed,
    };
    let result = transition(
        s,
        PipelineEvent::Reject {
            reason: "already done".into(),
        },
    );
    assert!(matches!(
        result,
        Err(TransitionError::InvalidTransition { .. })
    ));
}

// ── Freeze / Unfreeze (story 934, stage 4: orthogonal flag) ────────────────

/// Freeze sets the `frozen` flag without changing the stage register.
/// Unfreeze clears the flag — the stage was never touched so there's nothing
/// to "restore". Tests the freeze/unfreeze API on the apply layer, since
/// freeze/unfreeze are no longer pure stage transitions.
#[test]
fn freeze_transitions_to_frozen_variant_with_resume_to() {
    // Story 945: freeze/unfreeze move the typed stage to `Stage::Frozen
    // { resume_to }` and back, replacing the orthogonal boolean flag.
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "9950_story_freeze_regression";
    crate::db::write_item_with_content(
        story_id,
        "2_current",
        "---\nname: Freeze Regression\n---\n# Story\n",
        crate::db::ItemMeta::named("Freeze Regression"),
    );

    let item = read_typed(story_id).unwrap().unwrap();
    assert!(matches!(item.stage, Stage::Coding { .. }));
    assert!(!matches!(item.stage, Stage::Frozen { .. }));

    super::apply::transition_to_frozen(story_id).expect("freeze should succeed");

    let item = read_typed(story_id).unwrap().unwrap();
    match &item.stage {
        Stage::Frozen { resume_to } => assert!(
            matches!(**resume_to, Stage::Coding { .. }),
            "resume_to should preserve the previous stage; got {resume_to:?}"
        ),
        other => panic!("stage should be Stage::Frozen after freeze; got {other:?}"),
    }
    assert!(
        matches!(item.stage, Stage::Frozen { .. }),
        "is_frozen() should be true after freeze"
    );

    super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");

    let item = read_typed(story_id).unwrap().unwrap();
    assert!(
        matches!(item.stage, Stage::Coding { .. }),
        "stage should return to Coding after unfreeze: {:?}",
        item.stage
    );
    assert!(
        !matches!(item.stage, Stage::Frozen { .. }),
        "is_frozen() should be false after unfreeze"
    );
}

// ── Story 1105: Freeze from Backlog round-trip ───────────────────────────────

/// Regression test (story 1105): freezing a Backlog story must store
/// `resume_to = Backlog` in the CRDT register so that unfreeze returns the
/// story to Backlog, not to Coding.
#[test]
fn freeze_from_backlog_round_trips_resume_to_backlog() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "991105_freeze_backlog_roundtrip";
    crate::db::write_item_with_content(
        story_id,
        "1_backlog",
        "---\nname: Freeze Backlog Round-trip\n---\n# Story\n",
        crate::db::ItemMeta::named("Freeze Backlog Round-trip"),
    );

    let item = read_typed(story_id).unwrap().unwrap();
    assert!(
        matches!(item.stage, Stage::Backlog),
        "expected Backlog, got {:?}",
        item.stage
    );

    super::apply::transition_to_frozen(story_id).expect("freeze should succeed");

    let item = read_typed(story_id).unwrap().unwrap();
    match &item.stage {
        Stage::Frozen { resume_to } => assert!(
            matches!(**resume_to, Stage::Backlog),
            "resume_to should be Backlog after freezing from Backlog; got {resume_to:?}"
        ),
        other => panic!("expected Stage::Frozen after freeze; got {other:?}"),
    }

    super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");

    let item = read_typed(story_id).unwrap().unwrap();
    assert!(
        matches!(item.stage, Stage::Backlog),
        "unfreeze should restore to Backlog, not Coding; got {:?}",
        item.stage
    );
}

// ── Story 868: MergeFailure regression ─────────────────────────────

/// Regression test (story 868): applying `PipelineEvent::MergeFailed` to a story
/// in `Stage::Merge` transitions it to `Stage::MergeFailure` and the emitted
/// `TransitionFired` event carries the full reason string in its payload.
#[test]
fn merge_failure_transition_emits_event_with_full_reason() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "99868_story_merge_failure_event";
    crate::db::write_item_with_content(
        story_id,
        "4_merge",
        "---\nname: Merge Failure Event Test\n---\n# Story\n",
        crate::db::ItemMeta::named("Merge Failure Event Test"),
    );

    let reason = "Conflict in server/src/main.rs: both modified";
    let fired = super::apply::apply_transition(
        story_id,
        PipelineEvent::MergeFailed {
            kind: MergeFailureKind::Other(reason.to_string()),
        },
        None,
    )
    .expect("MergeFailed transition should succeed");

    // The emitted event payload carries the full reason via display_reason().
    match &fired.event {
        PipelineEvent::MergeFailed { kind } => {
            assert_eq!(
                kind.display_reason(),
                reason,
                "emitted event should carry the full reason"
            );
        }
        other => panic!("expected MergeFailed event, got: {other:?}"),
    }

    // The story transitioned to MergeFailure.
    assert!(
        matches!(fired.after, Stage::MergeFailure { .. }),
        "after-stage should be MergeFailure: {:?}",
        fired.after
    );

    // Verify CRDT reflects the new stage.
    let item = read_typed(story_id)
        .expect("CRDT read should succeed")
        .expect("item should exist");
    assert_eq!(
        item.stage.dir_name(),
        "merge_failure",
        "CRDT stage should be 4_merge_failure"
    );
}

// ── Story 1178: MergeFailureFinal is not a trap state ───────────────

/// Regression test (story 1178): a story stuck in `Stage::MergeFailureFinal`
/// whose merge is later retried and actually succeeds must be able to reach
/// `Stage::Done` via `PipelineEvent::Accepted` — mirroring the existing
/// `MergeFailure -> Done` recovery path. Before this fix, `MergeFailureFinal`
/// had no `Accepted` arm at all, so `move_story_to_done` always errored even
/// after a successful re-merge.
#[test]
fn merge_failure_final_accepted_transitions_to_done() {
    let s = Stage::MergeFailureFinal {
        kind: MergeFailureKind::Other("gate failures".into()),
    };
    let s = transition(s, PipelineEvent::Accepted).unwrap();
    assert!(
        matches!(s, Stage::Done { .. }),
        "MergeFailureFinal + Accepted should land in Done, got: {s:?}"
    );
}

// ── Story 913: MergeFailure + MergeFailed self-loop ────────────────

/// AC1: `MergeFailure + MergeFailed` is a valid self-transition — no error logged.
#[test]
fn merge_failure_plus_merge_failed_is_self_loop() {
    let s = Stage::MergeFailure {
        kind: MergeFailureKind::Other("initial failure".into()),
        feature_branch: fb("feature/story-1"),
        commits_ahead: nz(1),
    };
    let result = transition(
        s,
        PipelineEvent::MergeFailed {
            kind: MergeFailureKind::Other("second failure".into()),
        },
    );
    assert!(
        matches!(result, Ok(Stage::MergeFailure { .. })),
        "MergeFailure + MergeFailed should self-loop to MergeFailure, got: {result:?}"
    );
}

/// AC2 + AC3: applying `MergeFailed` to a story already in `MergeFailure` succeeds and
/// the `TransitionFired::before` is `MergeFailure`, allowing callers to suppress the
/// duplicate notification.
#[test]
fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "99913_story_merge_failure_selfloop";
    crate::db::write_item_with_content(
        story_id,
        "merge_failure",
        "---\nname: MergeFailure Self-loop Test\n---\n# Story\n",
        crate::db::ItemMeta::named("MergeFailure Self-loop Test"),
    );

    // Apply a second MergeFailed to a story already in MergeFailure.
    let fired = super::apply::apply_transition(
        story_id,
        PipelineEvent::MergeFailed {
            kind: MergeFailureKind::Other("duplicate failure".into()),
        },
        None,
    )
    .expect("MergeFailed on already-failed story should succeed without error");

    // The before-stage was MergeFailure: this was a self-loop.
    // Callers check this to decide whether to suppress the chat notification.
    assert!(
        matches!(fired.before, Stage::MergeFailure { .. }),
        "fired.before should be MergeFailure (self-loop): {:?}",
        fired.before
    );
    assert!(
        matches!(fired.after, Stage::MergeFailure { .. }),
        "fired.after should remain MergeFailure: {:?}",
        fired.after
    );

    // Verify the CRDT stage is still merge_failure.
    let item = read_typed(story_id)
        .expect("CRDT read should succeed")
        .expect("item should still exist");
    assert_eq!(
        item.stage.dir_name(),
        "merge_failure",
        "CRDT stage should remain merge_failure after self-loop"
    );

    // Simulate the caller's de-dup logic: since fired.before is already MergeFailure,
    // no notification should be dispatched.
    let should_notify = !matches!(fired.before, Stage::MergeFailure { .. });
    assert!(
        !should_notify,
        "should_notify must be false for a self-loop to prevent duplicate notification"
    );
}

// ── Story 919: MergeFailure + Unblock → Merge (re-attempt) ─────────

/// AC1: `MergeFailure + Unblock` transitions to `Merge` (re-attempt), not `Coding` or `Backlog`.
#[test]
fn merge_failure_unblock_returns_to_merge() {
    let s = Stage::MergeFailure {
        kind: MergeFailureKind::ConflictDetected(Some("conflicts in server/src/main.rs".into())),
        feature_branch: fb("feature/story-42"),
        commits_ahead: nz(3),
    };
    let result = transition(s, PipelineEvent::Unblock).unwrap();
    assert!(
        matches!(result, Stage::Merge { .. }),
        "MergeFailure + Unblock should return to Merge for immediate re-attempt, got: {result:?}"
    );
}

/// AC1 (complement): `MergeFailure + Demote` still goes to `Backlog` for manual parking.
#[test]
fn merge_failure_demote_returns_to_backlog() {
    let s = Stage::MergeFailure {
        kind: MergeFailureKind::Other("conflicts".into()),
        feature_branch: fb("feature/story-1"),
        commits_ahead: nz(1),
    };
    let result = transition(s, PipelineEvent::Demote).unwrap();
    assert!(
        matches!(result, Stage::Backlog),
        "MergeFailure + Demote should park the story in Backlog, got: {result:?}"
    );
}

// ── Story 892: MergeFailure → Done (manual recovery) ───────────────

/// Regression test (story 892): `accept_story` on a story in `MergeFailure`
/// transitions it to `Done` and emits a `TransitionFired` event.
#[test]
fn merge_failure_accept_pure_transition() {
    let s = Stage::MergeFailure {
        kind: MergeFailureKind::ConflictDetected(Some("conflicts unresolvable".into())),
        feature_branch: fb("feature/story-1"),
        commits_ahead: nz(1),
    };
    let result = transition(s, PipelineEvent::Accepted).unwrap();
    assert!(
        matches!(result, Stage::Done { .. }),
        "MergeFailure + Accepted should yield Done, got: {result:?}"
    );
}

/// Regression test (story 892): `apply_transition` on a CRDT-stored `MergeFailure`
/// story moves it to `Done` and the emitted `TransitionFired` event is present.
#[test]
fn merge_failure_accept_moves_to_done_via_crdt() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "99892_story_merge_failure_accept";
    crate::db::write_item_with_content(
        story_id,
        "merge_failure",
        "---\nname: MergeFailure Accept Test\n---\n# Story\n",
        crate::db::ItemMeta::named("MergeFailure Accept Test"),
    );

    let fired = super::apply::apply_transition(story_id, PipelineEvent::Accepted, None)
        .expect("MergeFailure + Accepted should succeed");

    // The before-stage was MergeFailure.
    assert!(
        matches!(fired.before, Stage::MergeFailure { .. }),
        "fired.before should be MergeFailure: {:?}",
        fired.before
    );

    // The after-stage is Done.
    assert!(
        matches!(fired.after, Stage::Done { .. }),
        "fired.after should be Done: {:?}",
        fired.after
    );

    // TransitionFired carries the Accepted event.
    assert!(
        matches!(fired.event, PipelineEvent::Accepted),
        "fired.event should be Accepted: {:?}",
        fired.event
    );

    // CRDT reflects done.
    let item = read_typed(story_id)
        .expect("CRDT read should succeed")
        .expect("item should exist");
    assert_eq!(
        item.stage.dir_name(),
        "done",
        "CRDT stage should be done after MergeFailure + Accepted"
    );
}

// ── Story 919: MergeFailure + Unblock → Merge (regression) ─────────

/// AC3: CRDT-based regression — set stage to `MergeFailure`, call `unblock_story`
/// via `apply_transition`, assert the Stage register becomes `Stage::Merge`.
#[test]
fn merge_failure_unblock_moves_to_merge_via_crdt() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "99919_story_merge_failure_unblock";
    crate::db::write_item_with_content(
        story_id,
        "merge_failure",
        "---\nname: MergeFailure Unblock Regression\n---\n# Story\n",
        crate::db::ItemMeta::named("MergeFailure Unblock Regression"),
    );

    let fired = super::apply::apply_transition(story_id, PipelineEvent::Unblock, None)
        .expect("MergeFailure + Unblock should succeed");

    assert!(
        matches!(fired.before, Stage::MergeFailure { .. }),
        "fired.before should be MergeFailure: {:?}",
        fired.before
    );
    assert!(
        matches!(fired.after, Stage::Merge { .. }),
        "fired.after should be Merge, not Coding or Backlog: {:?}",
        fired.after
    );

    let item = read_typed(story_id)
        .expect("CRDT read should succeed")
        .expect("item should exist");
    assert_eq!(
        item.stage.dir_name(),
        "merge",
        "CRDT stage should be merge after MergeFailure + Unblock"
    );
    assert!(
        !matches!(item.stage, Stage::MergeFailure { .. }),
        "MergeFailure variant must not remain after Unblock"
    );
}

// ── Story 973: Merge → Coding (abort in-flight merge) ───────────────

/// AC1 (pure): `Merge + MergeAborted` transitions to `Coding`.
#[test]
fn merge_aborted_returns_to_coding() {
    let s = Stage::Merge {
        feature_branch: fb("feature/story-73"),
        commits_ahead: nz(2),
        claim: None,
        retries: 0,
        server_start_time: None,
    };
    let result = transition(s, PipelineEvent::MergeAborted).unwrap();
    assert!(
        matches!(result, Stage::Coding { .. }),
        "Merge + MergeAborted should return to Coding, got: {result:?}"
    );
}

/// AC1 (CRDT): set stage to `Merge`, apply `MergeAborted`, assert CRDT stage is `coding`.
#[test]
fn merge_aborted_moves_to_coding_via_crdt() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "99973_story_merge_aborted";
    crate::db::write_item_with_content(
        story_id,
        "merge",
        "---\nname: Merge Aborted Test\n---\n# Story\n",
        crate::db::ItemMeta::named("Merge Aborted Test"),
    );

    let fired = super::apply::apply_transition(story_id, PipelineEvent::MergeAborted, None)
        .expect("Merge + MergeAborted should succeed");

    assert!(
        matches!(fired.before, Stage::Merge { .. }),
        "fired.before should be Merge: {:?}",
        fired.before
    );
    assert!(
        matches!(fired.after, Stage::Coding { .. }),
        "fired.after should be Coding: {:?}",
        fired.after
    );

    let item = read_typed(story_id)
        .expect("CRDT read should succeed")
        .expect("item should exist");
    assert_eq!(
        item.stage.dir_name(),
        "coding",
        "CRDT stage should be coding after Merge + MergeAborted"
    );
}

/// AC1 (move_story): `move_story_to_stage` with target "current" on a Merge story succeeds.
#[test]
fn move_story_merge_to_current_succeeds() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "99973_story_move_merge_to_current";
    crate::db::write_item_with_content(
        story_id,
        "merge",
        "---\nname: Move Merge To Current\n---\n",
        crate::db::ItemMeta::named("Move Merge To Current"),
    );

    let result = crate::agents::lifecycle::move_story_to_stage(story_id, "current");
    assert!(
        result.is_ok(),
        "move_story_to_stage(merge → current) should succeed: {result:?}"
    );

    let (from, to) = result.unwrap();
    assert_eq!(from, "merge", "from_stage should be 'merge'");
    assert_eq!(to, "current", "to_stage should be 'current'");

    let item = read_typed(story_id)
        .expect("CRDT read should succeed")
        .expect("item should exist");
    assert!(
        matches!(item.stage, Stage::Coding { .. }),
        "story should be in Coding after move_story_to_stage(merge → current): {:?}",
        item.stage
    );
}

// ── Story 974: Done → Coding (hotfix) ─────────────────────────────

#[test]
fn hotfix_requested_from_done_lands_in_coding() {
    let done = Stage::Done {
        merged_at: chrono::Utc::now(),
        merge_commit: sha("abc123"),
    };
    let result = transition(done, PipelineEvent::HotfixRequested).unwrap();
    assert!(
        matches!(result, Stage::Coding { .. }),
        "Done + HotfixRequested must land in Coding; got: {:?}",
        result
    );
}

#[test]
fn hotfix_requested_rejected_from_non_done_stages() {
    for stage in [
        Stage::Backlog,
        Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        Stage::Qa,
        Stage::Merge {
            feature_branch: fb("feature/story-1"),
            commits_ahead: nz(1),
            claim: None,
            retries: 0,
            server_start_time: None,
        },
    ] {
        let result = transition(stage.clone(), PipelineEvent::HotfixRequested);
        assert!(
            result.is_err(),
            "HotfixRequested must be rejected from {:?}",
            stage
        );
    }
}

// ── Audit log subscriber (story 1014) ──────────────────────────────────────

#[test]
fn audit_entry_backlog_to_coding_exact_format() {
    let at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
        .unwrap()
        .with_timezone(&chrono::Utc);
    let fired = TransitionFired {
        story_id: StoryId("1014_my_story".into()),
        before: Stage::Backlog,
        after: Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        event: PipelineEvent::DepsMet,
        at,
    };
    assert_eq!(
        format_audit_entry(&fired),
        "audit ts=2026-01-01T00:00:00Z id=1014_my_story from=Backlog to=Coding event=DepsMet"
    );
}

#[test]
fn audit_entry_is_single_line_with_all_fields() {
    let fired = TransitionFired {
        story_id: StoryId("42_test".into()),
        before: Stage::Qa,
        after: Stage::Merge {
            feature_branch: fb("feature/story-42"),
            commits_ahead: nz(3),
            claim: None,
            retries: 0,
            server_start_time: None,
        },
        event: PipelineEvent::GatesPassed {
            feature_branch: fb("feature/story-42"),
            commits_ahead: nz(3),
        },
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(!line.contains('\n'), "audit entry must be a single line");
    assert!(line.starts_with("audit "), "must start with 'audit '");
    assert!(line.contains("id=42_test"), "must contain id field");
    assert!(line.contains("from=Qa"), "must contain from field");
    assert!(line.contains("to=Merge"), "must contain to field");
    assert!(
        line.contains("event=GatesPassed"),
        "must contain event field"
    );
    // Stable field ordering: ts before id before from before to before event.
    let ts_pos = line.find("ts=").unwrap();
    let id_pos = line.find("id=").unwrap();
    let from_pos = line.find("from=").unwrap();
    let to_pos = line.find("to=").unwrap();
    let ev_pos = line.find("event=").unwrap();
    assert!(
        ts_pos < id_pos && id_pos < from_pos && from_pos < to_pos && to_pos < ev_pos,
        "fields must appear in order ts, id, from, to, event"
    );
}

#[test]
fn audit_entry_merge_to_done() {
    let fired = TransitionFired {
        story_id: StoryId("100_s".into()),
        before: Stage::Merge {
            feature_branch: fb("f"),
            commits_ahead: nz(1),
            claim: None,
            retries: 0,
            server_start_time: None,
        },
        after: Stage::Done {
            merged_at: chrono::Utc::now(),
            merge_commit: sha("abc"),
        },
        event: PipelineEvent::MergeSucceeded {
            merge_commit: sha("abc"),
        },
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(line.contains("from=Merge"), "from=Merge");
    assert!(line.contains("to=Done"), "to=Done");
    assert!(
        line.contains("event=MergeSucceeded"),
        "event=MergeSucceeded"
    );
}

#[test]
fn audit_entry_done_to_archived() {
    let fired = TransitionFired {
        story_id: StoryId("200_s".into()),
        before: Stage::Done {
            merged_at: chrono::Utc::now(),
            merge_commit: sha("x"),
        },
        after: Stage::Archived {
            archived_at: chrono::Utc::now(),
            reason: ArchiveReason::Completed,
        },
        event: PipelineEvent::Accepted,
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(line.contains("from=Done"), "from=Done");
    assert!(line.contains("to=Archived"), "to=Archived");
    assert!(line.contains("event=Accepted"), "event=Accepted");
}

#[test]
fn audit_entry_coding_to_blocked() {
    let fired = TransitionFired {
        story_id: StoryId("300_s".into()),
        before: Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        after: Stage::Blocked {
            reason: "waiting".into(),
        },
        event: PipelineEvent::Block {
            reason: "waiting".into(),
        },
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(line.contains("from=Coding"), "from=Coding");
    assert!(line.contains("to=Blocked"), "to=Blocked");
    assert!(line.contains("event=Block"), "event=Block");
}

#[test]
fn audit_entry_blocked_to_coding() {
    let fired = TransitionFired {
        story_id: StoryId("400_s".into()),
        before: Stage::Blocked {
            reason: "test".into(),
        },
        after: Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        event: PipelineEvent::Unblock,
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(line.contains("from=Blocked"), "from=Blocked");
    assert!(line.contains("to=Coding"), "to=Coding");
    assert!(line.contains("event=Unblock"), "event=Unblock");
}

#[test]
fn audit_entry_merge_to_merge_failure() {
    let fired = TransitionFired {
        story_id: StoryId("500_s".into()),
        before: Stage::Merge {
            feature_branch: fb("f"),
            commits_ahead: nz(1),
            claim: None,
            retries: 0,
            server_start_time: None,
        },
        after: Stage::MergeFailure {
            kind: MergeFailureKind::Other("conflicts".into()),
            feature_branch: fb("f"),
            commits_ahead: nz(1),
        },
        event: PipelineEvent::MergeFailed {
            kind: MergeFailureKind::Other("conflicts".into()),
        },
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(line.contains("from=Merge"), "from=Merge");
    assert!(line.contains("to=MergeFailure"), "to=MergeFailure");
    assert!(line.contains("event=MergeFailed"), "event=MergeFailed");
}

#[test]
fn audit_entry_coding_to_frozen() {
    let fired = TransitionFired {
        story_id: StoryId("600_s".into()),
        before: Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        after: Stage::Frozen {
            resume_to: Box::new(Stage::Coding {
                claim: None,
                plan: PlanState::Missing,
                retries: 0,
            }),
        },
        event: PipelineEvent::Freeze,
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(line.contains("from=Coding"), "from=Coding");
    assert!(line.contains("to=Frozen"), "to=Frozen");
    assert!(line.contains("event=Freeze"), "event=Freeze");
}

#[test]
fn audit_entry_coding_to_abandoned() {
    let fired = TransitionFired {
        story_id: StoryId("700_s".into()),
        before: Stage::Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        },
        after: Stage::Abandoned {
            ts: chrono::Utc::now(),
        },
        event: PipelineEvent::Abandon,
        at: chrono::Utc::now(),
    };
    let line = format_audit_entry(&fired);
    assert!(line.contains("from=Coding"), "from=Coding");
    assert!(line.contains("to=Abandoned"), "to=Abandoned");
    assert!(line.contains("event=Abandon"), "event=Abandon");
}

// ── ProjectionError Display ─────────────────────────────────────────
