//! Tests for the `status` command submodules.

use super::*;
use crate::agents::{AgentPool, AgentStatus};
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(),
    }
}

/// 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(),
    }
}

#[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 {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    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 {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    // 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();
    crate::service::agents::cost_rollup::init_from_disk(tmp.path());

    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 {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    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 {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    // 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();
    }
    crate::service::agents::cost_rollup::init_from_disk(tmp.path());

    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 {
                claim: None,
                plan: Default::default(),
                retries: 0,
            },
            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 {
                claim: None,
                plan: Default::default(),
                retries: 0,
            },
            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 {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    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!(matches!(
        stage,
        Stage::Blocked { .. }
            | Stage::MergeFailure { .. }
            | Stage::MergeFailureFinal { .. }
            | Stage::Archived {
                reason: ArchiveReason::Blocked { .. },
                ..
            }
    ));
}

#[test]
fn stage_is_blocked_returns_false_for_coding() {
    assert!(!matches!(
        Stage::Coding {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
        Stage::Blocked { .. }
            | Stage::MergeFailure { .. }
            | Stage::MergeFailureFinal { .. }
            | Stage::Archived {
                reason: ArchiveReason::Blocked { .. },
                ..
            }
    ));
}

#[test]
fn stage_is_blocked_returns_false_for_archived_completed() {
    let stage = Stage::Archived {
        archived_at: Utc::now(),
        reason: ArchiveReason::Completed,
    };
    assert!(!matches!(
        stage,
        Stage::Blocked { .. }
            | Stage::MergeFailure { .. }
            | Stage::MergeFailureFinal { .. }
            | Stage::Archived {
                reason: ArchiveReason::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 {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    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.get(backlog_pos..done_pos).unwrap_or("");
    assert!(
        !backlog_section.contains("503"),
        "503 must not appear in Backlog section: {backlog_section}"
    );
}

// -- merge-stage breakdown indicators ------------------------------------

fn merge_stage() -> Stage {
    use crate::pipeline_state::BranchName;
    Stage::Merge {
        feature_branch: BranchName("feature/test".to_string()),
        commits_ahead: std::num::NonZeroU32::new(1).unwrap(),
        claim: None,
        retries: 0,
        server_start_time: None,
    }
}

#[test]
fn merge_item_queued_shows_hourglass() {
    use tempfile::TempDir;
    let tmp = TempDir::new().unwrap();
    let items = vec![make_item(
        "901_story_merge_q",
        "Queued Story",
        merge_stage(),
    )];
    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(tmp.path(), &agents, &items);
    assert!(
        output.contains("\u{23F3}"), // ⏳
        "queued merge item should show hourglass: {output}"
    );
}

#[test]
fn merge_item_with_active_agent_shows_robot() {
    use tempfile::TempDir;
    let tmp = TempDir::new().unwrap();
    let items = vec![make_item(
        "902_story_merge_mm",
        "Mergemaster Story",
        merge_stage(),
    )];
    let agents = AgentPool::new_test(3000);
    agents.inject_test_agent("902_story_merge_mm", "mergemaster", AgentStatus::Running);
    let output = build_status_from_items(tmp.path(), &agents, &items);
    assert!(
        output.contains("\u{1F916}"), // 🤖
        "merge item with running agent should show robot: {output}"
    );
    assert!(
        output.contains("mergemaster running"),
        "output should contain 'mergemaster running': {output}"
    );
}

#[test]
fn merge_item_with_failure_shows_stop_sign_and_snippet() {
    use tempfile::TempDir;
    let tmp = TempDir::new().unwrap();
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    // Post-929: merge_failure detail lives on the MergeJob CRDT entry, not in YAML.
    crate::crdt_state::write_merge_job(
        "903_story_merge_fail",
        "failed",
        0.0,
        None,
        Some("conflicts in src/lib.rs"),
    );
    let items = vec![make_item(
        "903_story_merge_fail",
        "Failed Story",
        merge_stage(),
    )];
    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(tmp.path(), &agents, &items);
    assert!(
        output.contains("\u{26D4}"), // ⛔
        "merge item with failure should show stop sign: {output}"
    );
    assert!(
        output.contains("conflicts in src/lib.rs"),
        "output should contain failure snippet: {output}"
    );
}

#[test]
fn merge_item_failure_snippet_truncated_at_120_chars() {
    use tempfile::TempDir;
    let tmp = TempDir::new().unwrap();
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    let long_reason = "x".repeat(200);
    // Post-929: merge_failure detail lives on the MergeJob CRDT entry, not in YAML.
    crate::crdt_state::write_merge_job(
        "904_story_long_fail",
        "failed",
        0.0,
        None,
        Some(&long_reason),
    );
    let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())];
    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(tmp.path(), &agents, &items);
    assert!(
        output.contains("…"),
        "long failure should be truncated with ellipsis: {output}"
    );
    // The snippet should not exceed 120 chars plus the ellipsis character.
    let snippet_start = output.find("\u{26D4}").expect("stop sign must be present");
    let line = output
        .get(snippet_start..)
        .unwrap_or("")
        .lines()
        .next()
        .unwrap_or("");
    // Find the last " — " separator (before the snippet) and take what follows.
    if let Some(sep_pos) = line.rfind(" \u{2014} ") {
        // " — " is 5 bytes (space + 3-byte em dash + space)
        let snippet = line.get(sep_pos + 5..).unwrap_or("");
        assert!(
            snippet.chars().count() <= 122, // 120 chars + "…" (1 char) + possible trailing
            "snippet should be at most ~121 chars: {snippet}"
        );
    }
}

#[test]
fn merge_item_failure_snippet_is_first_non_empty_line() {
    use tempfile::TempDir;
    let tmp = TempDir::new().unwrap();
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();
    // Multi-line failure reason — first non-empty line should be used.
    // Post-929: merge_failure detail lives on the MergeJob CRDT entry.
    crate::crdt_state::write_merge_job(
        "905_story_multiline",
        "failed",
        0.0,
        None,
        Some("\nfirst line of error\nsecond line"),
    );
    let items = vec![make_item(
        "905_story_multiline",
        "Multi Line",
        merge_stage(),
    )];
    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(tmp.path(), &agents, &items);
    assert!(
        output.contains("first line of error"),
        "snippet should use first non-empty line: {output}"
    );
    assert!(
        !output.contains("second line"),
        "snippet should not include second line: {output}"
    );
}

#[test]
fn merge_item_det_merge_running_preferred_over_failure() {
    use tempfile::TempDir;
    let tmp = TempDir::new().unwrap();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "906_story_det_over_fail",
        "4_merge",
        "---\nname: Det Over Fail\n---\n",
        crate::db::ItemMeta::named("Det Over Fail"),
    );
    // Record a running deterministic merge in the CRDT.
    crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None);
    let items = vec![make_item(
        "906_story_det_over_fail",
        "Det Over Fail",
        merge_stage(),
    )];
    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(tmp.path(), &agents, &items);
    assert!(
        output.contains("\u{1F504}"), // 🔄
        "deterministic merge running should be preferred over failure: {output}"
    );
    assert!(
        !output.contains("\u{26D4}"), // ⛔ should not appear
        "stop sign should not appear when det merge is running: {output}"
    );
}

// -- first_non_empty_snippet unit tests ---------------------------------

#[test]
fn snippet_empty_text_returns_empty() {
    assert_eq!(first_non_empty_snippet("", 120), "");
}

#[test]
fn snippet_short_text_returned_as_is() {
    assert_eq!(first_non_empty_snippet("short error", 120), "short error");
}

#[test]
fn snippet_long_text_truncated_with_ellipsis() {
    let text = "a".repeat(200);
    let result = first_non_empty_snippet(&text, 120);
    assert!(result.ends_with('…'), "should end with ellipsis: {result}");
    assert_eq!(
        result.chars().count(),
        121,
        "should be 120 + ellipsis: {result}"
    );
}

#[test]
fn snippet_skips_leading_empty_lines() {
    let text = "\n\n  \nactual error here\nsecond line";
    let result = first_non_empty_snippet(text, 120);
    assert_eq!(result, "actual error here");
}

// -- AC1: blocked items appear in-place, not in a separate "Blocked" section --

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

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

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

    // Must NOT appear under a separate "Blocked" section.
    assert!(
        !output.contains("**Blocked**"),
        "output must not have a separate Blocked section: {output}"
    );

    // Must appear under "In Progress".
    let in_progress_pos = output
        .find("**In Progress**")
        .expect("In Progress section must exist");
    let qa_pos = output.find("**QA**").expect("QA section must exist");
    let story_pos = output
        .find("42 [story]")
        .expect("story must appear in output");

    assert!(
        story_pos > in_progress_pos && story_pos < qa_pos,
        "blocked story should be in In Progress section: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}"
    );
}

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

    let items = vec![make_item(
        "50_story_blocked_dot",
        "Blocked With Dot",
        Stage::Blocked {
            reason: "too many failures".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}"
    );
}

// -- AC2: stage counts include blocked items --

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

    let items = vec![
        make_item(
            "10_story_coding",
            "Coding Story",
            Stage::Coding {
                claim: None,
                plan: Default::default(),
                retries: 0,
            },
        ),
        make_item(
            "11_story_blocked",
            "Blocked Story",
            Stage::Blocked {
                reason: "failed".to_string(),
            },
        ),
    ];

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

    // "In Progress" header should show count of 2 (one coding + one blocked).
    assert!(
        output.contains("**In Progress** (2)"),
        "In Progress count should include blocked items: {output}"
    );
}

// -- AC4: frozen items appear in-place, with ❄️ indicator --

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

    let items = vec![make_item(
        "60_story_frozen",
        "Frozen Coding Story",
        Stage::Frozen {
            resume_to: Box::new(Stage::Coding {
                claim: None,
                plan: Default::default(),
                retries: 0,
            }),
        },
    )];

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

    let in_progress_pos = output
        .find("**In Progress**")
        .expect("In Progress section must exist");
    let qa_pos = output.find("**QA**").expect("QA section must exist");
    let story_pos = output
        .find("60 [story]")
        .expect("story must appear in output");

    assert!(
        story_pos > in_progress_pos && story_pos < qa_pos,
        "frozen-coding story should appear in In Progress: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}"
    );
}

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

    let items = vec![make_item(
        "70_story_frozen_qa",
        "Frozen QA Story",
        Stage::Frozen {
            resume_to: Box::new(Stage::Qa),
        },
    )];

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

    let qa_pos = output.find("**QA**").expect("QA section must exist");
    let merge_pos = output.find("**Merge**").expect("Merge section must exist");
    let story_pos = output
        .find("70 [story]")
        .expect("story must appear in output");

    assert!(
        story_pos > qa_pos && story_pos < merge_pos,
        "frozen-QA story should appear in QA section: qa={qa_pos} story={story_pos} merge={merge_pos}\n{output}"
    );
}

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

    let items = vec![make_item(
        "80_story_frozen_flake",
        "Frozen Flake Story",
        Stage::Frozen {
            resume_to: Box::new(Stage::Coding {
                claim: None,
                plan: Default::default(),
                retries: 0,
            }),
        },
    )];

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

    assert!(
        output.contains("\u{2744}\u{FE0F}"), // ❄️
        "frozen story should show snowflake prefix: {output}"
    );
}

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

    let items = vec![
        make_item(
            "90_story_blocked_ind",
            "Blocked Story",
            Stage::Blocked {
                reason: "failed".to_string(),
            },
        ),
        make_item(
            "91_story_frozen_ind",
            "Frozen Story",
            Stage::Frozen {
                resume_to: Box::new(Stage::Coding {
                    claim: None,
                    plan: Default::default(),
                    retries: 0,
                }),
            },
        ),
    ];

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

    assert!(
        output.contains("\u{1F534} "), // 🔴 for blocked
        "blocked story should show red dot: {output}"
    );
    assert!(
        output.contains("\u{2744}\u{FE0F}"), // ❄️ for frozen
        "frozen story should show snowflake: {output}"
    );
}

// -- merge-failure items appear in Merge section --

#[test]
fn merge_failure_item_appears_in_merge_section_not_blocked() {
    use crate::pipeline_state::BranchName;
    use tempfile::TempDir;
    let tmp = TempDir::new().unwrap();

    let items = vec![make_item(
        "100_story_merge_fail",
        "Merge Failure Story",
        Stage::MergeFailure {
            kind: crate::pipeline_state::MergeFailureKind::ConflictDetected(Some(
                "conflict in lib.rs".to_string(),
            )),
            feature_branch: BranchName("feature/100".to_string()),
            commits_ahead: std::num::NonZeroU32::new(1).unwrap(),
        },
    )];

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

    // Must not be in a separate "Blocked" section.
    assert!(
        !output.contains("**Blocked**"),
        "output must not have a separate Blocked section: {output}"
    );

    let merge_pos = output.find("**Merge**").expect("Merge section must exist");
    let done_pos = output.find("**Done**").expect("Done section must exist");
    let story_pos = output
        .find("100 [story]")
        .expect("story must appear in output");

    assert!(
        story_pos > merge_pos && story_pos < done_pos,
        "merge-failure story should appear in Merge section: merge={merge_pos} story={story_pos} done={done_pos}\n{output}"
    );

    assert!(
        output.contains("\u{26D4}"), // ⛔
        "merge failure should show stop sign: {output}"
    );
    assert!(
        output.contains("conflict in lib.rs"),
        "merge failure reason should be shown: {output}"
    );
}

// -- Story 984: Abandoned / Superseded / Rejected appear in Closed section ----

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

    let items = vec![make_item(
        "984_story_abandoned",
        "Abandoned Story",
        Stage::Abandoned { ts: Utc::now() },
    )];

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

    assert!(
        output.contains("**Closed**"),
        "output must have a Closed section: {output}"
    );
    let closed_pos = output.find("**Closed**").unwrap();
    let story_pos = output
        .find("984 [story]")
        .expect("story must appear in output");
    assert!(
        story_pos > closed_pos,
        "abandoned story should be after Closed header: {output}"
    );
    assert!(
        output.contains("\u{1F5D1}\u{FE0F}"), // 🗑️
        "abandoned story should show wastebasket icon: {output}"
    );
}

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

    let items = vec![make_item(
        "985_story_superseded",
        "Superseded Story",
        Stage::Superseded {
            ts: Utc::now(),
            superseded_by: crate::pipeline_state::StoryId("999_story_new".to_string()),
        },
    )];

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

    let closed_pos = output
        .find("**Closed**")
        .expect("Closed section must exist");
    let story_pos = output
        .find("985 [story]")
        .expect("story must appear in output");
    assert!(story_pos > closed_pos, "superseded story must be in Closed");
    assert!(
        output.contains("\u{1F500}"), // 🔀
        "superseded story should show shuffle icon: {output}"
    );
    assert!(
        output.contains("999_story_new"),
        "superseded story should show the replacement ID: {output}"
    );
}

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

    let items = vec![make_item(
        "986_story_rejected",
        "Rejected Story",
        Stage::Rejected {
            ts: Utc::now(),
            reason: "not aligned with roadmap".to_string(),
        },
    )];

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

    let closed_pos = output
        .find("**Closed**")
        .expect("Closed section must exist");
    let story_pos = output
        .find("986 [story]")
        .expect("story must appear in output");
    assert!(story_pos > closed_pos, "rejected story must be in Closed");
    assert!(
        output.contains("\u{1F6AB}"), // 🚫
        "rejected story should show no-entry icon: {output}"
    );
    assert!(
        output.contains("not aligned with roadmap"),
        "rejected story should show the rejection reason: {output}"
    );
}

#[test]
fn display_section_returns_closed_for_new_terminal_variants() {
    assert_eq!(
        display_section(&Stage::Abandoned { ts: Utc::now() }),
        Some("Closed")
    );
    assert_eq!(
        display_section(&Stage::Superseded {
            ts: Utc::now(),
            superseded_by: crate::pipeline_state::StoryId("1".to_string()),
        }),
        Some("Closed")
    );
    assert_eq!(
        display_section(&Stage::Rejected {
            ts: Utc::now(),
            reason: "x".to_string(),
        }),
        Some("Closed")
    );
}

// -- Story 1029: working tree dirty-files summary in status output -------

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

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

    let tmp = TempDir::new().unwrap();
    let project_root = tmp.path();
    let story_id = "1029_story_dirty_worktree";

    // Set up a fake worktree with committed + uncommitted changes.
    let wt_path = project_root
        .join(".huskies")
        .join("worktrees")
        .join(story_id);
    fs::create_dir_all(&wt_path).unwrap();
    init_git_repo(&wt_path);

    // Modify a tracked file (unstaged) and add an untracked file.
    fs::write(wt_path.join("README.md"), "# changed").unwrap();
    fs::write(wt_path.join("new_feature.rs"), "fn foo() {}").unwrap();

    let items = vec![make_item(
        story_id,
        "Dirty Worktree Story",
        Stage::Coding {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(project_root, &agents, &items);

    assert!(
        output.contains("Working tree:"),
        "status should show working tree summary when changes exist: {output}"
    );
    assert!(
        output.contains("(uncommitted)"),
        "working tree line should say '(uncommitted)': {output}"
    );
    // Overview must NOT list individual file paths — those belong in `status N`.
    assert!(
        !output.contains("README.md") && !output.contains("new_feature.rs"),
        "overview must not list individual dirty file paths: {output}"
    );
}

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

    let tmp = TempDir::new().unwrap();
    let project_root = tmp.path();
    let story_id = "1029_story_clean_worktree";

    // Set up a clean worktree with only committed changes.
    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 items = vec![make_item(
        story_id,
        "Clean Worktree Story",
        Stage::Coding {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(project_root, &agents, &items);

    assert!(
        !output.contains("Working tree:"),
        "status should not show working tree line when working tree is clean: {output}"
    );
}

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

    let tmp = TempDir::new().unwrap();
    let project_root = tmp.path();
    let story_id = "1029_story_many_files";

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

    // Create 25 untracked files.
    for i in 0..25 {
        fs::write(wt_path.join(format!("file_{i:02}.rs")), "fn f() {}").unwrap();
    }

    let items = vec![make_item(
        story_id,
        "Many Files Story",
        Stage::Coding {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
    )];

    let agents = AgentPool::new_test(3000);
    let output = build_status_from_items(project_root, &agents, &items);

    // Overview shows only the summary line — no path listing, no overflow.
    assert!(
        !output.contains("...and 5 more"),
        "overview must not show overflow line — paths belong in triage view: {output}"
    );
    assert!(
        output.contains("Working tree:") && output.contains("(uncommitted)"),
        "overview should still show the summary line when dirty: {output}"
    );
}
