//! Pipeline state — types and loading functions for the story pipeline.

use crate::agents::AgentStatus;
use crate::http::context::AppContext;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;

/// Agent assignment embedded in a pipeline stage item.
#[derive(Clone, Debug, Serialize)]
pub struct AgentAssignment {
    pub agent_name: crate::config::AgentName,
    pub model: Option<crate::agents::AgentModel>,
    pub status: crate::agents::AgentStatus,
}

/// A story/bug/spike item as it appears in a pipeline stage listing.
#[derive(Clone, Debug, Serialize)]
pub struct UpcomingStory {
    pub story_id: String,
    pub name: String,
    pub error: Option<String>,
    /// Merge failure reason persisted to front matter by the mergemaster agent.
    pub merge_failure: Option<String>,
    /// Active agent working on this item, if any.
    pub agent: Option<AgentAssignment>,
    /// Display column (story 1085) — derived from `Stage::pipeline()`.
    pub pipeline: crate::pipeline_state::Pipeline,
    /// Display badge/indicator (story 1085) — derived from `Stage::status()`.
    pub status: crate::pipeline_state::Status,
    /// True when the item is held in QA for human review.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub review_hold: Option<bool>,
    /// QA mode for this item.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub qa: Option<crate::io::story_metadata::QaMode>,
    /// Number of retries at the current pipeline stage.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub retry_count: Option<u32>,
    /// True when the item is in `Stage::Blocked` (awaiting human unblock).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub blocked: Option<bool>,
    /// True when the item is in `Stage::Frozen` (paused at its current stage).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub frozen: Option<bool>,
    /// Story numbers this story depends on.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub depends_on: Option<Vec<u32>>,
    /// Epic this item belongs to.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub epic_id: Option<crate::crdt_state::EpicId>,
}

/// Validation outcome for a single story.
pub struct StoryValidationResult {
    pub story_id: String,
    pub valid: bool,
    pub error: Option<String>,
}

/// Full pipeline state across all stages.
#[derive(Clone, Debug, Serialize)]
pub struct PipelineState {
    pub backlog: Vec<UpcomingStory>,
    pub current: Vec<UpcomingStory>,
    pub qa: Vec<UpcomingStory>,
    pub merge: Vec<UpcomingStory>,
    pub done: Vec<UpcomingStory>,
    /// Abandoned, superseded, and rejected items (story 984).
    pub closed: Vec<UpcomingStory>,
    /// Items swept from Done into the archived terminal state.
    pub archived: Vec<UpcomingStory>,
    /// Story IDs that currently have a deterministic merge in progress.
    pub deterministic_merges_in_flight: Vec<String>,
}

/// Unwrap nested `Stage::Frozen` layers to find the innermost resume target.
///
/// Mirrors the routing in `load_pipeline_state` for non-frozen items so that
/// a frozen story always appears under the same section it was in before freezing.
fn unwrap_frozen(stage: &crate::pipeline_state::Stage) -> &crate::pipeline_state::Stage {
    match stage {
        crate::pipeline_state::Stage::Frozen { resume_to: inner } => unwrap_frozen(inner),
        other => other,
    }
}

/// Load the full pipeline state (all 5 active stages).
///
/// Reads from the CRDT document and enriches with content from the
/// in-memory content store.  Agent assignments are overlaid from the
/// in-memory agent pool.
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
    let agent_map = build_active_agent_map(ctx);

    use crate::pipeline_state::Stage;

    let typed_items = crate::pipeline_state::read_all_typed();

    let deterministic_merges_in_flight = ctx
        .services
        .agents
        .list_running_merges()
        .unwrap_or_default();

    let mut state = PipelineState {
        backlog: Vec::new(),
        current: Vec::new(),
        qa: Vec::new(),
        merge: Vec::new(),
        done: Vec::new(),
        closed: Vec::new(),
        archived: Vec::new(),
        deterministic_merges_in_flight,
    };

    for item in typed_items {
        let sid = &item.story_id.0;
        let agent = agent_map.get(sid).cloned();

        // Story 945: review_hold is `Stage::ReviewHold`; qa_mode and epic_id
        // come from typed CRDT registers. merge_failure detail lives on the
        // MergeJob CRDT entry, but only surfaces for items currently in a
        // failure stage — Done/terminal items must never inherit stale errors
        // from a previously-failed merge attempt (story 1052).
        let view = crate::crdt_state::read_item(sid);
        let review_hold = if matches!(item.stage, Stage::ReviewHold { .. }) {
            Some(true)
        } else {
            None
        };
        let qa = view.as_ref().and_then(|v| v.qa_mode());
        let epic_id = view.as_ref().and_then(|v| v.epic());
        let merge_failure = if matches!(
            item.stage,
            Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. }
        ) {
            crate::crdt_state::read_merge_job(sid).and_then(|j| j.error)
        } else {
            None
        };

        let story = UpcomingStory {
            story_id: sid.clone(),
            name: item.name.clone(),
            error: None,
            merge_failure,
            agent,
            pipeline: item.stage.pipeline(),
            status: item.stage.status(),
            review_hold,
            qa,
            retry_count: if item.retry_count() > 0 {
                Some(item.retry_count())
            } else {
                None
            },
            blocked: if matches!(
                item.stage,
                Stage::Blocked { .. }
                    | Stage::MergeFailure { .. }
                    | Stage::MergeFailureFinal { .. }
                    | Stage::Archived {
                        reason: crate::pipeline_state::ArchiveReason::Blocked { .. },
                        ..
                    }
            ) {
                Some(true)
            } else {
                None
            },
            frozen: if matches!(item.stage, Stage::Frozen { .. }) {
                Some(true)
            } else {
                None
            },
            depends_on: if item.depends_on.is_empty() {
                None
            } else {
                Some(
                    item.depends_on
                        .iter()
                        .filter_map(|d| d.0.split('_').next()?.parse::<u32>().ok())
                        .collect(),
                )
            },
            epic_id,
        };
        match &item.stage {
            Stage::Upcoming => state.backlog.push(story), // upcoming shown with backlog
            Stage::Backlog => state.backlog.push(story),
            Stage::Coding { .. } => state.current.push(story),
            Stage::Blocked { .. } => state.current.push(story), // blocked shown with current
            Stage::Qa => state.qa.push(story),
            Stage::Merge { .. } => state.merge.push(story),
            Stage::MergeFailure { .. } => state.merge.push(story), // show merge failures with merge
            Stage::MergeFailureFinal { .. } => state.merge.push(story), // mergemaster gave up
            Stage::ReviewHold { .. } => state.qa.push(story),      // review-held shown with QA
            Stage::Frozen { resume_to } => {
                // Route to the section matching the stage that was active when
                // the item was frozen, so it appears in-place.
                match unwrap_frozen(resume_to) {
                    Stage::Coding { .. } | Stage::Blocked { .. } => state.current.push(story),
                    Stage::Qa | Stage::ReviewHold { .. } => state.qa.push(story),
                    Stage::Merge { .. }
                    | Stage::MergeFailure { .. }
                    | Stage::MergeFailureFinal { .. } => state.merge.push(story),
                    _ => state.backlog.push(story),
                }
            }
            Stage::Done { .. } => state.done.push(story),
            Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
                state.closed.push(story)
            }
            Stage::Archived { .. } => state.archived.push(story),
        }
    }

    // Sort each stage for deterministic output.
    state.backlog.sort_by(|a, b| a.story_id.cmp(&b.story_id));
    state.current.sort_by(|a, b| a.story_id.cmp(&b.story_id));
    state.qa.sort_by(|a, b| a.story_id.cmp(&b.story_id));
    state.merge.sort_by(|a, b| a.story_id.cmp(&b.story_id));
    state.done.sort_by(|a, b| a.story_id.cmp(&b.story_id));
    state.closed.sort_by(|a, b| a.story_id.cmp(&b.story_id));
    state.archived.sort_by(|a, b| a.story_id.cmp(&b.story_id));

    Ok(state)
}

/// Build a map from story_id → AgentAssignment for all pending/running agents.
fn build_active_agent_map(ctx: &AppContext) -> HashMap<String, AgentAssignment> {
    let agents = ctx.services.agents.list_agents_nonblocking();

    let config_opt = ctx
        .state
        .get_project_root()
        .ok()
        .and_then(|root| crate::config::ProjectConfig::load(&root).ok());

    let mut map = HashMap::new();
    for agent in agents {
        if !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) {
            continue;
        }
        let Ok(agent_name) = agent.agent_name.parse::<crate::config::AgentName>() else {
            continue;
        };
        let model = config_opt
            .as_ref()
            .and_then(|cfg| cfg.find_agent(agent_name.as_str()))
            .and_then(|ac| ac.model.clone());
        map.insert(
            agent.story_id.clone(),
            AgentAssignment {
                agent_name,
                model,
                status: agent.status,
            },
        );
    }
    map
}

/// Load all stories currently in the backlog stage.
pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
    use crate::pipeline_state::Stage;

    let typed_items = crate::pipeline_state::read_all_typed();

    let mut stories: Vec<UpcomingStory> = typed_items
        .into_iter()
        .filter(|item| matches!(item.stage, Stage::Backlog))
        .map(|item| {
            let sid = &item.story_id.0;
            let epic_id = crate::crdt_state::read_item(sid).and_then(|v| v.epic());
            let item_retry_count = item.retry_count();
            UpcomingStory {
                story_id: item.story_id.0.clone(),
                name: item.name,
                error: None,
                merge_failure: None,
                agent: None,
                pipeline: item.stage.pipeline(),
                status: item.stage.status(),
                review_hold: None,
                qa: None,
                retry_count: if item_retry_count > 0 {
                    Some(item_retry_count)
                } else {
                    None
                },
                blocked: if matches!(
                    item.stage,
                    Stage::Blocked { .. }
                        | Stage::MergeFailure { .. }
                        | Stage::MergeFailureFinal { .. }
                        | Stage::Archived {
                            reason: crate::pipeline_state::ArchiveReason::Blocked { .. },
                            ..
                        }
                ) {
                    Some(true)
                } else {
                    None
                },
                frozen: if matches!(item.stage, Stage::Frozen { .. }) {
                    Some(true)
                } else {
                    None
                },
                depends_on: if item.depends_on.is_empty() {
                    None
                } else {
                    Some(
                        item.depends_on
                            .iter()
                            .filter_map(|d| d.0.split('_').next()?.parse::<u32>().ok())
                            .collect(),
                    )
                },
                epic_id,
            }
        })
        .collect();
    stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));

    Ok(stories)
}

/// Validate stories for all backlog and current items.
///
/// Story 929: validation reads the typed CRDT `name` register; the legacy YAML
/// front-matter parse is gone.
///
/// Story 946: nameless items are filtered at the CRDT layer (`extract_item_view`
/// returns `None` for items with no name register set) and therefore never reach
/// this function.  Every item in `read_all_typed()` is guaranteed to have a
/// non-empty name, so the only validation left here is stage filtering.
pub fn validate_story_dirs(_root: &Path) -> Result<Vec<StoryValidationResult>, String> {
    use crate::pipeline_state::Stage;

    let mut results = Vec::new();

    for item in crate::pipeline_state::read_all_typed() {
        if !matches!(item.stage, Stage::Backlog | Stage::Coding { .. }) {
            continue;
        }
        results.push(StoryValidationResult {
            story_id: item.story_id.0.clone(),
            valid: true,
            error: None,
        });
    }

    results.sort_by(|a, b| a.story_id.cmp(&b.story_id));
    Ok(results)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn load_pipeline_state_loads_all_stages() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().to_path_buf();

        crate::db::ensure_content_store();
        for (stage, id) in &[
            ("1_backlog", "9810_story_upcoming"),
            ("2_current", "9820_story_current"),
            ("3_qa", "9830_story_qa"),
            ("4_merge", "9840_story_merge"),
            ("5_done", "9850_story_done"),
        ] {
            crate::db::write_item_with_content(
                id,
                stage,
                &format!("---\nname: {id}\n---\n"),
                crate::db::ItemMeta {
                    name: Some((*id).to_string()),
                    ..Default::default()
                },
            );
        }

        let ctx = crate::http::context::AppContext::new_test(root);
        let state = load_pipeline_state(&ctx).unwrap();

        assert!(
            state
                .backlog
                .iter()
                .any(|s| s.story_id == "9810_story_upcoming")
        );
        assert!(
            state
                .current
                .iter()
                .any(|s| s.story_id == "9820_story_current")
        );
        assert!(state.qa.iter().any(|s| s.story_id == "9830_story_qa"));
        assert!(state.merge.iter().any(|s| s.story_id == "9840_story_merge"));
        assert!(state.done.iter().any(|s| s.story_id == "9850_story_done"));
    }

    #[test]
    fn load_upcoming_returns_empty_when_no_dir() {
        // With CRDT there is no filesystem dependency.  The function should
        // succeed even without a .huskies directory.  Other tests may have
        // inserted items into the global CRDT, so we only assert no error.
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().to_path_buf();
        let ctx = crate::http::context::AppContext::new_test(root);
        let _result = load_upcoming_stories(&ctx).unwrap();
    }

    #[test]
    fn pipeline_state_includes_agent_for_running_story() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().to_path_buf();

        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9860_story_test",
            "2_current",
            "---\nname: Test Story\n---\n# Story\n",
            crate::db::ItemMeta::named("Test Story"),
        );

        let ctx = crate::http::context::AppContext::new_test(root);
        ctx.services.agents.inject_test_agent(
            "9860_story_test",
            "coder-1",
            crate::agents::AgentStatus::Running,
        );

        let state = load_pipeline_state(&ctx).unwrap();

        let item = state
            .current
            .iter()
            .find(|s| s.story_id == "9860_story_test")
            .unwrap();
        assert!(
            item.agent.is_some(),
            "running agent should appear on work item"
        );
        let agent = item.agent.as_ref().unwrap();
        assert_eq!(agent.agent_name, crate::config::AgentName::Coder1);
        assert_eq!(agent.status, crate::agents::AgentStatus::Running);
    }

    #[test]
    fn pipeline_state_no_agent_for_completed_story() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().to_path_buf();

        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9861_story_done",
            "2_current",
            "---\nname: Done Story\n---\n# Story\n",
            crate::db::ItemMeta::named("Done Story"),
        );

        let ctx = crate::http::context::AppContext::new_test(root);
        ctx.services.agents.inject_test_agent(
            "9861_story_done",
            "coder-1",
            crate::agents::AgentStatus::Completed,
        );

        let state = load_pipeline_state(&ctx).unwrap();

        let item = state
            .current
            .iter()
            .find(|s| s.story_id == "9861_story_done")
            .unwrap();
        assert!(
            item.agent.is_none(),
            "completed agent should not appear on work item"
        );
    }

    #[test]
    fn pipeline_state_pending_agent_included() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().to_path_buf();

        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9862_story_pending",
            "2_current",
            "---\nname: Pending Story\n---\n# Story\n",
            crate::db::ItemMeta::named("Pending Story"),
        );

        let ctx = crate::http::context::AppContext::new_test(root);
        ctx.services.agents.inject_test_agent(
            "9862_story_pending",
            "coder-1",
            crate::agents::AgentStatus::Pending,
        );

        let state = load_pipeline_state(&ctx).unwrap();

        let item = state
            .current
            .iter()
            .find(|s| s.story_id == "9862_story_pending")
            .unwrap();
        assert!(
            item.agent.is_some(),
            "pending agent should appear on work item"
        );
        assert_eq!(
            item.agent.as_ref().unwrap().status,
            crate::agents::AgentStatus::Pending
        );
    }

    #[test]
    fn pipeline_state_includes_depends_on() {
        crate::crdt_state::init_for_test();
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9863_story_dependent",
            "1_backlog",
            "# Dependent Story\n",
            crate::db::ItemMeta::named("Dependent Story"),
        );
        crate::crdt_state::set_depends_on("9863_story_dependent", &[10, 11]);
        crate::db::write_item_with_content(
            "9864_story_independent",
            "1_backlog",
            "# Independent Story\n",
            crate::db::ItemMeta::named("Independent Story"),
        );

        let tmp = tempfile::tempdir().unwrap();
        let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
        let state = load_pipeline_state(&ctx).unwrap();

        let dependent = state
            .backlog
            .iter()
            .find(|s| s.story_id == "9863_story_dependent")
            .unwrap();
        assert_eq!(dependent.depends_on, Some(vec![10, 11]));

        let independent = state
            .backlog
            .iter()
            .find(|s| s.story_id == "9864_story_independent")
            .unwrap();
        assert_eq!(independent.depends_on, None);
    }

    #[test]
    fn load_upcoming_parses_metadata() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9870_story_view_upcoming",
            "1_backlog",
            "---\nname: View Upcoming\n---\n# Story\n",
            crate::db::ItemMeta::named("View Upcoming"),
        );
        crate::db::write_item_with_content(
            "9871_story_worktree",
            "1_backlog",
            "---\nname: Worktree Orchestration\n---\n# Story\n",
            crate::db::ItemMeta::named("Worktree Orchestration"),
        );

        let tmp = tempfile::tempdir().unwrap();
        let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
        let stories = load_upcoming_stories(&ctx).unwrap();
        let s1 = stories
            .iter()
            .find(|s| s.story_id == "9870_story_view_upcoming")
            .unwrap();
        assert_eq!(s1.name, "View Upcoming");
        let s2 = stories
            .iter()
            .find(|s| s.story_id == "9871_story_worktree")
            .unwrap();
        assert_eq!(s2.name, "Worktree Orchestration");
    }

    #[test]
    fn load_upcoming_skips_non_md_files() {
        // Non-.md files are a filesystem concept. With CRDT, only real items
        // appear. Just verify the CRDT item is returned.
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9872_story_example",
            "1_backlog",
            "---\nname: A Story\n---\n",
            crate::db::ItemMeta::named("A Story"),
        );

        let tmp = tempfile::tempdir().unwrap();
        let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
        let stories = load_upcoming_stories(&ctx).unwrap();
        assert!(stories.iter().any(|s| s.story_id == "9872_story_example"));
    }

    #[test]
    fn validate_story_dirs_valid_files() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9873_story_todos",
            "2_current",
            "---\nname: Show TODOs\n---\n# Story\n",
            crate::db::ItemMeta::named("Show TODOs"),
        );
        crate::db::write_item_with_content(
            "9874_story_front_matter",
            "1_backlog",
            "---\nname: Enforce Front Matter\n---\n# Story\n",
            crate::db::ItemMeta::named("Enforce Front Matter"),
        );

        let tmp = tempfile::tempdir().unwrap();
        let results = validate_story_dirs(tmp.path()).unwrap();
        let r1 = results
            .iter()
            .find(|r| r.story_id == "9873_story_todos")
            .unwrap();
        assert!(r1.valid);
        let r2 = results
            .iter()
            .find(|r| r.story_id == "9874_story_front_matter")
            .unwrap();
        assert!(r2.valid);
    }

    #[test]
    fn validate_story_dirs_missing_name_in_crdt() {
        crate::crdt_state::init_for_test();
        crate::db::ensure_content_store();
        // Item exists in CRDT but with no name register set.
        crate::db::write_item_with_content(
            "9875_story_no_fm",
            "2_current",
            "# No front matter\n",
            crate::db::ItemMeta::default(),
        );

        let tmp = tempfile::tempdir().unwrap();
        let results = validate_story_dirs(tmp.path()).unwrap();
        // Story 946: nameless items are invisible at the CRDT layer (AC 5).
        // `extract_item_view` returns `None` for items with no name register,
        // so they never surface to `validate_story_dirs`.
        assert!(
            results.iter().all(|r| r.story_id != "9875_story_no_fm"),
            "nameless items must be invisible to validate_story_dirs"
        );
    }

    #[test]
    fn validate_story_dirs_missing_required_fields() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9876_story_no_name",
            "2_current",
            "---\n---\n# Story\n",
            crate::db::ItemMeta::default(),
        );

        let tmp = tempfile::tempdir().unwrap();
        let results = validate_story_dirs(tmp.path()).unwrap();
        // Story 946: nameless items are invisible at the CRDT layer (AC 5).
        assert!(
            results.iter().all(|r| r.story_id != "9876_story_no_name"),
            "nameless items must be invisible to validate_story_dirs"
        );
    }

    #[test]
    fn validate_story_dirs_empty_when_no_dirs() {
        // With CRDT there's always global state; this test just ensures no panic.
        let tmp = tempfile::tempdir().unwrap();
        let _results = validate_story_dirs(tmp.path());
    }
}
