//! Pure state transition functions for pipeline and execution state machines.

use chrono::Utc;
use serde::{Deserialize, Serialize};

use super::{
    AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, PlanState,
    Stage, StoryId, TransitionError, stage_label,
};

// ── Pipeline events ─────────────────────────────────────────────────────────

/// Events that drive Stage transitions. Each variant carries the data needed
/// to construct the destination state, so the transition function can never
/// accidentally land in an underspecified state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PipelineEvent {
    /// Dependencies met; promote from backlog.
    DepsMet,
    /// Coder starting gates.
    GatesStarted,
    /// Gates passed — ready to merge.
    GatesPassed {
        feature_branch: BranchName,
        commits_ahead: std::num::NonZeroU32,
    },
    /// Gates failed; retry.
    GatesFailed { reason: String },
    /// QA mode is "server" — skip QA, go straight to merge.
    QaSkipped {
        feature_branch: BranchName,
        commits_ahead: std::num::NonZeroU32,
    },
    /// Mergemaster squash succeeded.
    MergeSucceeded { merge_commit: GitSha },
    /// Merge pipeline failed (conflicts or gate failures); story moves to
    /// `Stage::MergeFailure` awaiting human intervention or retry.
    MergeFailed { kind: MergeFailureKind },
    /// Mergemaster gave up after retry budget.
    MergeFailedFinal { reason: String },
    /// Story accepted (Done → Archived).
    Accepted,
    /// User blocked the story.
    Block { reason: String },
    /// User unblocked.
    Unblock,
    /// User abandoned.
    Abandon,
    /// Story superseded by another.
    Supersede { by: StoryId },
    /// Story put on review hold.
    ReviewHold { reason: String },
    /// Story rejected by QA or reviewer.
    Reject { reason: String },
    /// Story triaged from upcoming to backlog.
    Triage,
    /// Direct completion — item closed without going through the full pipeline
    /// (e.g. spike auto-merge, bug closure, manual acceptance).
    Close,
    /// Manual demotion back to backlog from an active stage.
    Demote,
    /// Story 945: freeze a story at its current stage.
    Freeze,
    /// Story 945: unfreeze a frozen story, returning to `resume_to`.
    Unfreeze,
    /// Story 945: clear a `ReviewHold`, returning to `resume_to`.
    ReviewHoldCleared,
    /// Story 945: mergemaster has been auto-spawned and gave up; transitions
    /// `Stage::MergeFailure` → `Stage::MergeFailureFinal`.
    MergemasterAttempted,
    /// Story 971: user sends a MergeFailure story back to Coding for coder fixup.
    FixupRequested,
    /// Story 972: user sends a MergeFailure story back to Qa for re-review.
    ReQueuedForQa,
    /// Story 973: user aborts an in-flight merge, sending the story back to Coding.
    MergeAborted,
    /// Story 974: user re-opens a Done story for a post-merge hotfix, sending it back to Coding.
    HotfixRequested,
    /// Story 1051: deterministic-merge pipeline started a retry on a story in
    /// `Stage::MergeFailure`; transitions back to `Stage::Merge` so the UI
    /// shows the active retry rather than the stale failure state.
    MergeRetryStarted,
}

// ── Per-node execution events ───────────────────────────────────────────────

/// Events that drive per-node [`ExecutionState`] transitions (agent lifecycle).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExecutionEvent {
    SpawnRequested { agent: AgentName },
    SpawnedSuccessfully,
    Heartbeat,
    HitRateLimit { resume_at: chrono::DateTime<Utc> },
    Exited { exit_code: i32 },
    Stopped,
    Reset,
}

// ── Label helper ────────────────────────────────────────────────────────────

/// Human-readable label for a `PipelineEvent` variant.
pub fn event_label(e: &PipelineEvent) -> &'static str {
    match e {
        PipelineEvent::DepsMet => "DepsMet",
        PipelineEvent::GatesStarted => "GatesStarted",
        PipelineEvent::GatesPassed { .. } => "GatesPassed",
        PipelineEvent::GatesFailed { .. } => "GatesFailed",
        PipelineEvent::QaSkipped { .. } => "QaSkipped",
        PipelineEvent::MergeSucceeded { .. } => "MergeSucceeded",
        PipelineEvent::MergeFailed { .. } => "MergeFailed",
        PipelineEvent::MergeFailedFinal { .. } => "MergeFailedFinal",
        PipelineEvent::Accepted => "Accepted",
        PipelineEvent::Block { .. } => "Block",
        PipelineEvent::Unblock => "Unblock",
        PipelineEvent::Abandon => "Abandon",
        PipelineEvent::Supersede { .. } => "Supersede",
        PipelineEvent::ReviewHold { .. } => "ReviewHold",
        PipelineEvent::Reject { .. } => "Reject",
        PipelineEvent::Triage => "Triage",
        PipelineEvent::Close => "Close",
        PipelineEvent::Demote => "Demote",
        PipelineEvent::Freeze => "Freeze",
        PipelineEvent::Unfreeze => "Unfreeze",
        PipelineEvent::ReviewHoldCleared => "ReviewHoldCleared",
        PipelineEvent::MergemasterAttempted => "MergemasterAttempted",
        PipelineEvent::FixupRequested => "FixupRequested",
        PipelineEvent::ReQueuedForQa => "ReQueuedForQa",
        PipelineEvent::MergeAborted => "MergeAborted",
        PipelineEvent::HotfixRequested => "HotfixRequested",
        PipelineEvent::MergeRetryStarted => "MergeRetryStarted",
    }
}

// ── The pipeline transition function ────────────────────────────────────────

/// Pure state transition. Takes the current Stage and an event, returns the
/// new Stage or a TransitionError. Side effects are dispatched separately
/// via the event bus.
pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, TransitionError> {
    use PipelineEvent::*;
    use Stage::*;

    let sl = stage_label(&state);
    let el = event_label(&event);
    let invalid = || TransitionError::InvalidTransition {
        from_stage: sl.to_string(),
        event: el.to_string(),
    };

    let now = Utc::now();

    match (state, event) {
        // ── Triage: upcoming → backlog ──────────────────────────────────
        (Upcoming, Triage) => Ok(Backlog),

        // ── Forward path ────────────────────────────────────────────────
        (Backlog, DepsMet) => Ok(Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        }),
        (Coding { .. }, GatesStarted) => Ok(Qa),
        (
            Coding { .. },
            QaSkipped {
                feature_branch,
                commits_ahead,
            },
        ) => Ok(Merge {
            feature_branch,
            commits_ahead,
            claim: None,
            retries: 0,
            server_start_time: None,
        }),
        (
            Qa,
            GatesPassed {
                feature_branch,
                commits_ahead,
            },
        ) => Ok(Merge {
            feature_branch,
            commits_ahead,
            claim: None,
            retries: 0,
            server_start_time: None,
        }),
        (Qa, GatesFailed { .. }) => Ok(Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        }),
        (Merge { .. }, MergeSucceeded { merge_commit }) => Ok(Done {
            merged_at: now,
            merge_commit,
        }),

        // ── Done → Archived(Completed) ──────────────────────────────────
        (Done { .. }, Accepted) => Ok(Archived {
            archived_at: now,
            reason: ArchiveReason::Completed,
        }),

        // ── MergeFailure → Done (manual recovery) ───────────────────────
        // Allows a human operator to accept a story whose merge permanently
        // failed, marking it Done without going through the normal merge path.
        (MergeFailure { .. }, Accepted) => Ok(Done {
            merged_at: now,
            merge_commit: GitSha("manual".to_string()),
        }),

        // ── MergeFailureFinal → Done (successful re-merge) ──────────────
        // Story 1178: MergeFailureFinal was a trap state — even when a later
        // re-merge attempt actually succeeded (squash commit landed, gates
        // passed), there was no transition out of MergeFailureFinal into
        // Done, so `move_story_to_done` always failed for these stories.
        // Mirrors the `MergeFailure -> Done` arm above.
        (MergeFailureFinal { .. }, Accepted) => Ok(Done {
            merged_at: now,
            merge_commit: GitSha("manual".to_string()),
        }),

        // ── Block: any active → Blocked ──────────────────────────────
        (Backlog, Block { reason })
        | (Coding { .. }, Block { reason })
        | (Qa, Block { reason })
        | (Merge { .. }, Block { reason })
        | (MergeFailure { .. }, Block { reason })
        | (MergeFailureFinal { .. }, Block { reason }) => Ok(Blocked { reason }),

        // Story 945: ReviewHold no longer auto-archives.  It transitions the
        // story to `Stage::ReviewHold { resume_to, reason }`, preserving the
        // current stage as the resume target so a reviewer can clear the
        // hold and continue.
        (
            s @ (Backlog | Coding { .. } | Qa | Merge { .. }),
            PipelineEvent::ReviewHold { reason },
        ) => Ok(Stage::ReviewHold {
            resume_to: Box::new(s),
            reason,
        }),

        // ── MergeFailed: Merge → MergeFailure (recoverable intermediate) ──
        (
            Merge {
                feature_branch,
                commits_ahead,
                ..
            },
            MergeFailed { kind },
        ) => Ok(MergeFailure {
            kind,
            feature_branch,
            commits_ahead,
        }),

        // ── MergeFailure self-loop: repeated failure is a no-op ─────────────
        // When the mergemaster retries and fails again while the story is already
        // in MergeFailure, treat it as a silent self-transition so callers can
        // detect the no-op via `fired.before == MergeFailure` and skip re-notifying.
        (
            MergeFailure {
                feature_branch,
                commits_ahead,
                ..
            },
            MergeFailed { kind },
        ) => Ok(MergeFailure {
            kind,
            feature_branch,
            commits_ahead,
        }),

        (Merge { .. }, MergeFailedFinal { reason }) => Ok(Archived {
            archived_at: now,
            reason: ArchiveReason::MergeFailed { reason },
        }),

        // ── Abandon / supersede from any active or done stage ───────────
        (Upcoming, Abandon)
        | (Backlog, Abandon)
        | (Coding { .. }, Abandon)
        | (Qa, Abandon)
        | (Merge { .. }, Abandon)
        | (Done { .. }, Abandon) => Ok(Abandoned { ts: now }),

        (Upcoming, Supersede { by })
        | (Backlog, Supersede { by })
        | (Coding { .. }, Supersede { by })
        | (Qa, Supersede { by })
        | (Merge { .. }, Supersede { by })
        | (Done { .. }, Supersede { by }) => Ok(Superseded {
            ts: now,
            superseded_by: by,
        }),

        // ── Reject from any active stage or QA ──────────────────────────
        (Backlog, Reject { reason })
        | (Coding { .. }, Reject { reason })
        | (Qa, Reject { reason })
        | (Merge { .. }, Reject { reason }) => Ok(Rejected { ts: now, reason }),

        // ── Demote: send an active item back to backlog ────────────────
        // `Blocked + Demote → Backlog` lets operators park a stuck story in
        // the backlog while waiting on dependent fixes, without losing it to
        // Archived. Unlike `Unblock` (Blocked → Coding), this does not
        // re-enter the active flow.
        (Coding { .. }, Demote)
        | (Qa, Demote)
        | (Merge { .. }, Demote)
        | (Blocked { .. }, Demote) => Ok(Backlog),

        // ── Close: direct completion from any active stage ─────────────
        (Backlog, Close) | (Coding { .. }, Close) | (Qa, Close) | (Merge { .. }, Close) => {
            Ok(Done {
                merged_at: now,
                merge_commit: GitSha("closed".to_string()),
            })
        }

        // ── Freeze: any non-terminal stage → Frozen { resume_to } ──────
        (
            s @ (Upcoming
            | Backlog
            | Coding { .. }
            | Qa
            | Merge { .. }
            | Blocked { .. }
            | MergeFailure { .. }
            | MergeFailureFinal { .. }
            | Stage::ReviewHold { .. }),
            Freeze,
        ) => Ok(Frozen {
            resume_to: Box::new(s),
        }),

        // ── Unfreeze: Frozen → resume_to ───────────────────────────────
        (Frozen { resume_to }, Unfreeze) => Ok(*resume_to),

        // ── ReviewHoldCleared: ReviewHold → resume_to ──────────────────
        (Stage::ReviewHold { resume_to, .. }, ReviewHoldCleared) => Ok(*resume_to),

        // ── FixupRequested: MergeFailure → Coding (coder fixup) ────────
        (MergeFailure { .. }, FixupRequested) => Ok(Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        }),

        // ── FixupRequested: MergeFailureFinal → Coding (operator override)
        //
        // The exhausted-respawn-budget terminal state is not actually
        // terminal as far as the operator is concerned; a human can decide
        // the gate failure is fixable and send the story back for another
        // coder attempt.  The budget counter is a mergemaster bookkeeping
        // detail, not a hard ceiling.
        (MergeFailureFinal { .. }, FixupRequested) => Ok(Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        }),

        // ── ReQueuedForQa: MergeFailure → Qa (re-review) ────────────────
        (MergeFailure { .. }, ReQueuedForQa) => Ok(Qa),

        // ── MergeAborted: Merge → Coding (abort in-flight merge) ─────────
        (Merge { .. }, MergeAborted) => Ok(Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        }),

        // ── HotfixRequested: Done → Coding (post-merge hotfix) ───────────
        // Allows reopening a completed story so a coder can apply a hotfix.
        // A fresh feature branch is forked from master when auto-assign spawns
        // the coder.
        (Done { .. }, HotfixRequested) => Ok(Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        }),

        // ── MergeRetryStarted: MergeFailure → Merge (deterministic retry) ─
        // When the deterministic-merge pipeline starts a retry on a MergeFailure
        // story, transition back to Merge so the UI shows the active retry rather
        // than the stale failure state (story 1051).  Preserves feature_branch and
        // commits_ahead so the retry continues with the same branch.
        (
            MergeFailure {
                feature_branch,
                commits_ahead,
                ..
            },
            MergeRetryStarted,
        ) => Ok(Merge {
            feature_branch,
            commits_ahead,
            claim: None,
            retries: 0,
            server_start_time: None,
        }),

        // ── MergemasterAttempted: MergeFailure → MergeFailureFinal ─────
        (MergeFailure { kind, .. }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),
        (MergeFailureFinal { kind }, MergemasterAttempted) => Ok(MergeFailureFinal { kind }),

        // ── Unblock: from Frozen/ReviewHold → resume_to ────────────────
        (Frozen { resume_to }, Unblock) => Ok(*resume_to),
        (Stage::ReviewHold { resume_to, .. }, Unblock) => Ok(*resume_to),

        // ── Unblock: Blocked → Coding ─────────────────────────────────
        (Blocked { .. }, Unblock) => Ok(Coding {
            claim: None,
            plan: PlanState::Missing,
            retries: 0,
        }),

        // ── Unblock MergeFailure → Merge (re-attempt) ────────────────────
        // `unblock_story` on a failed merge re-queues it for merge, restoring
        // the exact `Merge { feature_branch, commits_ahead }` that was in place
        // before the failure so the mergemaster can retry immediately.
        (
            MergeFailure {
                feature_branch,
                commits_ahead,
                ..
            },
            Unblock,
        ) => Ok(Merge {
            feature_branch,
            commits_ahead,
            claim: None,
            retries: 0,
            server_start_time: None,
        }),

        // ── Demote MergeFailure → Backlog (manual parking) ───────────────
        // Lets operators park a failed-merge story in the backlog without an
        // agent retry. Complements `Unblock` (→ Coding) which triggers an
        // immediate retry by a coder agent.
        (MergeFailure { .. }, Demote) => Ok(Backlog),

        // ── Legacy unblock: Archived(Blocked|MergeFailed) → Backlog ──
        (
            Archived {
                reason: ArchiveReason::Blocked { .. },
                ..
            },
            Unblock,
        )
        | (
            Archived {
                reason: ArchiveReason::MergeFailed { .. },
                ..
            },
            Unblock,
        ) => Ok(Backlog),

        // ── Everything else is invalid ──────────────────────────────────
        _ => Err(invalid()),
    }
}

// ── The execution state transition function ─────────────────────────────────

/// Pure execution-state transition. Takes the current ExecutionState and an
/// ExecutionEvent, returns the new ExecutionState or a TransitionError.
pub fn execution_transition(
    state: ExecutionState,
    event: ExecutionEvent,
) -> Result<ExecutionState, TransitionError> {
    use ExecutionEvent::*;
    use ExecutionState::*;

    let now = Utc::now();

    match (state, event) {
        (Idle, SpawnRequested { agent }) => Ok(Pending { agent, since: now }),

        (Pending { agent, .. }, SpawnedSuccessfully) => Ok(Running {
            agent,
            started_at: now,
            last_heartbeat: now,
        }),

        (
            Running {
                agent, started_at, ..
            },
            Heartbeat,
        ) => Ok(Running {
            agent,
            started_at,
            last_heartbeat: now,
        }),

        (Running { agent, .. }, HitRateLimit { resume_at })
        | (Pending { agent, .. }, HitRateLimit { resume_at }) => {
            Ok(RateLimited { agent, resume_at })
        }

        (RateLimited { agent, .. }, SpawnedSuccessfully) => Ok(Running {
            agent,
            started_at: now,
            last_heartbeat: now,
        }),

        (Running { agent, .. }, Exited { exit_code })
        | (Pending { agent, .. }, Exited { exit_code })
        | (RateLimited { agent, .. }, Exited { exit_code }) => Ok(Completed {
            agent,
            exit_code,
            completed_at: now,
        }),

        (_, Stopped) | (_, Reset) => Ok(Idle),

        _ => Err(TransitionError::InvalidTransition {
            from_stage: "ExecutionState".to_string(),
            event: "<exec event>".to_string(),
        }),
    }
}
