//! Handler for the story triage dump subcommand of `status`.
//!
//! Produces a triage dump for a story that is currently in-progress
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
//! git diff, recent commits, and the tail of the agent log.
//!
//! The command is handled entirely at the bot level — no LLM invocation.

use super::CommandContext;
use std::path::{Path, PathBuf};
use std::process::Command;

/// 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() {
        return Some(format!(
            "Usage: `{} status <number>`\n\nShows a triage dump for a story currently in progress.",
            ctx.bot_name
        ));
    }
    if !num_str.chars().all(|c| c.is_ascii_digit()) {
        return Some(format!(
            "Invalid story number: `{num_str}`. Usage: `{} status <number>`",
            ctx.bot_name
        ));
    }

    let current_dir = ctx
        .project_root
        .join(".storkit")
        .join("work")
        .join("2_current");

    match find_story_in_dir(&current_dir, num_str) {
        Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)),
        None => Some(format!(
            "Story **{num_str}** is not currently in progress (not found in `work/2_current/`)."
        )),
    }
}

/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`.
///
/// Returns `(path, file_stem)` for the first match.
fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> {
    let entries = std::fs::read_dir(dir).ok()?;
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("md") {
            continue;
        }
        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
            let file_num = stem
                .split('_')
                .next()
                .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
                .unwrap_or("");
            if file_num == num_str {
                return Some((path.clone(), stem.to_string()));
            }
        }
    }
    None
}

/// Build the full triage dump for a story.
fn build_triage_dump(
    ctx: &CommandContext,
    story_path: &Path,
    story_id: &str,
    num_str: &str,
) -> String {
    let contents = match std::fs::read_to_string(story_path) {
        Ok(c) => c,
        Err(e) => return format!("Failed to read story {num_str}: {e}"),
    };

    let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
    let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");

    let mut out = String::new();

    // ---- Header ----
    out.push_str(&format!("## Story {num_str} — {name}\n"));
    out.push_str("**Stage:** In Progress (`2_current`)\n\n");

    // ---- Front matter fields ----
    if let Some(ref m) = meta {
        let mut fields: Vec<String> = Vec::new();
        if let Some(true) = m.blocked {
            fields.push("**blocked:** true".to_string());
        }
        if let Some(ref agent) = m.agent {
            fields.push(format!("**agent:** {agent}"));
        }
        if let Some(ref qa) = m.qa {
            fields.push(format!("**qa:** {qa}"));
        }
        if let Some(true) = m.review_hold {
            fields.push("**review_hold:** true".to_string());
        }
        if let Some(rc) = m.retry_count
            && rc > 0
        {
            fields.push(format!("**retry_count:** {rc}"));
        }
        if let Some(ref cb) = m.coverage_baseline {
            fields.push(format!("**coverage_baseline:** {cb}"));
        }
        if let Some(ref mf) = m.merge_failure {
            fields.push(format!("**merge_failure:** {mf}"));
        }
        if !fields.is_empty() {
            out.push_str("**Front matter:**\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.project_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");
        }
    } else {
        out.push_str(&format!("**Branch:** `{branch}`\n"));
        out.push_str("**Worktree:** *(not yet created)*\n\n");
    }

    // ---- Agent log tail ----
    let log_dir = ctx
        .project_root
        .join(".storkit")
        .join("logs")
        .join(story_id);
    match latest_log_file(&log_dir) {
        Some(log_path) => {
            let tail = read_log_tail(&log_path, 20);
            let filename = log_path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("agent.log");
            if tail.is_empty() {
                out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n"));
            } else {
                out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n"));
                out.push_str(&tail);
                out.push_str("\n```\n");
            }
        }
        None => {
            out.push_str("**Agent log:** *(no log found)*\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()
}

/// Find the most recently modified `.log` file in the given directory,
/// regardless of agent name.
fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
    if !log_dir.is_dir() {
        return None;
    }
    let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
    for entry in std::fs::read_dir(log_dir).ok()?.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("log") {
            continue;
        }
        let modified = match entry.metadata().and_then(|m| m.modified()) {
            Ok(t) => t,
            Err(_) => continue,
        };
        if best.as_ref().is_none_or(|(_, t)| modified > *t) {
            best = Some((path, modified));
        }
    }
    best.map(|(p, _)| p)
}

/// Read the last `n` non-empty lines from a file as a single string.
fn read_log_tail(path: &Path, n: usize) -> String {
    let contents = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return String::new(),
    };
    let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
    let start = lines.len().saturating_sub(n);
    lines[start..].join("\n")
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agents::AgentPool;
    use std::collections::HashSet;
    use std::sync::{Arc, Mutex};

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

    fn status_triage_cmd(root: &Path, args: &str) -> Option<String> {
        let agents = Arc::new(AgentPool::new_test(3000));
        let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
        let room_id = "!test:example.com".to_string();
        let dispatch = CommandDispatch {
            bot_name: "Timmy",
            bot_user_id: "@timmy:homeserver.local",
            project_root: root,
            agents: &agents,
            ambient_rooms: &ambient_rooms,
            room_id: &room_id,
        };
        try_handle_command(&dispatch, &format!("@timmy status {args}"))
    }

    fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
        let dir = root.join(".storkit/work").join(stage);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join(filename), content).unwrap();
    }

    // -- 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_returns_error() {
        let tmp = tempfile::TempDir::new().unwrap();
        let output = status_triage_cmd(tmp.path(), "abc").unwrap();
        assert!(
            output.contains("Invalid"),
            "non-numeric arg should return error: {output}"
        );
    }

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

    #[test]
    fn whatsup_story_not_in_current_returns_friendly_message() {
        let tmp = tempfile::TempDir::new().unwrap();
        // Create the directory but put the story in backlog, not current
        write_story_file(
            tmp.path(),
            "1_backlog",
            "42_story_not_in_current.md",
            "---\nname: Not in current\n---\n",
        );
        let output = status_triage_cmd(tmp.path(), "42").unwrap();
        assert!(
            output.contains("42"),
            "message should include story number: {output}"
        );
        assert!(
            output.contains("not") || output.contains("Not"),
            "message should say not found/in progress: {output}"
        );
    }

    // -- found in 2_current -------------------------------------------------

    #[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",
            "---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
        );
        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("In Progress") || output.contains("2_current"),
            "should show pipeline stage: {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",
        );
        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",
        );
        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",
            "---\nname: Agent Story\nagent: coder-1\n---\n",
        );
        let output = status_triage_cmd(tmp.path(), "55").unwrap();
        assert!(
            output.contains("coder-1"),
            "should show agent field: {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",
        );
        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}"
        );
    }

    #[test]
    fn whatsup_no_log_shows_no_log_message() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "77_story_no_log.md",
            "---\nname: No Log\n---\n",
        );
        let output = status_triage_cmd(tmp.path(), "77").unwrap();
        assert!(
            output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
            "should indicate no log exists: {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());
    }

    // -- read_log_tail -------------------------------------------------------

    #[test]
    fn read_log_tail_returns_last_n_lines() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("test.log");
        let content = (1..=30).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
        std::fs::write(&path, &content).unwrap();
        let tail = read_log_tail(&path, 5);
        let lines: Vec<&str> = tail.lines().collect();
        assert_eq!(lines.len(), 5);
        assert_eq!(lines[0], "line 26");
        assert_eq!(lines[4], "line 30");
    }

    #[test]
    fn read_log_tail_fewer_lines_than_n() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("short.log");
        std::fs::write(&path, "line A\nline B\n").unwrap();
        let tail = read_log_tail(&path, 20);
        assert!(tail.contains("line A"));
        assert!(tail.contains("line B"));
    }

    // -- latest_log_file ----------------------------------------------------

    #[test]
    fn latest_log_file_returns_none_for_missing_dir() {
        let tmp = tempfile::TempDir::new().unwrap();
        let result = latest_log_file(&tmp.path().join("nonexistent"));
        assert!(result.is_none());
    }

    #[test]
    fn latest_log_file_finds_log() {
        let tmp = tempfile::TempDir::new().unwrap();
        let log_path = tmp.path().join("coder-1-sess-abc.log");
        std::fs::write(&log_path, "some log content\n").unwrap();
        let result = latest_log_file(tmp.path());
        assert!(result.is_some());
        assert_eq!(result.unwrap(), log_path);
    }
}
