//! The agentic loop: model calls, tool dispatch, approvals, middleware,
//! context compaction. UI-agnostic — emits `AgentEvent`s over a channel and
//! receives approval decisions through an [`ApprovalRegistry`] keyed by
//! request id, so events stay fully serializable.

use crate::config::{estimate_tokens, ApprovalPolicy, Config};
use crate::guard::{PublishGuard, GUARD_EXIT_CODE};
use crate::llm::{ChatMessage, LlmClient, LlmDelta, LlmError, ToolCall};
use crate::middleware::{round_token_reminder, should_compact, HintState};
use crate::prompt::{system_prompt, COMPACT_PROMPT};
use crate::security::{assess, Risk};
use crate::tools::background::BackgroundManager;
use crate::tools::shell::run_shell;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
    Approve,
    ApproveForSession,
    Deny,
}

/// Pending approval responders, keyed by request id. `AgentEvent` no longer
/// carries a oneshot sender (so it can be serialized); instead the agent
/// registers a responder here and emits the id — any consumer holding a clone
/// of the registry resolves it via [`ApprovalRegistry::respond`].
#[derive(Clone, Default)]
pub struct ApprovalRegistry {
    pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
    next_id: Arc<AtomicU64>,
}

impl ApprovalRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a pending approval: returns its id and the receiver that
    /// resolves when [`respond`](Self::respond) is called with that id.
    pub fn register(&self) -> (String, oneshot::Receiver<ApprovalDecision>) {
        let id = format!("approval-{}", self.next_id.fetch_add(1, Ordering::Relaxed) + 1);
        let (tx, rx) = oneshot::channel();
        self.pending.lock().unwrap().insert(id.clone(), tx);
        (id, rx)
    }

    /// Resolve a pending approval. Returns false when the id is unknown
    /// (already answered, discarded, or never issued).
    pub fn respond(&self, id: &str, decision: ApprovalDecision) -> bool {
        match self.pending.lock().unwrap().remove(id) {
            Some(tx) => tx.send(decision).is_ok(),
            None => false,
        }
    }

    /// Drop a pending approval without answering (e.g. the turn was
    /// interrupted while the request was outstanding).
    pub fn discard(&self, id: &str) {
        self.pending.lock().unwrap().remove(id);
    }
}

#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
    AssistantDelta {
        text: String,
    },
    ReasoningDelta {
        text: String,
    },
    /// One model response finished streaming (may be followed by tool calls).
    AssistantDone,
    ToolBegin {
        call_id: String,
        command: String,
        description: String,
        background: bool,
    },
    ToolEnd {
        call_id: String,
        output: String,
        exit_code: i32,
        duration_ms: u128,
        success: bool,
    },
    ApprovalRequest {
        request_id: String,
        command: String,
        risk_label: String,
        reason: String,
    },
    Notice {
        text: String,
    },
    Usage {
        prompt_tokens: u32,
        /// Cumulative input tokens sent this session.
        session_input: u64,
        /// Cumulative output tokens generated this session.
        session_output: u64,
    },
    /// Current plan from the update_plan tool: (step, status) pairs.
    Plan {
        steps: Vec<(String, String)>,
    },
    /// The current model id isn't recognized by the provider (404). The UI
    /// guides recovery: download a local model, or set an OpenRouter key.
    ModelUnavailable {
        model: String,
        /// True when the provider needs a key the user hasn't supplied.
        needs_key: bool,
    },
    /// A Codex/ChatGPT auth failure (expired or missing session). The UI
    /// silently refreshes the on-disk login or re-runs "Sign in with ChatGPT" —
    /// it must NOT fall back to the OpenRouter API-key prompt.
    CodexReauth {
        reason: String,
    },
    /// Lifecycle of an explore fan-out child: state is
    /// "started" | "finished" | "failed". UIs can render fan-out progress.
    SubagentStatus {
        id: usize,
        task: String,
        state: String,
    },
    /// A file was created/updated via write_file/edit_file — render as a diff.
    FileEdit {
        path: String,
        op: &'static str,
        added: usize,
        removed: usize,
        lines: Vec<crate::tools::edit::DiffLine>,
    },
    TurnComplete,
    Error {
        message: String,
    },
}

impl AgentEvent {
    pub fn assistant_delta(text: String) -> Self {
        AgentEvent::AssistantDelta { text }
    }
    pub fn reasoning_delta(text: String) -> Self {
        AgentEvent::ReasoningDelta { text }
    }
    pub fn notice(text: String) -> Self {
        AgentEvent::Notice { text }
    }
    pub fn plan(steps: Vec<(String, String)>) -> Self {
        AgentEvent::Plan { steps }
    }
    pub fn error(message: String) -> Self {
        AgentEvent::Error { message }
    }
}

pub struct Agent {
    pub cfg: Config,
    client: LlmClient,
    pub history: Vec<ChatMessage>,
    hints: HintState,
    guard: PublishGuard,
    pub bg: BackgroundManager,
    session_approved: bool,
    last_prompt_tokens: u32,
    session_input_tokens: u64,
    session_output_tokens: u64,
    plan: Vec<(String, String)>,
    mcp: Option<crate::mcp::McpManager>,
    repo: Option<crate::repomap::RepoContext>,
    /// Text-protocol tool mode (ernie_x1 style): used for models whose chat
    /// template rejects native tools — tools are described in the prompt and
    /// calls parsed from `<tool_call>` text via toolparse.
    text_tool_mode: bool,
    /// Pending approval responders; clones of this handle resolve
    /// `ApprovalRequest` events by id.
    approvals: ApprovalRegistry,
}

fn text_tool_instructions(tools: &Value) -> String {
    format!(
        "\n\n## Tool calling (text protocol)\n\
         Native function calling is unavailable for this model. To call a tool, emit EXACTLY this format and nothing after it:\n\
         <tool_call>\n{{\"name\": \"<tool_name>\", \"arguments\": {{<args>}}}}\n</tool_call>\n\
         Rules: one tool call per reply; wait for the tool result before the next call; arguments must be valid JSON matching the schema. \
         When the task is complete, reply normally WITHOUT any <tool_call> block.\n\
         Available tools (JSON Schema):\n{tools}"
    )
}

/// Build the LLM client for a config. Codex uses the OAuth token from the
/// shared `~/.codex/auth.json`; everyone else uses the static API key.
fn build_client(cfg: &Config) -> LlmClient {
    if cfg.provider == crate::config::Provider::Codex {
        // Always build a Codex (Responses API) client, even with no login on
        // disk yet: empty tokens make the first request fail as a 401, which
        // routes to the Codex sign-in/refresh flow — NOT a chat-completions
        // request that would come back as a misleading "model not found".
        let tokens = crate::codex_auth::load().unwrap_or_default();
        return LlmClient::new_codex(&cfg.base_url, tokens, cfg.temperature, &cfg.reasoning_effort);
    }
    LlmClient::new(&cfg.base_url, &cfg.api_key, cfg.temperature)
}

impl Agent {
    pub fn new(cfg: Config) -> Self {
        Self::with_approvals(cfg, ApprovalRegistry::new())
    }

    /// Construct with an externally owned approval registry so consumers
    /// (TUI, serve protocol, embedders) can resolve approval requests from
    /// outside the agent task.
    pub fn with_approvals(cfg: Config, approvals: ApprovalRegistry) -> Self {
        let client = build_client(&cfg);
        Agent {
            cfg,
            client,
            history: Vec::new(),
            hints: HintState::default(),
            guard: PublishGuard::default(),
            bg: BackgroundManager::new(),
            session_approved: false,
            last_prompt_tokens: 0,
            session_input_tokens: 0,
            session_output_tokens: 0,
            plan: Vec::new(),
            mcp: None,
            repo: None,
            text_tool_mode: false,
            approvals,
        }
    }

    /// A clone of the approval registry: resolves `ApprovalRequest` events by
    /// id from any thread/task.
    pub fn approvals(&self) -> ApprovalRegistry {
        self.approvals.clone()
    }

    /// Resolve a pending approval request by id. Returns false when the id is
    /// unknown (already answered or discarded).
    pub fn respond_approval(&self, request_id: &str, decision: ApprovalDecision) -> bool {
        self.approvals.respond(request_id, decision)
    }

    /// Build the repo map once per session (off the main thread — large repos
    /// index in the background without blocking the first token).
    async fn ensure_repo_context(&mut self) {
        if self.repo.is_some() {
            return;
        }
        let cwd = self.cfg.cwd.clone();
        let ctx = tokio::task::spawn_blocking(move || crate::repomap::RepoContext::build(&cwd))
            .await
            .unwrap_or_default();
        self.repo = Some(ctx);
    }

    /// Lazily connect configured MCP servers (first turn, or after /mcp add).
    async fn ensure_mcp(&mut self, tx: &mpsc::UnboundedSender<AgentEvent>) {
        if self.mcp.is_some() || self.cfg.mcp_servers.is_empty() {
            return;
        }
        let mgr = crate::mcp::McpManager::start(&self.cfg.mcp_servers).await;
        for err in &mgr.errors {
            let _ = tx.send(AgentEvent::notice(err.clone()));
        }
        if !mgr.tools.is_empty() {
            let names: Vec<String> = mgr.tools.iter().map(|t| t.llm_name()).collect();
            let _ = tx.send(AgentEvent::notice(format!(
                "MCP connected: {} tool(s) — {}",
                names.len(),
                names.join(", ")
            )));
        }
        self.mcp = Some(mgr);
    }

    /// Drop MCP connections so the next turn reconnects with fresh config.
    pub fn reset_mcp(&mut self) {
        self.mcp = None;
    }

    /// Rebuild the LLM client after /model or /provider changes.
    pub fn reconfigure(&mut self, cfg: Config) {
        self.client = build_client(&cfg);
        self.cfg = cfg;
    }

    pub fn clear_history(&mut self) {
        self.history.clear();
        self.guard = PublishGuard::default();
        self.hints = HintState::default();
        self.last_prompt_tokens = 0;
    }

    fn estimate_prompt_tokens(&self) -> u32 {
        let est: u32 = self.history.iter().map(|m| estimate_tokens(&m.content) + 8).sum();
        est.max(self.last_prompt_tokens)
    }

    pub async fn run_turn(
        &mut self,
        user_input: String,
        tx: &mpsc::UnboundedSender<AgentEvent>,
        cancel: &CancellationToken,
    ) {
        self.run_turn_with_images(user_input, Vec::new(), tx, cancel).await;
    }

    pub async fn run_turn_with_images(
        &mut self,
        user_input: String,
        images: Vec<String>,
        tx: &mpsc::UnboundedSender<AgentEvent>,
        cancel: &CancellationToken,
    ) {
        self.ensure_mcp(tx).await;
        self.ensure_repo_context().await;
        // Rebuild the system prompt every turn: picks up skills installed
        // mid-session (/skills install) and config changes.
        let sys = ChatMessage::system(system_prompt(&self.cfg));
        if self.history.is_empty() {
            self.history.push(sys);
        } else {
            self.history[0] = sys;
        }
        // Query-relevant repo context, injected ephemerally (never persisted,
        // so it can't accumulate and blow the context window).
        let repo_snippet = self
            .repo
            .as_ref()
            .map(|r| r.retrieve(&user_input, crate::repomap::CONTEXT_BUDGET_CHARS))
            .unwrap_or_default();
        if images.is_empty() {
            self.history.push(ChatMessage::user(user_input));
        } else {
            self.history.push(ChatMessage::user_with_images(user_input, images));
        }

        let mut models = vec![self.cfg.model.clone()];
        models.extend(self.cfg.fallback_models.iter().cloned());
        let mcp_defs = self.mcp.as_ref().map(|m| m.tool_definitions()).unwrap_or_default();
        // Profile + mode gating: ReadOnly (explore subagents) = read_file +
        // read-only shell; plan = read_file + explore + update_plan;
        // research = web tools only.
        let tools = crate::tools::tools_for(self.cfg.tool_profile, self.cfg.mode, &mcp_defs);

        // Models known to lack native tool templates go straight to the text
        // protocol (ERNIE thinking, gemma3, deepseek-r1 distills on Ollama).
        if !self.text_tool_mode
            && self.cfg.provider == crate::config::Provider::Ollama
            && crate::hardware::find_local(&self.cfg.model).map(|m| !m.tool_calling).unwrap_or(false)
        {
            self.text_tool_mode = true;
            let _ = tx.send(AgentEvent::notice(
                "this model has no native tool calling — using the ernie_x1-style text protocol".into(),
            ));
        }

        for iteration in 1..=self.cfg.max_iterations {
            if cancel.is_cancelled() {
                let _ = tx.send(AgentEvent::notice("Interrupted.".into()));
                let _ = tx.send(AgentEvent::TurnComplete);
                return;
            }

            // ---- compaction check ----
            let prompt_tokens = self.estimate_prompt_tokens();
            if should_compact(prompt_tokens, self.cfg.max_context_tokens) {
                self.compact(tx).await;
            }

            // ---- assemble messages with ephemeral repo context + reminder ----
            let mut messages = self.history.clone();
            if !repo_snippet.is_empty() {
                messages.insert(
                    1,
                    ChatMessage::user(format!(
                        "[repo context — auto-generated map of this project, for orientation only]\n{repo_snippet}"
                    )),
                );
            }
            let has_assistant = messages.iter().any(|m| m.role == "assistant");
            if has_assistant {
                messages.push(ChatMessage::user(round_token_reminder(
                    iteration,
                    self.cfg.max_iterations,
                    self.estimate_prompt_tokens(),
                    self.cfg.max_context_tokens,
                )));
            }

            // ---- model call (with one emergency-compaction retry) ----
            let mut attempt = 0;
            let mut out = loop {
                attempt += 1;
                // Text-protocol mode: no native tools; describe them in the
                // system message instead and parse calls from text.
                let (effective_tools, mut effective_messages) = if self.text_tool_mode {
                    let mut msgs = messages.clone();
                    if let Some(first) = msgs.first_mut() {
                        if first.role == "system" && !first.content.contains("Tool calling (text protocol)") {
                            first.content.push_str(&text_tool_instructions(&tools));
                        }
                    }
                    (serde_json::json!([]), msgs)
                } else {
                    (tools.clone(), messages.clone())
                };
                let _ = &mut effective_messages;
                let tx2 = tx.clone();
                let result = self
                    .client
                    .chat_stream(&models, &effective_messages, &effective_tools, cancel, move |delta| {
                        let ev = match delta {
                            LlmDelta::Content(t) => AgentEvent::assistant_delta(t),
                            LlmDelta::Reasoning(t) => AgentEvent::reasoning_delta(t),
                            LlmDelta::ToolCallStarted => return,
                        };
                        let _ = tx2.send(ev);
                    })
                    .await;
                match result {
                    Ok(out) => break out,
                    Err(e)
                        if !self.text_tool_mode
                            && format!("{e}").contains("does not support tools") =>
                    {
                        // Ollama rejects native tools for this model's chat
                        // template — switch to the text protocol and retry.
                        self.text_tool_mode = true;
                        let _ = tx.send(AgentEvent::notice(
                            "model template rejects native tools — switching to ernie_x1-style text protocol".into(),
                        ));
                        continue;
                    }
                    Err(LlmError::ContextOverflow(_)) if attempt == 1 => {
                        let _ = tx.send(AgentEvent::notice(
                            "Context overflow — emergency compaction.".into(),
                        ));
                        self.emergency_compact();
                        messages = self.history.clone();
                        continue;
                    }
                    Err(LlmError::ModelNotFound(_)) => {
                        // Drop the just-added user message so a retry (after
                        // switching model / adding a key) doesn't double it.
                        self.pop_pending_user();
                        let needs_key = self.cfg.provider != crate::config::Provider::Ollama
                            && self.cfg.api_key.trim().is_empty();
                        let _ = tx.send(AgentEvent::ModelUnavailable {
                            model: self.cfg.model.clone(),
                            needs_key,
                        });
                        let _ = tx.send(AgentEvent::TurnComplete);
                        return;
                    }
                    Err(LlmError::Unauthorized(_)) => {
                        self.pop_pending_user();
                        // Codex carries an OAuth session, not an API key — a 401
                        // means the session lapsed, so heal it (refresh / sign
                        // in again) rather than prompting for an OpenRouter key.
                        if self.cfg.provider == crate::config::Provider::Codex {
                            let _ = tx.send(AgentEvent::CodexReauth {
                                reason: "your ChatGPT/Codex session expired".into(),
                            });
                        } else {
                            let _ = tx.send(AgentEvent::ModelUnavailable {
                                model: self.cfg.model.clone(),
                                needs_key: true,
                            });
                        }
                        let _ = tx.send(AgentEvent::TurnComplete);
                        return;
                    }
                    Err(e) => {
                        if !cancel.is_cancelled() {
                            let _ = tx.send(AgentEvent::error(format!("{e}")));
                        } else {
                            let _ = tx.send(AgentEvent::notice("Interrupted.".into()));
                        }
                        let _ = tx.send(AgentEvent::TurnComplete);
                        return;
                    }
                }
            };

            // Fallback for models that emit tool calls as text (ERNIE's
            // ernie_x1 format, Qwen XML) instead of structured tool_calls.
            if out.tool_calls.is_empty() && crate::toolparse::looks_like_text_tool_call(&out.content) {
                let parsed = crate::toolparse::parse(&out.content);
                if !parsed.tool_calls.is_empty() {
                    let _ = tx.send(AgentEvent::notice(format!(
                        "parsed {} text-format tool call(s) (ernie/qwen compatibility)",
                        parsed.tool_calls.len()
                    )));
                    out.content = parsed.content;
                    out.tool_calls = parsed.tool_calls;
                    if !parsed.reasoning.is_empty() {
                        out.reasoning.push_str(&parsed.reasoning);
                    }
                }
            }

            if let Some(usage) = out.usage {
                self.last_prompt_tokens = usage.prompt_tokens + usage.completion_tokens;
                self.session_input_tokens += usage.prompt_tokens as u64;
                self.session_output_tokens += usage.completion_tokens as u64;
            }
            if out.served_by != self.cfg.model {
                let _ = tx.send(AgentEvent::notice(format!("served by fallback model {}", out.served_by)));
            }
            let _ = tx.send(AgentEvent::Usage {
                prompt_tokens: self.estimate_prompt_tokens(),
                session_input: self.session_input_tokens,
                session_output: self.session_output_tokens,
            });

            self.history
                .push(ChatMessage::assistant(out.content.clone(), out.tool_calls.clone()));
            let _ = tx.send(AgentEvent::AssistantDone);

            if out.tool_calls.is_empty() {
                let _ = tx.send(AgentEvent::TurnComplete);
                return;
            }

            for call in &out.tool_calls {
                if cancel.is_cancelled() {
                    self.history
                        .push(ChatMessage::tool(call.id.clone(), "Interrupted by user."));
                    continue;
                }
                let result = self.execute_tool(call, tx, cancel).await;
                self.history.push(ChatMessage::tool(call.id.clone(), result));
            }
        }

        let _ = tx.send(AgentEvent::notice(format!(
            "Reached the maximum of {} iterations for this turn.",
            self.cfg.max_iterations
        )));
        let _ = tx.send(AgentEvent::TurnComplete);
    }

    async fn execute_tool(
        &mut self,
        call: &ToolCall,
        tx: &mpsc::UnboundedSender<AgentEvent>,
        cancel: &CancellationToken,
    ) -> String {
        let args: Value = match serde_json::from_str(&call.arguments) {
            Ok(v) => v,
            Err(e) => {
                return format!(
                    "Error: tool arguments were not valid JSON ({e}). Raw: {}",
                    call.arguments.chars().take(400).collect::<String>()
                )
            }
        };

        // Hard enforcement of the ReadOnly profile (explore subagents): even a
        // hallucinated call to an unadvertised tool is refused at dispatch.
        if self.cfg.tool_profile == crate::config::ToolProfile::ReadOnly
            && !matches!(call.name.as_str(), "read_file" | "run_shell_command")
        {
            return format!(
                "Error: tool '{}' is not available to read-only explorer subagents. Use read_file and read-only shell commands only.",
                call.name
            );
        }
        // Plan mode keeps its no-edits contract even against hallucinated calls.
        if self.cfg.mode == crate::config::Mode::Plan
            && matches!(call.name.as_str(), "write_file" | "edit_file")
        {
            return "Error: plan mode is read-only — produce the implementation plan instead of editing files.".into();
        }

        match call.name.as_str() {
            "run_shell_command" => self.run_shell_tool(call, &args, tx, cancel).await,
            "write_file" => self.write_file_tool(&args, tx),
            "edit_file" => self.edit_file_tool(&args, tx),
            "read_file" => {
                let rel = args["path"].as_str().unwrap_or("");
                if rel.is_empty() {
                    return "Error: path required.".into();
                }
                let path = self.resolve_path(rel);
                let _ = tx.send(AgentEvent::ToolBegin {
                    call_id: call.id.clone(),
                    command: format!("read_file {rel}"),
                    description: String::new(),
                    background: false,
                });
                let out = crate::tools::read::read_file(&path);
                let _ = tx.send(AgentEvent::ToolEnd {
                    call_id: call.id.clone(),
                    output: out.lines().take(6).collect::<Vec<_>>().join("\n"),
                    exit_code: 0,
                    duration_ms: 0,
                    success: !out.starts_with("Error"),
                });
                out
            }
            "update_plan" => {
                let steps: Vec<(String, String)> = args["steps"]
                    .as_array()
                    .map(|arr| {
                        arr.iter()
                            .filter_map(|s| {
                                Some((
                                    s["step"].as_str()?.to_string(),
                                    s["status"].as_str().unwrap_or("pending").to_string(),
                                ))
                            })
                            .collect()
                    })
                    .unwrap_or_default();
                if steps.is_empty() {
                    return "Error: steps array required.".into();
                }
                self.plan = steps.clone();
                let _ = tx.send(AgentEvent::plan(steps));
                "Plan updated.".into()
            }
            "explore" => {
                let n = args["tasks"].as_array().map(|a| a.len()).unwrap_or(0);
                let _ = tx.send(AgentEvent::ToolBegin {
                    call_id: call.id.clone(),
                    command: format!("explore — fan out {n} subagent(s)"),
                    description: String::new(),
                    background: false,
                });
                let started = std::time::Instant::now();
                let out = crate::tools::subagent::run_explore(&self.cfg, &args, tx, cancel).await;
                let _ = tx.send(AgentEvent::ToolEnd {
                    call_id: call.id.clone(),
                    output: out.lines().take(8).collect::<Vec<_>>().join("\n"),
                    exit_code: 0,
                    duration_ms: started.elapsed().as_millis(),
                    success: !out.starts_with("Error"),
                });
                out
            }
            "analyze_image" => {
                let rel = args["path"].as_str().unwrap_or("");
                if rel.is_empty() {
                    return "Error: path required.".into();
                }
                let question = args["question"]
                    .as_str()
                    .filter(|q| !q.trim().is_empty())
                    .unwrap_or(crate::tools::visual::DEFAULT_QUESTION)
                    .to_string();
                let path = self.resolve_path(rel);
                let _ = tx.send(AgentEvent::ToolBegin {
                    call_id: call.id.clone(),
                    command: format!("analyze_image {rel}"),
                    description: String::new(),
                    background: false,
                });
                let started = std::time::Instant::now();
                let out = match crate::tools::visual::image_data_url(&path) {
                    Err(e) => e,
                    Ok(data_url) => {
                        // One multimodal request against the `visual` role model.
                        let vcfg = self.cfg.role_config("visual");
                        let client = build_client(&vcfg);
                        let messages = vec![
                            ChatMessage::system(
                                "You are a precise visual analyst. Answer the question about the attached image concretely: describe what is actually visible (text, UI elements, structure, errors).",
                            ),
                            ChatMessage::user_with_images(question, vec![data_url]),
                        ];
                        let result = tokio::select! {
                            r = client.complete_once(&vcfg.model, &messages) => r,
                            _ = cancel.cancelled() => Ok("Image analysis interrupted.".to_string()),
                        };
                        match result {
                            Ok(s) if !s.trim().is_empty() => s,
                            Ok(_) => "Error: the visual model returned an empty answer.".into(),
                            Err(e) => format!("Error: visual model request failed: {e}"),
                        }
                    }
                };
                let _ = tx.send(AgentEvent::ToolEnd {
                    call_id: call.id.clone(),
                    output: out.lines().take(8).collect::<Vec<_>>().join("\n"),
                    exit_code: 0,
                    duration_ms: started.elapsed().as_millis(),
                    success: !out.starts_with("Error"),
                });
                out
            }
            "web_search" => {
                let query = args["query"].as_str().unwrap_or("").to_string();
                if query.is_empty() {
                    return "Error: query required.".into();
                }
                let max = args["max_results"].as_u64().unwrap_or(5).clamp(1, 10) as usize;
                let _ = tx.send(AgentEvent::ToolBegin {
                    call_id: call.id.clone(),
                    command: format!("web_search \"{query}\""),
                    description: String::new(),
                    background: false,
                });
                let started = std::time::Instant::now();
                let out = tokio::select! {
                    r = crate::tools::web::web_search(&query, max) => r,
                    _ = cancel.cancelled() => "Search interrupted.".to_string(),
                };
                let _ = tx.send(AgentEvent::ToolEnd {
                    call_id: call.id.clone(),
                    output: out.clone(),
                    exit_code: 0,
                    duration_ms: started.elapsed().as_millis(),
                    success: !out.starts_with("Search failed"),
                });
                out
            }
            "read_url" => {
                let url = args["url"].as_str().unwrap_or("").to_string();
                let _ = tx.send(AgentEvent::ToolBegin {
                    call_id: call.id.clone(),
                    command: format!("read_url {url}"),
                    description: String::new(),
                    background: false,
                });
                let started = std::time::Instant::now();
                let out = tokio::select! {
                    r = crate::tools::web::read_url(&url) => r,
                    _ = cancel.cancelled() => "Fetch interrupted.".to_string(),
                };
                let _ = tx.send(AgentEvent::ToolEnd {
                    call_id: call.id.clone(),
                    output: out.lines().take(8).collect::<Vec<_>>().join("\n"),
                    exit_code: 0,
                    duration_ms: started.elapsed().as_millis(),
                    success: !out.starts_with("Error"),
                });
                out
            }
            name if name.starts_with("mcp__") => {
                // MCP tools need approval under `auto` (third-party code).
                if self.cfg.approval != ApprovalPolicy::FullAuto && !self.session_approved {
                    let (request_id, resp_rx) = self.approvals.register();
                    let _ = tx.send(AgentEvent::ApprovalRequest {
                        request_id: request_id.clone(),
                        command: format!("{name} {}", call.arguments.chars().take(120).collect::<String>()),
                        risk_label: "caution".into(),
                        reason: "external MCP tool".into(),
                    });
                    let decision = tokio::select! {
                        d = resp_rx => d.unwrap_or(ApprovalDecision::Deny),
                        _ = cancel.cancelled() => {
                            self.approvals.discard(&request_id);
                            ApprovalDecision::Deny
                        }
                    };
                    match decision {
                        ApprovalDecision::Deny => {
                            return "The user declined this MCP tool call.".into()
                        }
                        ApprovalDecision::ApproveForSession => self.session_approved = true,
                        ApprovalDecision::Approve => {}
                    }
                }
                let _ = tx.send(AgentEvent::ToolBegin {
                    call_id: call.id.clone(),
                    command: name.to_string(),
                    description: String::new(),
                    background: false,
                });
                let started = std::time::Instant::now();
                let out = match self.mcp.as_mut() {
                    Some(mgr) => mgr.call(name, args.clone()).await,
                    None => "Error: no MCP servers connected.".into(),
                };
                let _ = tx.send(AgentEvent::ToolEnd {
                    call_id: call.id.clone(),
                    output: out.lines().take(8).collect::<Vec<_>>().join("\n"),
                    exit_code: 0,
                    duration_ms: started.elapsed().as_millis(),
                    success: !out.starts_with("Error") && !out.starts_with("MCP call failed"),
                });
                out
            }
            "manage_background_task" => {
                let action = args["action"].as_str().unwrap_or("status");
                let id = args["id"].as_u64().map(|v| v as u32);
                let lines = args["lines"].as_u64().unwrap_or(60) as usize;
                let _ = tx.send(AgentEvent::ToolBegin {
                    call_id: call.id.clone(),
                    command: format!("[bg:{action}{}]", id.map(|i| format!(" #{i}")).unwrap_or_default()),
                    description: String::new(),
                    background: true,
                });
                let out = self.bg.manage(action, id, lines).await;
                let _ = tx.send(AgentEvent::ToolEnd {
                    call_id: call.id.clone(),
                    output: out.clone(),
                    exit_code: 0,
                    duration_ms: 0,
                    success: true,
                });
                out
            }
            other => format!("Error: unknown tool '{other}'. Available: run_shell_command, manage_background_task, update_plan, web_search, read_url."),
        }
    }

    /// Remove the trailing user message (used when a turn fails before any
    /// assistant reply, so a retry doesn't stack duplicate user turns).
    fn pop_pending_user(&mut self) {
        if matches!(self.history.last(), Some(m) if m.role == "user") {
            self.history.pop();
        }
    }

    /// Resolve a possibly-relative path against the session working dir.
    fn resolve_path(&self, p: &str) -> std::path::PathBuf {
        let path = std::path::Path::new(p);
        if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.cfg.cwd.join(path)
        }
    }

    fn write_file_tool(&mut self, args: &Value, tx: &mpsc::UnboundedSender<AgentEvent>) -> String {
        let rel = args["path"].as_str().unwrap_or("");
        if rel.is_empty() {
            return "Error: path required.".into();
        }
        let content = args["content"].as_str().unwrap_or("");
        let path = self.resolve_path(rel);
        match crate::tools::edit::write_file(&path, content) {
            Ok(r) => {
                let _ = tx.send(AgentEvent::FileEdit {
                    path: rel.to_string(),
                    op: r.op,
                    added: r.added,
                    removed: r.removed,
                    lines: r.lines,
                });
                format!("{} {rel} (+{} -{})", r.op, r.added, r.removed)
            }
            Err(e) => format!("Error writing {rel}: {e}"),
        }
    }

    fn edit_file_tool(&mut self, args: &Value, tx: &mpsc::UnboundedSender<AgentEvent>) -> String {
        let rel = args["path"].as_str().unwrap_or("");
        if rel.is_empty() {
            return "Error: path required.".into();
        }
        let old_string = args["old_string"].as_str().unwrap_or("");
        let new_string = args["new_string"].as_str().unwrap_or("");
        let replace_all = args["replace_all"].as_bool().unwrap_or(false);
        let path = self.resolve_path(rel);
        match crate::tools::edit::edit_file(&path, old_string, new_string, replace_all) {
            Ok(r) => {
                let _ = tx.send(AgentEvent::FileEdit {
                    path: rel.to_string(),
                    op: r.op,
                    added: r.added,
                    removed: r.removed,
                    lines: r.lines,
                });
                format!("{} {rel} (+{} -{})", r.op, r.added, r.removed)
            }
            Err(e) => format!("Error editing {rel}: {e}"),
        }
    }

    async fn run_shell_tool(
        &mut self,
        call: &ToolCall,
        args: &Value,
        tx: &mpsc::UnboundedSender<AgentEvent>,
        cancel: &CancellationToken,
    ) -> String {
        let command = args["command"].as_str().unwrap_or("").to_string();
        if command.trim().is_empty() {
            return "Error: command cannot be empty.".into();
        }
        let description = args["description"].as_str().unwrap_or("").to_string();
        let background = args["is_background"].as_bool().unwrap_or(false);
        let timeout_ms = args["timeout_ms"].as_u64().unwrap_or(self.cfg.shell_timeout_ms);
        let cwd = args["dir_path"]
            .as_str()
            .map(std::path::PathBuf::from)
            .unwrap_or_else(|| self.cfg.cwd.clone());
        if !cwd.is_dir() {
            return format!("Error: directory not found: {}", cwd.display());
        }

        // ---- security & publish-guard checks ----
        let assessment = assess(&command);
        if assessment.risk == Risk::Blocked {
            let msg = format!(
                "Blocked by Lucid Train security policy: this command would {}. It will not be executed under any approval mode. Find a safer approach.",
                assessment.reason
            );
            let _ = tx.send(AgentEvent::notice(format!("⛔ blocked: {command}")));
            return msg;
        }
        if let Some(block) = self.guard.check(&command, &description) {
            let _ = tx.send(AgentEvent::notice("publish-state guard blocked a command".into()));
            return format!("{block}\nExit Code: {GUARD_EXIT_CODE}");
        }

        // ReadOnly profile (explore subagents): approvals are auto-DENIED —
        // never prompt. Anything the risk classifier doesn't rate Safe is
        // refused outright, and background tasks are unavailable.
        if self.cfg.tool_profile == crate::config::ToolProfile::ReadOnly {
            if assessment.risk >= Risk::Caution {
                return format!(
                    "Denied: explorer subagents are read-only ({}). This command requires approval and approvals are auto-denied here — use read-only commands only.",
                    if assessment.reason.is_empty() { "not classified as safe" } else { &assessment.reason }
                );
            }
            if background {
                return "Denied: background tasks are not available to read-only explorer subagents.".into();
            }
        }

        let needs_approval = match self.cfg.approval {
            ApprovalPolicy::Plan => true,
            ApprovalPolicy::Auto => assessment.risk >= Risk::Caution,
            ApprovalPolicy::FullAuto => false,
        } && !self.session_approved;

        // Belt-and-braces: a ReadOnly child must never park on an approval
        // prompt, whatever its approval policy says — deny instead of asking.
        if needs_approval && self.cfg.tool_profile == crate::config::ToolProfile::ReadOnly {
            return "Denied: explorer subagents are read-only; approval-requiring commands are auto-denied.".into();
        }

        if needs_approval {
            let (request_id, resp_rx) = self.approvals.register();
            let risk_label = match assessment.risk {
                Risk::Safe => "safe",
                Risk::Caution => "caution",
                Risk::Dangerous => "dangerous",
                Risk::Blocked => unreachable!(),
            };
            let _ = tx.send(AgentEvent::ApprovalRequest {
                request_id: request_id.clone(),
                command: command.clone(),
                risk_label: risk_label.into(),
                reason: if assessment.reason.is_empty() {
                    if self.cfg.approval == ApprovalPolicy::Plan { "plan mode: all commands need approval".into() } else { String::new() }
                } else {
                    assessment.reason.clone()
                },
            });
            let decision = tokio::select! {
                d = resp_rx => d.unwrap_or(ApprovalDecision::Deny),
                _ = cancel.cancelled() => {
                    self.approvals.discard(&request_id);
                    ApprovalDecision::Deny
                }
            };
            match decision {
                ApprovalDecision::Deny => {
                    return "The user declined to run this command. Ask for guidance or propose an alternative; do not retry the same command verbatim.".into();
                }
                ApprovalDecision::ApproveForSession => self.session_approved = true,
                ApprovalDecision::Approve => {}
            }
        }

        let _ = tx.send(AgentEvent::ToolBegin {
            call_id: call.id.clone(),
            command: command.clone(),
            description: description.clone(),
            background,
        });

        if background {
            let result = match self.bg.spawn(&command, &cwd) {
                Ok(msg) => msg,
                Err(e) => format!("Error: {e}"),
            };
            let _ = tx.send(AgentEvent::ToolEnd {
                call_id: call.id.clone(),
                output: result.clone(),
                exit_code: 0,
                duration_ms: 0,
                success: !result.starts_with("Error"),
            });
            return result;
        }

        let outcome = run_shell(&command, &cwd, timeout_ms, cancel).await;

        // ---- build model-facing content ----
        let mut parts: Vec<String> = Vec::new();
        if outcome.timed_out {
            parts.push(format!(
                "Timeout: command timed out after {:.1} minutes.",
                timeout_ms as f64 / 60_000.0
            ));
            parts.push("Hint: for exploratory probes use a smaller timeout_ms; for long-running installs, servers or builds use is_background=true, then poll with short status/log checks.".into());
        } else if outcome.cancelled {
            parts.push("Command was interrupted by the user.".into());
        } else {
            parts.push(format!(
                "Output: {}",
                if outcome.output.is_empty() { "(empty)" } else { &outcome.output }
            ));
        }
        if outcome.exit_code != 0 {
            parts.push(format!("Exit Code: {}", outcome.exit_code));
        }

        for note in self.hints.after_shell(
            &command,
            &description,
            &outcome.output,
            outcome.exit_code,
            outcome.duration_ms,
            outcome.timed_out,
        ) {
            parts.push(format!("Execution note: {note}"));
        }
        if let Some(note) = self.guard.observe_success(
            &command,
            &description,
            outcome.exit_code,
            &cwd.display().to_string(),
        ) {
            parts.push(format!("Execution note: {note}"));
        }

        let _ = tx.send(AgentEvent::ToolEnd {
            call_id: call.id.clone(),
            output: outcome.output.clone(),
            exit_code: outcome.exit_code,
            duration_ms: outcome.duration_ms,
            success: outcome.exit_code == 0,
        });

        parts.join("\n")
    }

    /// Summarizing compaction: keep system + a recent window, replace the
    /// middle with an LLM-generated continuation brief.
    pub async fn compact(&mut self, tx: &mpsc::UnboundedSender<AgentEvent>) {
        const KEEP_RECENT: usize = 8;
        if self.history.len() <= KEEP_RECENT + 2 {
            self.emergency_compact();
            return;
        }
        let _ = tx.send(AgentEvent::notice("Compacting context…".into()));

        // Window start must not split an assistant/tool pair.
        let mut start = self.history.len() - KEEP_RECENT;
        while start < self.history.len() && self.history[start].role == "tool" {
            start += 1;
        }

        let to_summarize = &self.history[1..start];
        let transcript: String = to_summarize
            .iter()
            .map(|m| {
                let body: String = m.content.chars().take(4000).collect();
                format!("[{}] {}\n", m.role, body)
            })
            .collect();

        let summary_request = vec![
            ChatMessage::system(COMPACT_PROMPT),
            ChatMessage::user(transcript),
        ];
        let summary = match self.client.complete_once(&self.cfg.model, &summary_request).await {
            Ok(s) if !s.trim().is_empty() => s,
            _ => {
                // LLM summarization failed — fall back to dropping tool outputs.
                self.emergency_compact();
                return;
            }
        };

        let mut new_history = vec![self.history[0].clone()];
        new_history.push(ChatMessage::user(format!(
            "[Context was compacted to save space. Continuation brief of everything so far:]\n{summary}"
        )));
        new_history.extend_from_slice(&self.history[start..]);
        self.history = new_history;
        self.last_prompt_tokens = 0;
        let _ = tx.send(AgentEvent::notice("Context compacted.".into()));
    }

    /// Cheap fallback: elide old tool outputs, keep the last few intact.
    pub fn emergency_compact(&mut self) {
        let len = self.history.len();
        let keep_after = len.saturating_sub(6);
        for (i, msg) in self.history.iter_mut().enumerate() {
            if i < keep_after && msg.role == "tool" && msg.content.len() > 400 {
                let head: String = msg.content.chars().take(200).collect();
                msg.content = format!("{head}\n[...output elided to free context space...]");
            }
        }
        self.last_prompt_tokens = 0;
    }
}
