//! Handler for the story pipeline-info subcommand of `status`.
//!
//! Produces a pipeline info dump for a story: metadata, acceptance criteria,
//! worktree/branch state, git diff, and recent commits.
//!
//! Reads from the CRDT pipeline state and the in-memory content store — no
//! filesystem access for story content.  Works for stories in any pipeline
//! stage, not just `2_current`.
//!
//! For agent log output, use the `logs <number>` command instead.
//!
//! The command is handled entirely at the bot level — no LLM invocation.

use super::CommandContext;
use std::path::Path;
use std::process::Command;

/// Maximum number of dirty file paths listed per story before truncating.
const MAX_DIRTY_FILES_SHOWN: usize = 20;

/// Handle `{bot_name} status {number}`.
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
    let num_str = ctx.args.trim();
    if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
        return None;
    }

    match find_story_by_number(num_str) {
        Some((story_id, item)) => Some(build_triage_dump(ctx, &story_id, &item, num_str)),
        None => Some(format!("Story **{num_str}** not found in the pipeline.")),
    }
}

/// Find a pipeline item whose numeric prefix matches `num_str` by querying the
/// CRDT state.  Returns `(story_id, PipelineItem)` for the first match.
fn find_story_by_number(num_str: &str) -> Option<(String, crate::pipeline_state::PipelineItem)> {
    let items = crate::pipeline_state::read_all_typed();
    for item in items {
        let file_num = item
            .story_id
            .0
            .split('_')
            .next()
            .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
            .unwrap_or("");
        if file_num == num_str {
            let story_id = item.story_id.0.clone();
            return Some((story_id, item));
        }
    }
    None
}

/// Build the full triage dump for a story.
fn build_triage_dump(
    ctx: &CommandContext,
    story_id: &str,
    item: &crate::pipeline_state::PipelineItem,
    num_str: &str,
) -> String {
    let contents = match crate::db::read_content(crate::db::ContentKey::Story(story_id)) {
        Some(c) => c,
        None => return format!("Story {num_str}: content not found in content store."),
    };

    // Story metadata now comes from the CRDT registers and adjacent CRDT entries
    // (MergeJob.error), not from YAML front matter (story 929).
    let crdt_item = crate::crdt_state::read_item(story_id);
    let name = crdt_item.as_ref().map(|w| w.name()).unwrap_or("(unnamed)");

    let mut out = String::new();

    // ---- Header ----
    out.push_str(&format!("## Story {num_str} — {name}\n"));
    let stage_name = crate::pipeline_state::stage_label(&item.stage);
    let dir_name = crate::pipeline_state::stage_dir_name(&item.stage);
    out.push_str(&format!("**Stage:** {stage_name} (`{dir_name}`)\n\n"));

    // ---- CRDT metadata ----
    if let Some(ref w) = crdt_item {
        let mut fields: Vec<String> = Vec::new();
        // Story 945: `Stage::Blocked` is the source of truth.
        if matches!(
            w.stage(),
            crate::pipeline_state::Stage::Blocked { .. }
                | crate::pipeline_state::Stage::MergeFailure { .. }
                | crate::pipeline_state::Stage::MergeFailureFinal { .. }
                | crate::pipeline_state::Stage::Archived {
                    reason: crate::pipeline_state::ArchiveReason::Blocked { .. },
                    ..
                }
        ) {
            fields.push("**blocked:** true".to_string());
        }
        if let Some(agent) = w.agent() {
            fields.push(format!("**agent:** {agent}"));
        }
        if let Some(qa) = w.qa_mode() {
            fields.push(format!("**qa:** {qa}"));
        }
        let rc = w.retry_count();
        if rc > 0 {
            fields.push(format!("**retry_count:** {rc}"));
        }
        // merge_failure detail lives on the MergeJob CRDT entry, not on the
        // pipeline item itself.
        if let Some(job) = crate::crdt_state::read_merge_job(story_id)
            && let Some(err) = job.error
        {
            fields.push(format!("**merge_failure:** {err}"));
        }
        let deps = w.depends_on();
        if !deps.is_empty() {
            let nums: Vec<String> = deps.iter().map(|n| format!("#{n}")).collect();
            fields.push(format!("**depends_on:** {}", nums.join(", ")));
        }
        if !fields.is_empty() {
            out.push_str("**Metadata:**\n");
            for f in &fields {
                out.push_str(&format!("  • {f}\n"));
            }
            out.push('\n');
        }
    }

    // ---- Acceptance criteria ----
    let criteria = parse_acceptance_criteria(&contents);
    if !criteria.is_empty() {
        out.push_str("**Acceptance Criteria:**\n");
        for (checked, text) in &criteria {
            let mark = if *checked { "✅" } else { "⬜" };
            out.push_str(&format!("  {mark} {text}\n"));
        }
        let total = criteria.len();
        let done = criteria.iter().filter(|(c, _)| *c).count();
        out.push_str(&format!("  *{done}/{total} complete*\n"));
        out.push('\n');
    }

    // ---- Worktree and branch ----
    let wt_path = crate::worktree::worktree_path(ctx.effective_root(), story_id);
    let branch = format!("feature/story-{story_id}");
    if wt_path.is_dir() {
        out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
        out.push_str(&format!("**Branch:** `{branch}`\n\n"));

        // ---- git diff --stat ----
        let diff_stat = run_git(&wt_path, &["diff", "--stat", "master...HEAD"]);
        if !diff_stat.is_empty() {
            out.push_str("**Diff stat (vs master):**\n```\n");
            out.push_str(&diff_stat);
            out.push_str("```\n\n");
        } else {
            out.push_str("**Diff stat (vs master):** *(no changes)*\n\n");
        }

        // ---- Last 5 commits on feature branch ----
        let log = run_git(
            &wt_path,
            &["log", "master..HEAD", "--pretty=format:%h %s", "-5"],
        );
        if !log.is_empty() {
            out.push_str("**Recent commits (branch only):**\n```\n");
            out.push_str(&log);
            out.push_str("\n```\n\n");
        } else {
            out.push_str("**Recent commits (branch only):** *(none yet)*\n\n");
        }

        // ---- Uncommitted working tree changes ----
        let dirty = crate::service::git_ops::io::read_dirty_files_sync(&wt_path);
        if dirty.is_clean() {
            out.push_str("**Working tree:** working tree clean\n\n");
        } else {
            let summary = match (dirty.modified, dirty.new) {
                (m, 0) => format!("{m} modified"),
                (0, n) => format!("{n} new"),
                (m, n) => format!("{m} modified, {n} new"),
            };
            out.push_str(&format!("**Working tree:** {summary} (uncommitted)\n"));
            let shown = dirty.paths.len().min(MAX_DIRTY_FILES_SHOWN);
            for path in &dirty.paths[..shown] {
                out.push_str(&format!("  {path}\n"));
            }
            if dirty.paths.len() > MAX_DIRTY_FILES_SHOWN {
                let remaining = dirty.paths.len() - MAX_DIRTY_FILES_SHOWN;
                out.push_str(&format!("  ...and {remaining} more\n"));
            }
            out.push('\n');
        }
    } else {
        out.push_str(&format!("**Branch:** `{branch}`\n"));
        out.push_str("**Worktree:** *(not yet created)*\n\n");
    }

    out
}

/// Parse acceptance criteria from story markdown.
///
/// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line.
fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
    contents
        .lines()
        .filter_map(|line| {
            let trimmed = line.trim();
            if let Some(text) = trimmed
                .strip_prefix("- [x] ")
                .or_else(|| trimmed.strip_prefix("- [X] "))
            {
                Some((true, text.to_string()))
            } else {
                trimmed
                    .strip_prefix("- [ ] ")
                    .map(|text| (false, text.to_string()))
            }
        })
        .collect()
}

/// Run a git command in the given directory, returning trimmed stdout (or empty on error).
fn run_git(dir: &Path, args: &[&str]) -> String {
    Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_default()
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    use super::super::{CommandDispatch, try_handle_command};

    fn status_triage_cmd(root: &Path, args: &str) -> Option<String> {
        let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
        let room_id = "!test:example.com".to_string();
        let dispatch = CommandDispatch {
            services: &services,
            project_root: &services.project_root,
            bot_user_id: "@timmy:homeserver.local",
            room_id: &room_id,
        };
        try_handle_command(&dispatch, &format!("@timmy status {args}"))
    }

    use crate::chat::test_helpers::write_story_file;

    // -- registration -------------------------------------------------------

    #[test]
    fn whatsup_command_is_not_registered() {
        let found = super::super::commands().iter().any(|c| c.name == "whatsup");
        assert!(
            !found,
            "whatsup command must not be in the registry (renamed to status)"
        );
    }

    #[test]
    fn status_command_appears_in_help() {
        let result = super::super::tests::try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy help",
        );
        let output = result.unwrap();
        assert!(
            output.contains("status"),
            "help should list status command: {output}"
        );
    }

    // -- input validation ---------------------------------------------------

    #[test]
    fn whatsup_no_args_returns_usage() {
        let tmp = tempfile::TempDir::new().unwrap();
        let output = status_triage_cmd(tmp.path(), "").unwrap();
        assert!(
            output.contains("Pipeline Status"),
            "no args should show pipeline status: {output}"
        );
    }

    #[test]
    fn whatsup_non_numeric_routes_to_timmy() {
        let tmp = tempfile::TempDir::new().unwrap();
        let result = status_triage_cmd(tmp.path(), "what is going on");
        assert!(
            result.is_none(),
            "natural-language args should route to LLM (None): {result:?}"
        );
    }

    #[test]
    fn whatsup_well_formed_runs_handler() {
        crate::db::ensure_content_store();
        let tmp = tempfile::TempDir::new().unwrap();
        let result = status_triage_cmd(tmp.path(), "99996");
        assert!(
            result.is_some(),
            "well-formed numeric arg should run the command handler: {result:?}"
        );
    }

    // -- not found ----------------------------------------------------------

    #[test]
    fn whatsup_story_not_in_pipeline_returns_friendly_message() {
        // Initialize the content store so read_all_typed() returns nothing for
        // this number without panicking.
        crate::db::ensure_content_store();
        let tmp = tempfile::TempDir::new().unwrap();
        // Use a number unlikely to collide with other tests' CRDT entries.
        let output = status_triage_cmd(tmp.path(), "99997").unwrap();
        assert!(
            output.contains("99997"),
            "message should include story number: {output}"
        );
        assert!(
            output.contains("not found") || output.contains("Not found"),
            "message should say not found: {output}"
        );
    }

    // -- found in any pipeline stage ----------------------------------------

    #[test]
    fn whatsup_shows_story_name_and_stage() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "99_story_my_feature.md",
            "## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
            Some("My Feature"),
        );
        let output = status_triage_cmd(tmp.path(), "99").unwrap();
        assert!(output.contains("99"), "should show story number: {output}");
        assert!(
            output.contains("My Feature"),
            "should show story name: {output}"
        );
        assert!(
            output.contains("Coding") || output.contains("2_current"),
            "should show pipeline stage: {output}"
        );
    }

    #[test]
    fn whatsup_works_for_story_in_backlog() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "1_backlog",
            "9901_story_backlog_item.md",
            "",
            Some("Backlog Item"),
        );
        let output = status_triage_cmd(tmp.path(), "9901").unwrap();
        assert!(
            output.contains("9901"),
            "should show story number: {output}"
        );
        assert!(
            output.contains("Backlog Item"),
            "should show story name: {output}"
        );
        assert!(
            output.contains("Backlog") || output.contains("1_backlog"),
            "should show backlog stage: {output}"
        );
    }

    #[test]
    fn whatsup_works_for_story_in_qa() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "3_qa",
            "9902_story_qa_item.md",
            "",
            Some("QA Item"),
        );
        let output = status_triage_cmd(tmp.path(), "9902").unwrap();
        assert!(
            output.contains("9902"),
            "should show story number: {output}"
        );
        assert!(
            output.contains("QA Item"),
            "should show story name: {output}"
        );
    }

    #[test]
    fn whatsup_shows_acceptance_criteria() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "99_story_criteria_test.md",
            "---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
            Some("Criteria Test"),
        );
        let output = status_triage_cmd(tmp.path(), "99").unwrap();
        assert!(
            output.contains("First thing"),
            "should show unchecked criterion: {output}"
        );
        assert!(
            output.contains("Done thing"),
            "should show checked criterion: {output}"
        );
        // 1 of 3 done
        assert!(
            output.contains("1/3"),
            "should show checked/total count: {output}"
        );
    }

    #[test]
    fn whatsup_shows_blocked_field() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "55_story_blocked_story.md",
            "---\nname: Blocked Story\nblocked: true\n---\n",
            Some("Blocked Story"),
        );
        let output = status_triage_cmd(tmp.path(), "55").unwrap();
        assert!(
            output.contains("blocked"),
            "should show blocked field: {output}"
        );
    }

    #[test]
    fn whatsup_shows_agent_field() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "55_story_agent_story.md",
            "",
            Some("Agent Story"),
        );
        crate::crdt_state::set_agent(
            "55_story_agent_story",
            Some(crate::config::AgentName::Coder1),
        );
        let output = status_triage_cmd(tmp.path(), "55").unwrap();
        assert!(
            output.contains("coder-1"),
            "should show agent field: {output}"
        );
    }

    #[test]
    fn whatsup_shows_depends_on_field() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "55_story_depends_story.md",
            "",
            Some("Depends Story"),
        );
        crate::crdt_state::set_depends_on("55_story_depends_story", &[477, 478]);
        let output = status_triage_cmd(tmp.path(), "55").unwrap();
        assert!(
            output.contains("depends_on") || output.contains("#477"),
            "should show depends_on field: {output}"
        );
        assert!(
            output.contains("478"),
            "should list all dependency numbers: {output}"
        );
    }

    #[test]
    fn whatsup_no_worktree_shows_not_created() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "77_story_no_worktree.md",
            "---\nname: No Worktree\n---\n",
            Some("No Worktree"),
        );
        let output = status_triage_cmd(tmp.path(), "77").unwrap();
        // Branch name should still appear
        assert!(
            output.contains("feature/story-77"),
            "should show branch name: {output}"
        );
    }

    // -- parse_acceptance_criteria -----------------------------------------

    #[test]
    fn parse_criteria_mixed() {
        let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n";
        let result = parse_acceptance_criteria(input);
        assert_eq!(result.len(), 4);
        assert_eq!(result[0], (false, "First".to_string()));
        assert_eq!(result[1], (true, "Done".to_string()));
        assert_eq!(result[2], (true, "Also done".to_string()));
        assert_eq!(result[3], (false, "Last".to_string()));
    }

    #[test]
    fn parse_criteria_empty() {
        let input = "# Story\nNo checkboxes here.\n";
        let result = parse_acceptance_criteria(input);
        assert!(result.is_empty());
    }

    // -- working tree state in triage output --------------------------------

    /// Initialise a bare-minimum git repo in `dir` with one commit.
    fn init_git_repo(dir: &std::path::Path) {
        use std::process::Command as Cmd;
        Cmd::new("git")
            .args(["init", "-b", "main"])
            .current_dir(dir)
            .output()
            .unwrap();
        Cmd::new("git")
            .args(["config", "user.email", "test@test.com"])
            .current_dir(dir)
            .output()
            .unwrap();
        Cmd::new("git")
            .args(["config", "user.name", "Test"])
            .current_dir(dir)
            .output()
            .unwrap();
        std::fs::write(dir.join("README.md"), "# test").unwrap();
        Cmd::new("git")
            .args(["add", "README.md"])
            .current_dir(dir)
            .output()
            .unwrap();
        Cmd::new("git")
            .args(["commit", "-m", "init"])
            .current_dir(dir)
            .output()
            .unwrap();
    }

    #[test]
    fn triage_shows_dirty_files_when_worktree_is_dirty() {
        use std::fs;
        use tempfile::TempDir;

        crate::db::ensure_content_store();
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path();
        let story_id = "8801_story_triage_dirty";

        write_story_file(
            project_root,
            "2_current",
            &format!("{story_id}.md"),
            "",
            Some("Triage Dirty"),
        );

        let wt_path = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        fs::create_dir_all(&wt_path).unwrap();
        init_git_repo(&wt_path);
        fs::write(wt_path.join("README.md"), "# changed").unwrap();
        fs::write(wt_path.join("new_file.rs"), "fn foo() {}").unwrap();

        let output = status_triage_cmd(project_root, "8801").unwrap();

        assert!(
            output.contains("Working tree:") && output.contains("(uncommitted)"),
            "triage should show working tree summary when dirty: {output}"
        );
        assert!(
            output.contains("README.md") || output.contains("new_file.rs"),
            "triage should list dirty file paths: {output}"
        );
    }

    #[test]
    fn triage_shows_clean_when_worktree_is_clean() {
        use std::fs;
        use tempfile::TempDir;

        crate::db::ensure_content_store();
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path();
        let story_id = "8802_story_triage_clean";

        write_story_file(
            project_root,
            "2_current",
            &format!("{story_id}.md"),
            "",
            Some("Triage Clean"),
        );

        let wt_path = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        fs::create_dir_all(&wt_path).unwrap();
        init_git_repo(&wt_path);

        let output = status_triage_cmd(project_root, "8802").unwrap();

        assert!(
            output.contains("working tree clean"),
            "triage should show 'working tree clean' when no uncommitted changes: {output}"
        );
    }

    #[test]
    fn triage_dirty_files_capped_at_twenty_with_overflow() {
        use std::fs;
        use tempfile::TempDir;

        crate::db::ensure_content_store();
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path();
        let story_id = "8803_story_triage_many";

        write_story_file(
            project_root,
            "2_current",
            &format!("{story_id}.md"),
            "",
            Some("Triage Many"),
        );

        let wt_path = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        fs::create_dir_all(&wt_path).unwrap();
        init_git_repo(&wt_path);
        for i in 0..25 {
            fs::write(wt_path.join(format!("file_{i:02}.rs")), "fn f() {}").unwrap();
        }

        let output = status_triage_cmd(project_root, "8803").unwrap();

        assert!(
            output.contains("...and 5 more"),
            "triage overflow line should appear when more than 20 dirty files: {output}"
        );
    }
}
