use crate::agents::move_story_to_stage;
use crate::http::context::AppContext;
use crate::log_buffer;
use crate::slog;
use crate::slog_warn;
use serde_json::{Value, json};
use std::fs;

pub(super) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
    let lines_count = args
        .get("lines")
        .and_then(|v| v.as_u64())
        .map(|n| n.min(1000) as usize)
        .unwrap_or(100);
    let filter = args.get("filter").and_then(|v| v.as_str());
    let severity = args
        .get("severity")
        .and_then(|v| v.as_str())
        .and_then(log_buffer::LogLevel::from_str_ci);

    let recent = log_buffer::global().get_recent(lines_count, filter, severity.as_ref());
    let joined = recent.join("\n");
    // Clamp to lines_count actual lines in case any entry contains embedded newlines.
    let all_lines: Vec<&str> = joined.lines().collect();
    let start = all_lines.len().saturating_sub(lines_count);
    Ok(all_lines[start..].join("\n"))
}

/// Rebuild the server binary and re-exec (delegates to `crate::rebuild`).
pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
    slog!("[rebuild] Rebuild and restart requested via MCP tool");

    // Signal the Matrix bot (if active) so it can send its own shutdown
    // announcement before the process is replaced.  Best-effort: we wait up
    // to 1.5 s for the message to be delivered.
    if let Some(ref tx) = ctx.matrix_shutdown_tx {
        let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild));
        tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
    }

    let project_root = ctx.state.get_project_root().unwrap_or_default();
    let notifier = ctx.bot_shutdown.as_deref();
    crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root, notifier).await
}

/// Generate a Claude Code permission rule string for the given tool name and input.
///
/// - `Edit` / `Write` / `Read` / `Grep` / `Glob` etc. → just the tool name
/// - `Bash` → `Bash(first_word *)` derived from the `command` field in `tool_input`
/// - `mcp__*` → the full tool name (e.g. `mcp__storkit__create_story`)
fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
    if tool_name == "Bash" {
        // Extract command from tool_input.command and use first word as prefix
        let command_str = tool_input
            .get("command")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let first_word = command_str.split_whitespace().next().unwrap_or("unknown");
        format!("Bash({first_word} *)")
    } else {
        // For Edit, Write, Read, Glob, Grep, MCP tools, etc. — use the tool name directly
        tool_name.to_string()
    }
}

/// Add a permission rule to `.claude/settings.json` in the project root.
/// Does nothing if the rule already exists. Creates the file if missing.
pub(super) fn add_permission_rule(
    project_root: &std::path::Path,
    rule: &str,
) -> Result<(), String> {
    let claude_dir = project_root.join(".claude");
    fs::create_dir_all(&claude_dir)
        .map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;

    let settings_path = claude_dir.join("settings.json");
    let mut settings: Value = if settings_path.exists() {
        let content = fs::read_to_string(&settings_path)
            .map_err(|e| format!("Failed to read settings.json: {e}"))?;
        serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))?
    } else {
        json!({ "permissions": { "allow": [] } })
    };

    let allow_arr = settings
        .pointer_mut("/permissions/allow")
        .and_then(|v| v.as_array_mut());

    let allow = match allow_arr {
        Some(arr) => arr,
        None => {
            // Ensure the structure exists
            settings
                .as_object_mut()
                .unwrap()
                .entry("permissions")
                .or_insert(json!({ "allow": [] }));
            settings
                .pointer_mut("/permissions/allow")
                .unwrap()
                .as_array_mut()
                .unwrap()
        }
    };

    // Check for duplicates — exact string match
    let rule_value = Value::String(rule.to_string());
    if allow.contains(&rule_value) {
        return Ok(());
    }

    // Also check for wildcard coverage: if "mcp__storkit__*" exists, don't add
    // a more specific "mcp__storkit__create_story".
    let dominated = allow.iter().any(|existing| {
        if let Some(pat) = existing.as_str()
            && let Some(prefix) = pat.strip_suffix('*')
        {
            return rule.starts_with(prefix);
        }
        false
    });
    if dominated {
        return Ok(());
    }

    allow.push(rule_value);

    let pretty =
        serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
    fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?;
    Ok(())
}

/// MCP tool called by Claude Code via `--permission-prompt-tool`.
///
/// Forwards the permission request through the shared channel to the active
/// WebSocket session, which presents a dialog to the user. Blocks until the
/// user approves or denies (with a 5-minute timeout).
pub(super) async fn tool_prompt_permission(
    args: &Value,
    ctx: &AppContext,
) -> Result<String, String> {
    let tool_name = args
        .get("tool_name")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();
    let tool_input = args.get("input").cloned().unwrap_or(json!({}));

    let request_id = uuid::Uuid::new_v4().to_string();
    let (response_tx, response_rx) = tokio::sync::oneshot::channel();

    ctx.perm_tx
        .send(crate::http::context::PermissionForward {
            request_id: request_id.clone(),
            tool_name: tool_name.clone(),
            tool_input: tool_input.clone(),
            response_tx,
        })
        .map_err(|_| "No active WebSocket session to receive permission request".to_string())?;

    use crate::http::context::PermissionDecision;

    let decision = tokio::time::timeout(std::time::Duration::from_secs(300), response_rx)
        .await
        .map_err(|_| {
            let msg = format!("Permission request for '{tool_name}' timed out after 5 minutes");
            slog_warn!("[permission] {msg}");
            msg
        })?
        .map_err(|_| "Permission response channel closed unexpectedly".to_string())?;

    if decision == PermissionDecision::AlwaysAllow {
        // Persist the rule so Claude Code won't prompt again for this tool.
        if let Some(root) = ctx.state.project_root.lock().unwrap().clone() {
            let rule = generate_permission_rule(&tool_name, &tool_input);
            if let Err(e) = add_permission_rule(&root, &rule) {
                slog_warn!("[permission] Failed to write always-allow rule: {e}");
            } else {
                slog!("[permission] Added always-allow rule: {rule}");
            }
        }
    }

    if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
        // Claude Code SDK expects:
        //   Allow:  { behavior: "allow", updatedInput: <record> }
        //   Deny:   { behavior: "deny", message: string }
        Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string())
    } else {
        slog_warn!("[permission] User denied permission for '{tool_name}'");
        Ok(json!({
            "behavior": "deny",
            "message": format!("User denied permission for '{tool_name}'")
        })
        .to_string())
    }
}

pub(super) fn tool_get_token_usage(args: &Value, ctx: &AppContext) -> Result<String, String> {
    let root = ctx.state.get_project_root()?;
    let filter_story = args.get("story_id").and_then(|v| v.as_str());

    let all_records = crate::agents::token_usage::read_all(&root)?;
    let records: Vec<_> = all_records
        .into_iter()
        .filter(|r| filter_story.is_none_or(|s| r.story_id == s))
        .collect();

    let total_cost: f64 = records.iter().map(|r| r.usage.total_cost_usd).sum();
    let total_input: u64 = records.iter().map(|r| r.usage.input_tokens).sum();
    let total_output: u64 = records.iter().map(|r| r.usage.output_tokens).sum();
    let total_cache_create: u64 = records
        .iter()
        .map(|r| r.usage.cache_creation_input_tokens)
        .sum();
    let total_cache_read: u64 = records
        .iter()
        .map(|r| r.usage.cache_read_input_tokens)
        .sum();

    serde_json::to_string_pretty(&json!({
        "records": records.iter().map(|r| json!({
            "story_id": r.story_id,
            "agent_name": r.agent_name,
            "timestamp": r.timestamp,
            "input_tokens": r.usage.input_tokens,
            "output_tokens": r.usage.output_tokens,
            "cache_creation_input_tokens": r.usage.cache_creation_input_tokens,
            "cache_read_input_tokens": r.usage.cache_read_input_tokens,
            "total_cost_usd": r.usage.total_cost_usd,
        })).collect::<Vec<_>>(),
        "totals": {
            "records": records.len(),
            "input_tokens": total_input,
            "output_tokens": total_output,
            "cache_creation_input_tokens": total_cache_create,
            "cache_read_input_tokens": total_cache_read,
            "total_cost_usd": total_cost,
        }
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
    let story_id = args
        .get("story_id")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: story_id")?;
    let target_stage = args
        .get("target_stage")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: target_stage")?;

    let project_root = ctx.agents.get_project_root(&ctx.state)?;

    let (from_stage, to_stage) = move_story_to_stage(&project_root, story_id, target_stage)?;

    serde_json::to_string_pretty(&json!({
        "story_id": story_id,
        "from_stage": from_stage,
        "to_stage": to_stage,
        "message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.")
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// MCP tool: count lines in a specific file relative to the project root.
pub(super) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, String> {
    let file_path = args
        .get("file_path")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "Missing required argument: file_path".to_string())?;

    let project_root = ctx.state.get_project_root()?;
    Ok(crate::chat::commands::loc::loc_single_file(&project_root, file_path))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::http::context::AppContext;

    fn test_ctx(dir: &std::path::Path) -> AppContext {
        AppContext::new_test(dir.to_path_buf())
    }

    #[test]
    fn tool_get_server_logs_no_args_returns_string() {
        let result = tool_get_server_logs(&json!({})).unwrap();
        // Returns recent log lines (possibly empty in tests) — just verify no panic
        let _ = result;
    }

    #[test]
    fn tool_get_server_logs_with_filter_returns_matching_lines() {
        let result = tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap();
        assert_eq!(
            result, "",
            "filter with no matches should return empty string"
        );
    }

    #[test]
    fn tool_get_server_logs_with_line_limit() {
        let result = tool_get_server_logs(&json!({"lines": 5})).unwrap();
        assert!(result.lines().count() <= 5);
    }

    #[test]
    fn tool_get_server_logs_max_cap_is_1000() {
        // Lines > 1000 are capped — just verify it returns without error
        let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap();
        let _ = result;
    }

    #[test]
    fn tool_get_token_usage_empty_returns_zero_totals() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["records"].as_array().unwrap().len(), 0);
        assert_eq!(parsed["totals"]["records"], 0);
        assert_eq!(parsed["totals"]["total_cost_usd"], 0.0);
    }

    #[test]
    fn tool_get_token_usage_returns_written_records() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let ctx = test_ctx(root);

        let usage = crate::agents::TokenUsage {
            input_tokens: 100,
            output_tokens: 200,
            cache_creation_input_tokens: 5000,
            cache_read_input_tokens: 10000,
            total_cost_usd: 1.57,
        };
        let record =
            crate::agents::token_usage::build_record("42_story_foo", "coder-1", None, usage);
        crate::agents::token_usage::append_record(root, &record).unwrap();

        let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
        assert_eq!(parsed["records"][0]["story_id"], "42_story_foo");
        assert_eq!(parsed["records"][0]["agent_name"], "coder-1");
        assert_eq!(parsed["records"][0]["input_tokens"], 100);
        assert_eq!(parsed["totals"]["records"], 1);
        assert!((parsed["totals"]["total_cost_usd"].as_f64().unwrap() - 1.57).abs() < f64::EPSILON);
    }

    #[test]
    fn tool_get_token_usage_filters_by_story_id() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let ctx = test_ctx(root);

        let usage = crate::agents::TokenUsage {
            input_tokens: 50,
            output_tokens: 60,
            cache_creation_input_tokens: 0,
            cache_read_input_tokens: 0,
            total_cost_usd: 0.5,
        };
        let r1 =
            crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone());
        let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", None, usage);
        crate::agents::token_usage::append_record(root, &r1).unwrap();
        crate::agents::token_usage::append_record(root, &r2).unwrap();

        let result = tool_get_token_usage(&json!({"story_id": "10_story_a"}), &ctx).unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
        assert_eq!(parsed["records"][0]["story_id"], "10_story_a");
        assert_eq!(parsed["totals"]["records"], 1);
    }

    #[tokio::test]
    async fn tool_prompt_permission_approved_returns_updated_input() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());

        // Spawn a task that immediately sends approval through the channel.
        let perm_rx = ctx.perm_rx.clone();
        tokio::spawn(async move {
            let mut rx = perm_rx.lock().await;
            if let Some(forward) = rx.recv().await {
                let _ = forward
                    .response_tx
                    .send(crate::http::context::PermissionDecision::Approve);
            }
        });

        let result = tool_prompt_permission(
            &json!({"tool_name": "Bash", "input": {"command": "echo hello"}}),
            &ctx,
        )
        .await
        .expect("should succeed on approval");

        let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
        assert_eq!(
            parsed["behavior"], "allow",
            "approved must return behavior:allow"
        );
        assert_eq!(
            parsed["updatedInput"]["command"], "echo hello",
            "approved must return updatedInput with original tool input for Claude Code SDK compatibility"
        );
    }

    #[tokio::test]
    async fn tool_prompt_permission_denied_returns_deny_json() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());

        // Spawn a task that immediately sends denial through the channel.
        let perm_rx = ctx.perm_rx.clone();
        tokio::spawn(async move {
            let mut rx = perm_rx.lock().await;
            if let Some(forward) = rx.recv().await {
                let _ = forward
                    .response_tx
                    .send(crate::http::context::PermissionDecision::Deny);
            }
        });

        let result = tool_prompt_permission(&json!({"tool_name": "Write", "input": {}}), &ctx)
            .await
            .expect("denial must return Ok, not Err");

        let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
        assert_eq!(
            parsed["behavior"], "deny",
            "denied must return behavior:deny"
        );
        assert!(parsed["message"].is_string(), "deny must include a message");
    }

    // ── Permission rule generation tests ─────────────────────────

    #[test]
    fn generate_rule_for_edit_tool() {
        let rule = generate_permission_rule("Edit", &json!({}));
        assert_eq!(rule, "Edit");
    }

    #[test]
    fn generate_rule_for_write_tool() {
        let rule = generate_permission_rule("Write", &json!({}));
        assert_eq!(rule, "Write");
    }

    #[test]
    fn generate_rule_for_bash_git() {
        let rule = generate_permission_rule("Bash", &json!({"command": "git status"}));
        assert_eq!(rule, "Bash(git *)");
    }

    #[test]
    fn generate_rule_for_bash_cargo() {
        let rule = generate_permission_rule("Bash", &json!({"command": "cargo test --all"}));
        assert_eq!(rule, "Bash(cargo *)");
    }

    #[test]
    fn generate_rule_for_bash_empty_command() {
        let rule = generate_permission_rule("Bash", &json!({}));
        assert_eq!(rule, "Bash(unknown *)");
    }

    #[test]
    fn generate_rule_for_mcp_tool() {
        let rule = generate_permission_rule("mcp__storkit__create_story", &json!({"name": "foo"}));
        assert_eq!(rule, "mcp__storkit__create_story");
    }

    // ── Settings.json writing tests ──────────────────────────────

    #[test]
    fn add_rule_creates_settings_file_when_missing() {
        let tmp = tempfile::tempdir().unwrap();
        add_permission_rule(tmp.path(), "Edit").unwrap();

        let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
        let settings: Value = serde_json::from_str(&content).unwrap();
        let allow = settings["permissions"]["allow"].as_array().unwrap();
        assert!(allow.contains(&json!("Edit")));
    }

    #[test]
    fn add_rule_does_not_duplicate_existing() {
        let tmp = tempfile::tempdir().unwrap();
        add_permission_rule(tmp.path(), "Edit").unwrap();
        add_permission_rule(tmp.path(), "Edit").unwrap();

        let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
        let settings: Value = serde_json::from_str(&content).unwrap();
        let allow = settings["permissions"]["allow"].as_array().unwrap();
        let count = allow.iter().filter(|v| v == &&json!("Edit")).count();
        assert_eq!(count, 1);
    }

    #[test]
    fn add_rule_skips_when_wildcard_already_covers() {
        let tmp = tempfile::tempdir().unwrap();
        let claude_dir = tmp.path().join(".claude");
        fs::create_dir_all(&claude_dir).unwrap();
        fs::write(
            claude_dir.join("settings.json"),
            r#"{"permissions":{"allow":["mcp__storkit__*"]}}"#,
        )
        .unwrap();

        add_permission_rule(tmp.path(), "mcp__storkit__create_story").unwrap();

        let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
        let settings: Value = serde_json::from_str(&content).unwrap();
        let allow = settings["permissions"]["allow"].as_array().unwrap();
        assert_eq!(allow.len(), 1);
        assert_eq!(allow[0], "mcp__storkit__*");
    }

    #[test]
    fn add_rule_appends_to_existing_rules() {
        let tmp = tempfile::tempdir().unwrap();
        let claude_dir = tmp.path().join(".claude");
        fs::create_dir_all(&claude_dir).unwrap();
        fs::write(
            claude_dir.join("settings.json"),
            r#"{"permissions":{"allow":["Edit"]}}"#,
        )
        .unwrap();

        add_permission_rule(tmp.path(), "Write").unwrap();

        let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
        let settings: Value = serde_json::from_str(&content).unwrap();
        let allow = settings["permissions"]["allow"].as_array().unwrap();
        assert_eq!(allow.len(), 2);
        assert!(allow.contains(&json!("Edit")));
        assert!(allow.contains(&json!("Write")));
    }

    #[test]
    fn add_rule_preserves_other_settings_fields() {
        let tmp = tempfile::tempdir().unwrap();
        let claude_dir = tmp.path().join(".claude");
        fs::create_dir_all(&claude_dir).unwrap();
        fs::write(
            claude_dir.join("settings.json"),
            r#"{"permissions":{"allow":["Edit"]},"enabledMcpjsonServers":["storkit"]}"#,
        )
        .unwrap();

        add_permission_rule(tmp.path(), "Write").unwrap();

        let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
        let settings: Value = serde_json::from_str(&content).unwrap();
        let servers = settings["enabledMcpjsonServers"].as_array().unwrap();
        assert_eq!(servers.len(), 1);
        assert_eq!(servers[0], "storkit");
    }

    #[test]
    fn rebuild_and_restart_in_tools_list() {
        use super::super::handle_tools_list;
        let resp = handle_tools_list(Some(json!(1)));
        let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
        let tool = tools.iter().find(|t| t["name"] == "rebuild_and_restart");
        assert!(
            tool.is_some(),
            "rebuild_and_restart missing from tools list"
        );
        let t = tool.unwrap();
        assert!(t["description"].as_str().unwrap().contains("Rebuild"));
        assert!(t["inputSchema"].is_object());
    }

    #[tokio::test]
    async fn rebuild_and_restart_kills_agents_before_build() {
        // Verify that calling rebuild_and_restart on an empty pool doesn't
        // panic and proceeds to the build step. We can't test exec() in a
        // unit test, but we can verify the build attempt happens.
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());

        // The build will succeed (we're running in the real workspace) and
        // then exec() will be called — which would replace our test process.
        // So we only test that the function *runs* without panicking up to
        // the agent-kill step. We do this by checking the pool is empty.
        assert_eq!(ctx.agents.list_agents().unwrap().len(), 0);
        ctx.agents.kill_all_children(); // should not panic on empty pool
    }

    #[test]
    fn rebuild_uses_matching_build_profile() {
        // The build must use the same profile (debug/release) as the running
        // binary, otherwise cargo build outputs to a different target dir and
        // current_exe() still points at the old binary.
        let build_args: Vec<&str> = if cfg!(debug_assertions) {
            vec!["build", "-p", "storkit"]
        } else {
            vec!["build", "--release", "-p", "storkit"]
        };

        // Tests always run in debug mode, so --release must NOT be present.
        assert!(
            !build_args.contains(&"--release"),
            "In debug builds, rebuild must not pass --release (would put \
             the binary in target/release/ while current_exe() points to \
             target/debug/)"
        );
    }

    // ── move_story tool tests ─────────────────────────────────────

    #[test]
    fn move_story_in_tools_list() {
        use super::super::handle_tools_list;
        let resp = handle_tools_list(Some(json!(1)));
        let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
        let tool = tools.iter().find(|t| t["name"] == "move_story");
        assert!(tool.is_some(), "move_story missing from tools list");
        let t = tool.unwrap();
        assert!(t["description"].is_string());
        let required = t["inputSchema"]["required"].as_array().unwrap();
        let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
        assert!(req_names.contains(&"story_id"));
        assert!(req_names.contains(&"target_stage"));
    }

    #[test]
    fn tool_move_story_missing_story_id() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_move_story(&json!({"target_stage": "current"}), &ctx);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("story_id"));
    }

    #[test]
    fn tool_move_story_missing_target_stage() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_move_story(&json!({"story_id": "1_story_test"}), &ctx);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("target_stage"));
    }

    #[test]
    fn tool_move_story_invalid_target_stage() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        // Seed project root in state so get_project_root works
        let backlog = root.join(".storkit/work/1_backlog");
        fs::create_dir_all(&backlog).unwrap();
        fs::write(backlog.join("1_story_test.md"), "---\nname: Test\n---\n").unwrap();
        let ctx = test_ctx(root);
        let result = tool_move_story(
            &json!({"story_id": "1_story_test", "target_stage": "invalid"}),
            &ctx,
        );
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Invalid target_stage"));
    }

    #[test]
    fn tool_move_story_moves_from_backlog_to_current() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let backlog = root.join(".storkit/work/1_backlog");
        let current = root.join(".storkit/work/2_current");
        fs::create_dir_all(&backlog).unwrap();
        fs::create_dir_all(&current).unwrap();
        fs::write(backlog.join("5_story_test.md"), "---\nname: Test\n---\n").unwrap();

        let ctx = test_ctx(root);
        let result = tool_move_story(
            &json!({"story_id": "5_story_test", "target_stage": "current"}),
            &ctx,
        )
        .unwrap();

        assert!(!backlog.join("5_story_test.md").exists());
        assert!(current.join("5_story_test.md").exists());
        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["story_id"], "5_story_test");
        assert_eq!(parsed["from_stage"], "backlog");
        assert_eq!(parsed["to_stage"], "current");
    }

    #[test]
    fn tool_move_story_moves_from_current_to_backlog() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let current = root.join(".storkit/work/2_current");
        let backlog = root.join(".storkit/work/1_backlog");
        fs::create_dir_all(&current).unwrap();
        fs::create_dir_all(&backlog).unwrap();
        fs::write(current.join("6_story_back.md"), "---\nname: Back\n---\n").unwrap();

        let ctx = test_ctx(root);
        let result = tool_move_story(
            &json!({"story_id": "6_story_back", "target_stage": "backlog"}),
            &ctx,
        )
        .unwrap();

        assert!(!current.join("6_story_back.md").exists());
        assert!(backlog.join("6_story_back.md").exists());
        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["from_stage"], "current");
        assert_eq!(parsed["to_stage"], "backlog");
    }

    #[test]
    fn tool_move_story_idempotent_when_already_in_target() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let current = root.join(".storkit/work/2_current");
        fs::create_dir_all(&current).unwrap();
        fs::write(current.join("7_story_idem.md"), "---\nname: Idem\n---\n").unwrap();

        let ctx = test_ctx(root);
        let result = tool_move_story(
            &json!({"story_id": "7_story_idem", "target_stage": "current"}),
            &ctx,
        )
        .unwrap();

        assert!(current.join("7_story_idem.md").exists());
        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["from_stage"], "current");
        assert_eq!(parsed["to_stage"], "current");
    }

    #[test]
    fn tool_move_story_error_when_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_move_story(
            &json!({"story_id": "99_story_ghost", "target_stage": "current"}),
            &ctx,
        );
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .contains("not found in any pipeline stage")
        );
    }
}
