//! hagent — a thin terminal agent client for Hadrian's `/v1/responses` API.

mod app;
mod net;
mod render;
mod slash;
mod term;

use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use futures_util::StreamExt;
use hagent_core::config::{default_config_path, resolve, FileConfig, Overrides};
use hagent_core::mcp::{AuthKind, McpServerSettings};
use hagent_core::protocol::event::{KnownEvent, StreamEvent};
use hagent_core::protocol::items::{KnownItem, OutputItem};
use hagent_core::{
    Client, ConvLogger, Conversation, ConversationSnapshot, Settings, SnapshotMeta, TokenStore,
};

#[derive(Parser, Debug)]
#[command(name = "hagent", version, about)]
struct Cli {
    /// Hadrian base URL (default http://localhost:8080).
    #[arg(long, env = "HAGENT_BASE_URL")]
    base_url: Option<String>,
    /// API key (gw_...). Prefer the HAGENT_API_KEY env var.
    #[arg(long, env = "HAGENT_API_KEY", hide_env_values = true)]
    api_key: Option<String>,
    /// Model to route to.
    #[arg(long, env = "HAGENT_MODEL")]
    model: Option<String>,
    /// Path to config.toml (default ~/.config/hagent/config.toml).
    #[arg(long)]
    config: Option<PathBuf>,
    /// Server-side tools to declare, comma-separated: shell, web_search,
    /// file_search, mcp. Overrides the config file. Without this (and no config
    /// entry) all tools are enabled; use `--tools none` (or an empty value) to
    /// declare none.
    #[arg(long, value_delimiter = ',')]
    tools: Option<Vec<String>>,
    /// Reasoning effort: minimal, low, medium, high, xhigh, max — or off to
    /// disable. `xhigh`/`max` are the adaptive-Opus / GPT-5.x tiers above `high`
    /// (clamped down to `high` by providers that don't support them).
    /// Defaults to "high" (the switch that makes the model actually think).
    #[arg(long, env = "HAGENT_REASONING_EFFORT")]
    reasoning_effort: Option<String>,
    /// Reasoning summary verbosity: auto, concise, detailed — or off to
    /// request none. Defaults to "auto".
    #[arg(long, env = "HAGENT_REASONING_SUMMARY")]
    reasoning_summary: Option<String>,
    /// Disable per-conversation logging (it's on by default). Logs go to
    /// <cache>/conversations/<id>/{log.ndjson, conversation.json}.
    #[arg(long = "no-log-conversations", conflicts_with = "log_conversations")]
    no_log_conversations: bool,
    /// Force per-conversation logging on (overrides a config that disabled it).
    #[arg(long)]
    log_conversations: bool,
    /// Base directory for conversation logs (default: cache dir /conversations).
    #[arg(long, env = "HAGENT_LOG_DIR")]
    log_dir: Option<PathBuf>,
    /// Write the conversation log WITHOUT redacting secrets (the API key and
    /// MCP bearer tokens) — for debugging auth. Off by default. Distinct from
    /// HAGENT_LOG, which sets the tracing verbosity level.
    #[arg(long, env = "HAGENT_DEBUG")]
    debug: bool,
    /// One-shot headless prompt: stream the response to stdout and exit.
    #[arg(short = 'p', long)]
    prompt: Option<String>,
    /// Offer/upload the file-editing skill if this server has none cached. In
    /// the TUI you're prompted first; in headless (`-p`) this auto-uploads.
    #[arg(long)]
    bootstrap: bool,
    /// Never offer the file-editing skill bootstrap (suppresses the TUI prompt).
    #[arg(long = "no-bootstrap", conflicts_with = "bootstrap")]
    no_bootstrap: bool,
    /// Force a re-bootstrap even when a skill is cached: in the TUI this opens a
    /// prompt offering to delete the existing skill before re-uploading; in
    /// headless it deletes the existing skill(s) and uploads a fresh copy. Useful
    /// to pick up an updated bundled editor (scripts/edit.py).
    #[arg(long = "force-bootstrap", conflicts_with = "no_bootstrap")]
    force_bootstrap: bool,
    /// Resume a previous conversation by id (its directory name under the log
    /// dir). Continues the same `previous_response_id` chain and log.
    #[arg(long, conflicts_with = "resume_latest")]
    resume: Option<String>,
    /// Resume the most recently updated conversation in the log dir.
    #[arg(long = "continue")]
    resume_latest: bool,
    /// Subcommand (e.g. `hagent mcp login <server>`). When omitted, hagent runs
    /// the TUI (or a `-p` headless turn) as usual.
    #[command(subcommand)]
    command: Option<Command>,
}

/// Top-level subcommands. Absent for the default TUI/headless behaviour.
#[derive(Subcommand, Debug)]
enum Command {
    /// Manage authentication for remote MCP servers.
    Mcp {
        #[command(subcommand)]
        action: McpCommand,
    },
}

#[derive(Subcommand, Debug)]
enum McpCommand {
    /// Log in to an MCP server via OAuth (opens a browser; prints the URL too).
    Login {
        /// The server's `server_label` (from .mcp.json or config.toml).
        server: String,
    },
    /// List configured MCP servers and their authentication status.
    List,
    /// Remove stored credentials for an MCP server.
    Logout {
        /// The server's `server_label`.
        server: String,
    },
    /// Add a remote MCP server to ./.mcp.json or config.toml.
    Add {
        /// The server URL (http/https).
        url: String,
        /// Optional server label (derived from the URL host when omitted).
        label: Option<String>,
        /// Where to write it: "project" (./.mcp.json) or "user" (config.toml).
        /// Omit to be prompted.
        #[arg(long)]
        to: Option<String>,
    },
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    // Capture the optional subcommand before `cli`'s fields are moved into the
    // flag layer below.
    let command = cli.command;

    let config_path = cli.config.clone().or_else(default_config_path);
    let file = match &config_path {
        Some(p) => FileConfig::load(p).context("loading config file")?,
        None => None,
    };

    // Merge MCP servers declared in ./.mcp.json (Claude-Code project scope) with
    // the config file's [[mcp_servers]]; config.toml wins on a label conflict.
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let mcp_json = hagent_core::mcp::load_mcp_json(&cwd).context("loading .mcp.json")?;
    let file = if mcp_json.is_empty() {
        file
    } else {
        let mut f = file.unwrap_or_default();
        f.mcp_servers = hagent_core::mcp::merge_servers(mcp_json, f.mcp_servers);
        Some(f)
    };

    // CLI flags carry env values too (via clap `env`), so they form the
    // highest-precedence layer; the dedicated env layer is left empty.
    let log_conversations = if cli.no_log_conversations {
        Some(false)
    } else if cli.log_conversations {
        Some(true)
    } else {
        None
    };
    // Capture resume intent before `cli`'s fields are moved into `flags`.
    let resume = cli.resume.clone();
    let resume_latest = cli.resume_latest;
    let flags = Overrides {
        base_url: cli.base_url,
        api_key: cli.api_key,
        model: cli.model,
        tools: cli.tools,
        reasoning_effort: cli.reasoning_effort,
        reasoning_summary: cli.reasoning_summary,
        log_conversations,
        log_dir: cli
            .log_dir
            .clone()
            .map(|p| p.to_string_lossy().into_owned()),
        debug: cli.debug.then_some(true),
    };
    // Keep the flag layer so the TUI's `/settings` can re-resolve config edits
    // with the same precedence the run started with.
    let settings =
        resolve(file, Overrides::default(), flags.clone()).context("resolving settings")?;

    // The --debug / HAGENT_DEBUG escape hatch disables log redaction; warn loudly
    // since the log then contains the API key and any MCP bearer tokens verbatim.
    if !settings.redact_logs && settings.log_dir.is_some() {
        eprintln!(
            "[hagent] warning: log redaction is OFF (--debug/HAGENT_DEBUG) — the conversation \
             log will contain UNREDACTED secrets (API key, MCP tokens)"
        );
    }

    // A subcommand (e.g. `hagent mcp login …`) runs to completion and exits,
    // bypassing the TUI/headless turn path entirely.
    if let Some(command) = command {
        return run_command(command, &settings, config_path.as_deref()).await;
    }

    // Open the conversation log (best-effort: a failure warns and continues
    // without logging), resuming a prior conversation when asked. The logger is
    // attached to the client so every `/v1/responses` turn — TUI or headless —
    // is recorded through the single streaming choke point, and also handed to
    // the UI to write the resumable `conversation.json` snapshot.
    let resume_id = resolve_resume_id(resume, resume_latest, &settings);
    let (logger, initial) = open_logging(&settings, resume_id.as_deref());
    let client = Client::new(settings.base_url.clone(), settings.api_key.clone())?
        .with_logger(logger.clone());
    tracing::debug!(base_url = %settings.base_url, model = %settings.model, "resolved settings");

    // `--bootstrap` forces the offer, `--no-bootstrap` suppresses it; neither
    // leaves the default (offer in the TUI when needed, skip in headless).
    let bootstrap_pref = if cli.no_bootstrap {
        Some(false)
    } else if cli.bootstrap {
        Some(true)
    } else {
        None
    };
    let force_bootstrap = cli.force_bootstrap;

    match cli.prompt {
        Some(prompt) => {
            run_headless(
                &client,
                settings,
                prompt,
                config_path,
                bootstrap_pref,
                force_bootstrap,
                logger,
                initial,
            )
            .await
        }
        None => {
            init_file_logging();
            app::run(
                settings,
                client,
                config_path,
                flags,
                bootstrap_pref,
                force_bootstrap,
                logger,
                initial,
            )
            .await
        }
    }
}

/// Run a CLI subcommand to completion (no TUI). Currently only `mcp …`.
async fn run_command(
    command: Command,
    settings: &Settings,
    config_path: Option<&Path>,
) -> Result<()> {
    let Command::Mcp { action } = command;
    let store = TokenStore::new();
    match action {
        McpCommand::List => {
            list_mcp_servers(settings, &store);
            Ok(())
        }
        McpCommand::Login { server } => mcp_login(&server, settings, &store).await,
        McpCommand::Logout { server } => mcp_logout(&server, settings, &store),
        McpCommand::Add { url, label, to } => mcp_add(&url, label, to, settings, config_path).await,
    }
}

/// `hagent mcp add <url> [label] [--to project|user]`: add a remote MCP server
/// to ./.mcp.json or config.toml (prompting for the destination when `--to` is
/// omitted), probing it to default the auth kind.
async fn mcp_add(
    url: &str,
    label: Option<String>,
    to: Option<String>,
    settings: &Settings,
    config_path: Option<&Path>,
) -> Result<()> {
    if !(url.starts_with("http://") || url.starts_with("https://")) {
        anyhow::bail!("invalid MCP server URL '{url}' (must be http/https)");
    }
    // Provisional label (the user's, else the hostname heuristic) for the
    // up-front dup check; when auto-derived, `add_server` may upgrade it to the
    // server's well-known name.
    let provisional = label
        .clone()
        .or_else(|| hagent_core::suggest_label(url))
        .unwrap_or_else(|| "server".to_string());
    if settings
        .mcp_servers
        .iter()
        .any(|s| s.server_label == provisional)
    {
        anyhow::bail!(
            "an MCP server labelled '{provisional}' already exists — pass a different label"
        );
    }

    let dest = match to.as_deref() {
        Some("project") | Some("json") => "json",
        Some("user") | Some("toml") => "toml",
        Some(other) => anyhow::bail!("--to must be 'project' or 'user' (got '{other}')"),
        None if config_path.is_some() => prompt_add_dest()?,
        None => "json", // only the project file is available
    };

    let target = match dest {
        "toml" => {
            let path =
                config_path.ok_or_else(|| anyhow!("no config file path; use --to project"))?;
            hagent_core::AddTarget::ConfigToml(path.to_path_buf())
        }
        _ => {
            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
            hagent_core::AddTarget::McpJson(cwd)
        }
    };

    let server = hagent_core::add_server(target, label.as_deref(), url)
        .await
        .map_err(|e| anyhow!(e.to_string()))?;
    // `add_server` may have derived the label from the server's well-known name.
    let label = server.server_label.clone();
    let where_ = if dest == "toml" {
        "config.toml"
    } else {
        "./.mcp.json"
    };
    println!("Added MCP server '{label}' to {where_}.");
    if let Some(desc) = &server.server_description {
        println!("  name: {desc}");
    }
    // If it needs OAuth, log in now rather than just telling the user to.
    if server.needs_oauth() {
        println!("It requires authentication — starting login…");
        let store = TokenStore::new();
        do_login(&server, &store).await?;
    }
    Ok(())
}

/// Prompt on stdin for the `mcp add` destination (project vs user). Defaults to
/// the project file on an empty/unrecognized answer.
fn prompt_add_dest() -> Result<&'static str> {
    print!("Save to [1] ./.mcp.json (project) or [2] config.toml (user)? [1/2] ");
    std::io::stdout().flush().ok();
    let mut line = String::new();
    std::io::stdin()
        .read_line(&mut line)
        .context("reading destination choice")?;
    Ok(match line.trim().to_ascii_lowercase().as_str() {
        "2" | "user" | "toml" => "toml",
        _ => "json",
    })
}

/// Find a configured MCP server by `server_label`.
fn find_mcp_server<'a>(settings: &'a Settings, label: &str) -> Result<&'a McpServerSettings> {
    settings
        .mcp_servers
        .iter()
        .find(|s| s.server_label == label)
        .ok_or_else(|| {
            anyhow!("no MCP server '{label}' configured — check ./.mcp.json or config.toml")
        })
}

/// `hagent mcp list`: print each server's URL and authentication status.
fn list_mcp_servers(settings: &Settings, store: &TokenStore) {
    if settings.mcp_servers.is_empty() {
        println!("No MCP servers configured (add them to ./.mcp.json or config.toml).");
        return;
    }
    for s in &settings.mcp_servers {
        println!("{}  ({})", s.server_label, s.server_url);
        println!("    {}", mcp_status(s, store));
    }
}

/// A human-readable auth-status line for one server.
fn mcp_status(server: &McpServerSettings, store: &TokenStore) -> String {
    match &server.auth {
        AuthKind::None => "auth: none".into(),
        AuthKind::Bearer(_) => "auth: static bearer token".into(),
        AuthKind::Header { name, .. } => format!("auth: static header ({name})"),
        AuthKind::Oauth(_) => match store.load(&server.server_url) {
            None => "auth: oauth — NOT logged in (run `hagent mcp login`)".into(),
            Some(rec) => match rec.expires_at_ms {
                None => "auth: oauth — authenticated".into(),
                Some(exp) => {
                    let now = hagent_core::convlog::now_ms();
                    if exp <= now {
                        "auth: oauth — token expired (run `hagent mcp login`)".into()
                    } else {
                        format!(
                            "auth: oauth — authenticated (token expires in ~{} min)",
                            (exp - now) / 60_000
                        )
                    }
                }
            },
        },
    }
}

/// `hagent mcp login <server>`: run the OAuth flow, opening the system browser.
async fn mcp_login(label: &str, settings: &Settings, store: &TokenStore) -> Result<()> {
    let server = find_mcp_server(settings, label)?;
    if !server.needs_oauth() {
        anyhow::bail!(
            "server '{label}' is not configured for OAuth (its auth kind is static/none)"
        );
    }
    do_login(server, store).await
}

/// Run the OAuth login for an already-resolved server, opening the system
/// browser (and printing the URL for SSH/no-browser). Shared by `mcp login` and
/// the auto-login after `mcp add`.
async fn do_login(server: &McpServerSettings, store: &TokenStore) -> Result<()> {
    let label = server.server_label.clone();
    let open = {
        let label = label.clone();
        move |url: &str| {
            println!("Opening your browser to authenticate '{label}'…");
            println!("If it doesn't open automatically, visit:\n  {url}");
            let _ = open::that(url);
        }
    };
    let record = hagent_core::mcp::login(server, store, open)
        .await
        .map_err(|e| anyhow!(e.to_string()))?;
    match record.expires_at_ms {
        Some(exp) => {
            let mins = exp.saturating_sub(hagent_core::convlog::now_ms()) / 60_000;
            println!("Authenticated '{label}' (token expires in ~{mins} min; auto-refreshed).");
        }
        None => println!("Authenticated '{label}'."),
    }
    Ok(())
}

/// `hagent mcp logout <server>`: drop stored credentials.
fn mcp_logout(label: &str, settings: &Settings, store: &TokenStore) -> Result<()> {
    let server = find_mcp_server(settings, label)?;
    store
        .delete(&server.server_url)
        .map_err(|e| anyhow!(e.to_string()))?;
    println!("Removed stored credentials for '{label}'.");
    Ok(())
}

/// Resolve the conversation id to resume, if any: an explicit `--resume <id>`,
/// or the most-recent conversation for `--continue`. Warns (and returns `None`,
/// i.e. start fresh) when `--continue` finds nothing.
fn resolve_resume_id(
    resume: Option<String>,
    resume_latest: bool,
    settings: &Settings,
) -> Option<String> {
    if resume.is_some() {
        return resume;
    }
    if resume_latest {
        match settings
            .log_dir
            .as_deref()
            .and_then(hagent_core::convlog::latest_conversation_id)
        {
            Some(id) => return Some(id),
            None => {
                eprintln!("[hagent] --continue: no previous conversation found; starting fresh")
            }
        }
    }
    None
}

/// Reconstruct this run's command line with `--resume <id>` appended, so the
/// user can copy-paste it to continue the conversation. Drops any existing
/// `--resume <x>` / `--resume=<x>` / `--continue` from the original argv first so
/// the result targets the current conversation cleanly. Each token is shell-
/// quoted for safe paste.
pub(crate) fn resume_command(id: &str) -> String {
    let mut args = std::env::args();
    let prog = args.next().unwrap_or_else(|| "hagent".to_string());
    resume_command_from(&prog, args, id)
}

/// Pure core of [`resume_command`] (argv supplied explicitly, for testing).
fn resume_command_from<I: IntoIterator<Item = String>>(prog: &str, args: I, id: &str) -> String {
    let mut out = vec![shell_quote(prog)];
    let mut iter = args.into_iter().peekable();
    while let Some(a) = iter.next() {
        if a == "--resume" {
            iter.next(); // also drop its value
            continue;
        }
        if a == "--continue" || a.starts_with("--resume=") {
            continue;
        }
        out.push(shell_quote(&a));
    }
    out.push("--resume".to_string());
    out.push(shell_quote(id));
    out.join(" ")
}

/// Minimal POSIX shell quoting: leave “safe” tokens bare, else single-quote
/// (escaping embedded single quotes the `'\''` way).
fn shell_quote(s: &str) -> String {
    let safe = !s.is_empty()
        && s.bytes().all(|b| {
            b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'/' | b':' | b'=')
        });
    if safe {
        s.to_string()
    } else {
        format!("'{}'", s.replace('\'', "'\\''"))
    }
}

/// Open the logger and (when resuming) load the conversation snapshot. Resuming
/// reopens the *same* conversation directory so the log and chain continue;
/// a load failure warns and falls back to a fresh conversation.
fn open_logging(
    settings: &Settings,
    resume_id: Option<&str>,
) -> (Option<Arc<ConvLogger>>, Option<ConversationSnapshot>) {
    let Some(dir) = settings.log_dir.as_deref() else {
        if resume_id.is_some() {
            eprintln!("[hagent] warning: --resume/--continue need logging enabled; starting fresh");
        }
        return (None, None);
    };
    let Some(id) = resume_id else {
        return (build_logger(settings), None);
    };
    match hagent_core::convlog::load_snapshot(dir, id) {
        Ok(snapshot) => {
            if snapshot.base_url != settings.base_url {
                eprintln!(
                    "[hagent] warning: conversation {id} was on {} but you're using {}; \
                     server-side history may not match",
                    snapshot.base_url, settings.base_url
                );
            }
            if snapshot.model != settings.model {
                eprintln!(
                    "[hagent] warning: conversation {id} used model {} but you're using {}",
                    snapshot.model, settings.model
                );
            }
            let logger = match ConvLogger::resume(dir, id, settings) {
                Ok(l) => Some(l),
                Err(e) => {
                    eprintln!("[hagent] warning: couldn't reopen log for {id} ({e})");
                    None
                }
            };
            eprintln!("[hagent] resuming conversation {id}");
            (logger, Some(snapshot))
        }
        Err(e) => {
            eprintln!("[hagent] warning: couldn't load conversation {id} ({e}); starting fresh");
            (build_logger(settings), None)
        }
    }
}

/// Open the conversation logger for this run, or `None` when logging is
/// disabled or the log directory can't be opened (a non-fatal warning is
/// emitted; the session proceeds without logging).
fn build_logger(settings: &Settings) -> Option<Arc<ConvLogger>> {
    let dir = settings.log_dir.as_deref()?;
    match ConvLogger::create(dir, settings) {
        Ok(logger) => {
            tracing::info!(dir = %logger.dir().display(), "conversation logging enabled");
            Some(logger)
        }
        Err(e) => {
            eprintln!(
                "[hagent] warning: couldn't open conversation log ({e}); continuing without it"
            );
            None
        }
    }
}

/// In TUI mode stdout/stderr belong to the terminal, so route logs to a file.
fn init_file_logging() {
    use tracing_subscriber::EnvFilter;
    let dir = directories::ProjectDirs::from("", "", "hagent")
        .map(|d| d.cache_dir().to_path_buf())
        .unwrap_or_else(std::env::temp_dir);
    let _ = std::fs::create_dir_all(&dir);
    let appender = tracing_appender::rolling::never(&dir, "hagent.log");
    let _ = tracing_subscriber::fmt()
        .with_writer(appender)
        .with_ansi(false)
        .with_env_filter(
            EnvFilter::try_from_env("HAGENT_LOG").unwrap_or_else(|_| EnvFilter::new("info")),
        )
        .try_init();
}

/// Comma-separated names of the declared tools, for the headless banner.
fn tool_names(tools: &[hagent_core::protocol::request::Tool]) -> String {
    use hagent_core::protocol::request::Tool;
    tools
        .iter()
        .map(|t| match t {
            Tool::Shell { .. } => "shell".to_string(),
            Tool::WebSearch { .. } => "web_search".to_string(),
            Tool::FileSearch { .. } => "file_search".to_string(),
            Tool::Mcp(m) => format!("mcp:{}", m.server_label),
        })
        .collect::<Vec<_>>()
        .join(", ")
}

/// Stream one turn to stdout. Verifies framing/auth/`[DONE]` end to end. With
/// `--bootstrap` (and the shell tool enabled but no skill cached) it first
/// uploads the file-editing skill so the one-shot turn can use it.
#[allow(clippy::too_many_arguments)]
async fn run_headless(
    client: &Client,
    mut settings: hagent_core::Settings,
    prompt: String,
    config_path: Option<PathBuf>,
    bootstrap_pref: Option<bool>,
    force_bootstrap: bool,
    logger: Option<Arc<ConvLogger>>,
    initial: Option<ConversationSnapshot>,
) -> Result<()> {
    // `--force-bootstrap` deletes any existing skill and re-uploads; plain
    // `--bootstrap` uploads only when none is cached. Both need the shell tool.
    let shell = settings.tools.contains(&hagent_core::ToolKind::Shell);
    if force_bootstrap && bootstrap_pref != Some(false) && shell {
        maybe_bootstrap_headless(client, &mut settings, config_path.as_deref(), true).await;
    } else if bootstrap_pref == Some(true) && settings.skill_id.is_none() && shell {
        maybe_bootstrap_headless(client, &mut settings, config_path.as_deref(), false).await;
    }

    let created_ms = initial
        .as_ref()
        .map(|s| s.created_ms)
        .unwrap_or_else(hagent_core::convlog::now_ms);
    let mut conv = match &initial {
        Some(snapshot) => Conversation::restore(snapshot),
        None => Conversation::new(),
    };
    conv.push_user(prompt.clone());
    let request = conv.build_request(&settings, Some(prompt));

    if request.tools.is_empty() {
        eprintln!(
            "[hagent] no tools declared — the model has none available. \
             Enable some with --tools shell,web_search,file_search,mcp"
        );
    } else {
        eprintln!("[hagent] tools: {}", tool_names(&request.tools));
    }

    let mut stream = client.stream(request).await.context("opening stream")?;
    let mut stdout = std::io::stdout();

    while let Some(event) = stream.next().await {
        // Fold each event into the conversation too, so the snapshot we write
        // afterward carries the new chain pointer / transcript / totals.
        if let Ok(ev) = &event {
            conv.apply(ev.clone());
        }
        match event {
            Ok(StreamEvent::Known(KnownEvent::OutputTextDelta { delta, .. })) => {
                print!("{delta}");
                stdout.flush().ok();
            }
            // Reasoning (summary or raw) streams to stderr in dim grey, keeping
            // stdout the clean answer stream. Only present when reasoning is
            // requested and the model/provider emits it.
            Ok(StreamEvent::Known(
                KnownEvent::ReasoningSummaryDelta { delta, .. }
                | KnownEvent::ReasoningTextDelta { delta, .. },
            )) => {
                eprint!("\x1b[90m{delta}\x1b[0m");
                std::io::stderr().flush().ok();
            }
            Ok(StreamEvent::Known(KnownEvent::OutputItemDone { item, .. })) => {
                print_tool_line(&item);
            }
            Ok(StreamEvent::Known(KnownEvent::Completed { response })) => {
                println!();
                if let Some(u) = response.usage {
                    eprintln!(
                        "[done] id={} tokens: in={} out={} total={}",
                        response.id, u.input_tokens, u.output_tokens, u.total_tokens
                    );
                }
            }
            Ok(StreamEvent::Known(KnownEvent::Failed { response }))
            | Ok(StreamEvent::Known(KnownEvent::Incomplete { response })) => {
                let msg = response
                    .error
                    .and_then(|e| e.message)
                    .unwrap_or_else(|| "request did not complete".into());
                anyhow::bail!("response {}: {msg}", response.id);
            }
            Ok(StreamEvent::Known(KnownEvent::Error { message, code })) => {
                anyhow::bail!(
                    "stream error{}: {}",
                    code.map(|c| format!(" [{c}]")).unwrap_or_default(),
                    message.unwrap_or_default()
                );
            }
            Ok(_) => {}
            Err(e) => return Err(anyhow::anyhow!(e)).context("stream failed"),
        }
    }

    // Persist the resumable snapshot so this one-shot can be continued later,
    // and print the exact command to do so (tokens/cost were already shown in
    // the `[done]` line above).
    if let Some(logger) = &logger {
        let meta = SnapshotMeta {
            conversation_id: logger.conversation_id(),
            base_url: &settings.base_url,
            model: &settings.model,
            skill_id: settings.skill_id.as_deref(),
            created_ms,
        };
        logger.write_snapshot(&conv.capture_snapshot(meta));
        eprintln!(
            "[hagent] resume: {}",
            resume_command(logger.conversation_id())
        );
    }
    Ok(())
}

/// Upload the file-editing skill for a headless run and activate it for this
/// session (persisting the id to config). Failures are non-fatal — they warn to
/// stderr and the turn proceeds without the skill.
async fn maybe_bootstrap_headless(
    client: &Client,
    settings: &mut hagent_core::Settings,
    config_path: Option<&std::path::Path>,
    force: bool,
) {
    eprintln!(
        "[hagent] {} file-editing skill on {}…",
        if force {
            "re-bootstrapping"
        } else {
            "bootstrapping"
        },
        settings.base_url
    );
    match client.recreate_skill(force).await {
        Ok(skill) => {
            if let Some(path) = config_path {
                if let Err(e) = hagent_core::persist_skill_id(
                    path,
                    &settings.base_url,
                    settings.api_key.as_deref(),
                    &skill.id,
                ) {
                    eprintln!("[hagent] warning: couldn't save skill id to config: {e}");
                }
            }
            eprintln!("[hagent] file-editing skill ready ({})", skill.id);
            settings.skill_id = Some(skill.id);
        }
        Err(e) => {
            eprintln!("[hagent] warning: skill bootstrap failed: {e}");
            // A forced re-bootstrap deletes the old skill first; if the re-upload
            // failed, the cached id now points at a deleted skill, so drop it for
            // this run rather than send a dead skill_reference (a 400 every turn).
            if force {
                settings.skill_id = None;
            }
        }
    }
}

/// Print a one-line summary for a completed tool item (headless mode).
fn print_tool_line(item: &OutputItem) {
    let OutputItem::Known(known) = item else {
        return;
    };
    match known.as_ref() {
        KnownItem::ShellCall(sc) => {
            let cmd = sc
                .action
                .as_ref()
                .map(|a| a.commands.join("; "))
                .unwrap_or_default();
            eprintln!("\n[shell] $ {cmd}");
        }
        KnownItem::ShellCallOutput(out) => {
            let text = out.combined_output();
            if !text.is_empty() {
                eprintln!("[shell:out]\n{text}");
            }
        }
        KnownItem::WebSearchCall(_) => eprintln!("\n[web_search]"),
        KnownItem::FileSearchCall(_) => eprintln!("\n[file_search]"),
        KnownItem::McpCall(c) => eprintln!("\n[mcp] {}::{}", c.server_label, c.name),
        _ => {}
    }
}

#[cfg(test)]
mod tests {
    use super::{resume_command_from, shell_quote};

    #[test]
    fn shell_quote_leaves_safe_tokens_and_quotes_specials() {
        assert_eq!(shell_quote("gpt-5.2"), "gpt-5.2");
        assert_eq!(
            shell_quote("http://localhost:8080/api"),
            "http://localhost:8080/api"
        );
        assert_eq!(
            shell_quote("conv-20260602T020715690Z-671162"),
            "conv-20260602T020715690Z-671162"
        );
        assert_eq!(shell_quote("hello there"), "'hello there'");
        assert_eq!(shell_quote("a'b"), "'a'\\''b'");
        assert_eq!(shell_quote(""), "''");
    }

    fn args(a: &[&str]) -> Vec<String> {
        a.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn resume_command_appends_id_and_quotes_args() {
        let cmd = resume_command_from(
            "hagent",
            args(&["--model", "gpt-5.2", "-p", "hello there"]),
            "conv-123",
        );
        assert_eq!(
            cmd,
            "hagent --model gpt-5.2 -p 'hello there' --resume conv-123"
        );
    }

    #[test]
    fn resume_command_drops_prior_resume_and_continue() {
        // An existing `--resume <old>` and a `--continue` are both stripped so the
        // emitted command targets the current conversation only.
        let cmd = resume_command_from(
            "hagent",
            args(&["--resume", "conv-old", "--model", "m", "--continue"]),
            "conv-new",
        );
        assert_eq!(cmd, "hagent --model m --resume conv-new");

        // The `--resume=<old>` form is dropped too.
        let cmd = resume_command_from(
            "hagent",
            args(&["--resume=conv-old", "--tools", "none"]),
            "conv-new",
        );
        assert_eq!(cmd, "hagent --tools none --resume conv-new");
    }
}
