//! Handler for the `status` command and pipeline status helpers.

use crate::agents::{AgentPool, AgentStatus};
use crate::config::ProjectConfig;
use crate::pipeline_state::{PipelineItem, Stage};
use std::collections::{HashMap, HashSet};

use super::CommandContext;

pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
    if ctx.args.trim().is_empty() {
        Some(build_pipeline_status(
            ctx.effective_root(),
            &ctx.services.agents,
        ))
    } else {
        super::triage::handle_triage(ctx)
    }
}

/// Format a short display label for a work item.
///
/// Extracts the leading numeric ID and optional type tag from the file stem
/// (e.g. `"293"` and `"story"` from `"293_story_register_all_bot_commands"`)
/// and combines them with the human-readable name from the front matter when
/// available. Known types (`story`, `bug`, `spike`, `refactor`) are shown as
/// bracketed labels; unknown or missing types are omitted silently.
///
/// Examples:
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 [story] — Register all bot commands"`
/// - `("375_bug_foo", None)` → `"375 [bug]"`
/// - `("293_story_foo", None)` → `"293 [story]"`
/// - `("no_number_here", None)` → `"no_number_here"`
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
    let mut parts = stem.splitn(3, '_');
    let first = parts.next().unwrap_or(stem);
    let (number, type_label) = if !first.is_empty() && first.chars().all(|c| c.is_ascii_digit()) {
        let t = parts.next().and_then(|t| match t {
            "story" | "bug" | "spike" | "refactor" => Some(t),
            _ => None,
        });
        (first, t)
    } else {
        (stem, None)
    };
    let prefix = match type_label {
        Some(t) => format!("{number} [{t}]"),
        None => number.to_string(),
    };
    match name {
        Some(n) => format!("{prefix} — {n}"),
        None => prefix,
    }
}

/// Choose the traffic-light indicator for a work item.
///
/// Priority: blocked > throttled > running > idle.
/// Uses coloured emoji so indicators render natively in all Matrix clients
/// (Element X and others do not support `<font data-mx-color>` HTML tags).
///
/// - 🔴 hard-blocked (retry limit exceeded)
/// - 🟠 throttled (rate-limit warning received)
/// - 🟢 running normally (active agent, no throttle)
/// - ⚪ idle / no active agent
pub(super) fn traffic_light_dot(blocked: bool, throttled: bool, has_agent: bool) -> &'static str {
    if blocked {
        "\u{1F534} " // 🔴 — hard blocked
    } else if throttled {
        "\u{1F7E0} " // 🟠 — throttled
    } else if has_agent {
        "\u{1F7E2} " // 🟢 — running normally
    } else {
        "\u{26AA} " // ⚪ — idle / no agent
    }
}

/// Check which dependency numbers from `item.depends_on` are unmet.
///
/// A dependency is considered met if the dep is in `Done` or `Archived` stage
/// in `all_items`. If the dep is not found in `all_items` at all (e.g. it was
/// archived before the CRDT migration and has no row), it is treated as met.
pub(super) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec<u32> {
    item.depends_on
        .iter()
        .filter_map(|dep_id| {
            // dep_id.0 is the raw number string (e.g. "999") as projected
            // from PipelineItemView.depends_on: Vec<u32>.
            let dep_num: u32 = dep_id.0.parse().ok()?;
            // Find the dep by matching the numeric prefix of its story_id.
            let dep = all_items.iter().find(|i| {
                i.story_id.0 == dep_id.0
                    || i.story_id.0.split('_').next() == Some(dep_id.0.as_str())
            });
            match dep {
                Some(d) if matches!(d.stage, Stage::Done { .. } | Stage::Archived { .. }) => None,
                Some(_) => Some(dep_num), // Found but not done = unmet
                None => None,             // Not in CRDT; treat as met
            }
        })
        .collect()
}

/// Build the full pipeline status text formatted for Matrix (markdown).
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
    let items = crate::pipeline_state::read_all_typed();
    build_status_from_items(project_root, agents, &items)
}

/// Inner implementation that accepts pre-loaded items for testability.
fn build_status_from_items(
    project_root: &std::path::Path,
    agents: &AgentPool,
    items: &[PipelineItem],
) -> String {
    // Build a map from story_id → active AgentInfo for quick lookup.
    let active_agents = agents.list_agents().unwrap_or_default();
    let active_map: HashMap<String, &crate::agents::AgentInfo> = active_agents
        .iter()
        .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
        .map(|a| (a.story_id.clone(), a))
        .collect();

    // Read token usage once for all stories to avoid repeated file I/O.
    let cost_by_story: HashMap<String, f64> = crate::agents::token_usage::read_all(project_root)
        .unwrap_or_default()
        .into_iter()
        .fold(HashMap::new(), |mut map, r| {
            *map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
            map
        });

    let config = ProjectConfig::load(project_root).ok();

    let mut out = String::from("**Pipeline Status**\n\n");

    // Active pipeline stages to display (Archived is handled separately below).
    type StagePredicate = fn(&Stage) -> bool;
    let stage_filters: &[(&str, StagePredicate)] = &[
        ("Backlog", |s| matches!(s, Stage::Backlog)),
        ("In Progress", |s| matches!(s, Stage::Coding)),
        ("QA", |s| matches!(s, Stage::Qa)),
        ("Merge", |s| matches!(s, Stage::Merge { .. })),
        ("Done", |s| matches!(s, Stage::Done { .. })),
    ];

    for (label, filter) in stage_filters {
        let mut stage_items: Vec<&PipelineItem> =
            items.iter().filter(|i| filter(&i.stage)).collect();
        stage_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
        let count = stage_items.len();
        out.push_str(&format!("**{label}** ({count})\n"));
        if stage_items.is_empty() {
            out.push_str("  *(none)*\n");
        } else {
            for item in &stage_items {
                out.push_str(&render_item_line(
                    item,
                    items,
                    &active_map,
                    &cost_by_story,
                    &config,
                ));
            }
        }
        out.push('\n');
    }

    // Blocked items: Archived { reason: Blocked } shown with 🔴 indicator.
    let mut blocked_items: Vec<&PipelineItem> =
        items.iter().filter(|i| i.stage.is_blocked()).collect();
    blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
    if !blocked_items.is_empty() {
        out.push_str(&format!("**Blocked** ({})\n", blocked_items.len()));
        for item in &blocked_items {
            out.push_str(&render_item_line(
                item,
                items,
                &active_map,
                &cost_by_story,
                &config,
            ));
        }
        out.push('\n');
    }

    // Free agents: configured agents not currently running or pending.
    out.push_str("**Free Agents**\n");
    if let Some(cfg) = &config {
        let busy_names: HashSet<String> = active_agents
            .iter()
            .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
            .map(|a| a.agent_name.clone())
            .collect();

        let free: Vec<String> = cfg
            .agent
            .iter()
            .filter(|a| !busy_names.contains(&a.name))
            .map(|a| match &a.model {
                Some(m) => format!("{} ({})", a.name, m),
                None => a.name.clone(),
            })
            .collect();

        if free.is_empty() {
            out.push_str("  *(none — all agents busy)*\n");
        } else {
            for name in &free {
                out.push_str(&format!("  • {name}\n"));
            }
        }
    } else {
        out.push_str("  *(no agent config found)*\n");
    }

    out
}

/// Render a single status line for one pipeline item.
fn render_item_line(
    item: &PipelineItem,
    all_items: &[PipelineItem],
    active_map: &HashMap<String, &crate::agents::AgentInfo>,
    cost_by_story: &HashMap<String, f64>,
    config: &Option<ProjectConfig>,
) -> String {
    let story_id = &item.story_id.0;
    let name_opt = if item.name.is_empty() {
        None
    } else {
        Some(item.name.as_str())
    };
    let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id);
    let base_label = story_short_label(story_id, name_opt);
    let display = if frozen {
        format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
    } else {
        base_label
    };
    let cost_suffix = cost_by_story
        .get(story_id)
        .filter(|&&c| c > 0.0)
        .map(|c| format!(" — ${c:.2}"))
        .unwrap_or_default();
    let blocked = item.stage.is_blocked();
    let agent = active_map.get(story_id);
    let throttled = agent.map(|a| a.throttled).unwrap_or(false);
    let dot = traffic_light_dot(blocked, throttled, agent.is_some());
    let unmet = unmet_deps_from_items(item, all_items);
    let dep_suffix = if unmet.is_empty() {
        String::new()
    } else {
        let nums: Vec<String> = unmet.iter().map(|n| n.to_string()).collect();
        format!(" *(waiting on: {})*", nums.join(", "))
    };
    if let Some(agent) = agent {
        let model_str = config
            .as_ref()
            .and_then(|cfg| cfg.find_agent(&agent.agent_name))
            .and_then(|ac| ac.model.as_deref())
            .unwrap_or("?");
        format!(
            "  {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n",
            agent.agent_name
        )
    } else {
        format!("  {dot}{display}{cost_suffix}{dep_suffix}\n")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agents::AgentPool;
    use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage, StoryId};
    use chrono::Utc;

    /// Build a minimal PipelineItem for tests.
    fn make_item(id: &str, name: &str, stage: Stage) -> PipelineItem {
        PipelineItem {
            story_id: StoryId(id.to_string()),
            name: name.to_string(),
            stage,
            depends_on: Vec::new(),
            retry_count: 0,
        }
    }

    /// Build a PipelineItem with dependencies for tests.
    fn make_item_with_deps(id: &str, name: &str, stage: Stage, deps: Vec<u32>) -> PipelineItem {
        PipelineItem {
            story_id: StoryId(id.to_string()),
            name: name.to_string(),
            stage,
            depends_on: deps.iter().map(|n| StoryId(n.to_string())).collect(),
            retry_count: 0,
        }
    }

    #[test]
    fn status_command_matches() {
        let result = super::super::tests::try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy status",
        );
        assert!(result.is_some(), "status command should match");
    }

    #[test]
    fn status_command_returns_pipeline_text() {
        let result = super::super::tests::try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy status",
        );
        let output = result.unwrap();
        assert!(
            output.contains("Pipeline Status"),
            "status output should contain pipeline info: {output}"
        );
    }

    #[test]
    fn status_command_case_insensitive() {
        let result = super::super::tests::try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy STATUS",
        );
        assert!(result.is_some(), "STATUS should match case-insensitively");
    }

    // -- story_short_label --------------------------------------------------

    #[test]
    fn short_label_extracts_number_and_name() {
        let label = story_short_label(
            "293_story_register_all_bot_commands",
            Some("Register all bot commands"),
        );
        assert_eq!(label, "293 [story] — Register all bot commands");
    }

    #[test]
    fn short_label_number_only_when_no_name() {
        let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
        assert_eq!(label, "297 [story]");
    }

    #[test]
    fn short_label_falls_back_to_stem_when_no_numeric_prefix() {
        let label = story_short_label("no_number_here", None);
        assert_eq!(label, "no_number_here");
    }

    #[test]
    fn short_label_does_not_include_underscore_slug() {
        let label = story_short_label(
            "293_story_register_all_bot_commands_in_the_command_registry",
            Some("Register all bot commands"),
        );
        assert!(
            !label.contains("story_register"),
            "label should not contain the slug portion: {label}"
        );
    }

    #[test]
    fn short_label_shows_bug_type() {
        let label = story_short_label(
            "375_bug_default_project_toml",
            Some("Default project.toml issue"),
        );
        assert_eq!(label, "375 [bug] — Default project.toml issue");
    }

    #[test]
    fn short_label_shows_spike_type() {
        let label = story_short_label(
            "61_spike_filesystem_watcher_architecture",
            Some("Filesystem watcher architecture"),
        );
        assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
    }

    #[test]
    fn short_label_shows_refactor_type() {
        let label = story_short_label(
            "260_refactor_upgrade_libsqlite3_sys",
            Some("Upgrade libsqlite3-sys"),
        );
        assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
    }

    #[test]
    fn short_label_omits_unknown_type() {
        let label = story_short_label("42_task_do_something", Some("Do something"));
        assert_eq!(label, "42 — Do something");
    }

    #[test]
    fn short_label_no_type_when_only_id() {
        // Stem with only a numeric ID and no type segment
        let label = story_short_label("42", Some("Some item"));
        assert_eq!(label, "42 — Some item");
    }

    // -- build_status_from_items formatting -----------------------------------

    #[test]
    fn status_does_not_show_full_filename_stem() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        let items = vec![make_item(
            "293_story_register_all_bot_commands",
            "Register all bot commands",
            Stage::Coding,
        )];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            !output.contains("293_story_register_all_bot_commands"),
            "output must not show full filename stem: {output}"
        );
        assert!(
            output.contains("293 [story] — Register all bot commands"),
            "output must show number, type, and title: {output}"
        );
    }

    // -- token cost in status output ----------------------------------------

    #[test]
    fn status_shows_cost_when_token_usage_exists() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        let items = vec![make_item(
            "293_story_register_all_bot_commands",
            "Register all bot commands",
            Stage::Coding,
        )];

        // Write token usage for this story.
        let usage = crate::agents::TokenUsage {
            input_tokens: 100,
            output_tokens: 200,
            cache_creation_input_tokens: 0,
            cache_read_input_tokens: 0,
            total_cost_usd: 0.29,
        };
        let record = crate::agents::token_usage::build_record(
            "293_story_register_all_bot_commands",
            "coder-1",
            None,
            usage,
        );
        crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            output.contains("293 [story] — Register all bot commands — $0.29"),
            "output must show cost next to story: {output}"
        );
    }

    #[test]
    fn status_no_cost_when_no_usage() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        let items = vec![make_item(
            "293_story_register_all_bot_commands",
            "Register all bot commands",
            Stage::Coding,
        )];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            !output.contains("$"),
            "output must not show cost when no usage exists: {output}"
        );
    }

    #[test]
    fn status_aggregates_multiple_records_per_story() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        let items = vec![make_item(
            "293_story_register_all_bot_commands",
            "Register all bot commands",
            Stage::Coding,
        )];

        // Write two records for the same story — costs should be summed.
        for cost in [0.10, 0.19] {
            let usage = crate::agents::TokenUsage {
                input_tokens: 50,
                output_tokens: 100,
                cache_creation_input_tokens: 0,
                cache_read_input_tokens: 0,
                total_cost_usd: cost,
            };
            let record = crate::agents::token_usage::build_record(
                "293_story_register_all_bot_commands",
                "coder-1",
                None,
                usage,
            );
            crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
        }

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            output.contains("293 [story] — Register all bot commands — $0.29"),
            "output must show aggregated cost: {output}"
        );
    }

    // -- dependency display in status output --------------------------------

    #[test]
    fn status_shows_waiting_on_for_story_with_unmet_deps() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        // Story 10 depends on story 999, which is NOT in all_items (treated as met)
        // OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet).
        let items = vec![
            make_item_with_deps(
                "10_story_waiting",
                "Waiting Story",
                Stage::Coding,
                vec![999],
            ),
            make_item("999_story_dep", "Dep Story", Stage::Backlog),
        ];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            output.contains("waiting on: 999"),
            "status should show waiting-on info for unmet deps: {output}"
        );
    }

    #[test]
    fn status_does_not_show_waiting_on_when_dep_is_done() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        // Dep 999 is in Done stage — met.
        let items = vec![
            make_item_with_deps(
                "10_story_unblocked",
                "Unblocked Story",
                Stage::Coding,
                vec![999],
            ),
            make_item(
                "999_story_dep",
                "Dep Story",
                Stage::Done {
                    merged_at: Utc::now(),
                    merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
                },
            ),
        ];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            !output.contains("waiting on"),
            "status should not show waiting-on when all deps are done: {output}"
        );
    }

    #[test]
    fn status_shows_no_waiting_info_when_no_deps() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        let items = vec![make_item("42_story_nodeps", "No Deps Story", Stage::Coding)];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            !output.contains("waiting on"),
            "status should not show waiting-on for stories without deps: {output}"
        );
    }

    // -- traffic_light_dot --------------------------------------------------

    #[test]
    fn dot_idle_when_no_agent() {
        assert_eq!(traffic_light_dot(false, false, false), "\u{26AA} "); // ⚪
    }

    #[test]
    fn dot_running_when_agent_not_throttled() {
        assert_eq!(traffic_light_dot(false, false, true), "\u{1F7E2} "); // 🟢
    }

    #[test]
    fn dot_throttled_when_agent_throttled() {
        assert_eq!(traffic_light_dot(false, true, true), "\u{1F7E0} "); // 🟠
    }

    #[test]
    fn dot_blocked_takes_priority_over_throttled() {
        assert_eq!(traffic_light_dot(true, true, true), "\u{1F534} "); // 🔴
    }

    #[test]
    fn dot_blocked_when_no_agent_but_blocked_flag() {
        assert_eq!(traffic_light_dot(true, false, false), "\u{1F534} "); // 🔴
    }

    // -- Stage::is_blocked() replaces read_story_blocked --------------------

    #[test]
    fn stage_is_blocked_returns_true_for_archived_blocked() {
        let stage = Stage::Archived {
            archived_at: Utc::now(),
            reason: ArchiveReason::Blocked {
                reason: "too many retries".to_string(),
            },
        };
        assert!(stage.is_blocked());
    }

    #[test]
    fn stage_is_blocked_returns_false_for_coding() {
        assert!(!Stage::Coding.is_blocked());
    }

    #[test]
    fn stage_is_blocked_returns_false_for_archived_completed() {
        let stage = Stage::Archived {
            archived_at: Utc::now(),
            reason: ArchiveReason::Completed,
        };
        assert!(!stage.is_blocked());
    }

    // -- status output shows idle dot for items with no active agent --------

    #[test]
    fn status_shows_idle_dot_for_unassigned_story() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        let items = vec![make_item("42_story_idle", "Idle Story", Stage::Coding)];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            output.contains("\u{26AA} "), // ⚪
            "idle story should show white circle emoji: {output}"
        );
    }

    #[test]
    fn status_shows_blocked_dot_for_blocked_story() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        let items = vec![make_item(
            "42_story_blocked",
            "Blocked Story",
            Stage::Archived {
                archived_at: Utc::now(),
                reason: ArchiveReason::Blocked {
                    reason: "retry limit exceeded".to_string(),
                },
            },
        )];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        assert!(
            output.contains("\u{1F534} "), // 🔴
            "blocked story should show red circle emoji: {output}"
        );
    }

    // -- Regression: CRDT-only story appears in correct stage ---------------

    #[test]
    fn status_shows_crdt_done_story_in_done_not_backlog() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();

        // Story is Done in the typed API — even if a stale 1_backlog/ shadow existed
        // on the filesystem, the status must show it in Done, not Backlog.
        let items = vec![make_item(
            "503_story_some_feature",
            "Some Feature",
            Stage::Done {
                merged_at: Utc::now(),
                merge_commit: crate::pipeline_state::GitSha("deadbeef".to_string()),
            },
        )];

        let agents = AgentPool::new_test(3000);
        let output = build_status_from_items(tmp.path(), &agents, &items);

        // Must appear under Done, not Backlog.
        let done_pos = output.find("**Done**").expect("Done section must exist");
        let backlog_pos = output
            .find("**Backlog**")
            .expect("Backlog section must exist");
        let story_pos = output
            .find("503 [story]")
            .expect("story must appear in output");

        assert!(
            story_pos > done_pos,
            "story should appear after Done header: backlog={backlog_pos} done={done_pos} story={story_pos}\n{output}"
        );
        assert!(
            story_pos > backlog_pos,
            "story should not be in the Backlog section: {output}"
        );

        // Verify it's not in Backlog section specifically.
        let backlog_section = &output[backlog_pos..done_pos];
        assert!(
            !backlog_section.contains("503"),
            "503 must not appear in Backlog section: {backlog_section}"
        );
    }
}
