use std::borrow::Cow;
use std::sync::Arc;

use fabro_llm::types::{ToolCall, ToolResult};
use futures::future;
use tokio_util::sync::CancellationToken;
use tracing::debug;

use crate::config::{SessionOptions, ToolHookCallback, ToolHookDecision};
use crate::event::{Emitter, SessionBoundEmitter};
use crate::question_tools::{self, AgentToolRuntime, is_question_tool};
use crate::sandbox::{OutputCaptureStats, Sandbox};
use crate::session::ToolEnvProvider;
use crate::tool_registry::{AgentEventEmitter, RegisteredTool, ToolContext, ToolRegistry};
use crate::truncation::{
    MAX_RETAINED_TOOL_OUTPUT_BYTES, preview_tool_output, serialized_json_bytes,
    truncate_tool_output,
};
use crate::types::AgentEvent;

/// Execute tool calls, choosing parallel or sequential based on `parallel`
/// flag.
#[allow(
    clippy::too_many_arguments,
    reason = "Tool dispatch needs the shared runtime handles and call list together."
)]
pub async fn execute_tool_calls(
    tool_calls: &[ToolCall],
    parallel: bool,
    registry: &ToolRegistry,
    env: Arc<dyn Sandbox>,
    tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
    cancel_token: &CancellationToken,
    config: &SessionOptions,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
    agent_tool_runtime: &AgentToolRuntime,
) -> Vec<ToolResult> {
    if tool_calls.iter().any(|tc| is_question_tool(&tc.name)) {
        return execute_question_tool_round(
            tool_calls,
            registry,
            env,
            tool_hooks,
            cancel_token,
            config,
            emitter,
            session_id,
            root_session_id,
            tool_env_provider,
            agent_tool_runtime,
        )
        .await;
    }

    if parallel && tool_calls.len() > 1 {
        execute_tool_calls_parallel(
            tool_calls,
            registry,
            env,
            tool_hooks,
            cancel_token,
            config,
            emitter,
            session_id,
            root_session_id,
            tool_env_provider,
            agent_tool_runtime,
        )
        .await
    } else {
        execute_tool_calls_sequential(
            tool_calls,
            registry,
            env,
            tool_hooks,
            cancel_token,
            config,
            emitter,
            session_id,
            root_session_id,
            tool_env_provider,
            agent_tool_runtime,
        )
        .await
    }
}

#[allow(
    clippy::too_many_arguments,
    reason = "Sequential execution threads the runtime handles through each tool call."
)]
async fn execute_tool_calls_sequential(
    tool_calls: &[ToolCall],
    registry: &ToolRegistry,
    env: Arc<dyn Sandbox>,
    tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
    cancel_token: &CancellationToken,
    config: &SessionOptions,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
    agent_tool_runtime: &AgentToolRuntime,
) -> Vec<ToolResult> {
    let mut results = Vec::new();
    for tc in tool_calls {
        if cancel_token.is_cancelled() {
            results.push(ToolResult::error(tc.id.clone(), "Cancelled"));
            continue;
        }

        let result = execute_and_emit_one_tool_with_runtime(
            tc,
            registry,
            env.clone(),
            tool_hooks,
            cancel_token.child_token(),
            config,
            emitter,
            session_id,
            root_session_id,
            tool_env_provider,
            agent_tool_runtime,
        )
        .await;
        results.push(result);
    }
    results
}

#[allow(
    clippy::too_many_arguments,
    reason = "Parallel execution threads the runtime handles into each spawned tool task."
)]
async fn execute_tool_calls_parallel(
    tool_calls: &[ToolCall],
    registry: &ToolRegistry,
    env: Arc<dyn Sandbox>,
    tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
    cancel_token: &CancellationToken,
    config: &SessionOptions,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
    agent_tool_runtime: &AgentToolRuntime,
) -> Vec<ToolResult> {
    let tool_env_provider = tool_env_provider.cloned();
    let agent_tool_runtime = agent_tool_runtime.clone();
    let futures: Vec<_> = tool_calls
        .iter()
        .map(|tc| {
            let emitter = emitter.clone();
            let env = env.clone();
            let config = config.clone();
            let cancel_token = cancel_token.clone();
            let tc = tc.clone();
            let session_id = session_id.to_owned();
            let root_session_id = root_session_id.to_owned();
            let tool_hooks = tool_hooks.cloned();
            let tool_env_provider = tool_env_provider.clone();
            let agent_tool_runtime = agent_tool_runtime.clone();
            let access_denial = config.tool_access_denial_reason(&tc.name);
            // Look up the tool before spawning since ToolRegistry is not Send.
            let registered_tool = if access_denial.is_none() {
                registry.get(&tc.name).cloned()
            } else {
                None
            };
            async move {
                execute_and_emit_one_tool_with_lookup(
                    &tc,
                    registered_tool.as_ref(),
                    access_denial,
                    env,
                    tool_hooks.as_ref(),
                    cancel_token.child_token(),
                    &config,
                    &emitter,
                    &session_id,
                    &root_session_id,
                    tool_env_provider.as_ref(),
                    &agent_tool_runtime,
                )
                .await
            }
        })
        .collect();

    future::join_all(futures).await
}

#[allow(
    clippy::too_many_arguments,
    reason = "Question-tool round handling needs the same execution context as normal tool dispatch."
)]
async fn execute_question_tool_round(
    tool_calls: &[ToolCall],
    registry: &ToolRegistry,
    env: Arc<dyn Sandbox>,
    tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
    cancel_token: &CancellationToken,
    config: &SessionOptions,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
    agent_tool_runtime: &AgentToolRuntime,
) -> Vec<ToolResult> {
    let first_question_index = tool_calls
        .iter()
        .position(|tc| is_question_tool(&tc.name))
        .expect("question-tool round should contain a question tool");
    let mut results = Vec::with_capacity(tool_calls.len());

    for (index, tc) in tool_calls.iter().enumerate() {
        if cancel_token.is_cancelled() {
            results.push(ToolResult::error(tc.id.clone(), "Cancelled"));
            continue;
        }

        if index == first_question_index {
            results.push(
                execute_and_emit_one_tool_with_runtime(
                    tc,
                    registry,
                    env.clone(),
                    tool_hooks,
                    cancel_token.child_token(),
                    config,
                    emitter,
                    session_id,
                    root_session_id,
                    tool_env_provider,
                    agent_tool_runtime,
                )
                .await,
            );
        } else if is_question_tool(&tc.name) {
            results.push(error_tool_result_with_events(
                tc,
                emitter,
                session_id,
                config,
                "Only one human-question tool call may be used in a tool round. Combine all questions into a single questions[] batch and call the question tool once.",
            ));
        } else {
            results.push(error_tool_result_with_events(
                tc,
                emitter,
                session_id,
                config,
                "This tool call was not executed because human-question tools must run alone in a tool round. Retry non-question tools in a later round after the user answers.",
            ));
        }
    }

    results
}

fn error_tool_result_with_events(
    tc: &ToolCall,
    emitter: &Emitter,
    session_id: &str,
    config: &SessionOptions,
    message: &str,
) -> ToolResult {
    emit_tool_call_started(emitter, session_id, tc);
    finish_error_result(tc, emitter, session_id, config, message)
}

/// Bound, emit, and truncate an error result for a tool call whose
/// started event was already emitted.
fn finish_error_result(
    tc: &ToolCall,
    emitter: &Emitter,
    session_id: &str,
    config: &SessionOptions,
    message: &str,
) -> ToolResult {
    let retained = retain_tool_result(ToolResult::error(&tc.id, message), None);
    emit_tool_call_result(
        emitter,
        session_id,
        tc,
        &retained.result,
        retained.output_stats,
    );
    truncate_tool_result(&retained.result, &tc.name, config)
}

fn emit_tool_call_started(emitter: &Emitter, session_id: &str, tc: &ToolCall) {
    emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted {
        tool_name:    tc.name.clone(),
        tool_call_id: tc.id.clone(),
        arguments:    tc.arguments.clone(),
    });
}

fn emit_tool_call_result(
    emitter: &Emitter,
    session_id: &str,
    tc: &ToolCall,
    result: &ToolResult,
    output_stats: OutputCaptureStats,
) {
    emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta {
        delta: result.content.to_string(),
    });
    emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted {
        tool_name:             tc.name.clone(),
        tool_call_id:          tc.id.clone(),
        output:                result.content.clone(),
        is_error:              result.is_error,
        output_bytes_observed: output_stats.observed_bytes,
        output_bytes_retained: output_stats.retained_bytes,
        output_bytes_omitted:  output_stats.omitted_bytes,
    });
}

/// Execute a single tool call with event emission and output truncation.
#[allow(
    clippy::too_many_arguments,
    reason = "Single-tool execution needs the tool, runtime handles, and emission context."
)]
pub async fn execute_and_emit_one_tool(
    tc: &ToolCall,
    registry: &ToolRegistry,
    env: Arc<dyn Sandbox>,
    tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
    cancel_token: CancellationToken,
    config: &SessionOptions,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
) -> ToolResult {
    execute_and_emit_one_tool_with_runtime(
        tc,
        registry,
        env,
        tool_hooks,
        cancel_token,
        config,
        emitter,
        session_id,
        root_session_id,
        tool_env_provider,
        &AgentToolRuntime::default(),
    )
    .await
}

#[allow(
    clippy::too_many_arguments,
    reason = "Single-tool execution needs the tool, runtime handles, and emission context."
)]
async fn execute_and_emit_one_tool_with_runtime(
    tc: &ToolCall,
    registry: &ToolRegistry,
    env: Arc<dyn Sandbox>,
    tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
    cancel_token: CancellationToken,
    config: &SessionOptions,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
    agent_tool_runtime: &AgentToolRuntime,
) -> ToolResult {
    let access_denial = config.tool_access_denial_reason(&tc.name);
    let registered_tool = if access_denial.is_none() {
        registry.get(&tc.name)
    } else {
        None
    };
    execute_and_emit_one_tool_with_lookup(
        tc,
        registered_tool,
        access_denial,
        env,
        tool_hooks,
        cancel_token,
        config,
        emitter,
        session_id,
        root_session_id,
        tool_env_provider,
        agent_tool_runtime,
    )
    .await
}

/// Execute a single tool call with event emission, using a pre-looked-up tool
/// reference.
#[allow(
    clippy::too_many_arguments,
    reason = "The looked-up execution path still needs the tool, runtime handles, and emission context."
)]
async fn execute_and_emit_one_tool_with_lookup(
    tc: &ToolCall,
    registered_tool: Option<&RegisteredTool>,
    access_denial: Option<String>,
    env: Arc<dyn Sandbox>,
    tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
    cancel_token: CancellationToken,
    config: &SessionOptions,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
    agent_tool_runtime: &AgentToolRuntime,
) -> ToolResult {
    emit_tool_call_started(emitter, session_id, tc);

    if let Some(reason) = access_denial {
        return finish_error_result(tc, emitter, session_id, config, &reason);
    }

    // Pre-tool-use hook
    if let Some(hooks) = tool_hooks {
        debug!(tool = %tc.name, hook_event = "pre_tool_use", "Calling tool hook");
        let start = std::time::Instant::now();
        let decision = hooks.pre_tool_use(&tc.name, &tc.arguments).await;
        let elapsed = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
        debug!(tool = %tc.name, hook_event = "pre_tool_use", ?decision, duration_ms = elapsed, "Tool hook complete");

        if let ToolHookDecision::Block { reason } = decision {
            return finish_error_result(tc, emitter, session_id, config, &reason);
        }
    }

    let executed = execute_one_tool(
        tc,
        registered_tool,
        env,
        cancel_token,
        emitter,
        session_id,
        root_session_id,
        tool_env_provider,
        agent_tool_runtime,
    )
    .await;
    let retained = retain_tool_result(executed.result, executed.output_stats);
    let result = retained.result;

    emit_tool_call_result(emitter, session_id, tc, &result, retained.output_stats);

    // Post-tool-use hooks
    if let Some(hooks) = tool_hooks {
        let fallback;
        let content_str = if let Some(s) = result.content.as_str() {
            s
        } else {
            fallback = result.content.to_string();
            &fallback
        };
        if result.is_error {
            debug!(tool = %tc.name, hook_event = "post_tool_use_failure", "Calling tool hook");
            hooks
                .post_tool_use_failure(&tc.name, &tc.id, content_str)
                .await;
            debug!(tool = %tc.name, hook_event = "post_tool_use_failure", "Tool hook complete");
        } else {
            debug!(tool = %tc.name, hook_event = "post_tool_use", "Calling tool hook");
            hooks.post_tool_use(&tc.name, &tc.id, content_str).await;
            debug!(tool = %tc.name, hook_event = "post_tool_use", "Tool hook complete");
        }
    }

    truncate_tool_result(&result, &tc.name, config)
}

struct RetainedToolResult {
    result:       ToolResult,
    output_stats: OutputCaptureStats,
}

/// Bound model-native tool output before it reaches hooks, events, or history.
fn retain_tool_result(
    mut result: ToolResult,
    previous_stats: Option<OutputCaptureStats>,
) -> RetainedToolResult {
    let output_stats = match &mut result.content {
        serde_json::Value::String(output) => {
            let previously_omitted = previous_stats.map_or(0, |stats| stats.omitted_bytes);
            let previewed =
                preview_tool_output(output, MAX_RETAINED_TOOL_OUTPUT_BYTES, previously_omitted);
            let stats = previewed.stats;
            if let Cow::Owned(previewed_output) = previewed.output {
                *output = previewed_output;
            }
            stats
        }
        other => OutputCaptureStats::complete(serialized_json_bytes(other)),
    };

    RetainedToolResult {
        result,
        output_stats,
    }
}

struct ExecutedToolResult {
    result:       ToolResult,
    output_stats: Option<OutputCaptureStats>,
}

/// Execute a single tool call: argument validation and execution.
#[allow(
    clippy::too_many_arguments,
    reason = "Single-tool execution threads session identity plus runtime handles to populate ToolContext."
)]
async fn execute_one_tool(
    tc: &ToolCall,
    registered_tool: Option<&RegisteredTool>,
    env: Arc<dyn Sandbox>,
    cancel_token: CancellationToken,
    emitter: &Emitter,
    session_id: &str,
    root_session_id: &str,
    tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
    agent_tool_runtime: &AgentToolRuntime,
) -> ExecutedToolResult {
    match registered_tool {
        Some(tool) => {
            if tc.tool_type != "custom" {
                if let Err(validation_error) =
                    validate_tool_args(&tool.definition.parameters, &tc.arguments)
                {
                    return ExecutedToolResult {
                        result:       ToolResult::error(&tc.id, validation_error),
                        output_stats: None,
                    };
                }
            }

            let session_emitter = Arc::new(SessionBoundEmitter::new(
                emitter.clone(),
                session_id.to_owned(),
                Some(tc.id.clone()),
            ));
            let agent_event_emitter: Option<Arc<dyn AgentEventEmitter>> =
                Some(session_emitter.clone());
            let ctx = ToolContext {
                env,
                cancel: cancel_token,
                tool_env_provider: tool_env_provider.cloned(),
                session_id: Some(session_id.to_owned()),
                root_session_id: Some(root_session_id.to_owned()),
                tool_call_id: Some(tc.id.clone()),
                agent_event_emitter,
            };
            let execution = (tool.executor)(tc.arguments.clone(), ctx);
            let result = match question_tools::scope_agent_tool_runtime(
                agent_tool_runtime.clone(),
                execution,
            )
            .await
            {
                Ok(output) => ToolResult::success(&tc.id, serde_json::json!(output)),
                Err(err) => ToolResult::error(&tc.id, err),
            };
            ExecutedToolResult {
                result,
                output_stats: session_emitter.take_tool_output_stats(),
            }
        }
        None => ExecutedToolResult {
            result:       ToolResult::error(&tc.id, format!("Unknown tool: {}", tc.name)),
            output_stats: None,
        },
    }
}

/// Truncate tool output for history storage while preserving identity fields.
fn truncate_tool_result(
    result: &ToolResult,
    tool_name: &str,
    config: &SessionOptions,
) -> ToolResult {
    let truncated_content = match &result.content {
        serde_json::Value::String(s) => {
            serde_json::json!(truncate_tool_output(s, tool_name, config))
        }
        other => other.clone(),
    };

    ToolResult {
        tool_call_id:     result.tool_call_id.clone(),
        content:          truncated_content,
        is_error:         result.is_error,
        image_data:       result.image_data.clone(),
        image_media_type: result.image_media_type.clone(),
    }
}

pub fn validate_tool_args(
    schema: &serde_json::Value,
    args: &serde_json::Value,
) -> Result<(), String> {
    // Skip validation for empty/trivial schemas
    if schema.is_null() {
        return Ok(());
    }
    if let Some(obj) = schema.as_object() {
        if obj.is_empty() {
            return Ok(());
        }
    }

    let validator =
        jsonschema::validator_for(schema).map_err(|e| format!("Invalid tool schema: {e}"))?;

    let errors: Vec<String> = validator.iter_errors(args).map(|e| e.to_string()).collect();

    if errors.is_empty() {
        Ok(())
    } else {
        Err(format!(
            "Tool argument validation failed: {}",
            errors.join("; ")
        ))
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;
    use std::sync::{Arc, Mutex};

    use async_trait::async_trait;
    use fabro_llm::types::{ToolCall, ToolDefinition};
    use fabro_model::AgentProfileKind;
    use fabro_types::run_event::{AgentToolCompletedProps, MAX_RUN_EVENT_BODY_BYTES};
    use tokio::sync::broadcast;

    use super::*;
    use crate::config::{
        ToolAccess, ToolAccessPolicy, ToolExposureMode, ToolHookCallback, ToolHookDecision,
    };
    use crate::event::Emitter;
    use crate::local_sandbox::LocalSandbox;
    use crate::question_tools::{
        AgentQuestion, AgentQuestionAnswer, AgentQuestionAnswerStatus, AgentQuestionRuntime,
        AgentToolRuntime, register_question_tools,
    };
    use crate::test_support::MockSandbox;
    use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
    use crate::tools::make_shell_tool;
    use crate::truncation::MAX_SERIALIZED_TOOL_OUTPUT_BYTES;
    use crate::types::SessionEvent;

    struct NamedPolicy {
        decisions: HashMap<String, ToolAccess>,
    }

    impl NamedPolicy {
        fn new(decisions: impl IntoIterator<Item = (&'static str, ToolAccess)>) -> Self {
            Self {
                decisions: decisions
                    .into_iter()
                    .map(|(name, access)| (name.to_string(), access))
                    .collect(),
            }
        }
    }

    impl ToolAccessPolicy for NamedPolicy {
        fn access_for_tool(&self, tool_name: &str) -> ToolAccess {
            self.decisions
                .get(tool_name)
                .copied()
                .unwrap_or(ToolAccess::Denied)
        }
    }

    fn make_echo_tool() -> RegisteredTool {
        RegisteredTool {
            definition: ToolDefinition {
                name:        "echo".to_string(),
                description: "Echo input".to_string(),
                parameters:  serde_json::json!({
                    "type": "object",
                    "properties": {
                        "text": {"type": "string"}
                    },
                    "required": ["text"]
                }),
            },
            executor:   Arc::new(|args: serde_json::Value, _ctx: ToolContext| {
                Box::pin(async move {
                    let text = args["text"].as_str().unwrap_or("").to_string();
                    Ok(format!("echo: {text}"))
                })
            }),
            source:     ToolSource::Native,
        }
    }

    fn make_fail_tool() -> RegisteredTool {
        RegisteredTool {
            definition: ToolDefinition {
                name:        "fail_tool".to_string(),
                description: "Always fails".to_string(),
                parameters:  serde_json::json!({}),
            },
            executor:   Arc::new(|_args: serde_json::Value, _ctx: ToolContext| {
                Box::pin(async move { Err("tool failed".to_string()) })
            }),
            source:     ToolSource::Native,
        }
    }

    fn make_tool_call(name: &str, id: &str, args: serde_json::Value) -> ToolCall {
        ToolCall {
            id:                id.to_string(),
            name:              name.to_string(),
            tool_type:         "function".to_string(),
            arguments:         args,
            raw_arguments:     None,
            provider_metadata: None,
        }
    }

    struct StubQuestionRuntime;

    #[async_trait]
    impl AgentQuestionRuntime for StubQuestionRuntime {
        async fn ask_questions(
            &self,
            _tool_call_id: &str,
            questions: Vec<AgentQuestion>,
            _cancel_token: CancellationToken,
        ) -> Result<Vec<AgentQuestionAnswer>, String> {
            Ok(questions
                .into_iter()
                .map(|question| AgentQuestionAnswer {
                    original_id:       question.original_id,
                    original_question: question.original_question,
                    answers:           vec!["Ship".to_string()],
                    status:            AgentQuestionAnswerStatus::Answered,
                })
                .collect())
        }
    }

    #[tokio::test]
    async fn question_tool_round_rejects_non_question_peers_and_preserves_order() {
        let mut registry = ToolRegistry::new();
        register_question_tools(AgentProfileKind::OpenAi, &mut registry);
        registry.register(make_echo_tool());
        let tool_calls = vec![
            make_tool_call(
                "request_user_input",
                "call_question",
                serde_json::json!({
                    "questions": [{
                        "id": "q1",
                        "header": "Decision",
                        "question": "Ship it?",
                        "options": [{ "label": "Ship" }]
                    }]
                }),
            ),
            make_tool_call("echo", "call_echo", serde_json::json!({"text": "hello"})),
        ];
        let runtime = AgentToolRuntime::with_question_runtime(Arc::new(StubQuestionRuntime));

        let results = execute_tool_calls(
            &tool_calls,
            true,
            &registry,
            Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())),
            None,
            &CancellationToken::new(),
            &SessionOptions::default(),
            &Emitter::new(),
            "root",
            "root",
            None,
            &runtime,
        )
        .await;

        assert_eq!(results.len(), 2);
        assert_eq!(results[0].tool_call_id, "call_question");
        assert!(!results[0].is_error);
        assert_eq!(results[1].tool_call_id, "call_echo");
        assert!(results[1].is_error);
        assert!(
            results[1]
                .content
                .as_str()
                .unwrap()
                .contains("human-question tools must run alone")
        );
    }

    #[tokio::test]
    async fn multiple_question_tool_calls_execute_only_first() {
        let mut registry = ToolRegistry::new();
        register_question_tools(AgentProfileKind::OpenAi, &mut registry);
        let question_args = serde_json::json!({
            "questions": [{
                "id": "q1",
                "header": "Decision",
                "question": "Ship it?",
                "options": [{ "label": "Ship" }]
            }]
        });
        let tool_calls = vec![
            make_tool_call("request_user_input", "call_first", question_args.clone()),
            make_tool_call("request_user_input", "call_second", question_args),
        ];
        let runtime = AgentToolRuntime::with_question_runtime(Arc::new(StubQuestionRuntime));

        let results = execute_tool_calls(
            &tool_calls,
            true,
            &registry,
            Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())),
            None,
            &CancellationToken::new(),
            &SessionOptions::default(),
            &Emitter::new(),
            "root",
            "root",
            None,
            &runtime,
        )
        .await;

        assert!(!results[0].is_error);
        assert!(results[1].is_error);
        assert!(
            results[1]
                .content
                .as_str()
                .unwrap()
                .contains("Combine all questions into a single questions[] batch")
        );
    }

    struct MockHookCallback {
        pre_decision:       ToolHookDecision,
        post_calls:         Arc<Mutex<Vec<(String, String, String)>>>,
        post_failure_calls: Arc<Mutex<Vec<(String, String, String)>>>,
    }

    impl MockHookCallback {
        fn new(decision: ToolHookDecision) -> Self {
            Self {
                pre_decision:       decision,
                post_calls:         Arc::new(Mutex::new(Vec::new())),
                post_failure_calls: Arc::new(Mutex::new(Vec::new())),
            }
        }
    }

    #[async_trait::async_trait]
    impl ToolHookCallback for MockHookCallback {
        async fn pre_tool_use(
            &self,
            _tool_name: &str,
            _tool_input: &serde_json::Value,
        ) -> ToolHookDecision {
            self.pre_decision.clone()
        }

        async fn post_tool_use(&self, tool_name: &str, tool_call_id: &str, tool_output: &str) {
            self.post_calls.lock().unwrap().push((
                tool_name.to_string(),
                tool_call_id.to_string(),
                tool_output.to_string(),
            ));
        }

        async fn post_tool_use_failure(&self, tool_name: &str, tool_call_id: &str, error: &str) {
            self.post_failure_calls.lock().unwrap().push((
                tool_name.to_string(),
                tool_call_id.to_string(),
                error.to_string(),
            ));
        }
    }

    fn make_sandbox() -> Arc<dyn Sandbox> {
        Arc::new(LocalSandbox::new(std::env::current_dir().unwrap()))
    }

    #[tokio::test]
    async fn pre_tool_use_hook_blocks_execution() {
        let mut registry = ToolRegistry::new();
        registry.register(make_echo_tool());

        let hooks: Arc<dyn ToolHookCallback> =
            Arc::new(MockHookCallback::new(ToolHookDecision::Block {
                reason: "blocked by hook".to_string(),
            }));

        let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"}));
        let emitter = Emitter::new();
        let config = SessionOptions::default();

        let result = execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            Some(&hooks),
            CancellationToken::new(),
            &config,
            &emitter,
            "test-session",
            "test-session",
            None,
        )
        .await;

        assert!(result.is_error);
        let content = result.content.as_str().unwrap();
        assert!(content.contains("blocked by hook"));
    }

    #[tokio::test]
    async fn pre_tool_use_hook_proceeds() {
        let mut registry = ToolRegistry::new();
        registry.register(make_echo_tool());

        let hooks: Arc<dyn ToolHookCallback> =
            Arc::new(MockHookCallback::new(ToolHookDecision::Proceed));

        let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"}));
        let emitter = Emitter::new();
        let config = SessionOptions::default();

        let result = execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            Some(&hooks),
            CancellationToken::new(),
            &config,
            &emitter,
            "test-session",
            "test-session",
            None,
        )
        .await;

        assert!(!result.is_error);
        let content = result.content.to_string();
        assert!(content.contains("echo: hello"));
    }

    #[tokio::test]
    async fn tool_output_is_bounded_before_events_and_history() {
        let mut registry = ToolRegistry::new();
        registry.register(make_echo_tool());
        let text = "x".repeat(MAX_RETAINED_TOOL_OUTPUT_BYTES + 100);
        let tc = make_tool_call("echo", "call_large", serde_json::json!({"text": text}));
        let emitter = Emitter::new();
        let mut receiver = emitter.subscribe();

        let result = execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            None,
            CancellationToken::new(),
            &SessionOptions::default(),
            &emitter,
            "test-session",
            "test-session",
            None,
        )
        .await;

        let result_output = result.content.as_str().expect("string tool output");
        assert!(result_output.len() <= MAX_RETAINED_TOOL_OUTPUT_BYTES);
        assert!(result_output.starts_with("Warning: truncated output"));
        assert!(result_output.contains("bytes omitted"));
        assert!(result_output.contains("tokens truncated"));
        assert!(!result_output.contains("re-run"));

        let completed = loop {
            let event = receiver.try_recv().expect("tool completion event");
            if let AgentEvent::ToolCallCompleted {
                output,
                is_error,
                output_bytes_observed,
                output_bytes_retained,
                output_bytes_omitted,
                ..
            } = event.event
            {
                break (
                    output,
                    is_error,
                    output_bytes_observed,
                    output_bytes_retained,
                    output_bytes_omitted,
                );
            }
        };
        let event_output = completed.0.as_str().expect("string event output");
        assert_eq!(event_output, result_output);
        assert!(!completed.1, "truncation must not make the tool an error");
        assert_eq!(
            completed.2,
            MAX_RETAINED_TOOL_OUTPUT_BYTES + 100 + "echo: ".len()
        );
        assert!(completed.3 < MAX_RETAINED_TOOL_OUTPUT_BYTES);
        assert_eq!(completed.4, completed.2 - completed.3);
        assert!(event_output.contains(&format!("... {} bytes omitted ...", completed.4)));
    }

    #[tokio::test]
    async fn serialized_tool_output_and_full_event_stay_within_reserved_budgets() {
        let mut registry = ToolRegistry::new();
        registry.register(make_echo_tool());
        let text = format!(
            "HEAD{}TAIL",
            "\0".repeat(MAX_RETAINED_TOOL_OUTPUT_BYTES - "echo: HEADTAIL".len())
        );
        let tc = make_tool_call("echo", "call_escaped", serde_json::json!({"text": text}));
        let emitter = Emitter::new();
        let mut receiver = emitter.subscribe();

        let result = execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            None,
            CancellationToken::new(),
            &SessionOptions::default(),
            &emitter,
            "test-session",
            "test-session",
            None,
        )
        .await;

        assert!(!result.is_error);
        let completed = loop {
            let event = receiver.try_recv().expect("tool completion event");
            if let AgentEvent::ToolCallCompleted {
                tool_name,
                tool_call_id,
                output,
                is_error,
                output_bytes_observed,
                output_bytes_retained,
                output_bytes_omitted,
            } = event.event
            {
                break (
                    tool_name,
                    tool_call_id,
                    output,
                    is_error,
                    output_bytes_observed,
                    output_bytes_retained,
                    output_bytes_omitted,
                );
            }
        };

        let serialized_output_bytes = serde_json::to_vec(&completed.2)
            .expect("tool output serializes")
            .len();
        assert!(serialized_output_bytes <= MAX_SERIALIZED_TOOL_OUTPUT_BYTES);

        let run_id = fabro_types::RunId::new();
        let run_event = fabro_types::RunEvent {
            id: "evt-escaped-output".to_string(),
            ts: chrono::Utc::now(),
            run_id,
            node_id: None,
            node_label: None,
            stage_id: None,
            parallel_group_id: None,
            parallel_branch_id: None,
            session_id: Some("test-session".to_string()),
            parent_session_id: None,
            tool_call_id: Some(completed.1.clone()),
            actor: None,
            body: fabro_types::EventBody::AgentToolCompleted(AgentToolCompletedProps {
                tool_name:             completed.0,
                tool_call_id:          completed.1,
                output:                completed.2,
                is_error:              completed.3,
                visit:                 1,
                output_bytes_observed: Some(completed.4 as u64),
                output_bytes_retained: Some(completed.5 as u64),
                output_bytes_omitted:  Some(completed.6 as u64),
                tool_result:           None,
                turn_id:               None,
            }),
        };
        let serialized_event_bytes = serde_json::to_vec(&run_event)
            .expect("run event serializes")
            .len();
        // Leave at least 1 MiB of envelope headroom under the server's
        // run-event body limit.
        let event_body_budget = MAX_RUN_EVENT_BODY_BYTES - 1024 * 1024;
        assert!(
            serialized_event_bytes < event_body_budget,
            "serialized event was {serialized_event_bytes} bytes"
        );
    }

    #[tokio::test]
    async fn post_tool_use_hook_fires_on_success() {
        let mut registry = ToolRegistry::new();
        registry.register(make_echo_tool());

        let mock = Arc::new(MockHookCallback::new(ToolHookDecision::Proceed));
        let hooks: Arc<dyn ToolHookCallback> = mock.clone();

        let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"}));
        let emitter = Emitter::new();
        let config = SessionOptions::default();

        execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            Some(&hooks),
            CancellationToken::new(),
            &config,
            &emitter,
            "test-session",
            "test-session",
            None,
        )
        .await;

        let calls = mock.post_calls.lock().unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].0, "echo");
        assert_eq!(calls[0].1, "call_1");
        assert!(calls[0].2.contains("echo: hello"));

        let failure_calls = mock.post_failure_calls.lock().unwrap();
        assert!(failure_calls.is_empty());
    }

    #[tokio::test]
    async fn post_tool_use_failure_hook_fires_on_error() {
        let mut registry = ToolRegistry::new();
        registry.register(make_fail_tool());

        let mock = Arc::new(MockHookCallback::new(ToolHookDecision::Proceed));
        let hooks: Arc<dyn ToolHookCallback> = mock.clone();

        let tc = make_tool_call("fail_tool", "call_1", serde_json::json!({}));
        let emitter = Emitter::new();
        let config = SessionOptions::default();

        execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            Some(&hooks),
            CancellationToken::new(),
            &config,
            &emitter,
            "test-session",
            "test-session",
            None,
        )
        .await;

        let failure_calls = mock.post_failure_calls.lock().unwrap();
        assert_eq!(failure_calls.len(), 1);
        assert_eq!(failure_calls[0].0, "fail_tool");
        assert_eq!(failure_calls[0].1, "call_1");
        assert!(failure_calls[0].2.contains("tool failed"));

        let calls = mock.post_calls.lock().unwrap();
        assert!(calls.is_empty());
    }

    #[tokio::test]
    async fn no_hooks_skips_all_callbacks() {
        let mut registry = ToolRegistry::new();
        registry.register(make_echo_tool());

        let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"}));
        let emitter = Emitter::new();
        let config = SessionOptions::default();

        let result = execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            None,
            CancellationToken::new(),
            &config,
            &emitter,
            "test-session",
            "test-session",
            None,
        )
        .await;

        assert!(!result.is_error);
        let content = result.content.to_string();
        assert!(content.contains("echo: hello"));
    }

    #[tokio::test]
    async fn denied_policy_tool_is_blocked_before_executor_lookup() {
        let executions = Arc::new(Mutex::new(0usize));
        let mut registry = ToolRegistry::new();
        let executions_for_tool = Arc::clone(&executions);
        registry.register(RegisteredTool {
            definition: ToolDefinition {
                name:        "write_file".to_string(),
                description: "Writes a file".to_string(),
                parameters:  serde_json::json!({"type": "object"}),
            },
            executor:   Arc::new(move |_args: serde_json::Value, _ctx: ToolContext| {
                let executions = Arc::clone(&executions_for_tool);
                Box::pin(async move {
                    *executions.lock().unwrap() += 1;
                    Ok("wrote".to_string())
                })
            }),
            source:     ToolSource::Native,
        });
        let config = SessionOptions {
            tool_access_policy: Some(Arc::new(NamedPolicy::new([(
                "write_file",
                ToolAccess::Denied,
            )]))),
            tool_exposure_mode: ToolExposureMode::IncludeRequiresApproval,
            ..SessionOptions::default()
        };

        let tc = make_tool_call("write_file", "call_1", serde_json::json!({}));
        let result = execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            None,
            CancellationToken::new(),
            &config,
            &Emitter::new(),
            "test-session",
            "test-session",
            None,
        )
        .await;

        assert!(result.is_error);
        assert!(
            result
                .content
                .as_str()
                .unwrap_or_default()
                .contains("denied by tool access policy")
        );
        assert_eq!(*executions.lock().unwrap(), 0);
    }

    #[tokio::test]
    async fn approval_required_tool_hidden_by_exposure_mode_is_blocked() {
        let executions = Arc::new(Mutex::new(0usize));
        let mut registry = ToolRegistry::new();
        let executions_for_tool = Arc::clone(&executions);
        registry.register(RegisteredTool {
            definition: ToolDefinition {
                name:        "shell".to_string(),
                description: "Runs a command".to_string(),
                parameters:  serde_json::json!({"type": "object"}),
            },
            executor:   Arc::new(move |_args: serde_json::Value, _ctx: ToolContext| {
                let executions = Arc::clone(&executions_for_tool);
                Box::pin(async move {
                    *executions.lock().unwrap() += 1;
                    Ok("ran".to_string())
                })
            }),
            source:     ToolSource::Native,
        });
        let config = SessionOptions {
            tool_access_policy: Some(Arc::new(NamedPolicy::new([(
                "shell",
                ToolAccess::RequiresApproval,
            )]))),
            tool_exposure_mode: ToolExposureMode::AutoApprovedOnly,
            ..SessionOptions::default()
        };

        let tc = make_tool_call("shell", "call_1", serde_json::json!({}));
        let result = execute_and_emit_one_tool(
            &tc,
            &registry,
            make_sandbox(),
            None,
            CancellationToken::new(),
            &config,
            &Emitter::new(),
            "test-session",
            "test-session",
            None,
        )
        .await;

        assert!(result.is_error);
        assert!(
            result
                .content
                .as_str()
                .unwrap_or_default()
                .contains("requires approval")
        );
        assert_eq!(*executions.lock().unwrap(), 0);
    }

    fn shell_sandbox(result: fabro_sandbox::ExecResult) -> Arc<dyn Sandbox> {
        Arc::new(MockSandbox {
            exec_result: result,
            ..Default::default()
        })
    }

    fn exited(exit_code: i32) -> fabro_sandbox::ExecResult {
        fabro_sandbox::ExecResult {
            stdout:      "out".into(),
            stderr:      "err".into(),
            exit_code:   Some(exit_code),
            termination: fabro_types::CommandTermination::Exited,
            duration_ms: 12,
        }
    }

    fn cancelled() -> fabro_sandbox::ExecResult {
        fabro_sandbox::ExecResult {
            stdout:      "out".into(),
            stderr:      String::new(),
            exit_code:   None,
            termination: fabro_types::CommandTermination::Cancelled,
            duration_ms: 12,
        }
    }

    async fn run_shell_tool(
        exec_result: fabro_sandbox::ExecResult,
        hooks: Option<&Arc<dyn ToolHookCallback>>,
        emitter: &Emitter,
    ) -> ToolResult {
        let mut registry = ToolRegistry::new();
        registry.register(make_shell_tool());
        let tc = make_tool_call(
            "shell",
            "call_1",
            serde_json::json!({"command": "make test"}),
        );

        execute_and_emit_one_tool(
            &tc,
            &registry,
            shell_sandbox(exec_result),
            hooks,
            CancellationToken::new(),
            &SessionOptions::default(),
            emitter,
            "test-session",
            "test-session",
            None,
        )
        .await
    }

    fn drain(receiver: &mut broadcast::Receiver<SessionEvent>) -> Vec<SessionEvent> {
        let mut events = Vec::new();
        while let Ok(event) = receiver.try_recv() {
            events.push(event);
        }
        events
    }

    #[tokio::test]
    async fn shell_nonzero_exit_becomes_an_error_tool_result() {
        let emitter = Emitter::new();
        let result = run_shell_tool(exited(7), None, &emitter).await;

        assert!(result.is_error);
        assert!(
            result.content.as_str().unwrap().contains("Exit code: 7"),
            "got: {}",
            result.content
        );
    }

    #[tokio::test]
    async fn shell_exit_zero_remains_a_successful_tool_result() {
        let emitter = Emitter::new();
        let result = run_shell_tool(exited(0), None, &emitter).await;

        assert!(!result.is_error);
    }

    #[tokio::test]
    async fn shell_events_record_process_and_rendered_output_byte_counts() {
        let output_len = MAX_RETAINED_TOOL_OUTPUT_BYTES + 1_000;
        let emitter = Emitter::new();
        let mut receiver = emitter.subscribe();
        let result = run_shell_tool(
            fabro_sandbox::ExecResult {
                stdout:      "x".repeat(output_len),
                stderr:      String::new(),
                exit_code:   Some(0),
                termination: fabro_types::CommandTermination::Exited,
                duration_ms: 12,
            },
            None,
            &emitter,
        )
        .await;

        assert!(!result.is_error);
        let events = drain(&mut receiver);
        let process = events
            .iter()
            .find_map(|event| match &event.event {
                AgentEvent::ToolProcessCompleted {
                    output_bytes_observed,
                    output_bytes_retained,
                    output_bytes_omitted,
                    ..
                } => Some((
                    *output_bytes_observed,
                    *output_bytes_retained,
                    *output_bytes_omitted,
                )),
                _ => None,
            })
            .expect("process event");
        assert_eq!(process, (output_len, MAX_RETAINED_TOOL_OUTPUT_BYTES, 1_000));

        let completed = events
            .iter()
            .find_map(|event| match &event.event {
                AgentEvent::ToolCallCompleted {
                    output,
                    is_error,
                    output_bytes_observed,
                    output_bytes_retained,
                    output_bytes_omitted,
                    ..
                } => Some((
                    output.as_str().expect("string event output"),
                    *is_error,
                    *output_bytes_observed,
                    *output_bytes_retained,
                    *output_bytes_omitted,
                )),
                _ => None,
            })
            .expect("tool completion event");
        assert!(completed.0.starts_with("Warning: truncated output"));
        assert!(!completed.1, "truncation must not make the tool an error");
        assert!(completed.2 > output_len);
        assert!(completed.3 < MAX_RETAINED_TOOL_OUTPUT_BYTES);
        assert_eq!(completed.4, completed.2 - completed.3);
        assert!(
            completed
                .0
                .contains(&format!("... {} bytes omitted ...", completed.4))
        );
    }

    #[tokio::test]
    async fn shell_failure_emits_started_then_process_then_completed() {
        let emitter = Emitter::new();
        let mut receiver = emitter.subscribe();
        run_shell_tool(exited(7), None, &emitter).await;

        let events = drain(&mut receiver);
        let names: Vec<&str> = events
            .iter()
            .filter_map(|event| match &event.event {
                AgentEvent::ToolCallStarted { .. } => Some("started"),
                AgentEvent::ToolProcessCompleted { .. } => Some("process"),
                AgentEvent::ToolCallCompleted { .. } => Some("completed"),
                _ => None,
            })
            .collect();
        assert_eq!(names, vec!["started", "process", "completed"]);

        for event in &events {
            assert_eq!(event.session_id, "test-session");
        }
        let process = events
            .iter()
            .find(|event| matches!(event.event, AgentEvent::ToolProcessCompleted { .. }))
            .expect("process event");
        assert_eq!(process.tool_call_id.as_deref(), Some("call_1"));
        match &process.event {
            AgentEvent::ToolProcessCompleted {
                exit_code,
                termination,
                ..
            } => {
                assert_eq!(*exit_code, Some(7));
                assert_eq!(*termination, fabro_types::CommandTermination::Exited);
            }
            other => panic!("expected a process event, got {other:?}"),
        }

        let completed = events
            .iter()
            .find_map(|event| match &event.event {
                AgentEvent::ToolCallCompleted {
                    tool_call_id,
                    is_error,
                    ..
                } => Some((tool_call_id.clone(), *is_error)),
                _ => None,
            })
            .expect("tool completed event");
        assert_eq!(completed, ("call_1".to_string(), true));
    }

    #[tokio::test]
    async fn shell_failure_runs_only_the_failure_hook() {
        for exec_result in [exited(7), cancelled()] {
            let mock = Arc::new(MockHookCallback::new(ToolHookDecision::Proceed));
            let hooks: Arc<dyn ToolHookCallback> = mock.clone();
            run_shell_tool(exec_result, Some(&hooks), &Emitter::new()).await;

            assert_eq!(mock.post_failure_calls.lock().unwrap().len(), 1);
            assert!(mock.post_calls.lock().unwrap().is_empty());
        }
    }

    #[tokio::test]
    async fn shell_success_runs_only_the_success_hook() {
        let mock = Arc::new(MockHookCallback::new(ToolHookDecision::Proceed));
        let hooks: Arc<dyn ToolHookCallback> = mock.clone();
        run_shell_tool(exited(0), Some(&hooks), &Emitter::new()).await;

        assert_eq!(mock.post_calls.lock().unwrap().len(), 1);
        assert!(mock.post_failure_calls.lock().unwrap().is_empty());
    }

    #[test]
    fn truncation_preserves_tool_call_id_and_error_state() {
        let result = ToolResult::error("call_1", "x".repeat(60_000));

        let truncated = truncate_tool_result(&result, "shell", &SessionOptions::default());

        assert_eq!(truncated.tool_call_id, "call_1");
        assert!(truncated.is_error);
        assert!(truncated.content.as_str().unwrap().len() < 60_000);
    }
}
