//! Workflow helpers — shared story/bug file operations used by HTTP and MCP handlers.
mod bug_ops;
mod story_ops;
mod test_results;

pub use bug_ops::{
    create_bug_file, create_refactor_file, create_spike_file, list_bug_files, list_refactor_files,
};
pub use story_ops::{
    add_criterion_to_file, check_criterion_in_file, create_story_file, edit_criterion_in_file,
    remove_criterion_from_file, update_story_in_file,
};
pub use test_results::{
    read_test_results_from_story_file, write_coverage_baseline_to_story_file,
    write_test_results_to_story_file,
};

use crate::agents::AgentStatus;
use crate::http::context::AppContext;
use crate::io::story_metadata::parse_front_matter;
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: String,
    pub model: Option<String>,
    pub status: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct UpcomingStory {
    pub story_id: String,
    pub name: Option<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>,
    /// 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: "human", "server", or "agent".
    #[serde(skip_serializing_if = "Option::is_none")]
    pub qa: Option<String>,
    /// Number of retries at the current pipeline stage.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub retry_count: Option<u32>,
    /// True when the story has exceeded its retry limit and will not be auto-assigned.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub blocked: Option<bool>,
    /// Story numbers this story depends on.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub depends_on: Option<Vec<u32>>,
}

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>,
}

/// 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 mut state = PipelineState {
        backlog: Vec::new(),
        current: Vec::new(),
        qa: Vec::new(),
        merge: Vec::new(),
        done: Vec::new(),
    };

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

        // Enrich with content-derived metadata (merge_failure, review_hold, qa).
        let (merge_failure, review_hold, qa) = crate::db::read_content(sid)
            .and_then(|c| parse_front_matter(&c).ok())
            .map(|meta| {
                (
                    meta.merge_failure,
                    meta.review_hold,
                    meta.qa.map(|m| m.as_str().to_string()),
                )
            })
            .unwrap_or((None, None, None));

        let story = UpcomingStory {
            story_id: sid.clone(),
            name: if item.name.is_empty() {
                None
            } else {
                Some(item.name.clone())
            },
            error: None,
            merge_failure,
            agent,
            review_hold,
            qa,
            retry_count: if item.retry_count > 0 {
                Some(item.retry_count)
            } else {
                None
            },
            blocked: if item.stage.is_blocked() {
                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(),
                )
            },
        };
        match &item.stage {
            Stage::Backlog => state.backlog.push(story),
            Stage::Coding => state.current.push(story),
            Stage::Qa => state.qa.push(story),
            Stage::Merge { .. } => state.merge.push(story),
            Stage::Done { .. } => state.done.push(story),
            Stage::Archived { .. } => {} // skip archived
        }
    }

    // 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));

    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 = match ctx.services.agents.list_agents() {
        Ok(a) => a,
        Err(_) => return HashMap::new(),
    };

    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 model = config_opt
            .as_ref()
            .and_then(|cfg| cfg.find_agent(&agent.agent_name))
            .and_then(|ac| ac.model.clone());
        map.insert(
            agent.story_id.clone(),
            AgentAssignment {
                agent_name: agent.agent_name,
                model,
                status: agent.status.to_string(),
            },
        );
    }
    map
}

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| UpcomingStory {
            story_id: item.story_id.0,
            name: if item.name.is_empty() {
                None
            } else {
                Some(item.name)
            },
            error: None,
            merge_failure: None,
            agent: None,
            review_hold: None,
            qa: None,
            retry_count: if item.retry_count > 0 {
                Some(item.retry_count)
            } else {
                None
            },
            blocked: if item.stage.is_blocked() {
                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(),
                )
            },
        })
        .collect();
    stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));

    Ok(stories)
}

pub fn validate_story_dirs(_root: &std::path::Path) -> Result<Vec<StoryValidationResult>, String> {
    use crate::pipeline_state::Stage;

    let mut results = Vec::new();

    let typed_items = crate::pipeline_state::read_all_typed();
    for item in typed_items {
        // Only validate backlog and current items (matching the old behaviour).
        if !matches!(item.stage, Stage::Backlog | Stage::Coding) {
            continue;
        }
        let story_id = item.story_id.0.clone();

        match crate::db::read_content(&story_id) {
            Some(contents) => match parse_front_matter(&contents) {
                Ok(meta) => {
                    let mut errors = Vec::new();
                    if meta.name.is_none() {
                        errors.push("Missing 'name' field".to_string());
                    }
                    if errors.is_empty() {
                        results.push(StoryValidationResult {
                            story_id,
                            valid: true,
                            error: None,
                        });
                    } else {
                        results.push(StoryValidationResult {
                            story_id,
                            valid: false,
                            error: Some(errors.join("; ")),
                        });
                    }
                }
                Err(e) => results.push(StoryValidationResult {
                    story_id,
                    valid: false,
                    error: Some(e.to_string()),
                }),
            },
            None => results.push(StoryValidationResult {
                story_id,
                valid: false,
                error: Some("No content found in content store".to_string()),
            }),
        }
    }

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

// ── Shared utilities used by submodules ──────────────────────────

/// Read story content from the database content store.
///
/// Returns the story content or an error if not found.
pub(crate) fn read_story_content(_project_root: &Path, story_id: &str) -> Result<String, String> {
    crate::db::read_content(story_id)
        .ok_or_else(|| format!("Story '{story_id}' not found in any pipeline stage."))
}

/// Write story content to the DB content store and CRDT.
pub(crate) fn write_story_content(
    _project_root: &Path,
    story_id: &str,
    stage: &str,
    content: &str,
) {
    crate::db::write_item_with_content(story_id, stage, content);
}

/// Determine what stage a story is in (from CRDT).
pub(crate) fn story_stage(story_id: &str) -> Option<String> {
    crate::pipeline_state::read_typed(story_id)
        .ok()
        .flatten()
        .map(|item| item.stage.dir_name().to_string())
}

/// Replace the content of a named `## Section` in a story file.
///
/// Finds the first occurrence of `## {section_name}` and replaces everything
/// until the next `##` heading (or end of file) with the provided text.
/// Returns an error if the section is not found.
pub(crate) fn replace_section_content(
    content: &str,
    section_name: &str,
    new_text: &str,
) -> Result<String, String> {
    let lines: Vec<&str> = content.lines().collect();
    let heading = format!("## {section_name}");

    let mut section_start: Option<usize> = None;
    let mut section_end: Option<usize> = None;

    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.trim();
        if trimmed == heading {
            section_start = Some(i);
            continue;
        }
        if section_start.is_some() && trimmed.starts_with("## ") {
            section_end = Some(i);
            break;
        }
    }

    let section_start =
        section_start.ok_or_else(|| format!("Section '{heading}' not found in story file."))?;

    let mut new_lines: Vec<String> = Vec::new();
    // Keep everything up to and including the section heading.
    for line in lines.iter().take(section_start + 1) {
        new_lines.push(line.to_string());
    }
    // Blank line, new content, blank line.
    new_lines.push(String::new());
    new_lines.push(new_text.to_string());
    new_lines.push(String::new());
    // Resume from the next section heading (or EOF).
    let resume_from = section_end.unwrap_or(lines.len());
    for line in lines.iter().skip(resume_from) {
        new_lines.push(line.to_string());
    }

    let mut new_str = new_lines.join("\n");
    if content.ends_with('\n') {
        new_str.push('\n');
    }
    Ok(new_str)
}

/// Insert a new `## {section_name}` section into `content`.
///
/// The new section is placed immediately before the first occurrence of
/// `## {before_section}`.  If `before_section` is `None` or not found in the
/// document, the section is appended at the end.
pub(crate) fn create_section_content(
    content: &str,
    section_name: &str,
    new_text: &str,
    before_section: Option<&str>,
) -> String {
    let lines: Vec<&str> = content.lines().collect();

    let insert_at = before_section
        .and_then(|before| {
            let heading = format!("## {before}");
            lines.iter().position(|l| l.trim() == heading)
        })
        .unwrap_or(lines.len());

    let mut new_lines: Vec<String> = Vec::new();

    for line in lines.iter().take(insert_at) {
        new_lines.push(line.to_string());
    }

    // Ensure a blank line before the new heading.
    if new_lines.last().map(|l| !l.is_empty()).unwrap_or(false) {
        new_lines.push(String::new());
    }

    new_lines.push(format!("## {section_name}"));
    new_lines.push(String::new());
    new_lines.push(new_text.to_string());
    new_lines.push(String::new());

    for line in lines.iter().skip(insert_at) {
        new_lines.push(line.to_string());
    }

    let mut new_str = new_lines.join("\n");
    if content.ends_with('\n') {
        new_str.push('\n');
    }
    new_str
}

/// Replace the `## Test Results` section in `contents` with `new_section`,
/// or append it if not present.
pub(super) fn replace_or_append_section(contents: &str, header: &str, new_section: &str) -> String {
    let lines: Vec<&str> = contents.lines().collect();
    let header_trimmed = header.trim();

    // Find the start of the existing section
    let section_start = lines.iter().position(|l| l.trim() == header_trimmed);

    if let Some(start) = section_start {
        // Find the next `##` heading after the section start (the end of this section)
        let section_end = lines[start + 1..]
            .iter()
            .position(|l| {
                let t = l.trim();
                t.starts_with("## ") && t != header_trimmed
            })
            .map(|i| start + 1 + i)
            .unwrap_or(lines.len());

        let mut result = lines[..start].join("\n");
        if !result.is_empty() {
            result.push('\n');
        }
        result.push_str(new_section);
        if section_end < lines.len() {
            result.push('\n');
            result.push_str(&lines[section_end..].join("\n"));
        }
        if contents.ends_with('\n') {
            result.push('\n');
        }
        result
    } else {
        // Append at the end
        let mut result = contents.trim_end_matches('\n').to_string();
        result.push_str("\n\n");
        result.push_str(new_section);
        if !result.ends_with('\n') {
            result.push('\n');
        }
        result
    }
}

pub(crate) fn slugify_name(name: &str) -> String {
    let slug: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect();
    // Collapse consecutive underscores and trim edges
    let mut result = String::new();
    let mut prev_underscore = true; // start true to trim leading _
    for ch in slug.chars() {
        if ch == '_' {
            if !prev_underscore {
                result.push('_');
            }
            prev_underscore = true;
        } else {
            result.push(ch);
            prev_underscore = false;
        }
    }
    // Trim trailing underscore
    if result.ends_with('_') {
        result.pop();
    }
    result
}

/// Get the next available item number from the database/CRDT.
pub(crate) fn next_item_number(_root: &std::path::Path) -> Result<u32, String> {
    Ok(crate::db::next_item_number())
}

#[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"));
        }

        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",
        );

        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, "coder-1");
        assert_eq!(agent.status, "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",
        );

        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",
        );

        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, "pending");
    }

    #[test]
    fn pipeline_state_includes_depends_on() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9863_story_dependent",
            "1_backlog",
            "---\nname: Dependent Story\ndepends_on: [10, 11]\n---\n",
        );
        crate::db::write_item_with_content(
            "9864_story_independent",
            "1_backlog",
            "---\nname: Independent Story\n---\n",
        );

        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::write_item_with_content(
            "9871_story_worktree",
            "1_backlog",
            "---\nname: Worktree Orchestration\n---\n# Story\n",
        );

        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.as_deref(), Some("View Upcoming"));
        let s2 = stories
            .iter()
            .find(|s| s.story_id == "9871_story_worktree")
            .unwrap();
        assert_eq!(s2.name.as_deref(), Some("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",
        );

        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::write_item_with_content(
            "9874_story_front_matter",
            "1_backlog",
            "---\nname: Enforce Front Matter\n---\n# Story\n",
        );

        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_front_matter() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content("9875_story_no_fm", "2_current", "# No front matter\n");

        let tmp = tempfile::tempdir().unwrap();
        let results = validate_story_dirs(tmp.path()).unwrap();
        let r = results
            .iter()
            .find(|r| r.story_id == "9875_story_no_fm")
            .unwrap();
        assert!(!r.valid);
        assert_eq!(r.error.as_deref(), Some("Missing front matter"));
    }

    #[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",
        );

        let tmp = tempfile::tempdir().unwrap();
        let results = validate_story_dirs(tmp.path()).unwrap();
        let r = results
            .iter()
            .find(|r| r.story_id == "9876_story_no_name")
            .unwrap();
        assert!(!r.valid);
        let err = r.error.as_deref().unwrap();
        assert!(err.contains("Missing 'name' field"));
    }

    #[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());
    }

    // --- slugify_name tests ---

    #[test]
    fn slugify_simple_name() {
        assert_eq!(
            slugify_name("Enforce Front Matter on All Story Files"),
            "enforce_front_matter_on_all_story_files"
        );
    }

    #[test]
    fn slugify_with_special_chars() {
        assert_eq!(slugify_name("Hello, World! (v2)"), "hello_world_v2");
    }

    #[test]
    fn slugify_leading_trailing_underscores() {
        assert_eq!(slugify_name("  spaces  "), "spaces");
    }

    #[test]
    fn slugify_consecutive_separators() {
        assert_eq!(slugify_name("a--b__c  d"), "a_b_c_d");
    }

    #[test]
    fn slugify_empty_after_strip() {
        assert_eq!(slugify_name("!!!"), "");
    }

    #[test]
    fn slugify_already_snake_case() {
        assert_eq!(slugify_name("my_story_name"), "my_story_name");
    }

    // --- next_item_number tests ---

    #[test]
    fn next_item_number_returns_at_least_1() {
        let tmp = tempfile::tempdir().unwrap();
        // May be higher due to shared global CRDT state in tests.
        assert!(next_item_number(tmp.path()).unwrap() >= 1);
    }

    #[test]
    fn next_item_number_increments_beyond_existing() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content("9877_story_foo", "1_backlog", "---\nname: Foo\n---\n");
        let tmp = tempfile::tempdir().unwrap();
        assert!(next_item_number(tmp.path()).unwrap() >= 9878);
    }

    // --- read_story_content tests ---

    #[test]
    fn read_story_content_from_content_store() {
        crate::db::ensure_content_store();
        let content = "---\nname: Test\n---\n# Story\n";
        crate::db::write_content("9878_story_read_test", content);

        let tmp = tempfile::tempdir().unwrap();
        let result = read_story_content(tmp.path(), "9878_story_read_test").unwrap();
        assert_eq!(result, content);
    }

    #[test]
    fn read_story_content_not_found_returns_error() {
        let tmp = tempfile::tempdir().unwrap();
        let result = read_story_content(tmp.path(), "99999_missing");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("not found"));
    }

    // --- replace_or_append_section tests ---

    #[test]
    fn replace_or_append_section_appends_when_absent() {
        let contents = "---\nname: T\n---\n# Story\n";
        let new =
            replace_or_append_section(contents, "## Test Results", "## Test Results\n\nfoo\n");
        assert!(new.contains("## Test Results"));
        assert!(new.contains("foo"));
        assert!(new.contains("# Story"));
    }

    #[test]
    fn replace_or_append_section_replaces_existing() {
        let contents = "# Story\n\n## Test Results\n\nold content\n\n## Other\n\nother content\n";
        let new = replace_or_append_section(
            contents,
            "## Test Results",
            "## Test Results\n\nnew content\n",
        );
        assert!(new.contains("new content"));
        assert!(!new.contains("old content"));
        assert!(new.contains("## Other"));
    }
}
