//! Tool call parsing, execution dispatch, and provider error classification.

use axum::http::StatusCode;
use roboticus_agent::capability::CapabilitySource;
use roboticus_agent::script_runner::ScriptRunner;
use roboticus_agent::tools::ToolContext;
use roboticus_core::InputAuthority;
use serde_json::json;

use super::super::JsonError;
use super::AppState;
use super::flight_recorder::ToolSource;

pub(crate) struct ToolExecutionDetails {
    pub output: String,
    pub source: ToolSource,
}

/// Try to extract a tool call from the LLM's text response.
/// Looks for `{"tool_call": {"name": "...", "params": {...}}}` in the response.
pub(super) fn parse_tool_call(response: &str) -> Option<(String, serde_json::Value)> {
    // Search from the end to avoid locking onto a fake "tool_call" earlier in the text.
    // Try each candidate from last to first; accept the first valid parse.
    let mut search_end = response.len();
    while let Some(rel) = response[..search_end].rfind(r#""tool_call""#) {
        if let Some(brace_start) = response[..rel].rfind('{') {
            let mut depth = 0;
            let mut end = brace_start;
            for (i, ch) in response[brace_start..].char_indices() {
                match ch {
                    '{' => depth += 1,
                    '}' => {
                        depth -= 1;
                        if depth == 0 {
                            end = brace_start + i + 1;
                            break;
                        }
                    }
                    _ => {}
                }
            }

            let json_str = &response[brace_start..end];
            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str)
                && let Some((name, params)) = extract_tool_invocation(&parsed)
            {
                return Some((name, params));
            }
        }
        search_end = rel;
    }
    None
}

fn extract_tool_invocation(parsed: &serde_json::Value) -> Option<(String, serde_json::Value)> {
    let tool_call = parsed.get("tool_call")?;

    if let Some(name) = tool_call
        .get("name")
        .or_else(|| tool_call.get("tool_name"))
        .or_else(|| tool_call.get("tool"))
        .and_then(|n| n.as_str())
    {
        let params = tool_call
            .get("params")
            .or_else(|| tool_call.get("arguments"))
            .or_else(|| tool_call.get("args"))
            .or_else(|| tool_call.get("input"))
            .cloned()
            .or_else(|| parsed.get("params").cloned())
            .or_else(|| parsed.get("arguments").cloned())
            .or_else(|| parsed.get("args").cloned())
            .or_else(|| parsed.get("input").cloned())
            .unwrap_or(json!({}));
        return Some((name.to_string(), params));
    }

    // Accept shorthand shape:
    // {"tool_call":"bash","params":{"command":"ls"}}
    if let Some(name) = tool_call.as_str() {
        let params = parsed.get("params").cloned().unwrap_or(json!({}));
        return Some((name.to_string(), params));
    }

    None
}

/// Extract **all** tool calls from the LLM's text response.
///
/// Scans forward through the text for `{"tool_call": {"name": "...", "params": {...}}}`
/// blocks and returns them in order. This handles the case where the LLM (or the shim)
/// emits multiple tool calls separated by newlines.
pub(super) fn parse_tool_calls(response: &str) -> Vec<(String, serde_json::Value)> {
    let mut results = Vec::new();
    let mut search_start = 0;
    while search_start < response.len() {
        let Some(rel) = response[search_start..].find(r#""tool_call""#) else {
            break;
        };
        let abs_pos = search_start + rel;
        // Walk backwards to find the opening brace
        let Some(brace_start) = response[..abs_pos].rfind('{') else {
            search_start = abs_pos + 1;
            continue;
        };
        // But only accept it if there's no intervening closing brace (this brace
        // belongs to the tool_call, not a prior JSON object).
        if response[brace_start + 1..abs_pos].contains('}') {
            // The opening brace belongs to an earlier JSON object — skip past the
            // current "tool_use" marker and resume scanning for the next `{`.
            search_start = abs_pos + 1;
            continue;
        }

        // Find the matching closing brace
        let mut depth = 0;
        let mut end = brace_start;
        let mut found_end = false;
        for (i, ch) in response[brace_start..].char_indices() {
            match ch {
                '{' => depth += 1,
                '}' => {
                    depth -= 1;
                    if depth == 0 {
                        end = brace_start + i + 1;
                        found_end = true;
                        break;
                    }
                }
                _ => {}
            }
        }
        if !found_end {
            // Unterminated JSON — the model likely hit a token limit or generated
            // incomplete output. Try to recover by appending missing closing braces.
            let truncated = &response[brace_start..];
            let mut repaired = truncated.to_string();
            for _ in 0..depth {
                repaired.push('}');
            }
            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&repaired)
                && let Some((name, params)) = extract_tool_invocation(&parsed)
            {
                tracing::debug!(
                    tool = name.as_str(),
                    missing_braces = depth,
                    "recovered truncated tool call JSON by appending closing braces"
                );
                results.push((name, params));
            }
            break;
        }

        let json_str = &response[brace_start..end];
        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str)
            && let Some((name, params)) = extract_tool_invocation(&parsed)
        {
            results.push((name, params));
        }
        search_start = end;
    }
    results
}

pub(crate) fn classify_provider_error(raw: &str) -> &'static str {
    let lower = raw.to_ascii_lowercase();
    if lower.contains("circuit breaker") {
        "provider temporarily unavailable"
    } else if lower.contains("no api key") || lower.contains("no provider configured") {
        "no provider configured for this model"
    } else if lower.contains("401") || lower.contains("403") || lower.contains("authentication") {
        "provider authentication error"
    } else if lower.contains("429") || lower.contains("rate limit") || lower.contains("rate_limit")
    {
        "provider rate limit reached"
    } else if lower.contains("402")
        || lower.contains("quota")
        || lower.contains("billing")
        || lower.contains("credit")
    {
        "provider quota or billing issue"
    } else if lower.contains("500")
        || lower.contains("502")
        || lower.contains("503")
        || lower.contains("504")
    {
        "provider server error"
    } else if lower.contains("request failed")
        || lower.contains("timeout")
        || lower.contains("connection")
    {
        "network error reaching provider"
    } else {
        "provider error"
    }
}

pub(super) fn provider_failure_user_message(
    last_error: &str,
    message_already_stored: bool,
) -> String {
    let category = classify_provider_error(last_error);
    // Surface timeout config hints to the user so they know the limit is adjustable.
    let timeout_hint = extract_timeout_hint(last_error);
    let suffix = if !timeout_hint.is_empty() {
        format!(" {timeout_hint}")
    } else {
        String::new()
    };
    if message_already_stored {
        format!(
            "Acknowledged. I hit provider routing failure across all LLM providers ({category}).{suffix} \
             Your message is stored and I will retry as soon as a provider path is healthy."
        )
    } else {
        format!(
            "Acknowledged. I hit provider routing failure across all LLM providers ({category}).{suffix} Please retry."
        )
    }
}

/// Extract a user-safe timeout hint from the raw error string.
/// Returns a brief note like "The per-provider timeout is set to 30s (configurable in models.routing)."
/// Returns empty string if the error isn't timeout-related.
fn extract_timeout_hint(raw: &str) -> String {
    // Match: "configured limit: models.routing.per_provider_timeout_seconds = 30"
    // or:    "configured limit: models.routing.max_total_inference_seconds = 120"
    if let Some(start) = raw.find("configured limit: ") {
        let after = &raw[start + "configured limit: ".len()..];
        // Split on " = " first to handle labels containing parentheses, e.g.
        // "models.routing.max_total_inference_seconds (remaining budget) = 120)"
        if let Some((key_part, rest)) = after.split_once(" = ") {
            // Strip the trailing ')' from the value and any extra text.
            let value = rest.split(')').next().unwrap_or(rest).trim();
            // Strip any parenthetical suffix from the key for the user-facing label.
            let key = key_part
                .find(" (")
                .map(|i| &key_part[..i])
                .unwrap_or(key_part)
                .trim();
            let short_key = key.strip_prefix("models.routing.").unwrap_or(key);
            return format!(
                "The {short_key} is set to {value}s (configurable in [models.routing])."
            );
        }
    }
    String::new()
}

fn parse_risk_level(raw: &str) -> Result<roboticus_core::RiskLevel, String> {
    match raw.to_ascii_lowercase().as_str() {
        "safe" => Ok(roboticus_core::RiskLevel::Safe),
        "caution" => Ok(roboticus_core::RiskLevel::Caution),
        "dangerous" => Ok(roboticus_core::RiskLevel::Dangerous),
        "forbidden" => Ok(roboticus_core::RiskLevel::Forbidden),
        _ => Err(format!("invalid skill risk_level '{raw}'")),
    }
}

async fn resolve_survival_tier(state: &AppState) -> roboticus_core::SurvivalTier {
    let balance = match state.wallet.wallet.get_usdc_balance().await {
        Ok(balance) => balance,
        Err(e) => {
            tracing::warn!(
                error = %e,
                "wallet balance lookup failed; falling back to zero balance tier"
            );
            0.0
        }
    };
    roboticus_core::SurvivalTier::from_balance(balance, 0.0)
}

async fn resolve_run_script_policy(
    state: &AppState,
    tool_name: &str,
    params: &serde_json::Value,
    authority: InputAuthority,
    default_risk: roboticus_core::RiskLevel,
) -> Result<(roboticus_core::RiskLevel, Option<(String, String, String)>), String> {
    if tool_name != "run_script" {
        return Ok((default_risk, None));
    }

    let script_arg = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
    let config = state.config.read().await;
    let runner = ScriptRunner::new(config.skills.clone(), config.security.filesystem.clone());
    let maybe_script_path = runner
        .resolve_script_path(std::path::Path::new(script_arg))
        .inspect_err(|e| {
            tracing::debug!(
                script = script_arg,
                error = %e,
                "run_script policy: script path resolution failed (proceeding with default risk)"
            );
        })
        .ok()
        .map(|p| p.to_string_lossy().to_string());
    drop(config);

    let Some(script_path) = maybe_script_path else {
        return Ok((default_risk, None));
    };

    let skill = roboticus_db::skills::find_skill_by_script_path(&state.db, &script_path)
        .map_err(|e| format!("Skill policy lookup failed: {e}"))?;
    let Some(skill) = skill else {
        return Ok((default_risk, None));
    };

    if !skill.enabled {
        return Err(format!(
            "Policy override denied: skill '{}' is disabled",
            skill.name
        ));
    }

    let effective_risk = parse_risk_level(&skill.risk_level)
        .map_err(|e| format!("Policy override denied: skill '{}' has {}", skill.name, e))?;
    let matched_skill = Some((
        skill.id.clone(),
        skill.name.clone(),
        skill.content_hash.clone(),
    ));

    if let Some(overrides_raw) = skill.policy_overrides_json.as_deref() {
        let overrides = serde_json::from_str::<serde_json::Value>(overrides_raw).map_err(|e| {
            format!(
                "Policy override parse failed for skill '{}': {e}",
                skill.name
            )
        })?;

        let require_creator = overrides
            .get("require_creator")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let deny_external = overrides
            .get("deny_external")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let disabled = overrides
            .get("disabled")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        if disabled {
            return Err(format!(
                "Policy override denied: skill '{}' is disabled",
                skill.name
            ));
        }
        if require_creator && authority != roboticus_core::InputAuthority::Creator {
            return Err(format!(
                "Policy override denied: skill '{}' requires Creator authority",
                skill.name
            ));
        }
        if deny_external && authority == roboticus_core::InputAuthority::External {
            return Err(format!(
                "Policy override denied: skill '{}' denies External authority",
                skill.name
            ));
        }
    }

    Ok((effective_risk, matched_skill))
}

/// Execute a tool call through the ToolRegistry, enforcing policy and recording audit trails.
pub(crate) async fn execute_tool_call(
    state: &AppState,
    tool_name: &str,
    params: &serde_json::Value,
    turn_id: &str,
    authority: InputAuthority,
    channel: Option<&str>,
) -> Result<String, String> {
    execute_tool_call_detailed(state, tool_name, params, turn_id, authority, channel)
        .await
        .map(|details| details.output)
}

/// Replay a previously approved tool call.
///
/// This bypasses the approval gate (already satisfied) but still enforces policy and
/// records tool execution/audit trails exactly like normal execution.
pub(crate) async fn execute_tool_call_after_approval(
    state: &AppState,
    tool_name: &str,
    params: &serde_json::Value,
    turn_id: &str,
    authority: InputAuthority,
    channel: Option<&str>,
) -> Result<String, String> {
    execute_tool_call_internal(state, tool_name, params, turn_id, authority, channel, false)
        .await
        .map(|details| details.output)
}

fn capability_source_to_tool_source(source: CapabilitySource) -> ToolSource {
    match source {
        CapabilitySource::BuiltIn => ToolSource::BuiltIn,
        CapabilitySource::Plugin(name) => ToolSource::Plugin(name),
        CapabilitySource::Mcp { server, .. } => ToolSource::Mcp { server },
    }
}

pub(crate) async fn execute_tool_call_detailed(
    state: &AppState,
    tool_name: &str,
    params: &serde_json::Value,
    turn_id: &str,
    authority: InputAuthority,
    channel: Option<&str>,
) -> Result<ToolExecutionDetails, String> {
    execute_tool_call_internal(state, tool_name, params, turn_id, authority, channel, true).await
}

async fn execute_tool_call_internal(
    state: &AppState,
    tool_name: &str,
    params: &serde_json::Value,
    turn_id: &str,
    authority: InputAuthority,
    channel: Option<&str>,
    enforce_approval_gate: bool,
) -> Result<ToolExecutionDetails, String> {
    let tier = resolve_survival_tier(state).await;
    if super::is_virtual_delegation_tool(tool_name) {
        let start = std::time::Instant::now();
        let result = super::execute_virtual_subagent_tool_call(
            state, tool_name, params, turn_id, authority, tier,
        )
        .await;
        let duration_ms = start.elapsed().as_millis() as i64;
        let (output, status) = match &result {
            Ok(out) => (out.clone(), "success"),
            Err(err) => (err.clone(), "error"),
        };
        roboticus_db::tools::record_tool_call_with_skill(
            &state.db,
            turn_id,
            tool_name,
            &params.to_string(),
            Some(&output),
            status,
            Some(duration_ms),
            None,
            None,
            None,
        )
        .inspect_err(|e| tracing::warn!(error = %e, "failed to record virtual tool call"))
        .ok();
        return result.map(|output| ToolExecutionDetails {
            output,
            source: ToolSource::BuiltIn,
        });
    }
    if super::is_virtual_orchestration_tool(tool_name) {
        let start = std::time::Instant::now();
        let result = super::execute_virtual_orchestration_tool(
            state, tool_name, params, turn_id, authority, tier,
        )
        .await;
        let duration_ms = start.elapsed().as_millis() as i64;
        let (output, status) = match &result {
            Ok(out) => (out.clone(), "success"),
            Err(err) => (err.clone(), "error"),
        };
        roboticus_db::tools::record_tool_call_with_skill(
            &state.db,
            turn_id,
            tool_name,
            &params.to_string(),
            Some(&output),
            status,
            Some(duration_ms),
            None,
            None,
            None,
        )
        .inspect_err(|e| tracing::warn!(error = %e, "failed to record orchestration tool call"))
        .ok();
        return result.map(|output| ToolExecutionDetails {
            output,
            source: ToolSource::BuiltIn,
        });
    }
    let tool = match state.tools.get(tool_name) {
        Some(t) => t,
        None => return Err(format!("Unknown tool: {tool_name}")),
    };
    let capability = state.capabilities.get(tool_name).await;
    let tool_source = capability
        .as_ref()
        .map(|cap| capability_source_to_tool_source(cap.source()))
        .unwrap_or(ToolSource::BuiltIn);
    let (effective_risk, matched_skill) =
        resolve_run_script_policy(state, tool_name, params, authority, tool.risk_level()).await?;

    if authority == InputAuthority::Creator {
        roboticus_db::policy::record_policy_decision(
            &state.db,
            Some(turn_id),
            tool_name,
            "allow",
            Some("creator_override"),
            Some("Creator authority bypassed policy/approval gates"),
        )
        .inspect_err(|e| tracing::warn!(error = %e, "failed to record creator policy decision"))
        .ok();
    } else {
        let policy_result = super::check_tool_policy(
            &state.policy_engine,
            tool_name,
            params,
            authority,
            tier,
            effective_risk,
        );

        let (decision_str, rule_name, reason) = match &policy_result {
            Ok(()) => ("allow".to_string(), None, None),
            Err(JsonError(_status, msg)) => (
                "deny".to_string(),
                Some("policy_engine"),
                Some(msg.as_str()),
            ),
        };

        roboticus_db::policy::record_policy_decision(
            &state.db,
            Some(turn_id),
            tool_name,
            &decision_str,
            rule_name,
            reason,
        )
        .inspect_err(|e| tracing::warn!(error = %e, "failed to record policy decision"))
        .ok();

        if let Err(JsonError(_status, msg)) = policy_result {
            return Err(format!("Policy denied: {msg}"));
        }
    }

    if enforce_approval_gate && authority != InputAuthority::Creator {
        // Approval gate: block gated tools until a human approves
        match state.approvals.check_tool(tool_name) {
            Ok(roboticus_agent::approvals::ToolClassification::Gated) => {
                let request = state
                    .approvals
                    .request_approval(
                        tool_name,
                        &params.to_string(),
                        None,
                        Some(turn_id),
                        authority,
                    )
                    .map_err(|e| format!("Approval error: {e}"))?;
                roboticus_db::approvals::record_approval_request(
                    &state.db,
                    &request.id,
                    &request.tool_name,
                    &request.tool_input,
                    request.session_id.as_deref(),
                    request.turn_id.as_deref(),
                    "pending",
                    &request.timeout_at.to_rfc3339(),
                )
                .inspect_err(|e| tracing::warn!(error = %e, "failed to persist approval request"))
                .ok();
                state.event_bus.publish(
                    serde_json::json!({
                        "type": "pending_approval",
                        "tool": tool_name,
                        "request_id": request.id,
                    })
                    .to_string(),
                );
                return Err(format!(
                    "Tool '{tool_name}' requires approval (request: {})",
                    request.id
                ));
            }
            Err(e) => {
                return Err(format!("Tool blocked: {e}"));
            }
            Ok(_) => {}
        }
    }

    let (workspace_root, agent_id, agent_name, tool_allowed_paths, sandbox) = {
        let cfg = state.config.read().await;
        (
            cfg.agent.workspace.clone(),
            cfg.agent.id.clone(),
            cfg.agent.name.clone(),
            cfg.security.filesystem.tool_allowed_paths.clone(),
            roboticus_agent::tools::ToolSandboxSnapshot::from_config(
                &cfg.security.filesystem,
                &cfg.skills,
            ),
        )
    };
    let ctx = ToolContext {
        session_id: turn_id.to_string(),
        agent_id,
        agent_name,
        authority,
        workspace_root,
        tool_allowed_paths,
        channel: channel.map(|s| s.to_string()),
        db: Some(state.db.clone()),
        sandbox,
    };

    let ws_agent_id = {
        let config = state.config.read().await;
        config.agent.id.clone()
    };
    state.event_bus.publish(
        serde_json::json!({
            "type": "agent_working",
            "agent_id": ws_agent_id,
            "workstation": "exec",
            "activity": format!("tool:{tool_name}"),
            "turn_id": turn_id,
        })
        .to_string(),
    );
    if let Some((_, skill_name, _)) = matched_skill.as_ref() {
        state.event_bus.publish(
            serde_json::json!({
                "type": "skill_activated",
                "agent_id": ws_agent_id,
                "skill": skill_name,
                "tool_name": tool_name,
                "turn_id": turn_id,
            })
            .to_string(),
        );
    }

    let start = std::time::Instant::now();
    let timeout_duration = std::time::Duration::from_secs(120);
    let result = match tokio::time::timeout(timeout_duration, async {
        if let Some(cap) = capability {
            cap.execute(params.clone(), &ctx).await
        } else {
            tool.execute(params.clone(), &ctx).await
        }
    })
    .await
    {
        Ok(result) => result,
        Err(_) => Err(roboticus_agent::tools::ToolError {
            message: format!("Tool '{tool_name}' timed out after {timeout_duration:?}"),
        }),
    };
    let duration_ms = start.elapsed().as_millis() as i64;

    const MAX_TOOL_OUTPUT: usize = 16_384;
    let (output, status) = match &result {
        Ok(r) => {
            tracing::info!(
                tool = tool_name,
                duration_ms,
                output_len = r.output.len(),
                "tool executed successfully"
            );
            let mut out = if r.output.len() > MAX_TOOL_OUTPUT {
                let boundary = r.output.floor_char_boundary(MAX_TOOL_OUTPUT);
                format!(
                    "{}...\n[truncated: {} bytes total]",
                    &r.output[..boundary],
                    r.output.len()
                )
            } else {
                r.output.clone()
            };
            let mut status = "success";
            if let Some(unreadable) = r
                .metadata
                .as_ref()
                .and_then(|m| m.get("unreadable_files"))
                .and_then(|v| v.as_u64())
                && unreadable > 0
            {
                status = "partial_success";
                out = format!("{out}\n\n[warning] Search skipped {unreadable} unreadable file(s).");
            }
            // Apply noise filter chain to clean tool output before LLM sees it.
            use roboticus_agent::tool_output_filter::ToolOutputFilterChain;
            let out = ToolOutputFilterChain::default_chain().apply(tool_name, &out);
            (out, status)
        }
        Err(e) => {
            tracing::warn!(tool = tool_name, duration_ms, error = %e.message, "tool execution failed");
            (e.message.clone(), "error")
        }
    };

    let (skill_id, skill_name, skill_hash) = match matched_skill.as_ref() {
        Some((id, name, hash)) => (Some(id.as_str()), Some(name.as_str()), Some(hash.as_str())),
        None => (None, None, None),
    };
    roboticus_db::tools::record_tool_call_with_skill(
        &state.db,
        turn_id,
        tool_name,
        &params.to_string(),
        Some(&output),
        status,
        Some(duration_ms),
        skill_id,
        skill_name,
        skill_hash,
    )
    .inspect_err(|e| tracing::warn!(error = %e, "failed to record tool call"))
    .ok();

    // Publish tool execution result to EventBus for dashboard visibility.
    if status == "error" {
        state.event_bus.publish(
            serde_json::json!({
                "type": "tool_error",
                "tool": tool_name,
                "turn_id": turn_id,
                "duration_ms": duration_ms,
                "error": &output,
            })
            .to_string(),
        );
    }

    state.event_bus.publish(
        serde_json::json!({
            "type": "agent_idle",
            "agent_id": ws_agent_id,
            "workstation": "exec",
            "turn_id": turn_id,
        })
        .to_string(),
    );

    if let Err(e) = result {
        Err(e.message)
    } else {
        Ok(ToolExecutionDetails {
            output,
            source: tool_source,
        })
    }
}

/// Checks whether a tool call is allowed by the policy engine.
/// Returns Ok(()) if allowed, or an error tuple for HTTP responses.
pub(crate) fn check_tool_policy(
    engine: &roboticus_agent::policy::PolicyEngine,
    tool_name: &str,
    params: &serde_json::Value,
    authority: InputAuthority,
    tier: roboticus_core::SurvivalTier,
    risk_level: roboticus_core::RiskLevel,
) -> Result<(), JsonError> {
    let call = roboticus_agent::policy::ToolCallRequest {
        tool_name: tool_name.into(),
        params: params.clone(),
        risk_level,
    };
    let ctx = roboticus_agent::policy::PolicyContext {
        authority,
        survival_tier: tier,
        claim: None,
    };
    let decision = engine.evaluate_all(&call, &ctx);
    match decision {
        roboticus_core::PolicyDecision::Allow => Ok(()),
        roboticus_core::PolicyDecision::Deny { rule, reason } => {
            tracing::warn!(tool = tool_name, rule = %rule, reason = %reason, "Policy denied tool call");
            Err(JsonError(
                StatusCode::FORBIDDEN,
                format!("Policy denied: {reason}"),
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_tool_calls_single() {
        let input = r#"{"tool_call": {"name": "echo", "params": {"message": "hi"}}}"#;
        let calls = parse_tool_calls(input);
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].0, "echo");
    }

    #[test]
    fn parse_tool_calls_multiple() {
        let input = r#"{"tool_call": {"name": "echo", "params": {"message": "hi"}}}
{"tool_call": {"name": "web-search", "params": {"query": "rust"}}}"#;
        let calls = parse_tool_calls(input);
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0].0, "echo");
        assert_eq!(calls[1].0, "web-search");
    }

    #[test]
    fn parse_tool_calls_with_surrounding_text() {
        let input = r#"Let me help you with that.
{"tool_call": {"name": "echo", "params": {"message": "test"}}}
I will also search:
{"tool_call": {"name": "web-search", "params": {"query": "rust lang"}}}"#;
        let calls = parse_tool_calls(input);
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0].0, "echo");
        assert_eq!(calls[1].0, "web-search");
    }

    #[test]
    fn parse_tool_calls_empty() {
        let calls = parse_tool_calls("No tool calls here");
        assert!(calls.is_empty());
    }

    #[test]
    fn parse_tool_calls_unterminated_json_recovers() {
        // Missing one closing brace — recovery should add it and parse successfully
        let input = r#"{"tool_call": {"name": "echo", "params": {"message": "hi"}}"#;
        let calls = parse_tool_calls(input);
        assert_eq!(calls.len(), 1, "should recover truncated tool call");
        assert_eq!(calls[0].0, "echo");
        assert_eq!(calls[0].1["message"], "hi");
    }

    #[test]
    fn parse_tool_call_backward_compat() {
        let input = r#"Some text {"tool_call": {"name": "echo", "params": {"message": "hi"}}}"#;
        let single = parse_tool_call(input);
        assert!(single.is_some());
        assert_eq!(single.unwrap().0, "echo");
    }

    #[test]
    fn parse_tool_call_shorthand_shape() {
        let input = r#"{"tool_call":"bash","params":{"command":"ls -la"}}"#;
        let single = parse_tool_call(input).expect("should parse shorthand shape");
        assert_eq!(single.0, "bash");
        assert_eq!(single.1["command"], "ls -la");
    }

    #[test]
    fn parse_tool_calls_shorthand_shape() {
        let input = r#"{"tool_call":"orchestrate-subagents","params":{"task":"sitrep"}}"#;
        let calls = parse_tool_calls(input);
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].0, "orchestrate-subagents");
        assert_eq!(calls[0].1["task"], "sitrep");
    }

    #[test]
    fn parse_tool_call_accepts_tool_name_and_arguments_shape() {
        let input = r#"{"tool_call":{"tool_name":"compose-subagent","arguments":{"name":"finance-specialist"}}}"#;
        let single = parse_tool_call(input).expect("should parse tool_name/arguments shape");
        assert_eq!(single.0, "compose-subagent");
        assert_eq!(single.1["name"], "finance-specialist");
    }

    #[test]
    fn parse_tool_call_accepts_tool_and_input_shape() {
        let input = r#"{"tool_call":{"tool":"compose-skill","input":{"name":"forecasting"}}}"#;
        let single = parse_tool_call(input).expect("should parse tool/input shape");
        assert_eq!(single.0, "compose-skill");
        assert_eq!(single.1["name"], "forecasting");
    }

    #[test]
    fn parse_tool_calls_narrated_with_backticks() {
        // Reproduces the exact model output from moonshot.ai that failed to parse
        let input = "Perfect! I can see the `ghola` skill is available in my workspace. Let me use it to fetch weather information for Meyrin, Switzerland from meteoswiss.ch.\n{\"tool_call\":{\"name\":\"run_script\",\"params\":{\"path\":\"ghola\",\"args\":[\"https://www.meteoswiss.ch/en/weather/weather-in-your-region/meyrin.html\"]}}}";
        let calls = parse_tool_calls(input);
        assert_eq!(
            calls.len(),
            1,
            "should parse narrated tool call: {:?}",
            calls
        );
        assert_eq!(calls[0].0, "run_script");
    }

    #[test]
    fn parse_openai_structured_tool_call_with_object_arguments() {
        // When a provider returns arguments as an object instead of a JSON string,
        // translate_response must still preserve the arguments.
        let response_body = serde_json::json!({
            "id": "chatcmpl-test",
            "object": "chat.completion",
            "model": "kimi-k2-turbo-preview",
            "choices": [{
                "index": 0,
                "message": {
                    "role": "assistant",
                    "content": "Let me fetch that for you.",
                    "tool_calls": [{
                        "id": "call_1",
                        "type": "function",
                        "function": {
                            "name": "run_script",
                            // Arguments as a pre-parsed OBJECT (not a JSON string)
                            "arguments": {"path": "ghola", "args": ["https://example.com"]}
                        }
                    }]
                },
                "finish_reason": "tool_calls"
            }],
            "usage": {"prompt_tokens": 100, "completion_tokens": 50}
        });
        let resp = roboticus_llm::format::translate_response(
            &response_body,
            roboticus_core::ApiFormat::OpenAiCompletions,
        )
        .unwrap();
        // The content should contain the tool call with preserved arguments
        let calls = parse_tool_calls(&resp.content);
        assert_eq!(
            calls.len(),
            1,
            "should find tool call in translated response"
        );
        assert_eq!(calls[0].0, "run_script");
        assert_eq!(
            calls[0].1["path"], "ghola",
            "arguments should be preserved even when provider returns them as an object, not a string. Got: {}",
            calls[0].1
        );
    }

    #[test]
    fn parse_tool_calls_truncated_json_recovery() {
        // Exact reproduction: model generates tool call but truncates the JSON
        // (missing one closing brace). Parser should recover.
        let input = "Let me try a different approach:\n{\"tool_call\":{\"name\":\"bash\",\"params\":{\"command\":\"curl -s \\\"wttr.in/Meyrin?format=3\\\"\",\"timeout_seconds\":10}}";
        let calls = parse_tool_calls(input);
        assert_eq!(
            calls.len(),
            1,
            "should recover truncated tool call: {:?}",
            calls
        );
        assert_eq!(calls[0].0, "bash");
        assert_eq!(calls[0].1["command"], "curl -s \"wttr.in/Meyrin?format=3\"");
    }

    #[test]
    fn parse_tool_calls_after_sanitize_simulation() {
        // Simulate the full sanitize → parse pipeline for the moonshot response.
        // If scan_output blocks the content, parse_tool_calls never sees it.
        let content = "Perfect! I can see the `ghola` skill is available in my workspace. Let me use it to fetch weather information for Meyrin, Switzerland from meteoswiss.ch.\n{\"tool_call\":{\"name\":\"run_script\",\"params\":{\"path\":\"ghola\",\"args\":[\"https://www.meteoswiss.ch/en/weather/weather-in-your-region/meyrin.html\"]}}}";

        // Step 1: Check injection scan would NOT block this
        let blocked = roboticus_agent::injection::scan_output(content);
        assert!(
            !blocked,
            "scan_output should NOT block a legitimate tool call response"
        );

        // Step 2: Parse tool calls from the (unblocked) content
        let calls = parse_tool_calls(content);
        assert_eq!(calls.len(), 1, "tool call should parse after sanitize pass");
        assert_eq!(calls[0].0, "run_script");
        assert_eq!(calls[0].1["path"], "ghola");
    }
}
