//! Handler for the `unblock` command.
//!
//! `{bot_name} unblock {number}` finds the blocked work item by number across
//! all pipeline stages, clears the `blocked` flag, resets `retry_count` to 0,
//! and returns a confirmation.

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

/// Handle the `unblock` command.
///
/// Parses `<number>` from `ctx.args`, locates the work item, checks that it is
/// blocked, clears the `blocked` flag, resets `retry_count` to 0, and returns
/// a Markdown confirmation string.
pub(super) fn handle_unblock(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;
    }

    Some(unblock_by_number(ctx.effective_root(), num_str))
}

/// Core unblock logic: find story by numeric prefix and reset its blocked state.
///
/// Returns a Markdown-formatted response string suitable for all transports.
/// Also used by the MCP `unblock` tool.
///
/// Lookup priority: CRDT → content store.
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
    let (story_id, _, _, _) =
        match crate::chat::lookup::find_story_by_number(project_root, story_number) {
            Some(found) => found,
            None => {
                return format!("No story, bug, or spike with number **{story_number}** found.");
            }
        };

    unblock_by_story_id(&story_id)
}

/// Unblock a story via the typed state machine.
///
/// Checks whether the story is in `Stage::Blocked` (new path) or has legacy
/// `blocked: true` / `merge_failure` front-matter, then routes through
/// [`crate::agents::lifecycle::transition_to_unblocked`].
fn unblock_by_story_id(story_id: &str) -> String {
    // Post-865, story content is CRDT-only (no YAML front matter on disk or in
    // the content store). Read the story name and blocked state from CRDT
    // registers; do NOT call `parse_front_matter` on the content.
    let crdt_item = crate::crdt_state::read_item(story_id);
    let story_name = crdt_item
        .as_ref()
        .map(|i| i.name().to_string())
        .unwrap_or_else(|| story_id.to_string());

    // Story 945: `Stage::Blocked` / `Stage::MergeFailure` are the single
    // source of truth — the legacy `blocked` boolean flag is gone.
    let typed_item = crate::pipeline_state::read_typed(story_id).ok().flatten();
    let typed_blocked = typed_item.as_ref().is_some_and(|item| {
        matches!(
            item.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 { .. },
                    ..
                }
        )
    });
    let typed_merge_failure = matches!(
        typed_item.as_ref().map(|i| &i.stage),
        Some(crate::pipeline_state::Stage::MergeFailure { .. })
    );

    if !typed_blocked {
        return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
    }

    // Route through the state machine. This clears blocked/merge_failure/
    // retry_count via `fields_to_clear_transform` and resets retry_count in
    // the CRDT.
    if let Err(e) = crate::agents::lifecycle::transition_to_unblocked(story_id) {
        // If the typed transition fails (e.g. a legacy Archived item with no
        // valid `Unblock` transition out of its current stage), at least
        // reset retry_count directly in the CRDT so the agent doesn't stay
        // tagged with a stale fail counter. Post-929 there's no FS shadow
        // to clean up alongside.
        crate::slog_warn!(
            "[unblock] State-machine transition failed for '{story_id}': {e}. \
             Falling back to direct CRDT retry_count reset."
        );
        crate::crdt_state::set_retry_count(story_id, 0);
    }

    let cleared = if typed_merge_failure {
        "merge_failure"
    } else {
        "blocked"
    };
    format!("Unblocked **{story_name}** ({story_id}). Cleared: {cleared}. Retry count reset to 0.")
}

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

#[cfg(test)]
mod tests {
    use super::super::{CommandDispatch, try_handle_command};

    fn unblock_cmd_with_root(root: &std::path::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 unblock {args}"))
    }

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

    #[test]
    fn unblock_command_is_registered() {
        use super::super::commands;
        assert!(
            commands().iter().any(|c| c.name == "unblock"),
            "unblock command must be in the registry"
        );
    }

    #[test]
    fn unblock_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("unblock"),
            "help should list unblock command: {output}"
        );
    }

    #[test]
    fn unblock_command_no_args_routes_to_timmy() {
        let tmp = tempfile::TempDir::new().unwrap();
        let result = unblock_cmd_with_root(tmp.path(), "");
        assert!(
            result.is_none(),
            "no args should route to LLM (None), not return a usage error: {result:?}"
        );
    }

    #[test]
    fn unblock_command_non_numeric_routes_to_timmy() {
        let tmp = tempfile::TempDir::new().unwrap();
        let result = unblock_cmd_with_root(tmp.path(), "to fix the blocking issue");
        assert!(
            result.is_none(),
            "natural-language args should route to LLM (None): {result:?}"
        );
    }

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

    #[test]
    fn unblock_command_not_found_returns_error() {
        let tmp = tempfile::TempDir::new().unwrap();
        let output = unblock_cmd_with_root(tmp.path(), "999").unwrap();
        assert!(
            output.contains("999") && output.contains("found"),
            "not-found message should include number and 'found': {output}"
        );
    }

    #[test]
    fn unblock_command_not_blocked_returns_error() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "42_story_test.md",
            "---\nname: Test Story\nretry_count: 2\n---\n# Story\n",
            None,
        );
        let output = unblock_cmd_with_root(tmp.path(), "42").unwrap();
        assert!(
            output.contains("not blocked"),
            "non-blocked story should return not-blocked error: {output}"
        );
    }

    #[test]
    fn unblock_command_clears_blocked_and_resets_retry_count() {
        crate::crdt_state::init_for_test();
        let tmp = tempfile::TempDir::new().unwrap();
        // Use a high story number (9903) to avoid collisions with other tests in the
        // global content store.
        write_story_file(
            tmp.path(),
            "2_blocked",
            "9903_story_stuck.md",
            "---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n",
            None,
        );
        // Seed the story in the CRDT in 2_blocked stage so the typed
        // Blocked → Coding transition fires and clears `blocked` properly.
        crate::crdt_state::write_item_str(
            "9903_story_stuck",
            "2_blocked",
            Some("Stuck Story"),
            None,
            None,
            None,
        );

        let output = unblock_cmd_with_root(tmp.path(), "9903").unwrap();
        assert!(
            output.contains("Unblocked") && output.contains("Stuck Story"),
            "should confirm unblock with story name: {output}"
        );
        assert!(
            output.contains("9903_story_stuck"),
            "should include story_id in response: {output}"
        );

        // Post-929: the CRDT is the sole source of truth; we no longer clean
        // YAML front matter from on-disk content. Verify the CRDT registers
        // were updated correctly.
        let item = crate::crdt_state::read_item("9903_story_stuck")
            .expect("story should be in CRDT after unblock");
        assert_eq!(
            item.retry_count(),
            0,
            "retry_count should be reset to 0 in CRDT after unblock"
        );
        // Story 945: `Stage::Blocked` was the source of truth; after unblock
        // the stage must have transitioned out of `Blocked`.
        assert!(
            !matches!(
                item.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 { .. },
                        ..
                    }
            ),
            "stage must no longer be Blocked after unblock"
        );
    }

    #[test]
    fn unblock_command_works_on_crdt_only_story_no_yaml() {
        // Bug 901 regression: post-865, story content is CRDT-only with no
        // YAML front matter on disk or in the content store. unblock_story
        // used to fail here with "Missing front matter" because the legacy
        // code parsed YAML before consulting the CRDT.
        crate::crdt_state::init_for_test();
        crate::db::ensure_content_store();
        let tmp = tempfile::TempDir::new().unwrap();

        // CRDT-only content: pure markdown body, no `---` block. Matches the
        // post-865 on-disk shape.
        let body = "# Stuck Story\n\nNo YAML front matter — this is the post-865 shape.\n";
        let story_id = "9904_story_crdt_only";
        // Canonical post-865 blocked story: stage is `2_blocked` (typed
        // Stage::Blocked), so transition_to_unblocked can fire the proper
        // Blocked → Coding state-machine transition.
        let stage = "2_blocked";

        // Seed content store with the YAML-less body so find_story_by_number's
        // content-store path returns it.
        crate::db::write_item_with_content(
            story_id,
            stage,
            body,
            crate::db::ItemMeta::named("Stuck Story"),
        );
        // Seed CRDT registers: blocked=true, retry_count=5, with a name so the
        // response can echo it back instead of falling through to the raw id.
        crate::crdt_state::write_item_str(story_id, stage, Some("Stuck Story"), None, None, None);

        let output = unblock_cmd_with_root(tmp.path(), "9904").unwrap();

        assert!(
            !output.contains("Missing front matter")
                && !output.contains("Failed to parse front matter"),
            "must not error on YAML parse for CRDT-only stories: {output}"
        );
        assert!(
            output.contains("Unblocked") && output.contains("Stuck Story"),
            "should confirm unblock with story name: {output}"
        );

        let item = crate::crdt_state::read_item(story_id)
            .expect("story should still be in CRDT after unblock");
        assert_eq!(
            item.retry_count(),
            0,
            "retry_count must be reset to 0 in CRDT after unblock"
        );
        // Story 945: `Stage::Blocked` was the source of truth; after unblock
        // the stage must have transitioned out of `Blocked`.
        assert!(
            !matches!(
                item.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 { .. },
                        ..
                    }
            ),
            "stage must no longer be Blocked after unblock"
        );
    }

    #[test]
    fn unblock_command_finds_story_in_any_stage() {
        let tmp = tempfile::TempDir::new().unwrap();
        write_story_file(
            tmp.path(),
            "3_qa",
            "9901_story_in_qa.md",
            "# Story\n",
            Some("In QA"),
        );
        // Story 945: blocked is now `Stage::Blocked` — seed via CRDT stage.
        crate::crdt_state::write_item_str(
            "9901_story_in_qa",
            "blocked",
            Some("In QA"),
            None,
            None,
            None,
        );

        let output = unblock_cmd_with_root(tmp.path(), "9901").unwrap();
        assert!(
            output.contains("Unblocked"),
            "should unblock story in qa stage: {output}"
        );
    }

    #[test]
    fn unblock_command_includes_story_id_in_response() {
        let tmp = tempfile::TempDir::new().unwrap();
        // Use a high story number (9902) to avoid collisions with other tests in the
        // global content store.
        write_story_file(
            tmp.path(),
            "1_backlog",
            "9902_story_blocked_one.md",
            "---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n",
            None,
        );

        let output = unblock_cmd_with_root(tmp.path(), "9902").unwrap();
        assert!(
            output.contains("9902_story_blocked_one"),
            "response should include story_id: {output}"
        );
    }
}
