//! MCP agent tools — start, stop, wait, list, and inspect agents via MCP.

use crate::agents::PipelineStage;
use crate::config::ProjectConfig;
use crate::http::context::AppContext;
use crate::slog_warn;
use serde_json::{Value, json};

mod worktree;

pub(crate) use worktree::{
    get_worktree_commits, tool_create_worktree, tool_get_editor_command, tool_list_worktrees,
    tool_remove_worktree,
};

pub(crate) async fn tool_start_agent(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 agent_name = args.get("agent_name").and_then(|v| v.as_str());

    let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
    let info = ctx
        .services
        .agents
        .start_agent(&project_root, story_id, agent_name, None, None)
        .await?;

    // Snapshot coverage baseline from the most recent coverage report (best-effort).
    if let Some(pct) = read_coverage_percent_from_json(&project_root)
        && let Err(e) = crate::http::workflow::write_coverage_baseline_to_story_file(
            &project_root,
            story_id,
            pct,
        )
    {
        slog_warn!("[start_agent] Could not write coverage baseline to story file: {e}");
    }

    serde_json::to_string_pretty(&json!({
        "story_id": info.story_id,
        "agent_name": info.agent_name,
        "status": info.status.to_string(),
        "session_id": info.session_id,
        "worktree_path": info.worktree_path,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// Try to read the overall line coverage percentage from the llvm-cov JSON report.
///
/// Expects the file at `{project_root}/.huskies/coverage/server.json`.
pub(crate) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> Option<f64> {
    let path = project_root
        .join(".huskies")
        .join("coverage")
        .join("server.json");
    let contents = std::fs::read_to_string(&path).ok()?;
    let json: Value = serde_json::from_str(&contents).ok()?;
    // cargo llvm-cov --json format: data[0].totals.lines.percent
    json.pointer("/data/0/totals/lines/percent")
        .and_then(|v| v.as_f64())
}

pub(crate) async fn tool_stop_agent(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 agent_name = args
        .get("agent_name")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: agent_name")?;

    let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
    ctx.services
        .agents
        .stop_agent(&project_root, story_id, agent_name)
        .await?;

    Ok(format!(
        "Agent '{agent_name}' for story '{story_id}' stopped."
    ))
}

pub(crate) fn tool_list_agents(ctx: &AppContext) -> Result<String, String> {
    let project_root = ctx.services.agents.get_project_root(&ctx.state).ok();
    let agents = ctx.services.agents.list_agents()?;
    let mut entries: Vec<serde_json::Value> = agents
        .iter()
        .filter(|a| {
            project_root
                .as_deref()
                .map(|root| !crate::service::agents::is_archived(root, &a.story_id))
                .unwrap_or(true)
        })
        .map(|a| {
            json!({
                "story_id": a.story_id,
                "agent_name": a.agent_name,
                "status": a.status.to_string(),
                "session_id": a.session_id,
                "worktree_path": a.worktree_path,
            })
        })
        .collect();

    // Append a synthetic entry for each deterministic merge holding the merge lock.
    let running_merges = ctx.services.agents.list_running_merges()?;
    for story_id in running_merges {
        entries.push(json!({
            "story_id": story_id,
            "agent_name": "deterministic-merge",
            "status": "Running",
            "session_id": null,
            "worktree_path": null,
        }));
    }

    serde_json::to_string_pretty(&json!(entries)).map_err(|e| format!("Serialization error: {e}"))
}

/// Read agent session logs from disk and return a human-readable timeline.
///
/// Stitches all session log files for the story together in chronological
/// order. If `agent_name` is omitted, logs from every agent are included.
/// Supports `lines` (tail the last N lines) and `filter` (substring match).
/// If a named agent is currently running, its buffered in-memory events are
pub(crate) async fn tool_get_agent_output(
    args: &Value,
    ctx: &AppContext,
) -> Result<String, String> {
    use crate::agent_log;

    let story_id = args
        .get("story_id")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: story_id")?;
    let agent_name_filter = args.get("agent_name").and_then(|v| v.as_str());
    let tail = args
        .get("lines")
        .and_then(|v| v.as_u64())
        .map(|n| n as usize);
    let filter = args.get("filter").and_then(|v| v.as_str());

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

    // Collect all matching log files, oldest first.
    let log_files = agent_log::list_story_log_files(&project_root, story_id, agent_name_filter);

    let mut all_lines: Vec<String> = Vec::new();

    for path in &log_files {
        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
        all_lines.push(format!("=== {} ===", file_name.trim_end_matches(".log")));
        match agent_log::read_log_as_readable_lines(path) {
            Ok(lines) => all_lines.extend(lines),
            Err(e) => all_lines.push(format!("[ERROR reading log: {e}]")),
        }
        all_lines.push(String::new()); // blank line between sessions
    }

    // Append buffered live events only when no disk logs exist yet.
    // emit_event() writes to disk synchronously, so disk is always up-to-date
    // when log files are present. Showing live events alongside disk logs would
    // produce duplicates. Only fall back to in-memory events when the log
    // writer failed and nothing was persisted to disk.
    if log_files.is_empty()
        && let Some(agent_name) = agent_name_filter
        && let Ok(live_events) = ctx.services.agents.drain_events(story_id, agent_name)
        && !live_events.is_empty()
    {
        all_lines.push(format!("=== {agent_name} (live) ==="));
        let now = chrono::Utc::now().to_rfc3339();
        for event in &live_events {
            if let Ok(event_value) = serde_json::to_value(event)
                && let Some(line) = agent_log::format_log_entry_as_text(&now, &event_value)
            {
                all_lines.push(line);
            }
        }
        all_lines.push(String::new());
    }

    if log_files.is_empty() {
        let agent_hint = agent_name_filter
            .map(|n| format!(" agent '{n}'"))
            .unwrap_or_default();
        return Ok(format!(
            "No log files found for story '{story_id}'{agent_hint}."
        ));
    }

    // Apply substring filter (always keep section headers).
    let filtered: Vec<String> = if let Some(f) = filter {
        all_lines
            .into_iter()
            .filter(|l| l.starts_with("===") || l.contains(f))
            .collect()
    } else {
        all_lines
    };

    // Apply tail (last N lines).
    let output = if let Some(n) = tail {
        let start = filtered.len().saturating_sub(n);
        filtered[start..].join("\n")
    } else {
        filtered.join("\n")
    };

    Ok(output)
}

pub(crate) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String> {
    let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
    let config = ProjectConfig::load(&project_root)?;

    // Collect available (idle) agent names across all stages so the caller can
    // see at a glance which agents are free to start (story 190).
    let mut available_names: std::collections::HashSet<String> = std::collections::HashSet::new();
    for stage in &[
        PipelineStage::Coder,
        PipelineStage::Qa,
        PipelineStage::Mergemaster,
        PipelineStage::Other,
    ] {
        if let Ok(names) = ctx
            .services
            .agents
            .available_agents_for_stage(&config, stage)
        {
            available_names.extend(names);
        }
    }

    serde_json::to_string_pretty(&json!(
        config
            .agent
            .iter()
            .map(|a| json!({
                "name": a.name,
                "role": a.role,
                "model": a.model,
                "allowed_tools": a.allowed_tools,
                "max_turns": a.max_turns,
                "max_budget_usd": a.max_budget_usd,
                "available": available_names.contains(&a.name),
            }))
            .collect::<Vec<_>>()
    ))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// Get remaining turns and budget for a running agent.
///
/// Returns turns used, max turns, remaining turns, budget used, max budget,
/// and remaining budget for the named agent. Fails if the agent is not
pub(crate) fn tool_get_agent_remaining_turns_and_budget(
    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 agent_name = args
        .get("agent_name")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: agent_name")?;

    // Verify the agent exists and is running/pending.
    let agents = ctx.services.agents.list_agents()?;
    let agent_info = agents
        .iter()
        .find(|a| a.story_id == story_id && a.agent_name == agent_name)
        .ok_or_else(|| format!("No agent '{agent_name}' found for story '{story_id}'"))?;

    if !matches!(
        agent_info.status,
        crate::agents::AgentStatus::Running | crate::agents::AgentStatus::Pending
    ) {
        let reason_suffix = agent_info
            .termination_reason
            .as_ref()
            .map(|r| {
                format!(
                    ", termination_reason: {}",
                    serde_json::to_string(r).unwrap_or_default()
                )
            })
            .unwrap_or_default();
        return Err(format!(
            "Agent '{agent_name}' for story '{story_id}' is not running (status: {}{reason_suffix})",
            agent_info.status
        ));
    }

    let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
    let config = ProjectConfig::load(&project_root)?;

    // Find the agent config (max_turns, max_budget_usd).
    let agent_config = config.agent.iter().find(|a| a.name == agent_name);

    let max_turns = agent_config.and_then(|a| a.max_turns);
    let max_budget_usd = agent_config.and_then(|a| a.max_budget_usd);

    // ── Cumulative counters (all sessions) ─────────────────────────────
    let log_files =
        crate::agent_log::list_story_log_files(&project_root, story_id, Some(agent_name));
    let mut cumulative_turns: u64 = 0;
    for path in &log_files {
        if let Ok(entries) = crate::agent_log::read_log(path) {
            for entry in &entries {
                if entry.event.get("type").and_then(|v| v.as_str()) == Some("agent_json")
                    && let Some(data) = entry.event.get("data")
                    && data.get("type").and_then(|v| v.as_str()) == Some("assistant")
                {
                    cumulative_turns += 1;
                }
            }
        }
    }

    let cumulative_log_cost = crate::agents::pool::auto_assign::watchdog::compute_budget_from_logs(
        &project_root,
        story_id,
        agent_name,
    );
    let all_records = crate::agents::token_usage::read_all(&project_root).unwrap_or_default();
    let record_cost: f64 = all_records
        .iter()
        .filter(|r| r.story_id == story_id && r.agent_name == agent_name)
        .map(|r| r.usage.total_cost_usd)
        .sum();
    let cumulative_budget: f64 = cumulative_log_cost.max(record_cost);

    // ── Per-session counters (current session only — enforcement basis) ──
    use crate::agents::pool::auto_assign::watchdog::{
        compute_budget_from_single_log, count_turns_in_log, resolve_session_log,
    };
    let session_log = resolve_session_log(
        &project_root,
        story_id,
        agent_name,
        &agent_info.log_session_id,
    );
    let session_turns: u64 = session_log
        .as_ref()
        .map(|p| count_turns_in_log(p))
        .unwrap_or(0);
    let session_budget: f64 = session_log
        .as_ref()
        .map(|p| compute_budget_from_single_log(p))
        .unwrap_or(0.0);

    let remaining_turns = max_turns.map(|max| (max as i64) - (session_turns as i64));
    let remaining_budget_usd = max_budget_usd.map(|max| max - session_budget);

    serde_json::to_string_pretty(&json!({
        "story_id": story_id,
        "agent_name": agent_name,
        "status": agent_info.status.to_string(),
        // Per-session values (watchdog enforcement basis):
        "turns_used": session_turns,
        "budget_used_usd": session_budget,
        // Cumulative values (all sessions, useful for cost analysis):
        "cumulative_turns_used": cumulative_turns,
        "cumulative_budget_used_usd": cumulative_budget,
        // Limits and remaining (computed from per-session values):
        "max_turns": max_turns,
        "remaining_turns": remaining_turns,
        "max_budget_usd": max_budget_usd,
        "remaining_budget_usd": remaining_budget_usd,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

pub(crate) async fn tool_wait_for_agent(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 agent_name = args
        .get("agent_name")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: agent_name")?;
    let timeout_ms = args
        .get("timeout_ms")
        .and_then(|v| v.as_u64())
        .unwrap_or(300_000); // default: 5 minutes

    let info = ctx
        .services
        .agents
        .wait_for_agent(story_id, agent_name, timeout_ms)
        .await?;

    let commits = match (&info.worktree_path, &info.base_branch) {
        (Some(wt_path), Some(base)) => get_worktree_commits(wt_path, base).await,
        _ => None,
    };

    let completion = info.completion.as_ref().map(|r| {
        json!({
            "summary": r.summary,
            "gates_passed": r.gates_passed,
            "gate_output": r.gate_output,
        })
    });

    serde_json::to_string_pretty(&json!({
        "story_id": info.story_id,
        "agent_name": info.agent_name,
        "status": info.status.to_string(),
        "session_id": info.session_id,
        "worktree_path": info.worktree_path,
        "base_branch": info.base_branch,
        "commits": commits,
        "completion": completion,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

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

    #[test]
    fn tool_list_agents_empty() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_list_agents(&ctx).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        assert!(parsed.is_empty());
    }

    #[test]
    fn tool_get_agent_config_no_project_toml_returns_default_agent() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        // No project.toml → default config with one fallback agent
        let result = tool_get_agent_config(&ctx).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        // Default config contains one agent entry with default values
        assert_eq!(
            parsed.len(),
            1,
            "default config should have one fallback agent"
        );
        assert!(parsed[0].get("name").is_some());
        assert!(parsed[0].get("role").is_some());
    }

    #[tokio::test]
    async fn tool_get_agent_output_missing_story_id() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_get_agent_output(&json!({}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("story_id"));
    }

    #[tokio::test]
    async fn tool_get_agent_output_no_logs_returns_not_found_message() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        // No agent registered, no log file → returns "no log files found" message
        let result =
            tool_get_agent_output(&json!({"story_id": "99_nope", "agent_name": "bot"}), &ctx)
                .await
                .unwrap();
        assert!(
            result.contains("No log files found"),
            "expected 'No log files found' message: {result}"
        );
    }

    #[tokio::test]
    async fn tool_get_agent_output_agent_name_is_optional() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        // No agent_name provided — should succeed (no error)
        let result = tool_get_agent_output(&json!({"story_id": "99_nope"}), &ctx)
            .await
            .unwrap();
        assert!(result.contains("No log files found"));
    }

    #[tokio::test]
    async fn tool_get_agent_output_reads_from_disk() {
        use crate::agent_log::AgentLogWriter;
        use crate::agents::AgentEvent;
        use crate::store::StoreOps;

        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        // Point the store at the tmp root so the tool can find log files.
        ctx.store
            .set("project_root", json!(tmp.path().to_string_lossy().as_ref()));

        // Write a log file
        let mut writer =
            AgentLogWriter::new(tmp.path(), "42_story_foo", "coder-1", "sess-test").unwrap();
        writer
            .write_event(&AgentEvent::Output {
                story_id: "42_story_foo".to_string(),
                agent_name: "coder-1".to_string(),
                text: "My readable output line".to_string(),
            })
            .unwrap();
        writer
            .write_event(&AgentEvent::Done {
                story_id: "42_story_foo".to_string(),
                agent_name: "coder-1".to_string(),
                session_id: Some("sess-test".to_string()),
            })
            .unwrap();
        drop(writer);

        let result = tool_get_agent_output(
            &json!({"story_id": "42_story_foo", "agent_name": "coder-1"}),
            &ctx,
        )
        .await
        .unwrap();

        assert!(
            result.contains("My readable output line"),
            "expected output text in result: {result}"
        );
        assert!(result.contains("DONE"), "expected DONE marker: {result}");
    }

    #[tokio::test]
    async fn tool_get_agent_output_tail_limits_lines() {
        use crate::agent_log::AgentLogWriter;
        use crate::agents::AgentEvent;
        use crate::store::StoreOps;

        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        ctx.store
            .set("project_root", json!(tmp.path().to_string_lossy().as_ref()));

        let mut writer =
            AgentLogWriter::new(tmp.path(), "42_story_bar", "coder-1", "sess-tail").unwrap();
        for i in 0..10 {
            writer
                .write_event(&AgentEvent::Output {
                    story_id: "42_story_bar".to_string(),
                    agent_name: "coder-1".to_string(),
                    text: format!("line {i}"),
                })
                .unwrap();
        }
        drop(writer);

        let result = tool_get_agent_output(
            &json!({"story_id": "42_story_bar", "agent_name": "coder-1", "lines": 3}),
            &ctx,
        )
        .await
        .unwrap();

        // Should contain "line 7", "line 8", "line 9" but NOT "line 0"
        assert!(
            result.contains("line 9"),
            "should contain last line: {result}"
        );
        assert!(
            !result.contains("line 0"),
            "should not contain early lines: {result}"
        );
    }

    #[tokio::test]
    async fn tool_get_agent_output_filter_narrows_results() {
        use crate::agent_log::AgentLogWriter;
        use crate::agents::AgentEvent;
        use crate::store::StoreOps;

        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        ctx.store
            .set("project_root", json!(tmp.path().to_string_lossy().as_ref()));

        let mut writer =
            AgentLogWriter::new(tmp.path(), "42_story_baz", "coder-1", "sess-filter").unwrap();
        writer
            .write_event(&AgentEvent::Output {
                story_id: "42_story_baz".to_string(),
                agent_name: "coder-1".to_string(),
                text: "needle line here".to_string(),
            })
            .unwrap();
        writer
            .write_event(&AgentEvent::Output {
                story_id: "42_story_baz".to_string(),
                agent_name: "coder-1".to_string(),
                text: "haystack only".to_string(),
            })
            .unwrap();
        drop(writer);

        let result = tool_get_agent_output(
            &json!({"story_id": "42_story_baz", "agent_name": "coder-1", "filter": "needle"}),
            &ctx,
        )
        .await
        .unwrap();

        assert!(
            result.contains("needle"),
            "filter should keep matching lines: {result}"
        );
        assert!(
            !result.contains("haystack"),
            "filter should remove non-matching lines: {result}"
        );
    }

    #[tokio::test]
    async fn tool_stop_agent_missing_story_id() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_stop_agent(&json!({"agent_name": "bot"}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("story_id"));
    }

    #[tokio::test]
    async fn tool_stop_agent_missing_agent_name() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_stop_agent(&json!({"story_id": "1_test"}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("agent_name"));
    }

    #[tokio::test]
    async fn tool_start_agent_missing_story_id() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_start_agent(&json!({}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("story_id"));
    }

    #[tokio::test]
    async fn tool_start_agent_no_agent_name_no_coder_returns_clear_error() {
        // Config has only a supervisor — start_agent without agent_name should
        // refuse rather than silently assigning supervisor.
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            r#"
[[agent]]
name = "supervisor"
stage = "other"
"#,
        )
        .unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_start_agent(&json!({"story_id": "42_my_story"}), &ctx).await;
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("coder"),
            "error should mention 'coder', got: {err}"
        );
    }

    #[tokio::test]
    async fn tool_start_agent_no_agent_name_picks_coder_not_supervisor() {
        // Config has supervisor first, then coder-1. Without agent_name the
        // coder should be selected, not supervisor. The call will fail due to
        // missing git repo / worktree, but the error must NOT be about
        // "No coder agent configured".
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            r#"
[[agent]]
name = "supervisor"
stage = "other"

[[agent]]
name = "coder-1"
stage = "coder"
"#,
        )
        .unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_start_agent(&json!({"story_id": "42_my_story"}), &ctx).await;
        // May succeed or fail for infrastructure reasons (no git repo), but
        // must NOT fail with "No coder agent configured".
        if let Err(err) = result {
            assert!(
                !err.contains("No coder agent configured"),
                "should not fail on agent selection, got: {err}"
            );
            // Should also not complain about supervisor being absent.
            assert!(
                !err.contains("supervisor"),
                "should not select supervisor, got: {err}"
            );
        }
    }

    #[tokio::test]
    async fn wait_for_agent_tool_missing_story_id() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_wait_for_agent(&json!({"agent_name": "bot"}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("story_id"));
    }

    #[tokio::test]
    async fn wait_for_agent_tool_missing_agent_name() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_wait_for_agent(&json!({"story_id": "1_test"}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("agent_name"));
    }

    #[tokio::test]
    async fn wait_for_agent_tool_nonexistent_agent_returns_error() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_wait_for_agent(
            &json!({"story_id": "99_nope", "agent_name": "bot", "timeout_ms": 50}),
            &ctx,
        )
        .await;
        // No agent registered — should error
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn wait_for_agent_tool_returns_completed_agent() {
        use crate::agents::AgentStatus;
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        ctx.services
            .agents
            .inject_test_agent("41_story", "worker", AgentStatus::Completed);

        let result = tool_wait_for_agent(
            &json!({"story_id": "41_story", "agent_name": "worker"}),
            &ctx,
        )
        .await
        .unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["status"], "completed");
        assert_eq!(parsed["story_id"], "41_story");
        assert_eq!(parsed["agent_name"], "worker");
        // commits key present (may be null since no real worktree)
        assert!(parsed.get("commits").is_some());
        // completion key present (null for agents that didn't call report_completion)
        assert!(parsed.get("completion").is_some());
    }

    #[test]
    fn wait_for_agent_tool_in_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 wait_tool = tools.iter().find(|t| t["name"] == "wait_for_agent");
        assert!(
            wait_tool.is_some(),
            "wait_for_agent missing from tools list"
        );
        let t = wait_tool.unwrap();
        assert!(
            t["description"].as_str().unwrap().contains("block")
                || t["description"].as_str().unwrap().contains("Block")
        );
        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(&"agent_name"));
    }

    #[test]
    fn read_coverage_percent_from_json_parses_llvm_cov_format() {
        use std::fs;
        let tmp = tempfile::tempdir().unwrap();
        let cov_dir = tmp.path().join(".huskies/coverage");
        fs::create_dir_all(&cov_dir).unwrap();
        let json_content =
            r#"{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"#;
        fs::write(cov_dir.join("server.json"), json_content).unwrap();

        let pct = read_coverage_percent_from_json(tmp.path());
        assert_eq!(pct, Some(78.0));
    }

    #[test]
    fn read_coverage_percent_from_json_returns_none_when_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let pct = read_coverage_percent_from_json(tmp.path());
        assert!(pct.is_none());
    }

    // ── get_agent_remaining_turns_and_budget tests ──────────────────────────

    #[test]
    fn tool_get_agent_remaining_turns_and_budget_missing_story_id() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result =
            tool_get_agent_remaining_turns_and_budget(&json!({"agent_name": "coder-1"}), &ctx);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("story_id"));
    }

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

    #[test]
    fn tool_get_agent_remaining_turns_and_budget_no_agent_returns_error() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_get_agent_remaining_turns_and_budget(
            &json!({"story_id": "99_nope", "agent_name": "coder-1"}),
            &ctx,
        );
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("No agent"),
            "expected 'No agent' error, got: {err}"
        );
    }

    #[test]
    fn tool_get_agent_remaining_turns_and_budget_completed_agent_returns_error() {
        use crate::agents::AgentStatus;
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        ctx.services
            .agents
            .inject_test_agent("42_story", "coder-1", AgentStatus::Completed);

        let result = tool_get_agent_remaining_turns_and_budget(
            &json!({"story_id": "42_story", "agent_name": "coder-1"}),
            &ctx,
        );
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("not running"),
            "expected 'not running' error, got: {err}"
        );
    }

    #[test]
    fn tool_get_agent_remaining_turns_and_budget_running_agent_returns_data() {
        use crate::agents::AgentStatus;
        use crate::store::StoreOps;

        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        ctx.store
            .set("project_root", json!(tmp.path().to_string_lossy().as_ref()));
        ctx.services
            .agents
            .inject_test_agent("42_story", "coder-1", AgentStatus::Running);

        let result = tool_get_agent_remaining_turns_and_budget(
            &json!({"story_id": "42_story", "agent_name": "coder-1"}),
            &ctx,
        )
        .unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();

        assert_eq!(parsed["story_id"], "42_story");
        assert_eq!(parsed["agent_name"], "coder-1");
        assert_eq!(parsed["status"], "running");
        // Per-session values (enforcement basis).
        assert!(parsed.get("turns_used").is_some());
        assert!(parsed.get("budget_used_usd").is_some());
        // Cumulative values (all sessions, for cost analysis).
        assert!(parsed.get("cumulative_turns_used").is_some());
        assert!(parsed.get("cumulative_budget_used_usd").is_some());
        // Limits and remaining.
        assert!(parsed.get("max_turns").is_some());
        assert!(parsed.get("remaining_turns").is_some());
        assert!(parsed.get("max_budget_usd").is_some());
        assert!(parsed.get("remaining_budget_usd").is_some());
    }

    #[test]
    fn tool_get_agent_remaining_turns_and_budget_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"] == "get_agent_remaining_turns_and_budget");
        assert!(
            tool.is_some(),
            "get_agent_remaining_turns_and_budget missing from tools list"
        );
        let t = tool.unwrap();
        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(&"agent_name"));
    }
}
