//! Agent conversation FSM.
//!
//! Pure state machine that returns effects as data.
//! The driver is responsible for executing effects and feeding events back.
//!
//! The FSM owns the conversation event log and tool lifecycle state.
//! It never performs IO directly.

pub mod effects;
pub mod events;
pub mod tools;

#[cfg(test)]
mod tests;

use std::collections::HashMap;

use effects::{Effect, ExitAction, PermissionTarget, TimeoutKind};
use events::{Event, PermissionChoice, PermissionResponse};
use serde_json::Value;
use tools::{ToolManager, ToolState};

use crate::context_window::ContextWindowBuilder;
use crate::tui::state::ConversationEvent;

// ============================================================================
// State
// ============================================================================

/// Result contents for tool calls the FSM resolves without executing them.
/// These are persisted in the event log, and the transcript view matches on
/// them to label the failure ("denied", "cancelled"), so they live as
/// constants rather than inline strings.
pub const RESULT_USER_CANCELLED: &str = "Error: user cancelled this operation";
pub const RESULT_DENIED_BY_RULES: &str = "Permission denied on the user's system";
pub const RESULT_DENIED_BY_USER: &str = "Permission denied by the user";

/// The discrete states of the agent FSM.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentState {
    /// Waiting for user input.
    Idle {
        confirmation: Option<PendingConfirmation>,
    },

    /// A conversation turn is in progress.
    Turn {
        stream: StreamPhase,
    },

    /// Unrecoverable error. User can retry or exit.
    Error(String),
}

/// Stream connection lifecycle within a Turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamPhase {
    /// Request sent, awaiting first stream frame.
    Connecting,
    /// Actively receiving streamed response.
    Streaming {
        status: Option<StreamingStatus>,
    },
    /// Stream connection has ended (Done received).
    Done,
}

/// Streaming status indicators from server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamingStatus {
    Processing,
    Searching,
    Thinking,
    WaitingForTools,
}

impl StreamingStatus {
    pub(crate) fn from_str(s: &str) -> Self {
        match s {
            "processing" => Self::Processing,
            "searching" => Self::Searching,
            "waiting_for_tools" => Self::WaitingForTools,
            _ => Self::Thinking,
        }
    }
}

impl StreamingStatus {
    pub(crate) fn to_str(&self) -> &str {
        match &self {
            Self::Processing => "processing",
            Self::Searching => "searching",
            Self::Thinking => "thinking",
            Self::WaitingForTools => "waiting for tools",
        }
    }
}

/// Pending dangerous command confirmation state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingConfirmation {
    pub command: String,
    pub timeout_id: u64,
}

/// The /model picker, rendered by the view when present on the context.
///
/// While `Loading` the input box stays visible (there'd be no focusable
/// component otherwise); `Ready` swaps it for the selection list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelPicker {
    Loading,
    Ready(crate::models::ModelList),
}

// ============================================================================
// Context
// ============================================================================

/// Shared context owned by the FSM.
#[derive(Debug, Clone)]
pub struct AgentContext {
    /// The full conversation event log (source of truth for API + persistence).
    pub events: Vec<ConversationEvent>,
    /// Server-assigned session ID.
    pub session_id: Option<String>,
    /// Accumulated text from current stream (committed to events on tool call or stream end).
    pub current_response: String,
    /// Per-tool lifecycle state and cached render data.
    /// Tools persist across turns for rendering history.
    pub tools: ToolManager,
    /// Tool IDs that belong to the current turn. Cleared on continuation start.
    /// Used to determine whether a turn needs continuation (has unprocessed results).
    current_turn_tool_ids: Vec<String>,
    /// Maps timeout_id → tool_id for active tool execution timeouts.
    /// Cleaned up when a tool completes naturally, so stale timeouts are ignored.
    tool_timeout_ids: HashMap<u64, String>,
    /// Counter for generating unique timeout IDs.
    next_timeout_id: u64,
    /// Capabilities advertised to the server.
    pub capabilities: Vec<String>,
    /// Unique invocation ID for this CLI invocation.
    pub invocation_id: String,
    /// Model alias sent with chat requests. `None` = server default.
    /// Seeded from `ai.model` at startup; updated by the /model picker.
    pub model: Option<String>,
    /// Model list fetched this invocation. Later /model calls reuse it
    /// instead of re-hitting the server.
    pub models_cache: Option<crate::models::ModelList>,
    /// Open /model picker, if any.
    pub model_picker: Option<ModelPicker>,

    // ─── View state (owned by FSM for atomic transitions) ───────
    /// Index into events where the current TUI invocation starts.
    /// Events before this are context for the API but not rendered.
    pub view_start_index: usize,
    /// Whether this session was resumed from a prior invocation.
    pub is_resumed: bool,
    /// Time of the last event from a previous invocation.
    pub last_event_time: Option<chrono::DateTime<chrono::Utc>>,
    /// Events from archived sessions (/new) still rendered on screen.
    pub archived_events: Vec<ConversationEvent>,
}

impl AgentContext {
    #[must_use]
    fn next_timeout_id(&mut self) -> u64 {
        let id = self.next_timeout_id;
        self.next_timeout_id += 1;
        id
    }
}

// ============================================================================
// The Agent FSM
// ============================================================================

/// The agent finite state machine.
///
/// Pure state machine — `handle()` takes an event, mutates internal state,
/// and returns effects as data for the driver to execute.
#[derive(Debug, Clone)]
pub struct AgentFsm {
    pub state: AgentState,
    pub ctx: AgentContext,
}

impl AgentFsm {
    /// Create a new FSM in Idle state.
    pub fn new(capabilities: Vec<String>, invocation_id: String) -> Self {
        Self {
            state: AgentState::Idle { confirmation: None },
            ctx: AgentContext {
                events: Vec::new(),
                session_id: None,
                current_response: String::new(),
                tools: ToolManager::new(),
                current_turn_tool_ids: Vec::new(),
                tool_timeout_ids: HashMap::new(),
                next_timeout_id: 0,
                capabilities,
                invocation_id,
                model: None,
                models_cache: None,
                model_picker: None,
                view_start_index: 0,
                is_resumed: false,
                last_event_time: None,
                archived_events: Vec::new(),
            },
        }
    }

    /// Create an FSM from saved session state (for resume).
    pub fn from_session(
        events: Vec<ConversationEvent>,
        session_id: Option<String>,
        capabilities: Vec<String>,
        invocation_id: String,
        view_start_index: usize,
        is_resumed: bool,
        last_event_time: Option<chrono::DateTime<chrono::Utc>>,
    ) -> Self {
        Self {
            state: AgentState::Idle { confirmation: None },
            ctx: AgentContext {
                events,
                session_id,
                current_response: String::new(),
                tools: ToolManager::new(),
                current_turn_tool_ids: Vec::new(),
                tool_timeout_ids: HashMap::new(),
                next_timeout_id: 0,
                capabilities,
                invocation_id,
                model: None,
                models_cache: None,
                model_picker: None,
                view_start_index,
                is_resumed,
                last_event_time,
                archived_events: Vec::new(),
            },
        }
    }

    /// Handle an event, returning effects to execute.
    #[must_use]
    pub fn handle(&mut self, event: Event) -> Vec<Effect> {
        // From all states: if the session ID arrives and isn't set, set it, then continue as normal.
        // This event fires from the stream response headers, rather than having to wait until the end
        // of a turn when StreamDone arrives, which can be lost for cancelled turns.
        if let Event::SessionIdReceived(session_id) = &event {
            self.ctx.session_id.get_or_insert_with(|| session_id.clone());
        }

        match (&self.state, event) {
            // ================================================================
            // Idle state
            // ================================================================
            (AgentState::Idle { confirmation: None }, Event::UserSubmit(msg)) => {
                self.start_turn(msg)
            }

            (
                AgentState::Idle {
                    confirmation: Some(_),
                },
                Event::UserSubmit(msg),
            ) => self.start_turn(msg),

            (AgentState::Idle { confirmation: None }, Event::ExecuteCommand) => {
                let cmd = self.current_command();
                let Some(cmd) = cmd else {
                    // No command suggested — exit
                    return vec![Effect::ExitApp(ExitAction::Cancel)];
                };
                if self.is_current_command_dangerous() {
                    let timeout_id = self.ctx.next_timeout_id();
                    self.state = AgentState::Idle {
                        confirmation: Some(PendingConfirmation {
                            command: cmd,
                            timeout_id,
                        }),
                    };
                    vec![Effect::ScheduleTimeout {
                        timeout_id,
                        duration: std::time::Duration::from_secs(5),
                        kind: TimeoutKind::Confirmation,
                    }]
                } else {
                    vec![Effect::ExitApp(ExitAction::Execute(cmd))]
                }
            }

            (
                AgentState::Idle {
                    confirmation: Some(_),
                },
                Event::ExecuteCommand,
            ) => {
                let confirm = self.state_confirmation().unwrap().clone();
                self.state = AgentState::Idle { confirmation: None };
                vec![Effect::ExitApp(ExitAction::Execute(confirm.command))]
            }

            (AgentState::Idle { .. }, Event::InsertCommand) => {
                let cmd = self.current_command();
                match cmd {
                    Some(cmd) => vec![Effect::ExitApp(ExitAction::Insert(cmd))],
                    None => vec![],
                }
            }

            (
                AgentState::Idle {
                    confirmation: Some(_),
                },
                Event::Cancel,
            ) => {
                self.state = AgentState::Idle { confirmation: None };
                vec![]
            }

            (AgentState::Idle { confirmation: None }, Event::Cancel) => {
                if self.ctx.model_picker.is_some() {
                    self.ctx.model_picker = None;
                    vec![]
                } else {
                    vec![Effect::ExitApp(ExitAction::Cancel)]
                }
            }

            (AgentState::Idle { .. }, Event::ConfirmationTimeout { timeout_id }) => {
                if self.state_confirmation().is_some_and(|c| c.timeout_id == timeout_id) {
                    self.state = AgentState::Idle { confirmation: None };
                }
                vec![]
            }

            (AgentState::Idle { .. }, Event::NewSession) => {
                // Archive visible events so they remain on screen but aren't
                // sent to the API. Tools persist for rendering.
                let visible = self.ctx.events[self.ctx.view_start_index..].to_vec();
                self.ctx.archived_events.extend(visible);

                self.ctx.events.clear();
                self.ctx.session_id = None;
                self.ctx.current_turn_tool_ids.clear();
                self.ctx.view_start_index = 0;
                self.ctx.is_resumed = false;
                self.ctx.model_picker = None;

                // Add OOB indicator for the new session
                self.ctx.events.push(ConversationEvent::OutOfBandOutput {
                    name: "System".to_string(),
                    command: Some("/new".to_string()),
                    content: "Started a new session.".to_string(),
                });

                self.state = AgentState::Idle { confirmation: None };
                vec![Effect::ArchiveSession, Effect::Persist]
            }

            (AgentState::Idle { .. }, Event::SlashCommand { command, content }) => {
                self.handle_slash_command(&command, &content);
                vec![]
            }

            (AgentState::Idle { .. }, Event::OpenModelPicker) => {
                if let Some(list) = self.ctx.models_cache.clone() {
                    self.ctx.model_picker = Some(ModelPicker::Ready(list));
                    vec![]
                } else {
                    self.ctx.model_picker = Some(ModelPicker::Loading);
                    vec![Effect::FetchModels]
                }
            }

            (AgentState::Idle { .. }, Event::ModelSelected(alias)) => {
                self.ctx.model_picker = None;
                self.ctx.model = Some(alias.clone());
                let display = self
                    .ctx
                    .models_cache
                    .as_ref()
                    .and_then(|list| list.models.iter().find(|m| m.alias == alias))
                    .map(|m| m.name.clone())
                    .unwrap_or_else(|| alias.clone());
                self.handle_slash_command(
                    "/model",
                    &format!("Model set to {display} for this and future sessions."),
                );
                vec![Effect::SaveModelSelection { alias }]
            }

            (
                AgentState::Idle { .. },
                Event::SkillLoaded {
                    name,
                    arguments,
                    content,
                },
            ) => {
                self.ctx.events.push(ConversationEvent::SkillInvocation {
                    name,
                    arguments,
                    content,
                });
                self.ctx.current_response.clear();
                self.ctx.current_turn_tool_ids.clear();

                let messages = self.build_messages();
                let session_id = self.ctx.session_id.clone();
                self.state = AgentState::Turn {
                    stream: StreamPhase::Connecting,
                };
                vec![Effect::StartStream {
                    messages,
                    session_id,
                }]
            }

            // ================================================================
            // Turn — stream lifecycle
            // ================================================================
            (
                AgentState::Turn {
                    stream: StreamPhase::Connecting,
                },
                Event::StreamStarted,
            ) => {
                self.state = AgentState::Turn {
                    stream: StreamPhase::Streaming { status: None },
                };
                vec![]
            }

            (
                AgentState::Turn {
                    stream: StreamPhase::Connecting,
                },
                Event::StreamError(e),
            ) => {
                self.state = AgentState::Error(e);
                vec![]
            }

            (
                AgentState::Turn {
                    stream: StreamPhase::Streaming { .. },
                },
                Event::StreamChunk(text),
            ) => {
                self.ctx.current_response.push_str(&text);
                vec![]
            }

            (
                AgentState::Turn {
                    stream: StreamPhase::Streaming { .. },
                },
                Event::StreamStatusChanged(status),
            ) => {
                self.state = AgentState::Turn {
                    stream: StreamPhase::Streaming {
                        status: Some(StreamingStatus::from_str(&status)),
                    },
                };
                vec![]
            }

            (AgentState::Turn { .. }, Event::StreamToolCall { id, name, input }) => {
                self.commit_streaming_text();
                self.handle_stream_tool_call(id, name, input)
            }

            (AgentState::Turn { .. }, Event::SuggestCommand { id, input }) => {
                self.commit_streaming_text();
                // Push the suggest_command as a ToolCall event (protocol requirement)
                self.ctx.events.push(ConversationEvent::ToolCall {
                    id,
                    name: "suggest_command".to_string(),
                    input,
                });
                self.state = AgentState::Idle { confirmation: None };
                vec![Effect::TurnEnded, Effect::Persist]
            }

            (
                AgentState::Turn {
                    stream: StreamPhase::Streaming { .. },
                },
                Event::StreamServerToolResult {
                    tool_use_id,
                    content,
                    is_error,
                    remote,
                    content_length,
                },
            ) => {
                self.ctx.events.push(ConversationEvent::ToolResult {
                    tool_use_id,
                    content,
                    is_error,
                    remote,
                    content_length,
                });
                vec![]
            }

            (AgentState::Turn { .. }, Event::StreamDone { session_id }) => {
                self.commit_streaming_text();
                if !session_id.is_empty() {
                    self.ctx.session_id = Some(session_id);
                }
                self.state = AgentState::Turn {
                    stream: StreamPhase::Done,
                };
                self.check_turn_completion()
            }

            (
                AgentState::Turn {
                    stream: StreamPhase::Streaming { .. },
                },
                Event::StreamError(e),
            ) => {
                // Abort any executing tools on stream error
                let abort_effects: Vec<_> = self
                    .ctx
                    .tools
                    .executing_ids()
                    .into_iter()
                    .map(|tool_id| Effect::AbortTool { tool_id })
                    .collect();
                self.ctx.tool_timeout_ids.clear();
                self.state = AgentState::Error(e);
                abort_effects
            }

            // ================================================================
            // Turn — tool lifecycle (any stream phase)
            // ================================================================
            (AgentState::Turn { .. }, Event::PermissionResolved { tool_id, response }) => {
                self.handle_permission_resolved(tool_id, response)
            }

            (AgentState::Turn { .. }, Event::PermissionUserChoice { tool_id, choice }) => {
                self.handle_permission_choice(tool_id, choice)
            }

            (
                AgentState::Turn { .. },
                Event::ToolExecutionDone {
                    tool_id,
                    outcome,
                    preview,
                },
            ) => self.handle_tool_done(tool_id, &outcome, preview),

            (
                AgentState::Turn { .. },
                Event::ToolPreviewUpdate {
                    tool_id,
                    lines,
                    exit_code,
                },
            ) => {
                if let Some(tracked) = self.ctx.tools.get_mut(&tool_id) {
                    if tracked.is_resolved() {
                        // Tool already completed — a late preview update raced with
                        // ToolExecutionDone. Update lines (they may carry the final
                        // screen) but preserve the finalized exit_code/interrupted.
                        if let Some(tools::ToolPreviewData::Shell {
                            lines: existing_lines,
                            ..
                        }) = &mut tracked.preview
                        {
                            *existing_lines = lines;
                        }
                    } else {
                        tracked.preview = Some(tools::ToolPreviewData::Shell {
                            lines,
                            exit_code,
                            interrupted: None,
                        });
                    }
                }
                vec![]
            }

            (AgentState::Turn { .. }, Event::InterruptTools) => {
                let ids = self.ctx.tools.executing_ids();
                for id in &ids {
                    if let Some(tracked) = self.ctx.tools.get_mut(id) {
                        tracked.interrupt_reason = Some(tools::InterruptReason::User);
                    }
                    // Clear any pending execution timeout for this tool
                    self.ctx.tool_timeout_ids.retain(|_, tid| tid != id);
                }
                ids.into_iter().map(|tool_id| Effect::AbortTool { tool_id }).collect()
            }

            (
                AgentState::Turn { .. },
                Event::ToolExecutionTimeout {
                    timeout_id,
                    tool_id,
                },
            ) => self.handle_tool_execution_timeout(timeout_id, tool_id),

            // ─── Cancel during Turn ─────────────────────────────────────
            (AgentState::Turn { stream }, Event::Cancel) => {
                let mut effects = Vec::new();

                // Abort stream if still active
                if !matches!(stream, StreamPhase::Done) {
                    effects.push(Effect::AbortStream);
                }

                // Cancel all pending tools
                let pending = self.ctx.tools.pending_ids();
                for id in &pending {
                    if let Some(tracked) = self.ctx.tools.get_mut(id) {
                        if tracked.state == ToolState::Executing {
                            effects.push(Effect::AbortTool {
                                tool_id: id.clone(),
                            });
                        }
                        tracked.state = ToolState::Completed;
                    }
                    self.ctx.events.push(ConversationEvent::ToolResult {
                        tool_use_id: id.clone(),
                        content: RESULT_USER_CANCELLED.to_string(),
                        is_error: true,
                        remote: false,
                        content_length: None,
                    });
                }

                // Commit any partial streaming text
                self.commit_streaming_text_as_cancelled();

                // Add context so the LLM knows what happened
                if !pending.is_empty() {
                    self.ctx.events.push(ConversationEvent::SystemContext {
                        content: "The user cancelled the previous generation. Tool calls that \
                                  were in progress have been aborted."
                            .to_string(),
                    });
                }

                // Clear timeout mappings — stale timeouts will be ignored by the guard
                self.ctx.tool_timeout_ids.clear();

                self.state = AgentState::Idle { confirmation: None };
                effects.push(Effect::Persist);
                effects
            }

            // ================================================================
            // Error state
            // ================================================================
            (AgentState::Error(_), Event::Retry) => {
                let messages = self.build_messages();
                let session_id = self.ctx.session_id.clone();
                self.state = AgentState::Turn {
                    stream: StreamPhase::Connecting,
                };
                vec![Effect::StartStream {
                    messages,
                    session_id,
                }]
            }

            (AgentState::Error(_), Event::Cancel) => {
                vec![Effect::ExitApp(ExitAction::Cancel)]
            }

            // ================================================================
            // Fallthrough — ignore events with no valid transition
            // ================================================================

            // StreamDone can arrive after SuggestCommand (which already moved to Idle).
            // We still need to capture the session_id from it.
            (_, Event::StreamDone { session_id }) => {
                if !session_id.is_empty() {
                    self.ctx.session_id = Some(session_id);
                }
                vec![Effect::Persist]
            }

            (_, Event::SlashCommand { command, content }) => {
                self.handle_slash_command(&command, &content);
                vec![]
            }

            // The fetch may finish after the picker was dismissed (user
            // submitted a message or hit Esc while loading) — always cache
            // the list, but only surface UI if the picker is still waiting.
            (_, Event::ModelListLoaded(result)) => {
                match result {
                    Ok(list) => {
                        self.ctx.models_cache = Some(list.clone());
                        if self.ctx.model_picker == Some(ModelPicker::Loading) {
                            self.ctx.model_picker = Some(ModelPicker::Ready(list));
                        }
                    }
                    Err(e) => {
                        if self.ctx.model_picker == Some(ModelPicker::Loading) {
                            self.ctx.model_picker = None;
                            self.handle_slash_command(
                                "/model",
                                &format!("Could not load the model list: {e}"),
                            );
                        }
                    }
                }
                vec![]
            }

            // Display metadata on a tracked tool; state-independent since
            // the lookup may land after the turn has moved on.
            (_, Event::OutputCommandResolved { tool_id, command }) => {
                if let Some(tracked) = self.ctx.tools.get_mut(&tool_id)
                    && let crate::tools::ClientToolCall::AtuinOutput(call) = &mut tracked.tool
                {
                    call.command = command;
                }
                vec![]
            }

            // RequestSkillLoad during non-idle: still emit the effect
            (_, Event::RequestSkillLoad { name, arguments }) => {
                vec![Effect::LoadSkill { name, arguments }]
            }

            // SkillLoaded during non-idle: queue so it's visible
            // in context for the next turn.
            (
                _,
                Event::SkillLoaded {
                    name,
                    arguments,
                    content,
                },
            ) => {
                self.ctx.events.push(ConversationEvent::SkillInvocation {
                    name,
                    arguments,
                    content,
                });
                vec![]
            }

            _ => vec![],
        }
    }

    // ────────────────────────────────────────────────────────────────────
    // Private helpers
    // ────────────────────────────────────────────────────────────────────

    /// Start a new turn: push user message, build messages, emit StartStream.
    fn start_turn(&mut self, msg: String) -> Vec<Effect> {
        // A message submitted while the picker was loading dismisses it.
        self.ctx.model_picker = None;
        self.ctx.events.push(ConversationEvent::UserMessage { content: msg });
        // Don't clear tools — completed tools persist for rendering history.
        // Tools are only cleared on /new (session reset).
        self.ctx.current_response.clear();
        self.ctx.current_turn_tool_ids.clear();

        let messages = self.build_messages();
        let session_id = self.ctx.session_id.clone();
        self.state = AgentState::Turn {
            stream: StreamPhase::Connecting,
        };
        vec![Effect::StartStream {
            messages,
            session_id,
        }]
    }

    /// Build API messages from the conversation event log.
    fn build_messages(&self) -> Vec<Value> {
        ContextWindowBuilder::with_default_budget().build(&self.ctx.events)
    }

    /// Commit accumulated streaming text to the event log.
    fn commit_streaming_text(&mut self) {
        let text = std::mem::take(&mut self.ctx.current_response);
        let trimmed = text.trim_start().to_string();
        if !trimmed.is_empty() {
            self.ctx.events.push(ConversationEvent::Text { content: trimmed });
        }
    }

    /// Commit streaming text with a cancellation suffix.
    fn commit_streaming_text_as_cancelled(&mut self) {
        let text = std::mem::take(&mut self.ctx.current_response);
        let trimmed = text.trim_start().to_string();
        if !trimmed.is_empty() {
            self.ctx.events.push(ConversationEvent::Text {
                content: format!("{trimmed}\n\n[User cancelled this generation]"),
            });
        }
    }

    /// Handle a client-side tool call from the stream.
    fn handle_stream_tool_call(&mut self, id: String, name: String, input: Value) -> Vec<Effect> {
        // Parse the tool call
        let tool = match crate::tools::ClientToolCall::try_from((name.as_str(), &input)) {
            Ok(tool) => tool,
            Err(err) => {
                // Unknown tool or malformed input — answer with an error
                // result rather than leaving the tool_use dangling. Some
                // providers reject a history where a tool call has no
                // result, and the error text lets the model correct itself.
                self.ctx.events.push(ConversationEvent::ToolCall {
                    id: id.clone(),
                    name,
                    input,
                });
                self.ctx.events.push(ConversationEvent::ToolResult {
                    tool_use_id: id.clone(),
                    content: format!("Error: {err}"),
                    is_error: true,
                    remote: false,
                    content_length: None,
                });
                // Counts toward the turn like any resolved tool, so the
                // continuation fires and the model can retry immediately.
                self.ctx.current_turn_tool_ids.push(id);
                return vec![];
            }
        };

        // Capability gating
        if let Some(required_cap) = tool.descriptor().capability
            && !self.ctx.capabilities.iter().any(|c| c == required_cap)
        {
            self.ctx.events.push(ConversationEvent::ToolCall {
                id: id.clone(),
                name,
                input,
            });
            self.ctx.events.push(ConversationEvent::ToolResult {
                tool_use_id: id.clone(),
                content: format!(
                    "Tool not enabled: capability '{required_cap}' was not advertised by this \
                     client"
                ),
                is_error: true,
                remote: false,
                content_length: None,
            });
            self.ctx.current_turn_tool_ids.push(id);
            return vec![];
        }

        // Track the tool and push ToolCall event
        let tool_for_effect = tool.clone();
        let output_history_id = match &tool {
            crate::tools::ClientToolCall::AtuinOutput(call) => Some(call.history_id),
            _ => None,
        };
        self.ctx.tools.insert(id.clone(), tool);
        self.ctx.current_turn_tool_ids.push(id.clone());
        self.ctx.events.push(ConversationEvent::ToolCall {
            id: id.clone(),
            name,
            input,
        });

        // Transition to Turn if we were Streaming
        if let AgentState::Turn {
            stream: StreamPhase::Streaming { .. },
        } = &self.state
        {
            self.state = AgentState::Turn {
                stream: StreamPhase::Streaming { status: None },
            };
        }

        let mut effects = Vec::new();
        // The permission prompt wants the command behind the UUID; kick the
        // lookup off alongside the permission check.
        if let Some(history_id) = output_history_id {
            effects.push(Effect::ResolveOutputCommand {
                tool_id: id.clone(),
                history_id,
            });
        }
        effects.push(Effect::CheckPermission {
            tool_id: id,
            tool: tool_for_effect,
        });
        effects
    }

    /// Handle permission resolver result.
    fn handle_permission_resolved(
        &mut self,
        tool_id: String,
        response: PermissionResponse,
    ) -> Vec<Effect> {
        let Some(tracked) = self.ctx.tools.get_mut(&tool_id) else {
            return vec![];
        };

        // If already resolved (e.g. cancelled while permission check was in flight),
        // ignore the stale result to avoid re-executing a cancelled tool.
        if tracked.is_resolved() {
            return vec![];
        }

        match response {
            PermissionResponse::Allowed | PermissionResponse::SessionGranted => {
                tracked.state = ToolState::Executing;
                let tool = tracked.tool.clone();
                self.emit_execute_tool(tool_id, &tool)
            }
            PermissionResponse::Ask => {
                tracked.state = ToolState::AwaitingPermission;
                vec![]
            }
            PermissionResponse::Denied => {
                tracked.state = ToolState::Denied;
                self.ctx.events.push(ConversationEvent::ToolResult {
                    tool_use_id: tool_id,
                    content: RESULT_DENIED_BY_RULES.to_string(),
                    is_error: true,
                    remote: false,
                    content_length: None,
                });
                self.check_turn_completion()
            }
        }
    }

    /// Handle user's permission choice from the dialog.
    fn handle_permission_choice(
        &mut self,
        tool_id: String,
        choice: PermissionChoice,
    ) -> Vec<Effect> {
        let Some(tracked) = self.ctx.tools.get_mut(&tool_id) else {
            return vec![];
        };

        if tracked.is_resolved() {
            return vec![];
        }

        match choice {
            PermissionChoice::Allow => {
                tracked.state = ToolState::Executing;
                let tool = tracked.tool.clone();
                self.emit_execute_tool(tool_id, &tool)
            }
            PermissionChoice::AllowForSession => {
                tracked.state = ToolState::Executing;
                let tool = tracked.tool.clone();
                let mut effects = self.emit_execute_tool(tool_id, &tool);
                if let Some(path) = tool.resolved_file_path() {
                    effects.push(Effect::CacheSessionGrant { path });
                }
                effects
            }
            PermissionChoice::AlwaysAllowInProject => {
                tracked.state = ToolState::Executing;
                let tool = tracked.tool.clone();
                let rule = crate::permissions::rule::Rule {
                    tool: tool.rule_name().to_string(),
                    scope: None, // project file provides the scoping
                };
                let mut effects = self.emit_execute_tool(tool_id, &tool);
                effects.push(Effect::WritePermissionRule {
                    target: PermissionTarget::Project,
                    rule,
                    disposition: crate::permissions::writer::RuleDisposition::Allow,
                });
                effects
            }
            PermissionChoice::AlwaysAllow => {
                tracked.state = ToolState::Executing;
                let tool = tracked.tool.clone();
                let scope = tool.resolved_file_path().map(|p| p.to_string_lossy().to_string());
                let rule = crate::permissions::rule::Rule {
                    tool: tool.rule_name().to_string(),
                    scope,
                };
                let mut effects = self.emit_execute_tool(tool_id, &tool);
                effects.push(Effect::WritePermissionRule {
                    target: PermissionTarget::Global,
                    rule,
                    disposition: crate::permissions::writer::RuleDisposition::Allow,
                });
                effects
            }
            PermissionChoice::Deny => {
                tracked.state = ToolState::Denied;
                self.ctx.events.push(ConversationEvent::ToolResult {
                    tool_use_id: tool_id,
                    content: RESULT_DENIED_BY_USER.to_string(),
                    is_error: true,
                    remote: false,
                    content_length: None,
                });
                self.check_turn_completion()
            }
        }
    }

    /// Handle tool execution completion.
    fn handle_tool_done(
        &mut self,
        tool_id: String,
        outcome: &crate::tools::ToolOutcome,
        preview: Option<tools::ToolPreviewData>,
    ) -> Vec<Effect> {
        let Some(tracked) = self.ctx.tools.get_mut(&tool_id) else {
            return vec![];
        };

        // If already completed (e.g. cancelled), ignore stale result
        if tracked.is_resolved() {
            return vec![];
        }

        tracked.state = ToolState::Completed;

        // If the FSM tagged this tool with an interrupt reason (user or timeout),
        // use it; otherwise derive from the outcome's interrupted flag.
        let reason = tracked.interrupt_reason.take().or({
            if let crate::tools::ToolOutcome::Structured {
                interrupted: true, ..
            } = outcome
            {
                Some(tools::InterruptReason::User)
            } else {
                None
            }
        });

        // Merge shell preview: the final ToolExecutionDone carries exit_code/interrupted
        // but has empty lines (the live lines were accumulated via ToolPreviewUpdate).
        // Preserve the accumulated lines and fold in the terminal metadata.
        match (&mut tracked.preview, preview) {
            (
                Some(tools::ToolPreviewData::Shell {
                    exit_code,
                    interrupted,
                    ..
                }),
                Some(tools::ToolPreviewData::Shell {
                    exit_code: final_exit,
                    ..
                }),
            ) => {
                *exit_code = final_exit;
                interrupted.clone_from(&reason);
            }
            (_, Some(mut p)) => {
                if let tools::ToolPreviewData::Shell {
                    ref mut interrupted,
                    ..
                } = p
                {
                    interrupted.clone_from(&reason);
                }
                tracked.preview = Some(p);
            }
            _ => {}
        }

        // Clean up any pending execution timeout for this tool
        self.ctx.tool_timeout_ids.retain(|_, tid| tid != &tool_id);

        let content = outcome.format_for_llm(reason.as_ref());
        let is_error = outcome.is_error();
        self.ctx.events.push(ConversationEvent::ToolResult {
            tool_use_id: tool_id,
            content,
            is_error,
            remote: false,
            content_length: None,
        });

        self.check_turn_completion()
    }

    /// Handle a tool execution timeout. Aborts the tool if it's still running.
    fn handle_tool_execution_timeout(&mut self, timeout_id: u64, tool_id: String) -> Vec<Effect> {
        // Guard: only act if this timeout is still registered (not cleaned up by natural completion)
        if self.ctx.tool_timeout_ids.remove(&timeout_id).is_none() {
            return vec![];
        }

        let Some(tracked) = self.ctx.tools.get_mut(&tool_id) else {
            return vec![];
        };

        if tracked.is_resolved() {
            return vec![];
        }

        // Tag the tool so handle_tool_done can distinguish timeout from user interrupt.
        // Only shell tools have entries in tool_timeout_ids, so this is always Shell.
        let timeout_secs = match &tracked.tool {
            crate::tools::ClientToolCall::Shell(s) => s.timeout_secs,
            _ => unreachable!("only shell tools have execution timeouts"),
        };
        tracked.interrupt_reason = Some(tools::InterruptReason::Timeout(timeout_secs));

        // Abort the tool — the driver sends the interrupt signal via oneshot,
        // and execute_shell_command_streaming returns a Structured outcome with
        // interrupted: true and partial stdout/stderr. This flows through the
        // normal ToolExecutionDone path.
        vec![Effect::AbortTool { tool_id }]
    }

    /// Emit effects to begin executing a tool. For shell commands, also schedules
    /// an execution timeout based on the LLM-specified timeout_secs.
    fn emit_execute_tool(
        &mut self,
        tool_id: String,
        tool: &crate::tools::ClientToolCall,
    ) -> Vec<Effect> {
        let mut effects = vec![Effect::ExecuteTool {
            tool_id: tool_id.clone(),
            tool: tool.clone(),
        }];

        if let crate::tools::ClientToolCall::Shell(shell) = tool {
            let timeout_id = self.ctx.next_timeout_id();
            self.ctx.tool_timeout_ids.insert(timeout_id, tool_id.clone());
            effects.push(Effect::ScheduleTimeout {
                timeout_id,
                duration: std::time::Duration::from_secs(shell.timeout_secs),
                kind: TimeoutKind::ToolExecution { tool_id },
            });
        }

        effects
    }

    /// Check if the turn is complete (stream done + all tools resolved).
    /// If so, either continue the conversation or go Idle.
    fn check_turn_completion(&mut self) -> Vec<Effect> {
        // Stream must be done
        if !matches!(self.state, AgentState::Turn {
            stream: StreamPhase::Done
        }) {
            return vec![];
        }

        // All current-turn tools must be resolved before the turn can complete
        if !self.ctx.tools.all_resolved(&self.ctx.current_turn_tool_ids) {
            return vec![];
        }

        // Turn is complete. Check if we need to continue (tool results to send back).
        // We continue if this turn had any client tool calls (the LLM needs to see
        // the results and respond).
        if self.ctx.current_turn_tool_ids.is_empty() {
            // No tools — turn is done, go idle
            self.state = AgentState::Idle { confirmation: None };
            vec![Effect::TurnEnded, Effect::Persist]
        } else {
            // Continue conversation with tool results.
            // Don't clear tools — they persist for rendering history.
            // Clear turn IDs so the continuation turn doesn't loop.
            self.ctx.current_turn_tool_ids.clear();
            let messages = self.build_messages();
            let session_id = self.ctx.session_id.clone();
            self.ctx.current_response.clear();
            self.state = AgentState::Turn {
                stream: StreamPhase::Connecting,
            };
            vec![Effect::StartStream {
                messages,
                session_id,
            }]
        }
    }

    /// Extract the current confirmation state (if any).
    fn state_confirmation(&self) -> Option<&PendingConfirmation> {
        if let AgentState::Idle {
            confirmation: Some(ref c),
        } = self.state
        {
            Some(c)
        } else {
            None
        }
    }

    /// Get the most recent suggested command from the conversation.
    /// Get the most recent command from the current invocation only.
    fn current_command(&self) -> Option<String> {
        self.current_invocation_events().rev().find_map(|e| e.as_command()).map(|s| s.to_string())
    }

    /// Check if the most recent command is dangerous.
    fn is_current_command_dangerous(&self) -> bool {
        self.current_invocation_events()
            .rev()
            .find_map(|e| {
                if let ConversationEvent::ToolCall { name, input, .. } = e
                    && name == "suggest_command"
                {
                    let danger = input.get("danger").and_then(|v| v.as_str()).unwrap_or("low");
                    Some(danger == "high" || danger == "medium" || danger == "med")
                } else {
                    None
                }
            })
            .unwrap_or(false)
    }

    /// Events from the current invocation only (from view_start_index onward).
    fn current_invocation_events(&self) -> impl DoubleEndedIterator<Item = &ConversationEvent> {
        let start = self.ctx.view_start_index.min(self.ctx.events.len());
        self.ctx.events[start..].iter()
    }

    /// Handle a slash command by pushing an OOB event.
    fn handle_slash_command(&mut self, command: &str, content: &str) {
        self.ctx.events.push(ConversationEvent::OutOfBandOutput {
            name: "System".to_string(),
            command: Some(command.to_string()),
            content: content.to_string(),
        });
    }
}
