mod browser_security;
mod ca_bundle;
mod chat;
mod color;
mod commands;
mod connection;
mod doctor;
mod flags;
mod install;
mod mcp;
mod native;
mod output;
mod plugins;
mod read;
mod session_files;
mod session_settings;
mod skills;
#[cfg(test)]
mod test_utils;
mod tls;
mod upgrade;
mod validation;

use serde_json::json;
use sha2::{Digest, Sha256};
use std::env;
use std::fs;
#[cfg(unix)]
use std::io::Write;
use std::path::PathBuf;
use std::process::{exit, Command};

#[cfg(windows)]
use windows_sys::Win32::Foundation::CloseHandle;
#[cfg(windows)]
use windows_sys::Win32::System::Threading::OpenProcess;

use commands::{gen_id, parse_cdp_headers, parse_command, ParseError};
use connection::{
    cleanup_stale_files, daemon_unreachable, ensure_daemon, get_socket_dir,
    is_agent_browser_process, is_pid_alive, send_command, walk_daemons, DaemonOptions, Response,
};
use flags::{clean_args, parse_flags, Flags};
use install::run_install;
use output::{
    print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use session_settings::parse_proxy;
use upgrade::run_upgrade;

fn serialize_json_value(value: &serde_json::Value) -> String {
    serde_json::to_string(value).unwrap_or_else(|_| {
        r#"{"success":false,"error":"Failed to serialize JSON response"}"#.to_string()
    })
}

fn print_json_value(value: serde_json::Value) {
    println!("{}", serialize_json_value(&value));
}

fn print_json_error(message: impl AsRef<str>) {
    print_json_value(json!({
        "success": false,
        "error": message.as_ref(),
    }));
}

fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
    print_json_value(json!({
        "success": false,
        "error": message.as_ref(),
        "type": error_type,
    }));
}

fn attach_pin_tab_to_command(cmd: &mut serde_json::Value, flags: &Flags) {
    if flags.pin_tab {
        cmd["pinTab"] = json!(true);
    } else if flags.cli_pin_tab {
        cmd["pinTab"] = json!(false);
    }
}

fn attach_input_mode(cmd: &mut serde_json::Value, flags: &Flags) {
    if flags.cli_input_mode {
        cmd["defaultInputMode"] = json!(flags.input_mode);
    }
}

fn attach_plugins_to_command(cmd: &mut serde_json::Value, plugins: &[plugins::PluginConfig]) {
    cmd["plugins"] = json!(plugins);
}

/// Identifies CDP metadata requests, including batches containing only those requests.
fn command_is_cdp_url_metadata(cmd: &serde_json::Value) -> bool {
    match cmd.get("action").and_then(|value| value.as_str()) {
        Some("cdp_url") => true,
        Some("batch") => {
            if let Some(commands) = cmd.get("commands").and_then(|value| value.as_array()) {
                !commands.is_empty()
                    && commands.iter().all(|command| {
                        command.as_str().is_some_and(|command| {
                            commands::shell_words_split(command)
                                .starts_with(&["get".into(), "cdp-url".into()])
                        })
                    })
            } else if let Some(argv) = cmd.get("argv").and_then(|value| value.as_array()) {
                !argv.is_empty()
                    && argv.iter().all(|args| {
                        args.as_array()
                            .is_some_and(|args| args.starts_with(&[json!("get"), json!("cdp-url")]))
                    })
            } else {
                false
            }
        }
        _ => false,
    }
}

/// The settings patch one command carries. A browserless command never
/// launches a browser, so CA trust does not apply to it.
fn command_settings(flags: &Flags, cmd: &serde_json::Value) -> session_settings::Patch {
    let patch = session_settings::Patch::from_flags(flags);
    // Asking for a running browser's CDP URL never launches or relaunches one,
    // so launch settings do not apply to it.
    if command_is_cdp_url_metadata(cmd) {
        return patch.without_launch_settings();
    }
    let mut patch = patch;
    if command_is_browserless(cmd) {
        patch.remove(session_settings::Key::CaCert);
        patch.remove(session_settings::Key::ClearCaCert);
    }
    patch
}

/// The settings for one command of a batch. `--relaunch` asks for one relaunch
/// per invocation: every command that can launch a browser carries it until the
/// daemon reports it applied (`relaunch_applied`).
fn batch_item_settings(
    flags: &Flags,
    cmd: &serde_json::Value,
    relaunch_applied: bool,
) -> session_settings::Patch {
    let mut patch = command_settings(flags, cmd);
    if relaunch_applied || !native::actions::command_launches_browser(cmd) {
        patch.clear_relaunch();
    }
    patch
}

/// Whether the daemon applied a forced relaunch for this response. A command
/// denied by policy or parked for confirmation never reaches the launch, so it
/// reports none and the next command carries the relaunch. `confirm` replays
/// the parked command and nests that command's response under `result`.
fn response_applied_relaunch(resp: &connection::Response) -> bool {
    let Some(data) = resp.data.as_ref() else {
        return false;
    };
    [
        "/lifecycle/settings/forcedRelaunch",
        "/result/data/lifecycle/settings/forcedRelaunch",
    ]
    .iter()
    .any(|path| data.pointer(path).and_then(|v| v.as_bool()) == Some(true))
}

/// Checks a `--cdp` value and its headers. The value is either a port number
/// or a ws, wss, http or https URL.
fn validate_cdp_flags(flags: &Flags) -> Result<(), String> {
    let Some(cdp_value) = flags.cdp.as_deref() else {
        return Ok(());
    };
    let is_url = ["ws://", "wss://", "http://", "https://"]
        .iter()
        .any(|scheme| cdp_value.starts_with(scheme));
    if !is_url {
        match cdp_value.parse::<u32>() {
            Ok(0) => return Err("Invalid CDP port: port must be greater than 0".to_string()),
            Ok(port) if port > 65535 => {
                return Err(format!(
                    "Invalid CDP port: {} is out of range (valid range: 1-65535)",
                    port
                ))
            }
            Ok(_) => {}
            Err(_) => {
                return Err(format!(
                    "Invalid CDP value: '{}' is not a valid port number or URL",
                    cdp_value
                ))
            }
        }
    }
    if let Some(raw) = flags.cdp_headers.as_deref() {
        parse_cdp_headers(raw)?;
    }
    Ok(())
}

/// Commands that make their own HTTPS requests and never need a browser
/// launch. `--ca-cert` applies only to CLI trust for these. A `batch` given
/// as arguments counts when every command in it is one of these.
fn command_is_browserless(cmd: &serde_json::Value) -> bool {
    match cmd.get("action").and_then(|value| value.as_str()) {
        Some("read") => true,
        Some("batch") => {
            let first_words: Vec<Option<String>> =
                if let Some(argv) = cmd.get("argv").and_then(|v| v.as_array()) {
                    argv.iter()
                        .map(|c| c.get(0).and_then(|w| w.as_str()).map(str::to_string))
                        .collect()
                } else if let Some(commands) = cmd.get("commands").and_then(|v| v.as_array()) {
                    commands
                        .iter()
                        .map(|c| {
                            c.as_str()
                                .and_then(|c| commands::shell_words_split(c).into_iter().next())
                        })
                        .collect()
                } else {
                    return false;
                };
            !first_words.is_empty() && first_words.iter().all(|w| w.as_deref() == Some("read"))
        }
        _ => false,
    }
}

fn restore_key_from_flags(flags: &Flags) -> Option<&str> {
    flags.restore.as_deref().or(flags.session_name.as_deref())
}

fn is_valid_restore_save_policy(policy: &str) -> bool {
    matches!(policy, "auto" | "always" | "never")
}

fn incompatible_launch_mode_error(flags: &Flags) -> Option<&'static str> {
    if flags.cdp.is_some() && flags.provider.is_some() {
        return Some("Cannot use --cdp and -p/--provider together");
    }

    if flags.cli_auto_connect && flags.no_auto_connect {
        return Some("Cannot use --auto-connect and --no-auto-connect together");
    }

    if flags.auto_connect && flags.cdp.is_some() {
        return Some("Cannot use --auto-connect and --cdp together");
    }

    if flags.auto_connect && flags.provider.is_some() {
        return Some("Cannot use --auto-connect and -p/--provider together");
    }

    if flags.provider.is_some() && !flags.extensions.is_empty() {
        return Some(
            "Cannot use --extension with -p/--provider (extensions require local browser)",
        );
    }

    if flags.cdp.is_some() && !flags.extensions.is_empty() {
        return Some("Cannot use --extension with --cdp (extensions require local browser)");
    }

    // The WebGPU preset is Chrome launch flags; it cannot be applied to a
    // browser agent-browser did not launch. Rejecting (rather than silently
    // ignoring) matches the --extension handling above. `--webgpu false`
    // overrides an env/config-enabled preset for these modes.
    if flags.webgpu && flags.cdp.is_some() {
        return Some(
            "Cannot use --webgpu with --cdp (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
        );
    }
    if flags.webgpu && flags.provider.is_some() {
        return Some(
            "Cannot use --webgpu with -p/--provider (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
        );
    }
    if flags.webgpu && flags.auto_connect {
        return Some(
            "Cannot use --webgpu with --auto-connect (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
        );
    }

    if flags.ca_cert.is_some() && flags.ignore_https_errors {
        return Some("Cannot use --ca-cert with --ignore-https-errors");
    }
    if flags.ca_cert.is_some() && flags.clear_ca_cert {
        return Some("Cannot use --ca-cert with --no-ca-cert");
    }
    if flags.ca_cert.is_some() && flags.cdp.is_some() {
        return Some(
            "Cannot use --ca-cert with --cdp (--ca-cert requires a locally launched Chromium browser on Linux)",
        );
    }
    if flags.ca_cert.is_some() && flags.auto_connect {
        return Some(
            "Cannot use --ca-cert with --auto-connect (--ca-cert requires a locally launched Chromium browser on Linux)",
        );
    }
    if flags.ca_cert.is_some() && flags.provider.is_some() {
        return Some(
            "Cannot use --ca-cert with -p/--provider (--ca-cert requires a locally launched Chromium browser on Linux)",
        );
    }
    if flags.ca_cert.is_some() && flags.profile.is_some() {
        return Some(
            "Cannot use --ca-cert with --profile because isolated CA trust would change the profile's NSS environment",
        );
    }
    if flags.ca_cert.is_some()
        && flags
            .engine
            .as_deref()
            .is_some_and(|engine| !engine.eq_ignore_ascii_case("chrome"))
    {
        return Some("--ca-cert is supported only with the Chrome engine on Linux");
    }
    if flags.ca_cert.is_some() && !cfg!(target_os = "linux") {
        return Some("--ca-cert browser trust is currently supported only on Linux (read, install, upgrade, and doctor accept it on every platform)");
    }

    None
}

fn attach_restore_config_to_command(cmd: &mut serde_json::Value, flags: &Flags) {
    if let Some(restore_key) = restore_key_from_flags(flags) {
        cmd["restoreKey"] = json!(restore_key);
        cmd["restoreSave"] = json!(flags.restore_save.as_deref().unwrap_or("auto"));
        cmd["restoreCheckUrl"] = flags
            .restore_check_url
            .as_ref()
            .map(|check| json!(check))
            .unwrap_or(serde_json::Value::Null);
        cmd["restoreCheckText"] = flags
            .restore_check_text
            .as_ref()
            .map(|check| json!(check))
            .unwrap_or(serde_json::Value::Null);
        cmd["restoreCheckFn"] = flags
            .restore_check_fn
            .as_ref()
            .map(|check| json!(check))
            .unwrap_or(serde_json::Value::Null);
    }
}

fn mark_restarted_background(resp: &mut Response) {
    if !resp.success {
        return;
    }

    let Some(data) = resp.data.as_mut().and_then(|v| v.as_object_mut()) else {
        return;
    };

    let lifecycle = data
        .entry("lifecycle".to_string())
        .or_insert_with(|| json!({}));
    if !lifecycle.is_object() {
        *lifecycle = json!({});
    }
    if let Some(obj) = lifecycle.as_object_mut() {
        obj.insert("restartedBackground".to_string(), json!(true));
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfirmationPrompt {
    action: String,
    category: String,
    description: String,
    confirmation_id: String,
}

fn confirmation_prompt_from_data(data: &serde_json::Value) -> Option<ConfirmationPrompt> {
    if data
        .get("confirmation_required")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        let action = data
            .get("action")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        return Some(ConfirmationPrompt {
            action: action.clone(),
            category: data
                .get("category")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            description: data
                .get("description")
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .unwrap_or(action.as_str())
                .to_string(),
            confirmation_id: data
                .get("confirmation_id")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
        });
    }

    data.get("result")
        .and_then(|v| v.get("data"))
        .and_then(confirmation_prompt_from_data)
}

fn confirmation_prompt_from_response(resp: &Response) -> Option<ConfirmationPrompt> {
    resp.data.as_ref().and_then(confirmation_prompt_from_data)
}

fn run_interactive_confirmations(
    mut resp: Response,
    flags: &Flags,
    output_opts: &OutputOptions,
) -> Response {
    while let Some(prompt) = confirmation_prompt_from_response(&resp) {
        eprintln!("[agent-browser] Action requires confirmation:");
        if prompt.category.is_empty() {
            eprintln!("  {}", prompt.description);
        } else {
            eprintln!("  {}: {}", prompt.category, prompt.description);
        }
        eprint!("  Allow? [y/N]: ");

        let mut input = String::new();
        let approved = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
            std::io::stdin().read_line(&mut input).is_ok()
                && matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
        } else {
            false
        };

        let confirm_cmd = if approved {
            json!({
                "id": gen_id(),
                "action": "confirm",
                "confirmationId": prompt.confirmation_id
            })
        } else {
            json!({
                "id": gen_id(),
                "action": "deny",
                "confirmationId": prompt.confirmation_id
            })
        };

        match send_command(confirm_cmd, &flags.session) {
            Ok(next_resp) => {
                if !approved {
                    eprintln!("{} Action denied", color::error_indicator());
                    exit(1);
                }
                resp = next_resp;
            }
            Err(e) => {
                eprintln!("{} {}", color::error_indicator(), e);
                exit(1);
            }
        }
    }

    print_response_with_opts(&resp, None, output_opts);
    resp
}

fn run_profiles(json_mode: bool) {
    use crate::native::cdp::chrome::{find_chrome_user_data_dir, list_chrome_profiles};

    let user_data_dir = match find_chrome_user_data_dir() {
        Some(dir) => dir,
        None => {
            if json_mode {
                print_json_error("No Chrome user data directory found");
            } else {
                eprintln!("{}", color::red("No Chrome user data directory found"));
            }
            exit(1);
        }
    };

    let profiles = list_chrome_profiles(&user_data_dir);
    if profiles.is_empty() {
        if json_mode {
            print_json_value(json!({
                "success": true,
                "data": []
            }));
        } else {
            println!("No Chrome profiles found");
        }
        return;
    }

    if json_mode {
        let items: Vec<serde_json::Value> = profiles
            .iter()
            .map(|p| {
                json!({
                    "directory": p.directory,
                    "name": p.name
                })
            })
            .collect();
        print_json_value(json!({
            "success": true,
            "data": items
        }));
    } else {
        println!(
            "{} ({}):\n",
            color::bold("Chrome profiles"),
            user_data_dir.display()
        );
        for p in &profiles {
            println!(
                "  {}  {}",
                color::bold(&p.directory),
                color::dim(&format!("({})", p.name))
            );
        }
    }
}

fn canonical_path(path: PathBuf) -> PathBuf {
    path.canonicalize().unwrap_or(path)
}

fn git_toplevel() -> Option<PathBuf> {
    let output = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let raw = String::from_utf8(output.stdout).ok()?;
    let path = raw.trim();
    if path.is_empty() {
        None
    } else {
        Some(canonical_path(PathBuf::from(path)))
    }
}

fn resolve_session_id_scope(scope: &str) -> Result<(String, PathBuf), String> {
    match scope {
        "worktree" => {
            let path = git_toplevel().unwrap_or_else(|| {
                canonical_path(env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
            });
            Ok(("worktree".to_string(), path))
        }
        "cwd" => {
            let path = canonical_path(env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
            Ok(("cwd".to_string(), path))
        }
        "git-root" => git_toplevel()
            .map(|path| ("git-root".to_string(), path))
            .ok_or_else(|| "Not inside a Git working tree".to_string()),
        other => Err(format!(
            "Unknown session id scope '{}'. Use worktree, cwd, or git-root.",
            other
        )),
    }
}

fn run_session_id(args: &[String], json_mode: bool) {
    let mut scope = "worktree".to_string();
    let mut prefix: Option<String> = None;
    let mut json_output = json_mode;

    let mut i = 2;
    while i < args.len() {
        match args[i].as_str() {
            "--scope" => {
                if let Some(value) = args.get(i + 1) {
                    scope = value.clone();
                    i += 1;
                }
            }
            "--prefix" => {
                if let Some(value) = args.get(i + 1) {
                    prefix = Some(value.clone());
                    i += 1;
                }
            }
            "--json" => json_output = true,
            _ => {}
        }
        i += 1;
    }

    let (resolved_scope, path) = match resolve_session_id_scope(&scope) {
        Ok(result) => result,
        Err(e) => {
            if json_output {
                print_json_error(e);
            } else {
                eprintln!("{} {}", color::error_indicator(), e);
            }
            exit(1);
        }
    };

    let path_str = path.to_string_lossy().to_string();
    let mut hasher = Sha256::new();
    hasher.update(path_str.as_bytes());
    let hash = format!("{:x}", hasher.finalize());
    let suffix = &hash[..12];

    let prefix = prefix
        .as_deref()
        .map(validation::sanitize_session_component)
        .filter(|s| !s.is_empty());
    let session = match prefix {
        Some(prefix) => format!("{}-{}", prefix, suffix),
        None => suffix.to_string(),
    };

    if json_output {
        print_json_value(json!({
            "success": true,
            "data": {
                "session": session,
                "scope": resolved_scope,
                "path": path_str,
                "hash": suffix
            }
        }));
    } else {
        println!("{}", session);
    }
}

fn run_session_info(session: &str, json_mode: bool) {
    let inventory = walk_daemons();
    let active = inventory.sessions.iter().find(|s| s.name == session);
    let runtime = active.and_then(|_| {
        send_command(
            json!({
                "id": gen_id(),
                "action": "session_info"
            }),
            session,
        )
        .ok()
    });

    let runtime_data = runtime.as_ref().and_then(|resp| resp.data.clone());
    let runtime_error = runtime.as_ref().and_then(|resp| {
        if resp.success {
            None
        } else {
            resp.error.clone()
        }
    });

    if json_mode {
        print_json_value(json!({
            "success": true,
            "data": {
                "session": session,
                "namespace": env::var("AGENT_BROWSER_NAMESPACE").ok(),
                "socketDir": get_socket_dir().to_string_lossy(),
                "active": active.is_some(),
                "pid": active.map(|s| s.pid),
                "version": active.and_then(|s| s.version.clone()),
                "runtime": runtime_data,
                "runtimeError": runtime_error,
            }
        }));
        return;
    }

    println!("Session: {}", session);
    println!("Socket dir: {}", get_socket_dir().to_string_lossy());
    if let Ok(namespace) = env::var("AGENT_BROWSER_NAMESPACE") {
        println!("Namespace: {}", namespace);
    }
    if let Some(active) = active {
        println!("Daemon: running (pid {})", active.pid);
        if let Some(ref version) = active.version {
            println!("Version: {}", version);
        }
    } else {
        println!("Daemon: not running");
    }
    if let Some(data) = runtime_data {
        if let Some(restore_status) = data.get("restoreStatus").and_then(|v| v.as_str()) {
            println!("Restore status: {}", restore_status);
        }
        if let Some(save_status) = data.get("saveStatus").and_then(|v| v.as_str()) {
            println!("Save status: {}", save_status);
        }
        if let Some(engine) = data.get("engine").and_then(|v| v.as_str()) {
            println!("Engine: {}", engine);
        }
        if let Some(launched) = data.get("browserLaunched").and_then(|v| v.as_bool()) {
            println!("Browser launched: {}", launched);
        }
    } else if let Some(err) = runtime_error {
        println!("Runtime info unavailable: {}", err);
    }
}

fn run_session(args: &[String], session: &str, json_mode: bool) {
    let subcommand = args.get(1).map(|s| s.as_str());

    match subcommand {
        Some("id") => run_session_id(args, json_mode),
        Some("info") => run_session_info(session, json_mode),
        Some("list") => {
            let sessions: Vec<String> = walk_daemons()
                .sessions
                .into_iter()
                .map(|s| s.name)
                .collect();

            if json_mode {
                println!(
                    r#"{{"success":true,"data":{{"sessions":{}}}}}"#,
                    serde_json::to_string(&sessions).unwrap_or_default()
                );
            } else if sessions.is_empty() {
                println!("No active sessions");
            } else {
                println!("Active sessions:");
                for s in &sessions {
                    let marker = if s == session {
                        color::cyan("→")
                    } else {
                        " ".to_string()
                    };
                    println!("{} {}", marker, s);
                }
            }
        }
        None | Some(_) => {
            // Just show current session
            if json_mode {
                print_json_value(json!({
                    "success": true,
                    "data": {
                        "session": session,
                    },
                }));
            } else {
                println!("{}", session);
            }
        }
    }
}

fn get_dashboard_pid_path() -> std::path::PathBuf {
    get_socket_dir().join("dashboard.pid")
}

fn get_dashboard_config_path() -> std::path::PathBuf {
    get_socket_dir().join("dashboard.config")
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
struct DashboardConfig {
    port: u16,
    allowed_origins: Vec<String>,
    #[serde(default)]
    access_token: Option<String>,
}

impl DashboardConfig {
    fn new(port: u16, allowed_origins: Vec<String>) -> Result<Self, String> {
        let access_token = if allowed_origins.is_empty() {
            None
        } else {
            Some(generate_dashboard_access_token()?)
        };
        Ok(Self {
            port,
            allowed_origins,
            access_token,
        })
    }

    fn allowed_origins_description(&self) -> String {
        if self.allowed_origins.is_empty() {
            "loopback origins only".to_string()
        } else {
            self.allowed_origins.join(",")
        }
    }

    fn has_valid_access_token(&self) -> bool {
        self.allowed_origins.is_empty()
            || self
                .access_token
                .as_deref()
                .is_some_and(native::stream::is_valid_dashboard_access_token)
    }

    fn has_same_settings_as(&self, requested: &Self) -> bool {
        self.port == requested.port && self.allowed_origins == requested.allowed_origins
    }

    fn access_urls(&self) -> Vec<String> {
        let Some(token) = self.access_token.as_deref() else {
            return Vec::new();
        };
        self.allowed_origins
            .iter()
            .map(|origin| format!("{origin}/#dashboard-access-token={token}"))
            .collect()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum DashboardCommand {
    Start {
        port: u16,
        allowed_origins: Option<String>,
    },
    Stop,
}

/// Parse dashboard-specific arguments independently of the general command
/// parser. This keeps the standalone lifecycle strict: unknown flags, missing
/// values, invalid ports, and options supplied to `stop` are all errors.
fn parse_dashboard_command(args: &[String]) -> Result<DashboardCommand, String> {
    let (subcommand, mut index) = match args.first().map(String::as_str) {
        Some("start") => ("start", 1),
        Some("stop") => ("stop", 1),
        Some(value) if !value.starts_with('-') => {
            return Err(format!("Unknown dashboard subcommand: {value}"));
        }
        _ => ("start", 0),
    };

    if subcommand == "stop" {
        if let Some(argument) = args.get(index) {
            return Err(format!(
                "Dashboard stop does not accept argument '{argument}'."
            ));
        }
        return Ok(DashboardCommand::Stop);
    }

    let mut port = 4848;
    let mut saw_port = false;
    let mut allowed_origins = None;

    while let Some(argument) = args.get(index) {
        match argument.as_str() {
            "--port" => {
                if saw_port {
                    return Err(
                        "Dashboard option '--port' was provided more than once.".to_string()
                    );
                }
                let value = args
                    .get(index + 1)
                    .filter(|value| !value.starts_with('-'))
                    .ok_or_else(|| "Dashboard option '--port' requires a value.".to_string())?;
                port = value.parse::<u16>().ok().filter(|port| *port > 0).ok_or_else(|| {
                    format!("Invalid dashboard port '{value}'. Expected an integer from 1 to 65535.")
                })?;
                saw_port = true;
                index += 2;
            }
            "--allowed-origins" => {
                if allowed_origins.is_some() {
                    return Err(
                        "Dashboard option '--allowed-origins' was provided more than once."
                            .to_string(),
                    );
                }
                let value = args
                    .get(index + 1)
                    .filter(|value| !value.starts_with('-'))
                    .ok_or_else(|| {
                        "Dashboard option '--allowed-origins' requires a value.".to_string()
                    })?;
                allowed_origins = Some(value.clone());
                index += 2;
            }
            value if value.starts_with('-') => {
                return Err(format!("Unknown dashboard option: {value}"));
            }
            value => {
                return Err(format!("Unexpected dashboard argument: {value}"));
            }
        }
    }

    Ok(DashboardCommand::Start {
        port,
        allowed_origins,
    })
}

fn generate_dashboard_access_token() -> Result<String, String> {
    let mut bytes = [0u8; 32];
    getrandom::getrandom(&mut bytes)
        .map_err(|error| format!("Failed to generate dashboard access token: {error}"))?;
    Ok(hex::encode(bytes))
}

fn read_dashboard_config() -> Option<DashboardConfig> {
    let contents = fs::read_to_string(get_dashboard_config_path()).ok()?;
    serde_json::from_str(&contents).ok()
}

fn remove_dashboard_sidecars() {
    let _ = fs::remove_file(get_dashboard_pid_path());
    let _ = fs::remove_file(get_dashboard_config_path());
}

fn write_dashboard_config(config: &DashboardConfig) -> std::io::Result<()> {
    let contents = serde_json::to_vec(config).map_err(std::io::Error::other)?;
    let path = get_dashboard_config_path();

    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;

        let mut file = fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .mode(0o600)
            .open(path)?;
        file.write_all(&contents)
    }

    #[cfg(windows)]
    {
        fs::write(path, contents)
    }
}

fn dashboard_config_issue(
    running: Option<&DashboardConfig>,
    requested: &DashboardConfig,
) -> Option<String> {
    let Some(running) = running else {
        return Some(
            "Dashboard is already running, but its configuration could not be verified. Run 'agent-browser dashboard stop' before starting it again."
                .to_string(),
        );
    };
    if !running.has_valid_access_token() {
        return Some(
            "Dashboard is already running, but its access-token configuration could not be verified. Run 'agent-browser dashboard stop' before starting it again."
                .to_string(),
        );
    }
    if running.has_same_settings_as(requested) {
        return None;
    }

    Some(format!(
        "Dashboard is already running with port {} and {}. Run 'agent-browser dashboard stop' before starting it with different settings.",
        running.port,
        running.allowed_origins_description()
    ))
}

/// Start the dashboard with an explicit proxy-origin allowlist and a unique
/// access token. Both are kept separate from the request Host so DNS rebinding
/// cannot grant access to an attacker-controlled origin. Persist the effective
/// settings beside the PID so repeated starts cannot silently claim that a live
/// process adopted different settings.
fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool) {
    let pid_path = get_dashboard_pid_path();

    // Check if already running
    if let Ok(pid_str) = fs::read_to_string(&pid_path) {
        if let Ok(pid) = pid_str.trim().parse::<u32>() {
            if is_pid_alive(pid) {
                let running_config = read_dashboard_config();
                let requested_config = DashboardConfig {
                    port,
                    allowed_origins,
                    access_token: None,
                };
                if let Some(error) =
                    dashboard_config_issue(running_config.as_ref(), &requested_config)
                {
                    if json_mode {
                        print_json_error(error);
                    } else {
                        eprintln!("{} {}", color::error_indicator(), error);
                    }
                    exit(1);
                }
                if json_mode {
                    let access_urls = running_config
                        .as_ref()
                        .map(DashboardConfig::access_urls)
                        .unwrap_or_default();
                    print_json_value(json!({
                        "success": true,
                        "data": { "port": requested_config.port, "pid": pid, "already_running": true, "access_urls": access_urls },
                    }));
                } else {
                    println!(
                        "Dashboard already running at http://localhost:{}",
                        requested_config.port
                    );
                    print_dashboard_access_urls(&running_config.expect("verified above"));
                }
                return;
            }
        }
        remove_dashboard_sidecars();
    } else {
        let _ = fs::remove_file(get_dashboard_config_path());
    }

    let requested_config = match DashboardConfig::new(port, allowed_origins) {
        Ok(config) => config,
        Err(error) => {
            if json_mode {
                print_json_error(error);
            } else {
                eprintln!("{} {}", color::error_indicator(), error);
            }
            exit(1);
        }
    };

    let socket_dir = get_socket_dir();
    if !socket_dir.exists() {
        let _ = fs::create_dir_all(&socket_dir);
    }

    let exe_path = match env::current_exe() {
        Ok(p) => p.canonicalize().unwrap_or(p),
        Err(e) => {
            if json_mode {
                print_json_error(format!("Failed to get executable path: {}", e));
            } else {
                eprintln!(
                    "{} Failed to get executable path: {}",
                    color::error_indicator(),
                    e
                );
            }
            exit(1);
        }
    };

    let mut cmd = std::process::Command::new(&exe_path);
    cmd.env("AGENT_BROWSER_DASHBOARD", "1").env(
        "AGENT_BROWSER_DASHBOARD_PORT",
        requested_config.port.to_string(),
    );
    if requested_config.allowed_origins.is_empty() {
        cmd.env_remove("AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS");
        cmd.env_remove("AGENT_BROWSER_DASHBOARD_ACCESS_TOKEN");
    } else {
        cmd.env(
            "AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS",
            requested_config.allowed_origins.join(","),
        )
        .env(
            "AGENT_BROWSER_DASHBOARD_ACCESS_TOKEN",
            requested_config
                .access_token
                .as_deref()
                .expect("generated above"),
        );
    }

    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        unsafe {
            cmd.pre_exec(|| {
                libc::setsid();
                Ok(())
            });
        }
    }

    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
        const DETACHED_PROCESS: u32 = 0x00000008;
        connection::prevent_stdio_handle_inheritance();
        cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
    }

    match cmd
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
    {
        Ok(mut child) => {
            let pid = child.id();
            let write_result = write_dashboard_config(&requested_config)
                .and_then(|()| fs::write(&pid_path, pid.to_string()));
            if let Err(error) = write_result {
                let _ = child.kill();
                remove_dashboard_sidecars();
                if json_mode {
                    print_json_error(format!("Failed to save dashboard state: {error}"));
                } else {
                    eprintln!(
                        "{} Failed to save dashboard state: {}",
                        color::error_indicator(),
                        error
                    );
                }
                exit(1);
            }

            if json_mode {
                print_json_value(json!({
                    "success": true,
                    "data": { "port": requested_config.port, "pid": pid, "access_urls": requested_config.access_urls() },
                }));
            } else {
                println!(
                    "Dashboard started{}",
                    if requested_config.access_urls().is_empty() {
                        format!(" at http://localhost:{}", requested_config.port)
                    } else {
                        "; open one of the private access URLs below".to_string()
                    }
                );
                print_dashboard_access_urls(&requested_config);
            }
        }
        Err(e) => {
            remove_dashboard_sidecars();
            if json_mode {
                print_json_error(format!("Failed to start dashboard: {}", e));
            } else {
                eprintln!(
                    "{} Failed to start dashboard: {}",
                    color::error_indicator(),
                    e
                );
            }
            exit(1);
        }
    }
}

fn print_dashboard_access_urls(config: &DashboardConfig) {
    let access_urls = config.access_urls();
    if access_urls.is_empty() {
        return;
    }

    eprintln!(
        "{} Keep these reverse-proxy dashboard URLs private: they contain access tokens.",
        color::warning_indicator()
    );
    for url in access_urls {
        println!("{url}");
    }
}

fn run_dashboard_stop(json_mode: bool) {
    let pid_path = get_dashboard_pid_path();

    let pid_str = match fs::read_to_string(&pid_path) {
        Ok(s) => s,
        Err(_) => {
            remove_dashboard_sidecars();
            if json_mode {
                print_json_value(
                    json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }),
                );
            } else {
                println!("Dashboard is not running");
            }
            return;
        }
    };

    let pid: u32 = match pid_str.trim().parse() {
        Ok(p) => p,
        Err(_) => {
            remove_dashboard_sidecars();
            if json_mode {
                print_json_value(
                    json!({ "success": true, "data": { "stopped": false, "reason": "invalid pid" } }),
                );
            } else {
                println!("Dashboard is not running");
            }
            return;
        }
    };

    if !is_agent_browser_process(pid) {
        remove_dashboard_sidecars();
        if json_mode {
            print_json_value(
                json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }),
            );
        } else {
            println!("Dashboard is not running");
        }
        return;
    }

    #[cfg(unix)]
    {
        unsafe {
            libc::kill(pid as i32, libc::SIGTERM);
        }
    }
    #[cfg(windows)]
    {
        unsafe {
            let handle = OpenProcess(1, 0, pid); // PROCESS_TERMINATE = 1
            if handle != 0 {
                windows_sys::Win32::System::Threading::TerminateProcess(handle, 0);
                CloseHandle(handle);
            }
        }
    }

    remove_dashboard_sidecars();

    if json_mode {
        print_json_value(json!({ "success": true, "data": { "stopped": true } }));
    } else {
        println!("{} Dashboard stopped", color::green("✓"));
    }
}

/// Forgets what a session keeps once it is closed: its saved TLS selection and
/// its stored security settings. Called only after the daemon confirmed the
/// close, so a close that policy denied or a confirmation parked leaves the
/// session contained.
fn clear_closed_session(session: &str) {
    tls::clear_session(session);
    if let Err(e) = session_settings::clear_file(session) {
        eprintln!("{} {}", color::warning_indicator(), e);
    }
}

/// What `close` forgets before it is sent. The stored security settings stay:
/// the daemon that handles the close (a running one, or one `close` starts)
/// enforces them, so a policy that denies `close` still applies, and drops
/// them itself once the close succeeds.
fn forget_before_close(session: &str) {
    tls::clear_session(session);
}

/// Starts or reuses the session's daemon. A `close` whose daemon refuses to
/// start because of the stored settings file clears that file and tries once
/// more, so a corrupt file can always be cleared with `close`.
fn ensure_daemon_for(
    session: &str,
    opts: &DaemonOptions,
    is_close: bool,
) -> Result<connection::DaemonResult, String> {
    match ensure_daemon(session, opts) {
        Err(e) if is_close && session_settings::is_refusal(&e) => {
            session_settings::clear_file(session)?;
            ensure_daemon(session, opts)
        }
        other => other,
    }
}

/// Whether a response says the browser was closed, as opposed to denied,
/// parked for confirmation or failed.
fn close_completed(resp: &connection::Response) -> bool {
    resp.success
        && resp
            .data
            .as_ref()
            .and_then(|d| d.get("closed"))
            .and_then(|v| v.as_bool())
            == Some(true)
}

fn run_close_all(flags: &Flags) {
    // walk_daemons auto-cleans stale .pid / .sock / .stream sidecar files and
    // separates out the standalone dashboard. We only want to send `close` to
    // real session daemons; the dashboard has its own `dashboard stop`.
    let inventory = walk_daemons();
    let mut sessions: Vec<(String, Option<u32>)> = inventory
        .sessions
        .iter()
        .map(|s| (s.name.clone(), Some(s.pid)))
        .collect();

    // Sessions whose daemon is gone still hold stored security settings that
    // govern them. They are closed like the others: a daemon starts with those
    // settings, so a policy denying `close` still applies.
    let orphans = session_settings::orphan_sessions(&get_socket_dir(), |session| {
        sessions.iter().any(|(name, _)| name == session) || connection::daemon_ready(session)
    });
    sessions.extend(orphans.into_iter().map(|name| (name, None)));

    if sessions.is_empty() {
        if flags.json {
            print_json_value(json!({
                "success": true,
                "data": { "closed": 0, "sessions": [] },
            }));
        } else {
            println!("No active sessions");
        }
        return;
    }

    let mut closed: Vec<String> = Vec::new();
    let mut failed: Vec<(String, String)> = Vec::new();

    for (session, pid) in &sessions {
        if pid.is_none() {
            if let Err(e) = ensure_daemon_for(session, &DaemonOptions::bare(), true) {
                failed.push((session.clone(), e));
                continue;
            }
        }
        let cmd = json!({ "id": gen_id(), "action": "close" });
        // A close whose response was lost leaves the daemon in an unknown
        // state; force-kill it like an unreachable one.
        let result = send_command(cmd, session).and_then(|resp| {
            if resp.code.as_deref() == Some("outcome_unknown") {
                Err(resp.error.unwrap_or_default())
            } else {
                Ok(resp)
            }
        });
        match result {
            Ok(resp) if close_completed(&resp) => {
                clear_closed_session(session);
                closed.push(session.clone());
            }
            Ok(resp) => {
                // Denied, waiting for confirmation or failed: still running.
                let waiting = resp
                    .data
                    .as_ref()
                    .and_then(|d| d.get("confirmation_required"))
                    .and_then(|v| v.as_bool())
                    == Some(true);
                let mut err = match (resp.error, waiting) {
                    (Some(err), _) => err,
                    (None, true) => "close is waiting for confirmation".to_string(),
                    (None, false) => "Unknown error".to_string(),
                };
                if pid.is_none() {
                    // The daemon was started only to close this session; stop
                    // it so the session is left as it was found, with its
                    // stored settings. A parked confirmation goes with it.
                    connection::stop_daemon(session);
                    if waiting {
                        err = format!(
                            "close needs confirmation; run `agent-browser --session {s} close`, then `agent-browser --session {s} confirm <id>` with the id it prints",
                            s = session
                        );
                    }
                }
                failed.push((session.clone(), err));
            }
            Err(e) if pid.is_none() => {
                // A daemon started only to close this session did not answer.
                connection::stop_daemon(session);
                failed.push((session.clone(), e));
            }
            Err(_) => {
                // Daemon is unreachable despite its process existing.
                // Force-kill the process and clean up stale files so future
                // sessions are not poisoned.
                let pid = &pid.unwrap_or_default();
                if is_agent_browser_process(*pid) {
                    #[cfg(unix)]
                    unsafe {
                        libc::kill(*pid as i32, libc::SIGKILL);
                    }
                    #[cfg(windows)]
                    unsafe {
                        let handle = OpenProcess(1, 0, *pid); // PROCESS_TERMINATE = 1
                        if handle != 0 {
                            windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
                            CloseHandle(handle);
                        }
                    }
                }
                cleanup_stale_files(session);
                clear_closed_session(session);
                closed.push(session.clone());
            }
        }
    }

    if flags.json {
        print_json_value(json!({
            "success": failed.is_empty(),
            "data": {
                "closed": closed.len(),
                "sessions": closed,
                "failed": failed.iter().map(|(s, e)| json!({"session": s, "error": e})).collect::<Vec<_>>(),
            },
        }));
    } else {
        for s in &closed {
            println!("{} Closed session: {}", color::green("✓"), s);
        }
        for (s, e) in &failed {
            eprintln!("{} Failed to close {}: {}", color::error_indicator(), s, e);
        }
        if closed.is_empty() && !failed.is_empty() {
            exit(1);
        }
    }

    if !failed.is_empty() {
        exit(1);
    }
}

fn main() {
    // Rust ignores SIGPIPE by default, causing println! to panic on broken pipes.
    // Reset to SIG_DFL so the OS terminates the process cleanly instead.
    #[cfg(unix)]
    unsafe {
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
    }

    // Prevent MSYS/Git Bash path translation from mangling arguments
    #[cfg(windows)]
    {
        env::set_var("MSYS_NO_PATHCONV", "1");
        env::set_var("MSYS2_ARG_CONV_EXCL", "*");
    }

    // Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
    if env::var("AGENT_BROWSER_DAEMON").is_ok() {
        // Ignore SIGPIPE so the daemon isn't killed when the parent drops
        // the piped stderr handle after confirming the daemon is ready.
        #[cfg(unix)]
        unsafe {
            libc::signal(libc::SIGPIPE, libc::SIG_IGN);
        }
        let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
        if !validation::is_valid_session_name(&session) {
            let msg = validation::session_name_error(&session);
            eprintln!("{} {}", color::error_indicator(), msg);
            exit(1);
        }
        let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
        rt.block_on(native::daemon::run_daemon(&session));
        return;
    }

    // Standalone dashboard server mode
    if env::var("AGENT_BROWSER_DASHBOARD").is_ok() {
        let port: u16 = env::var("AGENT_BROWSER_DASHBOARD_PORT")
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(4848);
        let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
        rt.block_on(native::stream::run_dashboard_server(port));
        return;
    }

    let args: Vec<String> = env::args().skip(1).collect();
    let mut flags = parse_flags(&args);
    if flags.restore_uses_session {
        flags.restore = Some(flags.session.clone());
    }
    if let Some(ref namespace) = flags.namespace {
        env::set_var("AGENT_BROWSER_NAMESPACE", namespace);
    }
    let mut clean = clean_args(&args);
    tls::configure_process(&flags);

    let options = flags::before_end_of_options(&clean);
    let has_help = options.iter().any(|a| a == "--help" || a == "-h");
    let has_version = options.iter().any(|a| a == "--version" || a == "-V");

    if has_help {
        clean.retain(|arg| arg != "--help" && arg != "-h");
        if clean.is_empty() || clean.iter().all(|arg| arg.starts_with('-')) {
            print_help();
            return;
        }
        if let Some(cmd) = clean.first() {
            if commands::is_top_level_command(cmd) {
                if !print_command_help(cmd) {
                    print_help();
                }
                return;
            }
        }
    }

    if has_version {
        print_version();
        return;
    }

    if clean.is_empty() {
        print_help();
        return;
    }

    // Handle install separately
    if clean.first().map(|s| s.as_str()) == Some("install") {
        let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
        run_install(with_deps);
        return;
    }

    // Handle upgrade separately
    if clean.first().map(|s| s.as_str()) == Some("upgrade") {
        run_upgrade();
        return;
    }

    // Handle doctor separately (doesn't need daemon; spawns its own scratch
    // session for the live launch test).
    if clean.first().map(|s| s.as_str()) == Some("doctor") {
        let opts = doctor::DoctorOptions {
            offline: args.iter().any(|a| a == "--offline"),
            quick: args.iter().any(|a| a == "--quick"),
            fix: args.iter().any(|a| a == "--fix"),
            json: flags.json,
            // Explicit CLI opt-in only: a global AGENT_BROWSER_WEBGPU/config
            // "webgpu": true must not make every doctor run launch the extra
            // Chrome probe (and fail on hosts missing Vulkan deps).
            webgpu: flags.cli_webgpu && flags.webgpu,
            debug: flags.debug,
            // Merged (env/config included) so the probe reflects how the
            // user's sessions actually launch.
            headed: flags.headed,
        };
        exit(doctor::run_doctor(opts));
    }

    // Handle dashboard subcommand
    if clean.first().map(|s| s.as_str()) == Some("dashboard") {
        let dashboard_args = &clean[1..];
        let command = match parse_dashboard_command(dashboard_args) {
            Ok(command) => command,
            Err(error) => {
                if flags.json {
                    print_json_error(error);
                } else {
                    eprintln!("{} {}", color::error_indicator(), error);
                }
                exit(1);
            }
        };

        match command {
            DashboardCommand::Stop => {
                run_dashboard_stop(flags.json);
                return;
            }
            DashboardCommand::Start {
                port,
                allowed_origins,
            } => {
                let env_allowed_origins = env::var("AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS").ok();
                let configured_origins = allowed_origins
                    .as_deref()
                    .or(env_allowed_origins.as_deref());
                let allowed_origins =
                    match native::stream::normalize_dashboard_allowed_origins(configured_origins) {
                        Ok(origins) => origins,
                        Err(error) => {
                            if flags.json {
                                print_json_error(error);
                            } else {
                                eprintln!("{} {}", color::error_indicator(), error);
                            }
                            exit(1);
                        }
                    };
                run_dashboard_start(port, allowed_origins, flags.json);
                return;
            }
        }
    }

    // Handle profiles command (doesn't need daemon)
    if clean.first().map(|s| s.as_str()) == Some("profiles") {
        run_profiles(flags.json);
        return;
    }

    // Handle skills command (doesn't need daemon)
    if clean.first().map(|s| s.as_str()) == Some("skills") {
        skills::run_skills(&clean, flags.json);
        return;
    }

    // Handle plugin registry commands (doesn't need daemon)
    if matches!(
        clean.first().map(|s| s.as_str()),
        Some("plugin") | Some("plugins")
    ) {
        plugins::run_plugin_command(&clean, &flags.plugins, flags.json);
        return;
    }

    // Handle MCP stdio server mode. This must never share stdout with normal
    // CLI output because stdout is reserved for JSON-RPC protocol messages.
    if clean.first().map(|s| s.as_str()) == Some("mcp") {
        if let Some(Some(path)) = flags::extract_config_path(&args) {
            env::set_var("AGENT_BROWSER_CONFIG", path);
        }
        if clean.get(1).map(|s| s.as_str()) == Some("call") {
            exit(mcp::run_mcp_call(&clean[2..]));
        }
        if let Err(err) = mcp::run_mcp(&clean[1..]) {
            eprintln!("{} {}", color::error_indicator(), err);
            exit(1);
        }
        return;
    }

    if !validation::is_valid_session_name(&flags.session) {
        let msg = format!(
            "{} Source: {}.",
            validation::session_name_error(&flags.session),
            flags.session_source
        );
        if flags.json {
            print_json_error_with_type(msg, "invalid_session_name");
        } else {
            eprintln!("{} {}", color::error_indicator(), msg);
        }
        exit(1);
    }

    // Handle session separately (doesn't need daemon)
    if clean.first().map(|s| s.as_str()) == Some("session") {
        run_session(&clean, &flags.session, flags.json);
        return;
    }

    // Handle close --all: close all active sessions
    if matches!(
        clean.first().map(|s| s.as_str()),
        Some("close") | Some("quit") | Some("exit")
    ) && clean.iter().any(|a| a == "--all")
    {
        run_close_all(&flags);
        return;
    }

    // Handle chat command
    if clean.first().map(|s| s.as_str()) == Some("chat") {
        let message = if clean.len() > 1 {
            Some(clean[1..].join(" "))
        } else {
            None
        };
        chat::run_chat(&flags, message);
        return;
    }

    let mut cmd = match parse_command(&clean, &flags) {
        Ok(c) => c,
        Err(e) => {
            if flags.json {
                let error_type = match &e {
                    ParseError::UnknownCommand { .. } => "unknown_command",
                    ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
                    ParseError::MissingArguments { .. } => "missing_arguments",
                    ParseError::InvalidValue { .. } => "invalid_value",
                    ParseError::InvalidSessionName { .. } => "invalid_session_name",
                };
                print_json_error_with_type(e.format(), error_type);
            } else {
                eprintln!("{}", color::red(&e.format()));
            }
            exit(1);
        }
    };
    attach_input_mode(&mut cmd, &flags);
    // Stdin batches are read before launch decisions so a batch of `read`
    // commands is recognized as browserless, like one given as arguments.
    if cmd.get("action").and_then(|v| v.as_str()) == Some("batch") && cmd.get("commands").is_none()
    {
        cmd["argv"] = json!(read_batch_stdin(&flags));
    }
    match cmd.get("action").and_then(|v| v.as_str()) {
        Some("read") => cmd["tls"] = json!(tls::session_options(&flags, &flags.session)),
        Some("close") => forget_before_close(&flags.session),
        _ => {}
    }
    // Handle --password-stdin for auth save
    if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
        if cmd.get("password").is_some() {
            eprintln!(
                "{} Passwords on the command line may be visible in process listings and shell history. Use --password-stdin instead.",
                color::warning_indicator()
            );
        }
        if cmd
            .get("passwordStdin")
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
        {
            let mut pass = String::new();
            if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
                eprintln!(
                    "{} Failed to read password from stdin",
                    color::error_indicator()
                );
                exit(1);
            }
            let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
            if pass.is_empty() {
                eprintln!("{} Password from stdin is empty", color::error_indicator());
                exit(1);
            }
            cmd["password"] = json!(pass);
            cmd.as_object_mut().unwrap().remove("passwordStdin");
        }
    }

    // Send plugin config with commands so an already-running daemon can use
    // current config without a restart. The daemon strips this from stream
    // broadcasts before observers see the command payload.
    attach_plugins_to_command(&mut cmd, &flags.plugins);

    attach_pin_tab_to_command(&mut cmd, &flags);
    attach_restore_config_to_command(&mut cmd, &flags);

    // Validate restore/session persistence name before starting daemon
    if let Some(name) = restore_key_from_flags(&flags) {
        if !validation::is_valid_session_name(name) {
            let msg = validation::session_name_error(name);
            if flags.json {
                print_json_error_with_type(msg, "invalid_session_name");
            } else {
                eprintln!("{} {}", color::error_indicator(), msg);
            }
            exit(1);
        }
    }

    if let Some(ref policy) = flags.restore_save {
        if !is_valid_restore_save_policy(policy) {
            let msg = format!(
                "Invalid --restore-save value '{}'. Use auto, always, or never.",
                policy
            );
            if flags.json {
                print_json_error_with_type(msg, "invalid_value");
            } else {
                eprintln!("{} {}", color::error_indicator(), msg);
            }
            exit(1);
        }
    }

    // Handle state management commands locally — these are pure file operations
    // that don't need a daemon, avoiding an unnecessary daemon startup that
    // would lack runtime config like session_name.
    if let Some(result) = native::state::dispatch_state_command(&cmd) {
        let action = cmd.get("action").and_then(|v| v.as_str());
        let resp = match result {
            Ok(data) => connection::Response {
                success: true,
                data: Some(data),
                error: None,
                code: None,
                warning: None,
                recorded: None,
            },
            Err(e) => connection::Response {
                success: false,
                data: None,
                error: Some(e),
                code: None,
                warning: None,
                recorded: None,
            },
        };
        let output_opts = OutputOptions::from_flags(&flags);
        output::print_response_with_opts(&resp, action, &output_opts);
        if !resp.success {
            exit(1);
        }
        return;
    }

    let launch_mode_error = if command_is_browserless(&cmd) {
        let ca_cert = flags.ca_cert.take();
        let clear_ca_cert = std::mem::take(&mut flags.clear_ca_cert);
        let error = incompatible_launch_mode_error(&flags);
        flags.ca_cert = ca_cert;
        flags.clear_ca_cert = clear_ca_cert;
        error
    } else {
        incompatible_launch_mode_error(&flags)
    };
    if let Some(msg) = launch_mode_error {
        if flags.json {
            print_json_error(msg);
        } else {
            eprintln!("{} {}", color::error_indicator(), msg);
        }
        exit(1);
    }

    if let Some(ref ca_path) = flags.ca_cert {
        if let Err(msg) = ca_bundle::load(ca_path) {
            if flags.json {
                print_json_error(&msg);
            } else {
                eprintln!("{} {}", color::error_indicator(), msg);
            }
            exit(1);
        }
        let canonical = std::path::Path::new(ca_path)
            .canonicalize()
            .unwrap_or_else(|_| std::path::PathBuf::from(ca_path));
        flags.ca_cert = Some(canonical.display().to_string());
    }

    let restore_key = restore_key_from_flags(&flags);

    // Parse proxy URL to separate server from credentials for the daemon.
    let (proxy_server, proxy_username, proxy_password) = if let Some(ref proxy_str) = flags.proxy {
        let parsed = parse_proxy(proxy_str);
        (Some(parsed.server), parsed.username, parsed.password)
    } else {
        (None, None, None)
    };
    let plugin_registry_json =
        serde_json::to_string(&flags.plugins).unwrap_or_else(|_| "[]".to_string());
    let daemon_opts = DaemonOptions {
        headed: flags.headed,
        debug: flags.debug,
        executable_path: flags.executable_path.as_deref(),
        extensions: &flags.extensions,
        init_scripts: &flags.init_scripts,
        enable: &flags.enable,
        args: flags.args.as_deref(),
        user_agent: flags.user_agent.as_deref(),
        proxy: proxy_server.as_deref(),
        proxy_bypass: flags.proxy_bypass.as_deref(),
        proxy_username: proxy_username.as_deref(),
        proxy_password: proxy_password.as_deref(),
        ignore_https_errors: flags.ignore_https_errors,
        allow_file_access: flags.allow_file_access,
        hide_scrollbars: flags.hide_scrollbars,
        webgpu: flags.webgpu,
        profile: flags.profile.as_deref(),
        state: flags.state.as_deref(),
        provider: flags.provider.as_deref(),
        device: flags.device.as_deref(),
        session_name: restore_key,
        restore_save: flags.restore_save.as_deref(),
        restore_check_url: flags.restore_check_url.as_deref(),
        restore_check_text: flags.restore_check_text.as_deref(),
        restore_check_fn: flags.restore_check_fn.as_deref(),
        download_path: flags.download_path.as_deref(),
        allowed_domains: flags.allowed_domains.as_deref(),
        action_policy: flags.action_policy.as_deref(),
        confirm_actions: flags.confirm_actions.as_deref(),
        engine: flags.engine.as_deref(),
        auto_connect: flags.auto_connect,
        pin_tab: flags.pin_tab,
        idle_timeout: flags.idle_timeout.as_deref(),
        default_timeout: flags.default_timeout,
        cdp: flags.cdp.as_deref(),
        cdp_headers: flags.cdp_headers.as_deref(),
        no_auto_dialog: flags.no_auto_dialog,
        plugins: Some(plugin_registry_json.as_str()),
    };

    let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
    let daemon_result = match ensure_daemon_for(&flags.session, &daemon_opts, is_close) {
        Ok(result) => result,
        Err(e) => {
            if flags.json {
                print_json_error(e);
            } else {
                eprintln!("{} {}", color::error_indicator(), e);
            }
            exit(1);
        }
    };
    let _daemon_was_already_running = daemon_result.already_running;
    let daemon_restarted = daemon_result.restarted;
    if flags.default_timeout.is_none() {
        commands::inherit_wait_timeout(
            &mut cmd,
            connection::running_daemon_default_timeout(&flags.session),
        );
    }

    // Reject a bad CDP target before the command runs, even when the daemon
    // is already up, so the user gets an immediate error.
    if let Err(msg) = validate_cdp_flags(&flags) {
        if flags.json {
            print_json_error(&msg);
        } else {
            eprintln!("{} {}", color::error_indicator(), msg);
        }
        exit(1);
    }

    // The settings this invocation set ride on the command. The daemon plans
    // them against the running session and reports what changed.
    if let Some(wire) = command_settings(&flags, &cmd).to_wire() {
        cmd["settings"] = wire;
    }

    // Handle batch command: from args or stdin
    if cmd.get("action").and_then(|v| v.as_str()) == Some("batch") {
        let bail = cmd.get("bail").and_then(|v| v.as_bool()).unwrap_or(false);
        let omit_command = cmd
            .get("omitCommand")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let arg_commands = cmd
            .get("commands")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str())
                    .map(commands::shell_words_split)
                    .collect::<Vec<Vec<String>>>()
            })
            .or_else(|| serde_json::from_value(cmd.get("argv")?.clone()).ok());
        run_batch(&flags, &daemon_opts, bail, omit_command, arg_commands);
        return;
    }

    let output_opts = OutputOptions::from_flags(&flags);

    match send_command_with_respawn(cmd.clone(), &flags.session, &daemon_opts) {
        Ok(mut resp) => {
            if daemon_restarted {
                mark_restarted_background(&mut resp);
            }
            if flags.confirm_interactive && confirmation_prompt_from_response(&resp).is_some() {
                resp = run_interactive_confirmations(resp, &flags, &output_opts);
                if daemon_restarted {
                    mark_restarted_background(&mut resp);
                }
                if !resp.success {
                    exit(1);
                }
                return;
            }
            let success = resp.success;
            // Extract action for context-specific output handling
            let action = cmd.get("action").and_then(|v| v.as_str());
            if action == Some("close") && close_completed(&resp) {
                clear_closed_session(&flags.session);
            }
            print_response_with_opts(&resp, action, &output_opts);
            if !success {
                exit(1);
            }
        }
        Err(e) => {
            if flags.json {
                print_json_error(e);
            } else {
                eprintln!("{} {}", color::error_indicator(), e);
            }
            exit(1);
        }
    }
}

/// send_command plus the daemon-shutdown-race recovery: ensure_daemon no
/// longer pays a settle-sleep on every invocation, so a daemon that exited
/// right after its liveness check surfaces as an unreachable socket on the
/// request itself. Respawn once and retry before reporting failure.
fn send_command_with_respawn(
    cmd: serde_json::Value,
    session: &str,
    daemon_opts: &DaemonOptions,
) -> Result<connection::Response, String> {
    let first_attempt = send_command(cmd.clone(), session);
    match first_attempt {
        Err(ref e) if daemon_unreachable(e) => match ensure_daemon(session, daemon_opts) {
            Ok(_) => send_command(cmd, session),
            Err(_) => first_attempt,
        },
        other => other,
    }
}

/// Read batch commands given on stdin as a JSON array of string arrays.
fn read_batch_stdin(flags: &Flags) -> Vec<Vec<String>> {
    use std::io::Read as _;

    let mut input = String::new();
    if let Err(e) = std::io::stdin().read_to_string(&mut input) {
        if flags.json {
            print_json_error(format!("Failed to read stdin: {}", e));
        } else {
            eprintln!("{} Failed to read stdin: {}", color::error_indicator(), e);
        }
        exit(1);
    }

    match serde_json::from_str(&input) {
        Ok(c) => c,
        Err(e) => {
            if flags.json {
                print_json_error(format!(
                    "Invalid JSON input: {}. Expected an array of string arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]",
                    e
                ));
            } else {
                eprintln!(
                    "{} Invalid JSON input: {}. Expected an array of string arrays.",
                    color::error_indicator(),
                    e
                );
            }
            exit(1);
        }
    }
}

/// Runs a batch, optionally omitting command arrays without redacting errors or response data.
fn run_batch(
    flags: &Flags,
    daemon_opts: &DaemonOptions,
    bail: bool,
    omit_command: bool,
    arg_commands: Option<Vec<Vec<String>>>,
) {
    let commands: Vec<Vec<String>> = arg_commands.unwrap_or_else(|| read_batch_stdin(flags));

    if commands.is_empty() {
        if flags.json {
            println!("[]");
        }
        return;
    }

    let output_opts = OutputOptions::from_flags(flags);

    let mut results: Vec<serde_json::Value> = Vec::new();
    let mut had_error = false;
    let mut relaunch_applied = false;

    for (i, cmd_args) in commands.iter().enumerate() {
        if cmd_args.is_empty() {
            continue;
        }

        let mut parsed = match commands::parse_batch_command(cmd_args, flags) {
            Ok(mut c) => {
                if flags.default_timeout.is_none() {
                    commands::inherit_wait_timeout(
                        &mut c,
                        connection::running_daemon_default_timeout(&flags.session),
                    );
                }
                c
            }
            Err(e) => {
                had_error = true;
                if flags.json {
                    let mut result = batch_result_identity(i, cmd_args, omit_command);
                    result["success"] = json!(false);
                    result["error"] = json!(e.format());
                    results.push(result);
                    if bail {
                        break;
                    }
                } else {
                    eprintln!(
                        "{} Command {}: {}",
                        color::error_indicator(),
                        i + 1,
                        e.format()
                    );
                    if bail {
                        exit(1);
                    }
                }
                continue;
            }
        };
        attach_input_mode(&mut parsed, flags);
        match parsed.get("action").and_then(|v| v.as_str()) {
            Some("read") => parsed["tls"] = json!(tls::session_options(flags, &flags.session)),
            Some("close") => tls::clear_session(&flags.session),
            _ => {}
        }

        let action = parsed
            .get("action")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());
        attach_plugins_to_command(&mut parsed, &flags.plugins);
        attach_restore_config_to_command(&mut parsed, flags);
        let settings = batch_item_settings(flags, &parsed, relaunch_applied);
        if let Some(wire) = settings.to_wire() {
            parsed["settings"] = wire;
        }

        attach_pin_tab_to_command(&mut parsed, flags);

        match send_command_with_respawn(parsed, &flags.session, daemon_opts) {
            Ok(resp) => {
                if action.as_deref() == Some("close") && close_completed(&resp) {
                    clear_closed_session(&flags.session);
                }
                relaunch_applied |= response_applied_relaunch(&resp);
                if flags.json {
                    let mut result = batch_result_identity(i, cmd_args, omit_command);
                    result["success"] = json!(resp.success);
                    result["result"] = json!(resp.data);
                    result["error"] = json!(resp.error);
                    // Match the single-command `Response` serialization,
                    // which only emits `code` when set (e.g. `tab_gone`).
                    // Without this, machine-readable error codes are
                    // silently dropped in batch mode.
                    if let Some(ref code) = resp.code {
                        result["code"] = json!(code);
                    }
                    // Mirror the single-command serialization: emit `warning`
                    // too, not just `code`.
                    if let Some(ref warning) = resp.warning {
                        result["warning"] = json!(warning);
                    }
                    results.push(result);
                } else {
                    if i > 0 {
                        println!();
                    }
                    print_response_with_opts(&resp, action.as_deref(), &output_opts);
                }
                if !resp.success {
                    had_error = true;
                    if bail {
                        if !flags.json {
                            exit(1);
                        }
                        break;
                    }
                }
            }
            Err(e) => {
                had_error = true;
                if flags.json {
                    let mut result = batch_result_identity(i, cmd_args, omit_command);
                    result["success"] = json!(false);
                    result["error"] = json!(e.to_string());
                    results.push(result);
                    if bail {
                        break;
                    }
                } else {
                    eprintln!("{} Command {}: {}", color::error_indicator(), i + 1, e);
                    if bail {
                        exit(1);
                    }
                }
            }
        }
    }

    if flags.json {
        println!(
            "{}",
            serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string())
        );
    }

    if had_error {
        exit(1);
    }
}

fn batch_result_identity(
    index: usize,
    command: &[String],
    omit_command: bool,
) -> serde_json::Value {
    if omit_command {
        json!({ "index": index })
    } else {
        json!({ "command": command })
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn input_mode_session_setting_preserves_command_override() {
        let args: Vec<String> = ["--input-mode", "smooth", "click", "#button", "--human"]
            .into_iter()
            .map(str::to_string)
            .collect();
        let flags = crate::flags::parse_flags(&args);
        let mut command =
            crate::commands::parse_command(&crate::flags::clean_args(&args), &flags).unwrap();
        super::attach_input_mode(&mut command, &flags);
        assert_eq!(command["defaultInputMode"], "smooth");
        assert_eq!(command["inputMode"], "human");
    }

    use super::*;

    #[test]
    fn dashboard_config_comparison_rejects_unknown_or_changed_settings() {
        let requested = DashboardConfig::new(
            4848,
            native::stream::normalize_dashboard_allowed_origins(Some(
                "https://second.example.com, https://dashboard.example.com",
            ))
            .unwrap(),
        )
        .unwrap();
        let equivalent = DashboardConfig::new(
            4848,
            native::stream::normalize_dashboard_allowed_origins(Some(
                "https://dashboard.example.com,https://second.example.com",
            ))
            .unwrap(),
        )
        .unwrap();
        let changed = DashboardConfig::new(
            4849,
            native::stream::normalize_dashboard_allowed_origins(Some(
                "https://dashboard.example.com",
            ))
            .unwrap(),
        )
        .unwrap();

        assert_eq!(dashboard_config_issue(Some(&equivalent), &requested), None);
        assert!(dashboard_config_issue(Some(&changed), &requested).is_some());
        assert!(dashboard_config_issue(None, &requested).is_some());
    }

    #[test]
    fn dashboard_parser_accepts_explicit_start_options() {
        let args = vec![
            "start".to_string(),
            "--port".to_string(),
            "8080".to_string(),
            "--allowed-origins".to_string(),
            "https://dashboard.example.com".to_string(),
        ];

        assert_eq!(
            parse_dashboard_command(&args),
            Ok(DashboardCommand::Start {
                port: 8080,
                allowed_origins: Some("https://dashboard.example.com".to_string()),
            })
        );
    }

    #[test]
    fn dashboard_parser_rejects_invalid_or_unknown_options() {
        for args in [
            vec!["--bogus"],
            vec!["start", "--allowed-origin", "https://dashboard.example.com"],
            vec!["start", "--port", "nope"],
            vec!["start", "--port", "0"],
            vec!["start", "--port"],
            vec!["start", "--allowed-origins"],
            vec!["stop", "--port", "4848"],
        ] {
            let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
            assert!(
                parse_dashboard_command(&args).is_err(),
                "unexpectedly accepted {args:?}"
            );
        }
    }

    fn settings_of(args: &[&str]) -> (serde_json::Value, serde_json::Value) {
        let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
        let flags = crate::flags::parse_flags(&args);
        let command =
            crate::commands::parse_command(&crate::flags::clean_args(&args), &flags).unwrap();
        let wire = command_settings(&flags, &command)
            .to_wire()
            .unwrap_or(serde_json::Value::Null);
        (command, wire)
    }

    #[test]
    fn bare_open_is_a_plain_launch_and_its_settings_ride_in_the_patch() {
        let (command, wire) = settings_of(&[
            "--profile",
            "/tmp/profile",
            "--args",
            "--lang=en-US",
            "--headed",
            "open",
        ]);

        assert_eq!(command["action"], "launch");
        for field in ["profile", "args", "headless"] {
            assert!(
                command.get(field).is_none(),
                "{field} must not ride on the launch"
            );
        }
        assert_eq!(wire["profile"], "/tmp/profile");
        assert_eq!(wire["args"], serde_json::json!(["--lang=en-US"]));
        assert_eq!(wire["headed"], true);
    }

    #[test]
    fn settings_distinguish_ca_trust_set_omitted_and_cleared() {
        let (_, omitted) = settings_of(&["get", "url"]);
        assert!(omitted.get("ca-cert").is_none() && omitted.get("clear-ca-cert").is_none());

        let (_, cleared) = settings_of(&["--no-ca-cert", "get", "url"]);
        assert_eq!(cleared["clear-ca-cert"], true);
        assert!(cleared.get("ca-cert").is_none());
    }

    #[test]
    fn browserless_commands_do_not_carry_ca_trust() {
        let (_, wire) = settings_of(&["--no-ca-cert", "read", "https://example.com"]);
        assert!(wire.get("clear-ca-cert").is_none(), "{wire}");
    }

    #[test]
    fn cdp_flags_are_validated_before_the_command_runs() {
        let flags = |args: &[&str]| {
            let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
            crate::flags::parse_flags(&args)
        };
        assert!(validate_cdp_flags(&flags(&["--cdp", "9222", "get", "url"])).is_ok());
        assert!(
            validate_cdp_flags(&flags(&["--cdp", "ws://127.0.0.1:9222/x", "get", "url"])).is_ok()
        );
        for (value, message) in [
            ("0", "greater than 0"),
            ("70000", "out of range"),
            ("nope", "not a valid port number or URL"),
        ] {
            let error = validate_cdp_flags(&flags(&["--cdp", value, "get", "url"])).unwrap_err();
            assert!(error.contains(message), "{value}: {error}");
        }
        let error = validate_cdp_flags(&flags(&[
            "--cdp",
            "9222",
            "--cdp-headers",
            "[]",
            "get",
            "url",
        ]))
        .unwrap_err();
        assert!(error.contains("JSON object"), "{error}");
    }

    #[test]
    fn test_cdp_url_metadata_carries_no_launch_settings() {
        let (command, wire) = settings_of(&[
            "--profile",
            "/tmp/p",
            "--args",
            "--disable-gpu",
            "--no-auto-dialog",
            "get",
            "cdp-url",
        ]);
        assert!(command_is_cdp_url_metadata(&command));
        assert_eq!(wire["no-auto-dialog"], true);
        assert!(
            wire.get("profile").is_none() && wire.get("args").is_none(),
            "{wire}"
        );

        let (command, wire) = settings_of(&["--profile", "/tmp/p", "snapshot"]);
        assert!(!command_is_cdp_url_metadata(&command));
        assert_eq!(wire["profile"], "/tmp/p");
    }

    #[test]
    fn test_cdp_url_metadata_batches_carry_no_launch_settings() {
        for command in [
            json!({ "action": "batch", "commands": ["get cdp-url", "get 'cdp-url' ignored"] }),
            json!({ "action": "batch", "argv": [["get", "cdp-url"]] }),
        ] {
            assert!(command_is_cdp_url_metadata(&command), "{command}");
        }
        for command in [
            json!({ "action": "batch", "commands": ["get cdp-url", "open about:blank"] }),
            json!({ "action": "batch", "argv": [["get", "cdp-url"], ["snapshot"]] }),
            json!({ "action": "batch", "commands": ["batch 'get cdp-url'"] }),
            json!({ "action": "batch", "argv": [["batch", "get cdp-url"]] }),
            json!({ "action": "batch", "commands": [] }),
            json!({ "action": "batch", "argv": [[]] }),
            json!({ "action": "batch" }),
        ] {
            assert!(!command_is_cdp_url_metadata(&command), "{command}");
        }
    }

    #[test]
    fn test_relaunch_is_sent_with_the_first_batch_command_only() {
        let args: Vec<String> = ["--relaunch", "--profile", "/tmp/p", "get", "url"]
            .iter()
            .map(|a| a.to_string())
            .collect();
        let flags = crate::flags::parse_flags(&args);
        let cmd = serde_json::json!({ "action": "url" });

        assert!(batch_item_settings(&flags, &cmd, false).forces_relaunch());
        let second = batch_item_settings(&flags, &cmd, true);
        assert!(!second.forces_relaunch());
        assert!(second.value(session_settings::Key::Profile).is_some());
    }

    #[test]
    fn test_relaunch_skips_batch_commands_that_cannot_launch() {
        let args: Vec<String> = ["--relaunch", "batch", "read https://example.com", "get url"]
            .iter()
            .map(|a| a.to_string())
            .collect();
        let flags = crate::flags::parse_flags(&args);

        for cmd in [
            serde_json::json!({ "action": "read", "url": "https://example.com" }),
            serde_json::json!({ "action": "auth_login", "noNavigate": true }),
            serde_json::json!({ "action": "state_list" }),
        ] {
            assert!(
                !batch_item_settings(&flags, &cmd, false).forces_relaunch(),
                "{cmd}"
            );
        }
        let url = serde_json::json!({ "action": "url" });
        assert!(batch_item_settings(&flags, &url, false).forces_relaunch());
    }

    #[test]
    fn test_only_a_reported_relaunch_counts_as_applied() {
        let response = |data: serde_json::Value| connection::Response {
            success: true,
            data: Some(data),
            ..Default::default()
        };
        assert!(response_applied_relaunch(&response(serde_json::json!({
            "lifecycle": { "settings": { "changes": [], "forcedRelaunch": true } }
        }))));
        assert!(response_applied_relaunch(&response(serde_json::json!({
            "confirmed": true,
            "result": { "success": true, "data": {
                "lifecycle": { "settings": { "changes": [], "forcedRelaunch": true } }
            } }
        }))));
        // Parked for confirmation or denied: the launch never ran.
        assert!(!response_applied_relaunch(&response(serde_json::json!({
            "confirmation_required": true, "confirmation_id": "c1"
        }))));
        assert!(!response_applied_relaunch(&connection::Response {
            success: false,
            error: Some("Action denied by policy".to_string()),
            ..Default::default()
        }));
    }

    #[test]
    fn test_parse_proxy_simple() {
        let result = parse_proxy("http://proxy.com:8080");
        assert_eq!(result.server, "http://proxy.com:8080");
        assert!(result.username.is_none());
        assert!(result.password.is_none());
    }

    #[test]
    fn test_parse_proxy_with_auth() {
        let result = parse_proxy("http://user:pass@proxy.com:8080");
        assert_eq!(result.server, "http://proxy.com:8080");
        assert_eq!(result.username.as_deref(), Some("user"));
        assert_eq!(result.password.as_deref(), Some("pass"));
    }

    #[test]
    fn test_parse_proxy_username_only() {
        let result = parse_proxy("http://user@proxy.com:8080");
        assert_eq!(result.server, "http://proxy.com:8080");
        assert_eq!(result.username.as_deref(), Some("user"));
        assert!(result.password.is_none());
    }

    #[test]
    fn test_parse_proxy_no_protocol() {
        let result = parse_proxy("proxy.com:8080");
        assert_eq!(result.server, "proxy.com:8080");
        assert!(result.username.is_none());
    }

    #[test]
    fn test_parse_proxy_socks5() {
        let result = parse_proxy("socks5://proxy.com:1080");
        assert_eq!(result.server, "socks5://proxy.com:1080");
        assert!(result.username.is_none());
    }

    #[test]
    fn test_parse_proxy_socks5_with_auth() {
        let result = parse_proxy("socks5://admin:secret@proxy.com:1080");
        assert_eq!(result.server, "socks5://proxy.com:1080");
        assert_eq!(result.username.as_deref(), Some("admin"));
        assert_eq!(result.password.as_deref(), Some("secret"));
    }

    #[test]
    fn test_parse_proxy_complex_password() {
        let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080");
        assert_eq!(result.server, "http://proxy.com:8080");
        assert_eq!(result.username.as_deref(), Some("user"));
        assert_eq!(result.password.as_deref(), Some("p@ss:w0rd"));
    }

    #[test]
    fn test_parse_proxy_decodes_encoded_credentials() {
        for (proxy, username, password) in [
            (
                "http://user%40name+tag:p%3Ass%40word%252F+tail@proxy.com:8080",
                Some("user@name+tag"),
                Some("p:ss@word%2F+tail"),
            ),
            ("http://user%3Aname@proxy.com:8080", Some("user:name"), None),
            (
                "http://user:pass%FF@proxy.com:8080",
                Some("user"),
                Some("pass%FF"),
            ),
        ] {
            let result = parse_proxy(proxy);
            assert_eq!(result.username.as_deref(), username, "proxy: {proxy}");
            assert_eq!(result.password.as_deref(), password, "proxy: {proxy}");
        }
    }

    #[test]
    fn test_serialize_json_value_escapes_control_characters() {
        let payload = serialize_json_value(&json!({
            "success": false,
            "error": "Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m",
        }));

        let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
        assert_eq!(parsed["success"], false);
        assert_eq!(
            parsed["error"],
            "Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m"
        );
    }

    fn neutral_launch_config_flags() -> Flags {
        let mut flags = parse_flags(&[]);
        flags.headed = false;
        flags.cli_headed = false;
        flags.executable_path = None;
        flags.profile = None;
        flags.state = None;
        flags.proxy = None;
        flags.args = None;
        flags.user_agent = None;
        flags.ignore_https_errors = false;
        flags.ca_cert = None;
        flags.clear_ca_cert = false;
        flags.allow_file_access = false;
        flags.hide_scrollbars = true;
        flags.cli_hide_scrollbars = false;
        flags.webgpu = false;
        flags.cli_webgpu = false;
        flags.no_webmcp = false;
        flags.cli_no_webmcp = false;
        flags.color_scheme = None;
        flags.download_path = None;
        flags.engine = None;
        flags.allowed_domains = None;
        flags.capture = None;
        flags.init_scripts.clear();
        flags.enable.clear();
        flags.extensions.clear();
        flags.cdp = None;
        flags.provider = None;
        flags.auto_connect = false;
        flags
    }

    #[test]
    fn test_attach_pin_tab_to_command_preserves_preference() {
        let mut flags = neutral_launch_config_flags();

        let mut absent_cmd = json!({ "action": "launch" });
        attach_pin_tab_to_command(&mut absent_cmd, &flags);
        assert!(absent_cmd.get("pinTab").is_none());

        flags.pin_tab = true;
        let mut enabled_cmd = json!({ "action": "launch" });
        attach_pin_tab_to_command(&mut enabled_cmd, &flags);
        assert_eq!(enabled_cmd["pinTab"], true);

        flags.pin_tab = false;
        flags.cli_pin_tab = true;
        let mut disabled_cmd = json!({ "action": "launch" });
        attach_pin_tab_to_command(&mut disabled_cmd, &flags);
        assert_eq!(disabled_cmd["pinTab"], false);
    }

    #[test]
    fn test_published_schemas_define_pin_tab_boolean() {
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("cli should have a repository parent");
        let root_schema: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(repo_root.join("agent-browser.schema.json")).unwrap(),
        )
        .unwrap();
        let docs_schema: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(repo_root.join("docs/public/schema.json")).unwrap(),
        )
        .unwrap();

        let root_pin_tab = &root_schema["properties"]["pinTab"];
        let docs_pin_tab = &docs_schema["properties"]["pinTab"];
        assert_eq!(root_pin_tab["type"], "boolean");
        assert_eq!(docs_pin_tab["type"], "boolean");
        assert_eq!(root_pin_tab, docs_pin_tab);
    }

    #[test]
    fn test_published_schemas_define_matching_ca_cert_string() {
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("cli should have a repository parent");
        let root_schema: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(repo_root.join("agent-browser.schema.json")).unwrap(),
        )
        .unwrap();
        let docs_schema: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(repo_root.join("docs/public/schema.json")).unwrap(),
        )
        .unwrap();

        let root_ca_cert = &root_schema["properties"]["caCert"];
        let docs_ca_cert = &docs_schema["properties"]["caCert"];
        assert_eq!(root_ca_cert["type"], "string");
        assert_eq!(docs_ca_cert["type"], "string");
        assert_eq!(root_ca_cert, docs_ca_cert);
        assert_eq!(root_schema["properties"]["clearCaCert"]["type"], "boolean");
        assert_eq!(
            root_schema["properties"]["clearCaCert"],
            docs_schema["properties"]["clearCaCert"]
        );
    }

    #[test]
    fn test_published_schemas_define_matching_no_webmcp_boolean() {
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("cli should have a repository parent");
        let root_schema: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(repo_root.join("agent-browser.schema.json")).unwrap(),
        )
        .unwrap();
        let docs_schema: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(repo_root.join("docs/public/schema.json")).unwrap(),
        )
        .unwrap();

        assert_eq!(root_schema["properties"]["noWebmcp"]["type"], "boolean");
        assert_eq!(
            root_schema["properties"]["noWebmcp"],
            docs_schema["properties"]["noWebmcp"]
        );
    }

    #[test]
    fn test_attach_plugins_to_command_adds_registry_payload() {
        let plugins = vec![crate::plugins::PluginConfig {
            name: "stealth".to_string(),
            command: "agent-browser-plugin-stealth".to_string(),
            capabilities: vec!["launch.mutate".to_string()],
            ..crate::plugins::PluginConfig::default()
        }];
        let mut cmd = json!({ "action": "navigate", "url": "https://example.com" });

        attach_plugins_to_command(&mut cmd, &plugins);

        assert_eq!(cmd["plugins"][0]["name"], "stealth");
        assert_eq!(cmd["plugins"][0]["capabilities"][0], "launch.mutate");
    }

    #[test]
    fn test_attach_plugins_to_command_adds_empty_registry_payload() {
        let mut cmd = json!({ "action": "navigate", "url": "https://example.com" });

        attach_plugins_to_command(&mut cmd, &[]);

        assert_eq!(cmd["plugins"], json!([]));
    }

    #[test]
    fn test_attach_restore_config_to_command_uses_session_for_bare_restore() {
        let args = vec![
            "--session".to_string(),
            "next-loop".to_string(),
            "--restore".to_string(),
            "--restore-save".to_string(),
            "always".to_string(),
            "--restore-check-url".to_string(),
            "**/dashboard".to_string(),
            "--restore-check-text".to_string(),
            "Dashboard".to_string(),
            "--restore-check-fn".to_string(),
            "!!localStorage.getItem('session')".to_string(),
            "open".to_string(),
            "http://localhost:3000".to_string(),
        ];
        let mut flags = parse_flags(&args);
        if flags.restore_uses_session {
            flags.restore = Some(flags.session.clone());
        }
        let mut cmd = json!({ "action": "launch" });

        attach_restore_config_to_command(&mut cmd, &flags);

        assert_eq!(cmd["restoreKey"], "next-loop");
        assert_eq!(cmd["restoreSave"], "always");
        assert_eq!(cmd["restoreCheckUrl"], "**/dashboard");
        assert_eq!(cmd["restoreCheckText"], "Dashboard");
        assert_eq!(cmd["restoreCheckFn"], "!!localStorage.getItem('session')");
    }

    #[test]
    fn test_attach_restore_config_to_command_sends_defaults_and_clears_checks() {
        let args = vec![
            "--session".to_string(),
            "next-loop".to_string(),
            "--restore".to_string(),
            "open".to_string(),
            "http://localhost:3000".to_string(),
        ];
        let mut flags = parse_flags(&args);
        if flags.restore_uses_session {
            flags.restore = Some(flags.session.clone());
        }
        let mut cmd = json!({ "action": "navigate" });

        attach_restore_config_to_command(&mut cmd, &flags);

        assert_eq!(cmd["restoreKey"], "next-loop");
        assert_eq!(cmd["restoreSave"], "auto");
        assert!(cmd["restoreCheckUrl"].is_null());
        assert!(cmd["restoreCheckText"].is_null());
        assert!(cmd["restoreCheckFn"].is_null());
    }

    fn launch_mode_flags(auto_connect: bool, cdp: bool, provider: bool, extensions: bool) -> Flags {
        let mut flags = parse_flags(&[]);
        // Deterministic regardless of ambient AGENT_BROWSER_WEBGPU.
        flags.webgpu = false;
        flags.auto_connect = auto_connect;
        flags.cdp = cdp.then(|| "9222".to_string());
        flags.provider = provider.then(|| "ios".to_string());
        flags.extensions = if extensions {
            vec!["/tmp/ext".to_string()]
        } else {
            Vec::new()
        };
        flags
    }

    #[test]
    fn test_incompatible_launch_mode_error_matches_existing_messages() {
        let cases = [
            (
                launch_mode_flags(false, true, true, false),
                "Cannot use --cdp and -p/--provider together",
            ),
            (
                launch_mode_flags(true, true, false, false),
                "Cannot use --auto-connect and --cdp together",
            ),
            (
                launch_mode_flags(true, false, true, false),
                "Cannot use --auto-connect and -p/--provider together",
            ),
            (
                launch_mode_flags(false, false, true, true),
                "Cannot use --extension with -p/--provider (extensions require local browser)",
            ),
            (
                launch_mode_flags(false, true, false, true),
                "Cannot use --extension with --cdp (extensions require local browser)",
            ),
        ];

        for (flags, expected) in cases {
            assert_eq!(incompatible_launch_mode_error(&flags), Some(expected));
        }
    }

    #[test]
    fn test_incompatible_launch_mode_error_rejects_no_auto_connect_with_auto_connect() {
        let mut flags = launch_mode_flags(true, false, false, false);
        flags.cli_auto_connect = true;
        flags.no_auto_connect = true;
        assert_eq!(
            incompatible_launch_mode_error(&flags),
            Some("Cannot use --auto-connect and --no-auto-connect together")
        );

        let mut reset = launch_mode_flags(false, false, false, false);
        reset.no_auto_connect = true;
        assert_eq!(incompatible_launch_mode_error(&reset), None);
    }

    #[test]
    fn test_incompatible_launch_mode_error_rejects_webgpu_attach_modes() {
        let with_webgpu = |mut flags: Flags| {
            flags.webgpu = true;
            flags
        };
        let cases = [
            (
                with_webgpu(launch_mode_flags(false, true, false, false)),
                "Cannot use --webgpu with --cdp (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
            ),
            (
                with_webgpu(launch_mode_flags(false, false, true, false)),
                "Cannot use --webgpu with -p/--provider (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
            ),
            (
                with_webgpu(launch_mode_flags(true, false, false, false)),
                "Cannot use --webgpu with --auto-connect (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
            ),
        ];
        for (flags, expected) in cases {
            assert_eq!(incompatible_launch_mode_error(&flags), Some(expected));
        }

        // webgpu alone (local launch) is fine.
        assert_eq!(
            incompatible_launch_mode_error(&with_webgpu(launch_mode_flags(
                false, false, false, false
            ))),
            None
        );
        // Attach modes without webgpu stay allowed.
        assert_eq!(
            incompatible_launch_mode_error(&launch_mode_flags(false, true, false, false)),
            None
        );
    }

    #[test]
    fn test_incompatible_launch_mode_error_rejects_ca_cert_modes() {
        let with_ca = |mut flags: Flags| {
            flags.ca_cert = Some("/tmp/ca.pem".to_string());
            flags
        };
        let mut clear = with_ca(launch_mode_flags(false, false, false, false));
        clear.clear_ca_cert = true;
        let mut ignore_errors = with_ca(launch_mode_flags(false, false, false, false));
        ignore_errors.ignore_https_errors = true;
        let mut profile = with_ca(launch_mode_flags(false, false, false, false));
        profile.profile = Some("/tmp/profile".to_string());
        let mut lightpanda = with_ca(launch_mode_flags(false, false, false, false));
        lightpanda.engine = Some("lightpanda".to_string());

        let cases = [
            (
                with_ca(launch_mode_flags(false, true, false, false)),
                "Cannot use --ca-cert with --cdp (--ca-cert requires a locally launched Chromium browser on Linux)",
            ),
            (
                with_ca(launch_mode_flags(true, false, false, false)),
                "Cannot use --ca-cert with --auto-connect (--ca-cert requires a locally launched Chromium browser on Linux)",
            ),
            (
                with_ca(launch_mode_flags(false, false, true, false)),
                "Cannot use --ca-cert with -p/--provider (--ca-cert requires a locally launched Chromium browser on Linux)",
            ),
            (
                ignore_errors,
                "Cannot use --ca-cert with --ignore-https-errors",
            ),
            (
                profile,
                "Cannot use --ca-cert with --profile because isolated CA trust would change the profile's NSS environment",
            ),
            (
                lightpanda,
                "--ca-cert is supported only with the Chrome engine on Linux",
            ),
            (clear, "Cannot use --ca-cert with --no-ca-cert"),
        ];

        for (flags, expected) in cases {
            assert_eq!(incompatible_launch_mode_error(&flags), Some(expected));
        }
    }

    #[test]
    fn test_docs_proxy_ca_config_uses_compatible_options() {
        let docs = include_str!("../../docs/content/docs/configuration.mdx");
        let marker = "### Behind a Proxy";
        let example = docs
            .split_once(marker)
            .and_then(|(_, rest)| rest.split_once("```json"))
            .and_then(|(_, rest)| rest.split_once("```"))
            .map(|(json, _)| json.trim())
            .expect("docs proxy CA configuration example");
        let config: flags::Config = serde_json::from_str(example).unwrap();
        let mut flags = neutral_launch_config_flags();
        flags.profile = config.profile;
        flags.ignore_https_errors = config.ignore_https_errors.unwrap_or(false);
        flags.ca_cert = config.ca_cert;
        flags.clear_ca_cert = config.clear_ca_cert.unwrap_or(false);
        flags.cdp = config.cdp;
        flags.auto_connect = config.auto_connect.unwrap_or(false);
        flags.provider = config.provider;
        flags.engine = config.engine;

        let error = incompatible_launch_mode_error(&flags);
        assert!(
            error.is_none() || error == Some("--ca-cert browser trust is currently supported only on Linux (read, install, upgrade, and doctor accept it on every platform)"),
            "docs proxy CA configuration is incompatible: {error:?}"
        );
    }

    #[test]
    fn test_incompatible_launch_mode_error_allows_compatible_flags() {
        assert_eq!(
            incompatible_launch_mode_error(&launch_mode_flags(true, false, false, false)),
            None
        );
        assert_eq!(
            incompatible_launch_mode_error(&launch_mode_flags(false, true, false, false)),
            None
        );
        assert_eq!(
            incompatible_launch_mode_error(&launch_mode_flags(false, false, true, false)),
            None
        );
    }

    #[test]
    fn test_resolve_session_id_scope_accepts_cwd_and_rejects_unknown() {
        let (scope, path) = resolve_session_id_scope("cwd").unwrap();

        assert_eq!(scope, "cwd");
        assert!(path.is_absolute());
        assert!(resolve_session_id_scope("branch").is_err());
    }

    #[test]
    fn test_confirmation_prompt_from_response_finds_nested_confirm_result() {
        let resp = Response {
            success: true,
            code: None,
            data: Some(json!({
                "confirmed": true,
                "action": "navigate",
                "result": {
                    "id": "original-command",
                    "success": true,
                    "data": {
                        "confirmation_required": true,
                        "confirmation_id": "original-command",
                        "action": "plugin:stealth:launch.mutate"
                    }
                }
            })),
            error: None,
            warning: None,
            recorded: None,
        };

        let prompt = confirmation_prompt_from_response(&resp).unwrap();

        assert_eq!(prompt.action, "plugin:stealth:launch.mutate");
        assert_eq!(prompt.description, "plugin:stealth:launch.mutate");
        assert_eq!(prompt.confirmation_id, "original-command");
    }
}