//! TransitionFired subscriber that owns the consecutive-MergeFailure budget:
//! auto-blocks stories at the threshold and auto-retries `GatesFailed`
//! failures below it.
//!
//! Listens on the pipeline transition broadcast channel and, for each story,
//! counts how many times it has entered [`Stage::MergeFailure`] consecutively.
//! One counter drives two policies sharing the `merge_failure_block_threshold`
//! budget (default 3):
//!
//! - **Below the threshold**, a `GatesFailed` failure schedules a delayed
//!   re-trigger of the deterministic server-side merge (story 1185) — gates
//!   failures are dominated by transients (flaky tests, stale base) that a
//!   plain re-run fixes.  Other kinds still count toward the budget but are
//!   not retried: `ConflictDetected` has its own mergemaster recovery path via
//!   [`super::merge_failure_subscriber`]; `EmptyDiff`/`NoCommits`/`Other`
//!   require human intervention.
//! - **At the threshold**, the story is transitioned to [`Stage::Blocked`]
//!   with a reason naming the failure kind.
//!
//! The counter resets when the story leaves `MergeFailure` for a real reason
//! (successful merge, `FixupRequested`, `Block`), but **not** on
//! [`PipelineEvent::MergeRetryStarted`] — that is the `MergeFailure → Merge`
//! bounce a retry itself causes.  Treating it as a reset made the budget
//! unreachable and let a deterministic gates failure retry forever (1185
//! review finding 1); counting across the bounce is what makes the budget
//! real.
//!
//! Bug 1025: while a mergemaster is actively running on the story, its
//! iteration loop (squash → fail → fix → retry) generates multiple
//! MergeFailure transitions.  Those are NOT consecutive give-ups — they are
//! recovery iterations in progress.  We neither count nor schedule retries
//! while a mergemaster is in the pool for the story.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;

use crate::io::watcher::WatcherEvent;
use crate::pipeline_state::{MergeFailureKind, PipelineEvent, Stage, Status, StoryId};
use crate::slog;
use crate::slog_warn;

use super::super::super::PipelineStage;
use super::super::AgentPool;
use super::scan::is_story_assigned_for_stage;
use super::story_checks::{
    has_review_hold, has_unmet_dependencies, is_story_blocked, is_story_frozen,
};

/// Delay before an auto-retry re-triggers the server-side merge for a
/// `GatesFailed` failure.  Gives transient conditions (a concurrently landing
/// master merge, an exhausted runner) a moment to clear; retrying instantly
/// would just replay the same failure.
const AUTO_RETRY_DELAY: Duration = Duration::from_secs(30);

/// Per-story scheduling generation, shared between the subscriber loop and the
/// delayed retry tasks it spawns.
///
/// Every scheduled retry captures the generation current at schedule time; the
/// timer only acts if that generation is still current when it fires.  The
/// subscriber bumps the generation on every (re)schedule and clears the entry
/// on counter reset, so stale timers left over from an earlier failure cycle
/// become no-ops instead of firing unaccounted retries (1185 review finding 6).
type Generations = Arc<Mutex<HashMap<String, u64>>>;

/// What the subscriber decided to do about one transition.  Split out from the
/// event loop so the counter/budget policy is synchronous and unit-testable.
#[derive(Debug, PartialEq, Eq)]
enum Decision {
    /// Nothing to do (not a MergeFailure, recovery in progress, retry bounce,
    /// budget disabled, or a non-retryable kind below the threshold).
    Nothing,
    /// Schedule a delayed auto-retry: this is consecutive failure `attempt` of
    /// a `budget`-sized budget, and the kind is `GatesFailed`.
    ScheduleRetry { attempt: u32, budget: u32 },
    /// The budget is exhausted: block the story.
    Block { count: u32 },
}

/// Reconcile: no-op for the periodic pass.
///
/// The consecutive-failure counter is in-memory and cannot be reconstructed
/// from CRDT state (only the current stage is stored, not the failure
/// history).  Restart catch-up for stories already parked in
/// `MergeFailure{GatesFailed}` is handled once, at subscriber startup, by
/// [`reconcile_stranded_gates_failed`] — running it from the periodic
/// reconciler instead would re-schedule retries for budget-exhausted stories
/// on every tick, reintroducing the unbounded-retry bug the startup-only scan
/// avoids.
pub(crate) fn reconcile_merge_failure_block() {}

/// Spawn the background task that owns the consecutive-MergeFailure budget:
/// auto-retry for `GatesFailed` below the threshold, auto-block at it.
pub(crate) fn spawn_merge_failure_block_subscriber(pool: Arc<AgentPool>, project_root: PathBuf) {
    let mut rx = crate::pipeline_state::subscribe_transitions();
    tokio::spawn(async move {
        let mut counters: HashMap<StoryId, (u32, MergeFailureKind)> = HashMap::new();
        let generations: Generations = Generations::default();

        // One-shot restart catch-up: stories already sitting in GatesFailed
        // when the process starts will never fire another transition on their
        // own, so without this they'd silently lose auto-retry coverage
        // (1185 review finding 3).
        reconcile_stranded_gates_failed(&pool, &project_root, &mut counters, &generations).await;

        loop {
            match rx.recv().await {
                Ok(fired) => {
                    let recovery_running =
                        is_mergemaster_running(&pool, &project_root, &fired.story_id.0).await;
                    let threshold = load_threshold(&project_root).await;
                    match decide(&fired, &mut counters, recovery_running, threshold) {
                        Decision::Nothing => {
                            // A real departure from MergeFailure also
                            // invalidates any pending retry timer.
                            if fired.after.status() != Status::MergeFailure
                                && !matches!(fired.event, PipelineEvent::MergeRetryStarted)
                            {
                                invalidate_generation(&generations, &fired.story_id.0);
                            }
                        }
                        Decision::ScheduleRetry { attempt, budget } => {
                            schedule_auto_retry(
                                Arc::clone(&pool),
                                project_root.clone(),
                                fired.story_id.0.clone(),
                                attempt,
                                budget,
                                Arc::clone(&generations),
                            );
                        }
                        Decision::Block { count } => {
                            let kind = counters
                                .get(&fired.story_id)
                                .map(|(_, k)| k.clone())
                                .unwrap_or(MergeFailureKind::Other(String::new()));
                            apply_block(&fired.story_id, count, &kind);
                            counters.remove(&fired.story_id);
                            invalidate_generation(&generations, &fired.story_id.0);
                        }
                    }
                }
                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                    slog_warn!(
                        "[merge-block-sub] Subscriber lagged, skipped {n} event(s). \
                         Some consecutive-failure counts may be understated."
                    );
                }
                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
            }
        }
    });
}

/// Pure budget policy: given a fired transition and the per-story counter,
/// decide whether to do nothing, schedule a `GatesFailed` auto-retry, or
/// block the story.
///
/// `threshold == 0` disables both policies (feature off), matching the
/// pre-1185 block-subscriber behaviour.
fn decide(
    fired: &crate::pipeline_state::TransitionFired,
    counters: &mut HashMap<StoryId, (u32, MergeFailureKind)>,
    recovery_running: bool,
    threshold: u32,
) -> Decision {
    // Story 1086: gate on the typed `Status` projection — `Status::MergeFailure`
    // is precisely the set of stages we count toward the budget.
    if fired.after.status() != Status::MergeFailure {
        // The MergeFailure → Merge bounce caused by a retry (auto or manual)
        // is not a recovery: the budget must survive it, or a deterministic
        // failure retries forever (1185 review finding 1).
        if !matches!(fired.event, PipelineEvent::MergeRetryStarted) {
            counters.remove(&fired.story_id);
        }
        return Decision::Nothing;
    }
    let Stage::MergeFailure { kind, .. } = &fired.after else {
        counters.remove(&fired.story_id);
        return Decision::Nothing;
    };

    if recovery_running {
        slog!(
            "[merge-block-sub] Story '{}' MergeFailure while mergemaster is running; \
             not counting toward budget (recovery in progress).",
            fired.story_id.0
        );
        return Decision::Nothing;
    }

    let entry = counters
        .entry(fired.story_id.clone())
        .or_insert_with(|| (0, kind.clone()));
    entry.0 += 1;
    entry.1 = kind.clone();
    let count = entry.0;

    if threshold == 0 {
        return Decision::Nothing;
    }
    if count >= threshold {
        return Decision::Block { count };
    }
    if matches!(kind, MergeFailureKind::GatesFailed(_)) {
        return Decision::ScheduleRetry {
            attempt: count,
            budget: threshold,
        };
    }
    Decision::Nothing
}

/// Transition `story_id` to `Blocked` with a reason naming the failure kind.
fn apply_block(story_id: &StoryId, count: u32, kind: &MergeFailureKind) {
    let kind_str = failure_kind_label(kind);
    let reason =
        format!("Auto-blocked after {count} consecutive MergeFailure ({kind_str}) transitions.");
    let story_id = story_id.0.as_str();
    slog!(
        "[merge-block-sub] Story '{story_id}' reached {count} consecutive \
         MergeFailure ({kind_str}); blocking."
    );
    if let Err(e) =
        crate::pipeline_state::apply_transition(story_id, PipelineEvent::Block { reason }, None)
    {
        slog_warn!("[merge-block-sub] Failed to block '{story_id}': {e}");
    }
}

/// Spawn the delayed auto-retry task for one `GatesFailed` failure.
///
/// Bumps the story's scheduling generation so any earlier still-sleeping timer
/// for the story becomes stale and skips itself.
fn schedule_auto_retry(
    pool: Arc<AgentPool>,
    project_root: PathBuf,
    story_id: String,
    attempt: u32,
    budget: u32,
    generations: Generations,
) {
    let generation = next_generation(&generations, &story_id);
    slog!(
        "[merge-block-sub] Story '{story_id}' GatesFailed (attempt {attempt}/{budget}); \
         scheduling auto-retry in {AUTO_RETRY_DELAY:?}."
    );
    tokio::spawn(async move {
        tokio::time::sleep(AUTO_RETRY_DELAY).await;
        perform_auto_retry(
            &pool,
            &project_root,
            &story_id,
            attempt,
            budget,
            &generations,
            generation,
        )
        .await;
    });
}

/// Fire one auto-retry: re-check that acting is still correct, start the
/// server-side merge, and notify chat only when the merge actually started
/// (1185 review finding 7).
///
/// Guards, in order:
/// - the scheduling generation is still current (no newer schedule/reset);
/// - the story is still parked in `MergeFailure{GatesFailed}`;
/// - the story is not frozen/blocked/on hold/dep-blocked — the same
///   eligibility gates `assign_merge_stage` applies (1185 review finding 4);
/// - no mergemaster is actively recovering the story (1185 review finding 2).
async fn perform_auto_retry(
    pool: &Arc<AgentPool>,
    project_root: &Path,
    story_id: &str,
    attempt: u32,
    budget: u32,
    generations: &Generations,
    generation: u64,
) {
    if !is_generation_current(generations, story_id, generation) {
        slog!(
            "[merge-block-sub] Story '{story_id}' auto-retry ({attempt}/{budget}) is stale \
             (superseded or reset); skipping."
        );
        return;
    }

    let still_gates_failed = matches!(
        crate::pipeline_state::read_typed(story_id),
        Ok(Some(item)) if matches!(
            item.stage,
            Stage::MergeFailure {
                kind: MergeFailureKind::GatesFailed(_),
                ..
            }
        )
    );
    if !still_gates_failed {
        slog!(
            "[merge-block-sub] Story '{story_id}' left GatesFailed before auto-retry \
             ({attempt}/{budget}) fired; skipping."
        );
        return;
    }

    if has_review_hold(story_id)
        || is_story_frozen(story_id)
        || is_story_blocked(story_id)
        || has_unmet_dependencies(story_id)
    {
        slog!(
            "[merge-block-sub] Story '{story_id}' is held/frozen/blocked/dep-blocked; \
             skipping auto-retry ({attempt}/{budget})."
        );
        return;
    }

    if is_mergemaster_running(pool, project_root, story_id).await {
        slog!(
            "[merge-block-sub] Story '{story_id}' has an active mergemaster; \
             skipping auto-retry ({attempt}/{budget}) — recovery owns the story."
        );
        return;
    }

    match pool.start_merge_agent_work(project_root, story_id) {
        Ok(()) => {
            slog!(
                "[merge-block-sub] Auto-retrying merge for '{story_id}' \
                 (attempt {attempt}/{budget})."
            );
            let _ = pool.watcher_tx.send(WatcherEvent::MergeAutoRetry {
                story_id: story_id.to_string(),
                attempt,
                budget,
            });
        }
        Err(e) => {
            slog_warn!(
                "[merge-block-sub] Auto-retry for '{story_id}' ({attempt}/{budget}) \
                 could not start: {e}; not notifying."
            );
        }
    }
}

/// One-shot startup scan: schedule a first auto-retry for every story already
/// parked in `MergeFailure{GatesFailed}`.
///
/// The pre-restart attempt count is unrecoverable, so the counter restarts at
/// 1 — worst case a story gets up to `threshold - 1` extra retries across a
/// restart, still bounded per process lifetime.
async fn reconcile_stranded_gates_failed(
    pool: &Arc<AgentPool>,
    project_root: &Path,
    counters: &mut HashMap<StoryId, (u32, MergeFailureKind)>,
    generations: &Generations,
) {
    let threshold = load_threshold(project_root).await;
    if threshold == 0 {
        return;
    }
    for item in crate::pipeline_state::read_all_typed() {
        let Stage::MergeFailure { kind, .. } = &item.stage else {
            continue;
        };
        if !matches!(kind, MergeFailureKind::GatesFailed(_)) {
            continue;
        }
        counters.insert(item.story_id.clone(), (1, kind.clone()));
        slog!(
            "[merge-block-sub] Story '{}' found parked in GatesFailed at startup; \
             scheduling catch-up auto-retry (attempt 1/{threshold}).",
            item.story_id.0
        );
        schedule_auto_retry(
            Arc::clone(pool),
            project_root.to_path_buf(),
            item.story_id.0.clone(),
            1,
            threshold,
            Arc::clone(generations),
        );
    }
}

/// Bump and return the scheduling generation for `story_id`.
fn next_generation(generations: &Generations, story_id: &str) -> u64 {
    let mut map = generations.lock().unwrap_or_else(|p| p.into_inner());
    let entry = map.entry(story_id.to_string()).or_insert(0);
    *entry += 1;
    *entry
}

/// Drop the generation entry for `story_id`, making every pending timer stale.
fn invalidate_generation(generations: &Generations, story_id: &str) {
    generations
        .lock()
        .unwrap_or_else(|p| p.into_inner())
        .remove(story_id);
}

/// True when `expected` is still the current scheduling generation.
fn is_generation_current(generations: &Generations, story_id: &str, expected: u64) -> bool {
    generations
        .lock()
        .unwrap_or_else(|p| p.into_inner())
        .get(story_id)
        == Some(&expected)
}

/// Return true if a mergemaster agent is currently in the pool for `story_id`.
/// Used to suppress counting and retries while recovery is actively iterating
/// (bug 1025).
async fn is_mergemaster_running(pool: &AgentPool, project_root: &Path, story_id: &str) -> bool {
    let root = project_root.to_path_buf();
    let config = match tokio::task::spawn_blocking(move || {
        crate::config::ProjectConfig::load(&root)
    })
    .await
    {
        Ok(Ok(c)) => c,
        _ => return false,
    };
    let agents = pool.agents.lock().await;
    is_story_assigned_for_stage(&config, &agents, story_id, &PipelineStage::Mergemaster)
}

/// Load the budget from project config off the async runtime (the read is
/// synchronous filesystem I/O — bug 1170 class), falling back to the compiled
/// default.
async fn load_threshold(project_root: &Path) -> u32 {
    let root = project_root.to_path_buf();
    tokio::task::spawn_blocking(move || {
        crate::config::ProjectConfig::load(&root)
            .map(|c| c.merge_failure_block_threshold)
            .unwrap_or(3)
    })
    .await
    .unwrap_or(3)
}

/// Short human-readable label for a [`MergeFailureKind`] variant.
fn failure_kind_label(kind: &MergeFailureKind) -> &'static str {
    match kind {
        MergeFailureKind::ConflictDetected(_) => "ConflictDetected",
        MergeFailureKind::GatesFailed(_) => "GatesFailed",
        MergeFailureKind::EmptyDiff => "EmptyDiff",
        MergeFailureKind::NoCommits => "NoCommits",
        MergeFailureKind::Other(_) => "Other",
    }
}

// ── Tests ──────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pipeline_state::{BranchName, PipelineEvent, Stage, StoryId, TransitionFired};
    use std::num::NonZeroU32;

    const THRESHOLD: u32 = 3;

    fn seed_at_merge(story_id: &str) {
        crate::crdt_state::init_for_test();
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            story_id,
            "4_merge",
            "---\nname: Test\n---\n",
            crate::db::ItemMeta::named("Test"),
        );
    }

    fn make_merge_failure_fired(story_id: &str, kind: MergeFailureKind) -> TransitionFired {
        TransitionFired {
            story_id: StoryId(story_id.to_string()),
            before: Stage::Merge {
                feature_branch: BranchName("feature/test".to_string()),
                commits_ahead: NonZeroU32::new(1).unwrap(),
                claim: None,
                retries: 0,
                server_start_time: None,
            },
            after: Stage::MergeFailure {
                kind: kind.clone(),
                feature_branch: BranchName("feature/test".to_string()),
                commits_ahead: NonZeroU32::new(1).unwrap(),
            },
            event: PipelineEvent::MergeFailed { kind },
            at: chrono::Utc::now(),
        }
    }

    fn make_coding_fired(story_id: &str) -> TransitionFired {
        TransitionFired {
            story_id: StoryId(story_id.to_string()),
            before: Stage::MergeFailure {
                kind: MergeFailureKind::GatesFailed("error".to_string()),
                feature_branch: BranchName("feature/test".to_string()),
                commits_ahead: NonZeroU32::new(1).unwrap(),
            },
            after: Stage::Coding {
                claim: None,
                plan: Default::default(),
                retries: 0,
            },
            event: PipelineEvent::FixupRequested,
            at: chrono::Utc::now(),
        }
    }

    /// The MergeFailure → Merge bounce a retry causes.
    fn make_retry_started_fired(story_id: &str) -> TransitionFired {
        TransitionFired {
            story_id: StoryId(story_id.to_string()),
            before: Stage::MergeFailure {
                kind: MergeFailureKind::GatesFailed("error".to_string()),
                feature_branch: BranchName("feature/test".to_string()),
                commits_ahead: NonZeroU32::new(1).unwrap(),
            },
            after: Stage::Merge {
                feature_branch: BranchName("feature/test".to_string()),
                commits_ahead: NonZeroU32::new(1).unwrap(),
                claim: None,
                retries: 1,
                server_start_time: None,
            },
            event: PipelineEvent::MergeRetryStarted,
            at: chrono::Utc::now(),
        }
    }

    fn gates_failed() -> MergeFailureKind {
        MergeFailureKind::GatesFailed("error".to_string())
    }

    /// Below the threshold, GatesFailed schedules a retry with the right
    /// attempt numbering.
    #[test]
    fn gates_failed_below_threshold_schedules_retry() {
        let mut counters = HashMap::new();
        let fired = make_merge_failure_fired("t_sched", gates_failed());

        assert_eq!(
            decide(&fired, &mut counters, false, THRESHOLD),
            Decision::ScheduleRetry {
                attempt: 1,
                budget: THRESHOLD
            }
        );
        assert_eq!(
            decide(&fired, &mut counters, false, THRESHOLD),
            Decision::ScheduleRetry {
                attempt: 2,
                budget: THRESHOLD
            }
        );
    }

    /// 1185 review finding 1 (regression): the retry's own MergeFailure→Merge
    /// bounce must NOT reset the counter — the third consecutive failure
    /// blocks even though retries happened in between.
    #[test]
    fn merge_retry_started_does_not_reset_counter() {
        let mut counters = HashMap::new();
        let story = "t_no_reset";
        let fail = make_merge_failure_fired(story, gates_failed());
        let bounce = make_retry_started_fired(story);

        assert!(matches!(
            decide(&fail, &mut counters, false, THRESHOLD),
            Decision::ScheduleRetry { attempt: 1, .. }
        ));
        assert_eq!(
            decide(&bounce, &mut counters, false, THRESHOLD),
            Decision::Nothing
        );
        assert!(matches!(
            decide(&fail, &mut counters, false, THRESHOLD),
            Decision::ScheduleRetry { attempt: 2, .. }
        ));
        assert_eq!(
            decide(&bounce, &mut counters, false, THRESHOLD),
            Decision::Nothing
        );
        // Third consecutive failure: budget exhausted despite the bounces.
        assert_eq!(
            decide(&fail, &mut counters, false, THRESHOLD),
            Decision::Block { count: 3 }
        );
    }

    /// A real departure (FixupRequested → Coding) still resets the counter.
    #[test]
    fn real_departure_resets_counter() {
        let mut counters = HashMap::new();
        let story = "t_reset";
        let fail = make_merge_failure_fired(story, gates_failed());

        decide(&fail, &mut counters, false, THRESHOLD);
        decide(&fail, &mut counters, false, THRESHOLD);
        assert_eq!(
            counters.get(&StoryId(story.to_string())).map(|e| e.0),
            Some(2)
        );

        decide(&make_coding_fired(story), &mut counters, false, THRESHOLD);
        assert!(!counters.contains_key(&StoryId(story.to_string())));

        // Fresh failures start a fresh budget.
        assert!(matches!(
            decide(&fail, &mut counters, false, THRESHOLD),
            Decision::ScheduleRetry { attempt: 1, .. }
        ));
    }

    /// Non-GatesFailed kinds count toward the block budget but never schedule
    /// a retry (ConflictDetected has its own mergemaster path; the rest need
    /// humans).
    #[test]
    fn non_gates_failed_counts_but_does_not_retry() {
        let mut counters = HashMap::new();
        let story = "t_conflict";
        let conflict = make_merge_failure_fired(story, MergeFailureKind::ConflictDetected(None));

        assert_eq!(
            decide(&conflict, &mut counters, false, THRESHOLD),
            Decision::Nothing
        );
        assert_eq!(
            counters.get(&StoryId(story.to_string())).map(|e| e.0),
            Some(1)
        );

        assert_eq!(
            decide(&conflict, &mut counters, false, THRESHOLD),
            Decision::Nothing
        );
        assert_eq!(
            decide(&conflict, &mut counters, false, THRESHOLD),
            Decision::Block { count: 3 }
        );
    }

    /// Mixed kinds share one budget: GatesFailed and ConflictDetected
    /// interleavings block at the same total count (1185 review finding 5).
    #[test]
    fn mixed_kinds_share_one_budget() {
        let mut counters = HashMap::new();
        let story = "t_mixed";
        let fail = make_merge_failure_fired(story, gates_failed());
        let conflict = make_merge_failure_fired(story, MergeFailureKind::ConflictDetected(None));

        assert!(matches!(
            decide(&fail, &mut counters, false, THRESHOLD),
            Decision::ScheduleRetry { attempt: 1, .. }
        ));
        assert_eq!(
            decide(&conflict, &mut counters, false, THRESHOLD),
            Decision::Nothing
        );
        assert_eq!(
            decide(&fail, &mut counters, false, THRESHOLD),
            Decision::Block { count: 3 }
        );
    }

    /// Bug 1025: recovery in progress neither counts nor schedules.
    #[test]
    fn mergemaster_running_suppresses_counting_and_retry() {
        let mut counters = HashMap::new();
        let story = "t_recovery";
        let fail = make_merge_failure_fired(story, gates_failed());

        for _ in 0..3 {
            assert_eq!(
                decide(&fail, &mut counters, true, THRESHOLD),
                Decision::Nothing
            );
        }
        assert!(!counters.contains_key(&StoryId(story.to_string())));
    }

    /// threshold == 0 disables both policies.
    #[test]
    fn threshold_zero_disables_block_and_retry() {
        let mut counters = HashMap::new();
        let fail = make_merge_failure_fired("t_disabled", gates_failed());
        for _ in 0..5 {
            assert_eq!(decide(&fail, &mut counters, false, 0), Decision::Nothing);
        }
    }

    /// Applying a Block decision transitions the story and names the kind.
    #[test]
    fn apply_block_blocks_with_failure_kind_in_reason() {
        let story_id = "1018_at_threshold";
        seed_at_merge(story_id);
        crate::agents::lifecycle::transition_to_merge_failure(
            story_id,
            MergeFailureKind::GatesFailed("fmt error".to_string()),
        )
        .expect("initial MergeFailure transition");

        apply_block(
            &StoryId(story_id.to_string()),
            3,
            &MergeFailureKind::GatesFailed("fmt error".to_string()),
        );

        let item = crate::pipeline_state::read_typed(story_id)
            .expect("read")
            .expect("item");
        match &item.stage {
            Stage::Blocked { reason } => {
                assert!(
                    reason.contains("GatesFailed"),
                    "block reason must name the failure kind: {reason}"
                );
            }
            other => panic!("story must be Blocked: {other:?}"),
        }
    }

    /// 1185 review finding 6 (regression): a newer schedule or a reset makes
    /// earlier timers stale.
    #[test]
    fn stale_generations_are_not_current() {
        let generations: Generations = Generations::default();
        let g1 = next_generation(&generations, "s");
        assert!(is_generation_current(&generations, "s", g1));

        let g2 = next_generation(&generations, "s");
        assert!(!is_generation_current(&generations, "s", g1));
        assert!(is_generation_current(&generations, "s", g2));

        invalidate_generation(&generations, "s");
        assert!(!is_generation_current(&generations, "s", g2));
    }
}
