//! Core pipeline types: newtypes, stage and execution-state enums, and errors.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::num::NonZeroU32;

// ── Newtypes ────────────────────────────────────────────────────────────────

/// Unique identifier for a pipeline work item (story, bug, spike, or refactor).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StoryId(pub String);

/// Git branch name associated with a work item.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BranchName(pub String);

/// A Git commit SHA (typically the merge commit written when a story lands on master).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GitSha(pub String);

/// The name of the agent process handling a work item on a given node.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AgentName(pub String);

/// Ed25519 public key (32 bytes) that uniquely identifies a huskies node in the mesh.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NodePubkey(pub [u8; 32]);

impl fmt::Display for StoryId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl fmt::Display for AgentName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

// ── Merge failure kind ──────────────────────────────────────────────────────

/// Typed reason for a merge pipeline failure.
///
/// Replaces the freeform `reason: String` that was previously stored on
/// `Stage::MergeFailure` (story 982).  Consumers match on the variant instead
/// of scanning substrings of an opaque string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MergeFailureKind {
    /// Git content conflicts detected during the squash-rebase.  The payload
    /// is the raw conflict output (e.g. the git CONFLICT lines), if available.
    ConflictDetected(Option<String>),
    /// Quality gates (fmt, clippy, tests) failed after the squash merge.
    /// The payload is the gate runner output.
    GatesFailed(String),
    /// Feature branch has no code changes ahead of master (empty diff).
    EmptyDiff,
    /// Squash merge produced no commits (e.g. empty-commit history).
    NoCommits,
    /// Unclassified merge failure; the payload carries the original message.
    Other(String),
}

impl MergeFailureKind {
    /// Human-readable description for display tools and `MergeJob.error`.
    pub fn display_reason(&self) -> String {
        match self {
            MergeFailureKind::ConflictDetected(details) => format!(
                "Merge conflict: {}",
                details.as_deref().unwrap_or("conflicts detected")
            ),
            MergeFailureKind::GatesFailed(output) => format!("Quality gates failed: {output}"),
            MergeFailureKind::EmptyDiff => "Feature branch has no code changes".to_string(),
            MergeFailureKind::NoCommits => "No commits to merge".to_string(),
            MergeFailureKind::Other(reason) => reason.clone(),
        }
    }

    /// String to persist in `ContentKey::GateOutput` so the CRDT projection
    /// layer can reconstruct the kind after a server restart.
    pub fn to_gate_output(&self) -> String {
        match self {
            // Always prefix with "Merge conflict:" so `infer_from_gate_output`
            // can identify ConflictDetected on reload.
            MergeFailureKind::ConflictDetected(details) => format!(
                "Merge conflict: {}",
                details.as_deref().unwrap_or("conflicts detected")
            ),
            // Raw gate output is the natural identifier for a GatesFailed kind.
            MergeFailureKind::GatesFailed(output) => output.clone(),
            MergeFailureKind::EmptyDiff => "empty diff".to_string(),
            MergeFailureKind::NoCommits => "no commits to merge".to_string(),
            MergeFailureKind::Other(reason) => reason.clone(),
        }
    }

    /// Infer a kind from a `ContentKey::GateOutput` string persisted by a
    /// previous call to [`Self::to_gate_output`] or by legacy code that wrote
    /// conflict markers directly.  Used by the CRDT projection layer.
    pub fn infer_from_gate_output(gate_output: &str) -> Self {
        if gate_output.contains("CONFLICT (content):") || gate_output.contains("Merge conflict") {
            MergeFailureKind::ConflictDetected(Some(gate_output.to_string()))
        } else if gate_output.is_empty() {
            MergeFailureKind::Other(String::new())
        } else {
            MergeFailureKind::GatesFailed(gate_output.to_string())
        }
    }
}

// ── Agent claim payload ────────────────────────────────────────────────────

/// Active claim on a pipeline item by a specific agent.
///
/// Embedded directly in [`Stage::Coding`] and [`Stage::Merge`] rather than
/// stored in separate CRDT registers.  Readers access the claim via
/// `item.stage()` rather than through a separate `item.claim()` accessor
/// (story 1009).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentClaim {
    /// The agent (e.g. `"coder-1"`) that has claimed this work item.
    pub agent: AgentName,
    /// When the claim was written.
    pub claimed_at: DateTime<Utc>,
}

// ── Plan state (PLAN.md lifecycle inside Stage::Coding) ────────────────────

/// Lifecycle state of the `PLAN.md` file inside a coding worktree.
///
/// Updated by the filesystem watcher whenever PLAN.md is created, modified,
/// or removed in a story's worktree.  Embedded in [`Stage::Coding`] so
/// callers access it via the typed projection instead of greping the filesystem.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PlanState {
    /// No `PLAN.md` file exists in the worktree yet.
    #[default]
    Missing,
    /// `PLAN.md` exists but contains `<TBD>` placeholders — the plan has been
    /// drafted but not yet confirmed with real file paths and descriptions.
    Drafted,
    /// `PLAN.md` exists and contains no `<TBD>` placeholders — the plan is
    /// considered confirmed.
    Confirmed,
}

impl PlanState {
    /// Wire-form string stored in the `plan_state` CRDT register.
    pub fn as_str(&self) -> &'static str {
        match self {
            PlanState::Missing => "missing",
            PlanState::Drafted => "drafted",
            PlanState::Confirmed => "confirmed",
        }
    }

    /// Parse from a `plan_state` CRDT register value.
    ///
    /// Unknown or empty strings default to [`PlanState::Missing`].
    pub fn from_str(s: &str) -> Self {
        match s {
            "drafted" => PlanState::Drafted,
            "confirmed" => PlanState::Confirmed,
            _ => PlanState::Missing,
        }
    }
}

// ── Synced pipeline stage (lives in CRDT, converges across nodes) ───────────

/// The pipeline stage for a work item.
///
/// This is the SHARED state — every node sees the same Stage for a given story
/// after CRDT convergence. Notice what is NOT a field:
///   - `agent` — local execution state, not pipeline state
///   - `retry_count` — also local
///   - `blocked` — now a first-class `Blocked { reason }` stage
///
/// ## Canonical state machine (story 857 / 984)
///
/// The following named lifecycle states map to `Stage` variants:
///
/// | Lifecycle state | Stage variant          |
/// |-----------------|------------------------|
/// | upcoming        | `Upcoming`             |
/// | backlog         | `Backlog`              |
/// | current         | `Coding`               |
/// | qa_pending      | `Qa`                   |
/// | merge_pending   | `Merge { .. }`         |
/// | merge_failure   | `MergeFailure { .. }`  |
/// | done            | `Done { .. }`          |
/// | blocked         | `Blocked { .. }`       |
/// | archived        | `Archived { .. }`      |
/// | superseded      | `Superseded { .. }`    |
/// | rejected        | `Rejected { .. }`      |
/// | abandoned       | `Abandoned { .. }`     |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Stage {
    /// Story has been created but not yet triaged into the backlog.
    Upcoming,

    /// Story exists, waiting for dependencies or auto-assign promotion.
    Backlog,

    /// Story is being actively coded somewhere in the mesh.
    ///
    /// Carries an optional [`AgentClaim`] identifying which agent is currently
    /// working on this item.  `None` means the item is in the coding stage but
    /// no agent has claimed it yet (e.g. just transitioned from Backlog and
    /// waiting for an agent to pick it up).
    ///
    /// `plan` tracks the lifecycle of the `PLAN.md` file in the worktree,
    /// updated by the filesystem watcher on create/modify/remove events.
    ///
    /// `retries` counts how many times the coder agent has been restarted for
    /// this item.  Replaces the separate `retry_count` CRDT register (story 997).
    Coding {
        claim: Option<AgentClaim>,
        plan: PlanState,
        /// Number of coder restarts for this item.  Zero on the first attempt.
        retries: u32,
    },

    /// Coder has run; gates are running.
    Qa,

    /// Gates passed; ready to merge.
    ///
    /// `commits_ahead: NonZeroU32` makes "Merge with nothing to merge"
    /// structurally impossible (eliminates bug 519).  The optional
    /// [`AgentClaim`] carries the mergemaster agent that owns this merge.
    ///
    /// `retries` counts how many times the mergemaster agent has been restarted
    /// for this item.  Replaces the separate `retry_count` CRDT register (story 997).
    ///
    /// `server_start_time` is the Unix timestamp (f64 seconds) captured when
    /// the current server process first called `server_start_time()`.  Written
    /// by the merge runner when it begins a merge task; `None` means no merge
    /// task is in flight on any node right now.  Used by `reap_stale_merge_jobs`
    /// to detect merges left running by a previous server process (story 1036).
    Merge {
        feature_branch: BranchName,
        commits_ahead: NonZeroU32,
        /// Agent currently running the merge, or `None` when unclaimed.
        claim: Option<AgentClaim>,
        /// Number of mergemaster restarts for this item.  Zero on the first attempt.
        retries: u32,
        /// Unix timestamp of the server process that started the active merge task.
        /// `None` means no merge task is currently in flight.
        server_start_time: Option<f64>,
    },

    /// Mergemaster squashed to master. Always carries merge metadata.
    Done {
        merged_at: DateTime<Utc>,
        merge_commit: GitSha,
    },

    /// Story is blocked, awaiting human resolution or retry-limit review.
    /// Unlike `Archived`, a blocked story is expected to return to active work
    /// (via `Unblock → Coding`).
    Blocked { reason: String },

    /// Out of the active flow. The reason explains why.
    Archived {
        archived_at: DateTime<Utc>,
        reason: ArchiveReason,
    },

    /// Merge pipeline failed (conflicts or gate failures). Story is held here
    /// awaiting human intervention or retry.  Unlike `Archived(MergeFailed)`,
    /// this is a recoverable intermediate state — `Unblock` returns to `Merge`
    /// (re-queues the merge) and `Demote` returns to `Backlog` (manual park).
    MergeFailure {
        /// Typed failure reason — callers match on the variant instead of
        /// substring-scanning a freeform string (story 982).
        kind: MergeFailureKind,
        /// Branch and commit count preserved from the preceding `Merge` state
        /// so `Unblock` can reconstruct the exact `Merge` variant.
        feature_branch: BranchName,
        commits_ahead: NonZeroU32,
    },

    /// Merge pipeline failed AND mergemaster has already been auto-spawned to
    /// recover; the agent gave up.  The story stays here awaiting human
    /// intervention — the auto-assigner will NOT spawn mergemaster again.
    /// Replaces the legacy `mergemaster_attempted: true` boolean flag.
    MergeFailureFinal {
        /// Typed failure reason carried through from the preceding `MergeFailure`.
        kind: MergeFailureKind,
    },

    /// Story is frozen — kept at this stage as a snapshot of its previous
    /// stage.  Replaces the legacy `frozen: true` boolean flag: there is no
    /// "frozen AND coding" combinatorial state; a story is either Frozen
    /// (with the stage it would resume to) or not.  The auto-assigner skips
    /// frozen stories; `Unfreeze` transitions back to `resume_to`.
    Frozen { resume_to: Box<Stage> },

    /// Story is held for human review at a pipeline-stage boundary (e.g.
    /// spikes after QA passes, human-QA items after gates pass).  Replaces
    /// the legacy `review_hold: true` boolean flag: the held story has a
    /// definite resume target stored on the variant.  The auto-assigner
    /// skips review-held stories; clearing the hold returns to `resume_to`.
    ReviewHold {
        resume_to: Box<Stage>,
        reason: String,
    },

    /// Story was abandoned by the user — no further work planned.
    /// Carries the timestamp of the abandonment.  Replaces the legacy
    /// `Archived { reason: ArchiveReason::Abandoned }` (story 984).
    Abandoned { ts: DateTime<Utc> },

    /// Story was superseded by another work item.
    /// Carries the timestamp and the ID of the replacing story.  Replaces
    /// the legacy `Archived { reason: ArchiveReason::Superseded { .. } }` (story 984).
    Superseded {
        ts: DateTime<Utc>,
        superseded_by: StoryId,
    },

    /// Story was permanently rejected (e.g. by QA or a reviewer).
    /// Carries the timestamp and the rejection reason.  Replaces the legacy
    /// `Archived { reason: ArchiveReason::Rejected { .. } }` (story 984).
    Rejected { ts: DateTime<Utc>, reason: String },
}

/// Why a story was archived.
///
/// Story 984: `Abandoned`, `Superseded`, and `Rejected` are now first-class
/// `Stage` variants and are no longer stored here.  The remaining variants
/// cover completion paths that stay under `Stage::Archived`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ArchiveReason {
    /// Normal happy-path completion.
    Completed,
    /// Manually blocked, awaiting human resolution (legacy — kept for CRDT
    /// backward compatibility; new blocked stories use `Stage::Blocked`).
    Blocked { reason: String },
    /// Mergemaster failed beyond the retry budget.
    MergeFailed { reason: String },
    /// Held in review at human request (legacy — kept for CRDT backward
    /// compatibility; new review-held stories use `Stage::ReviewHold`).
    ReviewHeld { reason: String },
}

// ── Stage convenience methods ──────────────────────────────────────────────

impl Stage {
    /// Returns the filesystem directory name for this stage.
    pub fn dir_name(&self) -> &'static str {
        stage_dir_name(self)
    }

    /// Parse a stage from its filesystem directory name.
    ///
    /// This is the single canonical conversion boundary for turning a loose
    /// stage-directory string (from CRDT fields or watcher events) into a
    /// typed `Stage`.  Rich variants (`Done`, `Archived`, `Merge`) are
    /// synthesised with zero-value fields — callers should use this only for
    /// stage *classification* (via `matches!`), not for accessing the rich
    /// metadata fields.
    pub fn from_dir(s: &str) -> Option<Self> {
        match s {
            "upcoming" => Some(Stage::Upcoming),
            "backlog" => Some(Stage::Backlog),
            "coding" => Some(Stage::Coding {
                claim: None,
                plan: PlanState::Missing,
                retries: 0,
            }),
            "blocked" => Some(Stage::Blocked {
                reason: String::new(),
            }),
            "qa" => Some(Stage::Qa),
            "merge" => Some(Stage::Merge {
                feature_branch: BranchName(String::new()),
                commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
                claim: None,
                retries: 0,
                server_start_time: None,
            }),
            "merge_failure" => Some(Stage::MergeFailure {
                kind: MergeFailureKind::Other(String::new()),
                feature_branch: BranchName(String::new()),
                commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
            }),
            "merge_failure_final" => Some(Stage::MergeFailureFinal {
                kind: MergeFailureKind::Other(String::new()),
            }),
            "frozen" => Some(Stage::Frozen {
                resume_to: Box::new(Stage::Coding {
                    claim: None,
                    plan: PlanState::Missing,
                    retries: 0,
                }),
            }),
            "review_hold" => Some(Stage::ReviewHold {
                resume_to: Box::new(Stage::Coding {
                    claim: None,
                    plan: PlanState::Missing,
                    retries: 0,
                }),
                reason: String::new(),
            }),
            "done" => Some(Stage::Done {
                merged_at: DateTime::<Utc>::UNIX_EPOCH,
                merge_commit: GitSha(String::new()),
            }),
            "archived" => Some(Stage::Archived {
                archived_at: DateTime::<Utc>::UNIX_EPOCH,
                reason: ArchiveReason::Completed,
            }),
            "abandoned" => Some(Stage::Abandoned {
                ts: DateTime::<Utc>::UNIX_EPOCH,
            }),
            "superseded" => Some(Stage::Superseded {
                ts: DateTime::<Utc>::UNIX_EPOCH,
                superseded_by: StoryId(String::new()),
            }),
            "rejected" => Some(Stage::Rejected {
                ts: DateTime::<Utc>::UNIX_EPOCH,
                reason: String::new(),
            }),
            _ => None,
        }
    }
}

// ── Display split (story 1085): Pipeline column + Status badge ─────────────

/// Column placement for a work item in the UI/chat status display.
///
/// Derived from [`Stage`] via [`Stage::pipeline`].  Display callers route items
/// to columns by this enum instead of pattern-matching `Stage` variants, so
/// new badges (e.g. `Frozen`, `Blocked`) do not produce new columns.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Pipeline {
    /// Items in `Upcoming` or `Backlog` stages.
    Backlog,
    /// Items being coded (or blocked while in the coding lane).
    Coding,
    /// Items in QA or `ReviewHold`.
    Qa,
    /// Items in `Merge`, `MergeFailure`, or `MergeFailureFinal`.
    Merge,
    /// Items in `Done`.
    Done,
    /// Abandoned, superseded, or rejected items.
    Closed,
    /// Items swept into `Archived`.
    Archived,
}

impl Pipeline {
    /// Stable wire-format identifier (kebab-case).
    pub fn as_str(&self) -> &'static str {
        match self {
            Pipeline::Backlog => "backlog",
            Pipeline::Coding => "coding",
            Pipeline::Qa => "qa",
            Pipeline::Merge => "merge",
            Pipeline::Done => "done",
            Pipeline::Closed => "closed",
            Pipeline::Archived => "archived",
        }
    }
}

/// Badge/indicator for a work item, orthogonal to its [`Pipeline`] column.
///
/// Derived from [`Stage`] via [`Stage::status`].  A `Frozen` story stays in
/// its underlying `Pipeline` column (e.g. `Coding`) and is decorated with
/// `Status::Frozen` for the display.  `Status::Done` is reserved for items in
/// the `Done` column and is never produced for items still in flight, so a
/// done item never carries a `MergeFailure*` badge (story 1052).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum Status {
    /// No special badge — normal in-progress item.
    Active,
    /// Item is paused (`Stage::Frozen`).
    Frozen,
    /// Item is held for human review (`Stage::ReviewHold`).
    ReviewHold,
    /// Item is blocked (`Stage::Blocked` or legacy `Archived(Blocked)`).
    Blocked,
    /// Merge failed; mergemaster may still be recovering.
    MergeFailure,
    /// Merge failed beyond automatic recovery.
    MergeFailureFinal,
    /// User abandoned the item.
    Abandoned,
    /// Item was superseded by another work item.
    Superseded,
    /// Item was permanently rejected.
    Rejected,
    /// Item completed successfully.
    Done,
}

impl Status {
    /// Stable wire-format identifier (kebab-case).
    pub fn as_str(&self) -> &'static str {
        match self {
            Status::Active => "active",
            Status::Frozen => "frozen",
            Status::ReviewHold => "review-hold",
            Status::Blocked => "blocked",
            Status::MergeFailure => "merge-failure",
            Status::MergeFailureFinal => "merge-failure-final",
            Status::Abandoned => "abandoned",
            Status::Superseded => "superseded",
            Status::Rejected => "rejected",
            Status::Done => "done",
        }
    }
}

impl Stage {
    /// Display column for this stage.  `Frozen { resume_to }` recurses so a
    /// paused story keeps its underlying column.
    pub fn pipeline(&self) -> Pipeline {
        match self {
            Stage::Upcoming | Stage::Backlog => Pipeline::Backlog,
            Stage::Coding { .. } | Stage::Blocked { .. } => Pipeline::Coding,
            Stage::Qa | Stage::ReviewHold { .. } => Pipeline::Qa,
            Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => {
                Pipeline::Merge
            }
            Stage::Frozen { resume_to } => resume_to.pipeline(),
            Stage::Done { .. } => Pipeline::Done,
            Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
                Pipeline::Closed
            }
            Stage::Archived {
                reason: ArchiveReason::Blocked { .. },
                ..
            } => Pipeline::Coding,
            Stage::Archived { .. } => Pipeline::Archived,
        }
    }

    /// Display badge for this stage.  `Frozen { resume_to }` returns
    /// `Status::Frozen` regardless of the inner stage; callers wanting the
    /// underlying badge inspect `resume_to` directly.
    pub fn status(&self) -> Status {
        match self {
            Stage::Frozen { .. } => Status::Frozen,
            Stage::ReviewHold { .. } => Status::ReviewHold,
            Stage::Blocked { .. }
            | Stage::Archived {
                reason: ArchiveReason::Blocked { .. },
                ..
            } => Status::Blocked,
            Stage::MergeFailure { .. } => Status::MergeFailure,
            Stage::MergeFailureFinal { .. } => Status::MergeFailureFinal,
            Stage::Abandoned { .. } => Status::Abandoned,
            Stage::Superseded { .. } => Status::Superseded,
            Stage::Rejected { .. } => Status::Rejected,
            Stage::Done { .. } => Status::Done,
            _ => Status::Active,
        }
    }
}

// ── Per-node execution state ────────────────────────────────────────────────

/// Per-node execution tracking, stored in the CRDT under each node's pubkey.
///
/// Each node only writes to entries where `node_pubkey == self.pubkey`, so
/// there are no inter-author CRDT merge conflicts. All nodes can READ all
/// entries to know what's happening across the mesh.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExecutionState {
    Idle,
    Pending {
        agent: AgentName,
        since: DateTime<Utc>,
    },
    Running {
        agent: AgentName,
        started_at: DateTime<Utc>,
        last_heartbeat: DateTime<Utc>,
    },
    RateLimited {
        agent: AgentName,
        resume_at: DateTime<Utc>,
    },
    Completed {
        agent: AgentName,
        exit_code: i32,
        completed_at: DateTime<Utc>,
    },
}

// ── Pipeline item (the aggregate) ───────────────────────────────────────────

/// A fully typed pipeline item. Every field is validated by construction.
///
/// The retry count is no longer a top-level field — callers read it from the
/// Stage variant (`Stage::Coding { retries }` / `Stage::Merge { retries }`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PipelineItem {
    pub story_id: StoryId,
    pub name: String,
    pub stage: Stage,
    pub depends_on: Vec<StoryId>,
}

impl PipelineItem {
    /// Returns the retry count embedded in the stage payload.
    pub fn retry_count(&self) -> u32 {
        match &self.stage {
            Stage::Coding { retries, .. } => *retries,
            Stage::Merge { retries, .. } => *retries,
            _ => 0,
        }
    }
}

// ── Transition errors ───────────────────────────────────────────────────────

/// Error returned when a pipeline event is not valid for the current stage.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionError {
    InvalidTransition { from_stage: String, event: String },
}

impl fmt::Display for TransitionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidTransition { from_stage, event } => {
                write!(f, "invalid transition: {from_stage} + {event}")
            }
        }
    }
}

impl std::error::Error for TransitionError {}

// ── Label helpers ───────────────────────────────────────────────────────────

/// Human-readable label for a `Stage` variant.
pub fn stage_label(s: &Stage) -> &'static str {
    match s {
        Stage::Upcoming => "Upcoming",
        Stage::Backlog => "Backlog",
        Stage::Coding { .. } => "Coding",
        Stage::Qa => "Qa",
        Stage::Merge { .. } => "Merge",
        Stage::MergeFailure { .. } => "MergeFailure",
        Stage::MergeFailureFinal { .. } => "MergeFailureFinal",
        Stage::Done { .. } => "Done",
        Stage::Blocked { .. } => "Blocked",
        Stage::Frozen { .. } => "Frozen",
        Stage::ReviewHold { .. } => "ReviewHold",
        Stage::Archived { .. } => "Archived",
        Stage::Abandoned { .. } => "Abandoned",
        Stage::Superseded { .. } => "Superseded",
        Stage::Rejected { .. } => "Rejected",
    }
}

/// Map a Stage to its canonical wire-format string (story 934).
///
/// Post-934 emits clean vocabulary with no numeric prefix. Legacy directory
/// strings (`"2_current"`, `"4_merge"`, etc.) are still accepted by `from_dir`
/// for migration but are never produced here.
pub fn stage_dir_name(s: &Stage) -> &'static str {
    match s {
        Stage::Upcoming => "upcoming",
        Stage::Backlog => "backlog",
        Stage::Coding { .. } => "coding",
        Stage::Blocked { .. } => "blocked",
        Stage::Qa => "qa",
        Stage::Merge { .. } => "merge",
        Stage::MergeFailure { .. } => "merge_failure",
        Stage::MergeFailureFinal { .. } => "merge_failure_final",
        Stage::Frozen { .. } => "frozen",
        Stage::ReviewHold { .. } => "review_hold",
        Stage::Done { .. } => "done",
        Stage::Archived { .. } => "archived",
        Stage::Abandoned { .. } => "abandoned",
        Stage::Superseded { .. } => "superseded",
        Stage::Rejected { .. } => "rejected",
    }
}
