use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;

use super::discovery::discover_cdp_url;

pub struct ChromeProcess {
    child: Child,
    pub ws_url: String,
    temp_user_data_dir: Option<PathBuf>,
    /// On Unix, the process group ID used to kill the entire Chrome process tree.
    #[cfg(unix)]
    pgid: Option<i32>,
}

impl ChromeProcess {
    pub fn kill(&mut self) {
        let _ = self.child.kill();
        // On Unix, kill the entire process group to ensure Chrome helper
        // processes (GPU, renderer, utility, crashpad) are also terminated.
        // This prevents orphaned Chrome processes from blocking the user's
        // normal Chrome (issue #1113).
        #[cfg(unix)]
        if let Some(pgid) = self.pgid {
            unsafe {
                libc::kill(-pgid, libc::SIGKILL);
            }
        }
        let _ = self.child.wait();
    }

    /// Returns the OS process ID of the Chrome child process.
    pub fn id(&self) -> u32 {
        self.child.id()
    }

    /// Non-blocking check whether Chrome has exited.
    /// Returns `true` if the process has exited (and reaps it), `false` if still running.
    pub fn has_exited(&mut self) -> bool {
        matches!(self.child.try_wait(), Ok(Some(_)) | Err(_))
    }

    /// Wait for Chrome to exit on its own (after Browser.close CDP command),
    /// falling back to kill() if it doesn't exit within the timeout.
    /// This allows Chrome to flush cookies and other state to the user-data-dir.
    pub fn wait_or_kill(&mut self, timeout: Duration) {
        let start = std::time::Instant::now();
        let poll_interval = Duration::from_millis(50);

        while start.elapsed() < timeout {
            match self.child.try_wait() {
                Ok(Some(_)) => return,
                Ok(None) => std::thread::sleep(poll_interval),
                Err(_) => break,
            }
        }

        self.kill();
    }
}

impl Drop for ChromeProcess {
    fn drop(&mut self) {
        self.kill();
        if let Some(ref dir) = self.temp_user_data_dir {
            for attempt in 0..3 {
                match std::fs::remove_dir_all(dir) {
                    Ok(()) => break,
                    Err(_) if attempt < 2 => {
                        std::thread::sleep(Duration::from_millis(100));
                    }
                    Err(e) => {
                        // Use write! instead of eprintln! to avoid panicking
                        // if the daemon's stderr pipe is broken (parent dropped it).
                        let _ = writeln!(
                            std::io::stderr(),
                            "Warning: failed to clean up temp profile {}: {}",
                            dir.display(),
                            e
                        );
                    }
                }
            }
        }
    }
}

#[derive(Clone)]
pub struct LaunchOptions {
    pub headless: bool,
    pub executable_path: Option<String>,
    pub proxy: Option<String>,
    pub proxy_bypass: Option<String>,
    pub proxy_username: Option<String>,
    pub proxy_password: Option<String>,
    pub profile: Option<String>,
    pub args: Vec<String>,
    pub allow_file_access: bool,
    pub extensions: Option<Vec<String>>,
    pub storage_state: Option<String>,
    pub user_agent: Option<String>,
    pub ignore_https_errors: bool,
    pub color_scheme: Option<String>,
    pub download_path: Option<String>,
    /// Hide native scrollbars in headless Chromium screenshots by launching
    /// Chrome with `--hide-scrollbars`.
    pub hide_scrollbars: bool,
    /// Initial viewport dimensions used for `--window-size` so the content
    /// area matches the desired viewport from the start.
    pub viewport_size: Option<(u32, u32)>,
    /// When true, omit `--password-store=basic` and `--use-mock-keychain` so
    /// Chrome uses the real system keychain. Set automatically when launching
    /// with a copied Chrome profile.
    pub use_real_keychain: bool,
}

impl Default for LaunchOptions {
    fn default() -> Self {
        Self {
            headless: true,
            executable_path: None,
            proxy: None,
            proxy_bypass: None,
            proxy_username: None,
            proxy_password: None,
            profile: None,
            args: Vec::new(),
            allow_file_access: false,
            extensions: None,
            storage_state: None,
            user_agent: None,
            ignore_https_errors: false,
            color_scheme: None,
            download_path: None,
            hide_scrollbars: true,
            viewport_size: None,
            use_real_keychain: false,
        }
    }
}

struct ChromeArgs {
    args: Vec<String>,
    user_data_dir: PathBuf,
    temp_user_data_dir: Option<PathBuf>,
}

/// Whether to launch Chrome headless. The stealth fork FORBIDS headless (it's a
/// bot-detection tell), so this is `false` unless an operator explicitly opts in
/// via `AGENT_BROWSER_ALLOW_HEADLESS=1` for a display-less server. The `headless`
/// LaunchOption is intentionally ignored — headed is non-negotiable for stealth.
fn launch_headless() -> bool {
    std::env::var("AGENT_BROWSER_ALLOW_HEADLESS")
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false)
}

/// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched
/// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior.
fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> {
    let opt_in = std::env::var("AGENT_BROWSER_BLOCK_WEBRTC").ok();
    let explicitly_off = opt_in
        .as_deref()
        .is_some_and(|v| v == "0" || v.eq_ignore_ascii_case("false"));
    if explicitly_off {
        return None;
    }
    let explicitly_on = opt_in
        .as_deref()
        .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
    if has_proxy {
        // Force all WebRTC UDP through the proxy so the real IP can't leak.
        Some("disable_non_proxied_udp")
    } else if explicitly_on {
        // No proxy, but the user asked to hide the local network IP.
        Some("default_public_interface_only")
    } else {
        None
    }
}

fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
    let mut args = vec![
        "--remote-debugging-port=0".to_string(),
        "--no-first-run".to_string(),
        "--no-default-browser-check".to_string(),
        // Stealth: reduce automation fingerprint surface
        "--disable-blink-features=AutomationControlled".to_string(),
        "--use-gl=angle".to_string(),
        "--use-angle=default".to_string(),
        "--disable-background-networking".to_string(),
        "--disable-backgrounding-occluded-windows".to_string(),
        "--disable-component-update".to_string(),
        "--disable-default-apps".to_string(),
        "--disable-hang-monitor".to_string(),
        "--disable-popup-blocking".to_string(),
        "--disable-prompt-on-repost".to_string(),
        "--disable-sync".to_string(),
        "--disable-features=Translate".to_string(),
        "--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
        "--metrics-recording-only".to_string(),
    ];

    if !options.use_real_keychain {
        args.push("--password-store=basic".to_string());
        args.push("--use-mock-keychain".to_string());
    }

    let has_extensions = options
        .extensions
        .as_ref()
        .is_some_and(|exts| !exts.is_empty());

    // Stealth fork: NEVER launch headless. Headless Chrome is a detectable tell
    // (creepjs scores ~33% headless even with new-headless; a real GPU and a
    // headed window score 0%). So we always launch headed and ignore the
    // `headless` option. The only escape is an explicit AGENT_BROWSER_ALLOW_HEADLESS=1
    // for genuinely display-less servers (discouraged — it forfeits stealth).
    // Extensions also require headed mode (content scripts aren't injected headless).
    if launch_headless() && !has_extensions {
        args.push("--headless=new".to_string());
        // Linux paints native scrollbars into viewport screenshots unless
        // Chrome is launched with this flag. `--hide-scrollbars` is
        // presence-based, so agent-browser exposes --hide-scrollbars false
        // as the public opt-out instead of forwarding a fake inverse switch.
        if options.hide_scrollbars {
            args.push("--hide-scrollbars".to_string());
        }
        // Enable SwiftShader software rendering in headless mode.  This
        // prevents silent crashes in environments where GPU drivers are
        // missing or restricted (VMs, containers, some cloud machines)
        // while preserving WebGL support.  Playwright uses the same flag.
        args.push("--enable-unsafe-swiftshader".to_string());
    }

    if let Some(ref proxy) = options.proxy {
        args.push(format!("--proxy-server={}", proxy));
    }

    if let Some(ref bypass) = options.proxy_bypass {
        args.push(format!("--proxy-bypass-list={}", bypass));
    }

    // WebRTC IP-leak handling. WebRTC enumerates ICE candidates that can expose
    // the machine's real local/public IP even when HTTP traffic goes through a
    // proxy — defeating the proxy. `--force-webrtc-ip-handling-policy` is a real
    // Chrome privacy switch (no detectable JS lie), applied here for launched
    // Chrome only (an attached real Chrome keeps the user's own flags).
    //   - proxy set            -> `disable_non_proxied_udp`: force WebRTC through
    //                             the proxy so the real IP can't leak.
    //   - AGENT_BROWSER_BLOCK_WEBRTC=1 (no proxy) -> `default_public_interface_only`:
    //                             hide the local network IP (Brave/uBlock default).
    // Opt out entirely with AGENT_BROWSER_BLOCK_WEBRTC=0.
    if let Some(policy) = webrtc_ip_handling_policy(options.proxy.is_some()) {
        args.push(format!("--force-webrtc-ip-handling-policy={}", policy));
    }

    let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
        let expanded = expand_tilde(profile);
        let dir = PathBuf::from(&expanded);
        args.push(format!("--user-data-dir={}", expanded));
        (dir, None)
    } else {
        let dir =
            std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir)
            .map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
        args.push(format!("--user-data-dir={}", dir.display()));
        (dir.clone(), Some(dir))
    };

    if options.ignore_https_errors {
        args.push("--ignore-certificate-errors".to_string());
    }

    if options.allow_file_access {
        args.push("--allow-file-access-from-files".to_string());
        args.push("--allow-file-access".to_string());
    }

    if let Some(ref exts) = options.extensions {
        if !exts.is_empty() {
            let ext_list = exts.join(",");
            args.push(format!("--load-extension={}", ext_list));
            args.push(format!("--disable-extensions-except={}", ext_list));
        }
    }

    let has_window_size = options
        .args
        .iter()
        .any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));

    if !has_window_size && launch_headless() && !has_extensions {
        let (w, h) = options.viewport_size.unwrap_or((1280, 720));
        args.push(format!("--window-size={},{}", w, h));
    }

    args.extend(options.args.iter().cloned());

    if should_disable_sandbox(&args) {
        args.push("--no-sandbox".to_string());
    }

    if should_disable_dev_shm(&args) {
        args.push("--disable-dev-shm-usage".to_string());
    }

    Ok(ChromeArgs {
        args,
        user_data_dir,
        temp_user_data_dir,
    })
}

pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
    let chrome_path = match &options.executable_path {
        Some(p) => PathBuf::from(p),
        None => find_chrome().ok_or_else(|| {
            let cache_dir = crate::install::get_browsers_dir();
            format!(
                "Chrome not found. Checked:\n  \
                 - agent-browser cache: {}\n  \
                 - System Chrome installations\n  \
                 - Puppeteer browser cache\n  \
                 - Playwright browser cache\n\
                 Run `agent-browser install` to download Chrome, or use --executable-path.",
                cache_dir.display()
            )
        })?,
    };

    // Profile name preprocessing: if --profile is a Chrome profile name (not a
    // path), resolve it to a directory, copy the profile to a temp dir, and
    // rewrite options so the retry loop uses the copied profile.
    let mut resolved_options: Option<LaunchOptions> = None;
    let mut profile_temp_dir: Option<PathBuf> = None;

    if let Some(ref profile) = options.profile {
        if is_chrome_profile_name(profile) {
            let user_data_dir = find_chrome_user_data_dir().ok_or_else(|| {
                "No Chrome user data directory found. Cannot resolve profile name.\n\
                 If you meant a directory path, use a full path (e.g., /path/to/profile)."
                    .to_string()
            })?;
            let resolved = resolve_chrome_profile(&user_data_dir, profile)?;
            let temp_path = copy_chrome_profile(&user_data_dir, &resolved)?;

            let mut opts = options.clone();
            opts.profile = Some(temp_path.display().to_string());
            opts.use_real_keychain = true;
            opts.args.push(format!("--profile-directory={}", resolved));
            profile_temp_dir = Some(temp_path);
            resolved_options = Some(opts);
        }
    }

    let effective_options = resolved_options.as_ref().unwrap_or(options);

    let max_attempts = 3;
    let mut last_err = String::new();

    for attempt in 1..=max_attempts {
        match try_launch_chrome(&chrome_path, effective_options) {
            Ok(mut process) => {
                // Transfer profile temp dir ownership to ChromeProcess for cleanup on Drop.
                // The try_launch_chrome temp_user_data_dir is None here because we set profile
                // to the temp path (treated as a user-supplied path, no second temp dir).
                if let Some(ref dir) = profile_temp_dir {
                    process.temp_user_data_dir = Some(dir.clone());
                }
                return Ok(process);
            }
            Err(e) => {
                last_err = e;
                if attempt < max_attempts {
                    // Use write! instead of eprintln! to avoid panicking
                    // if the daemon's stderr pipe is broken (parent dropped it).
                    let _ = writeln!(
                        std::io::stderr(),
                        "[chrome] Launch attempt {}/{} failed, retrying in 500ms...",
                        attempt,
                        max_attempts
                    );
                    std::thread::sleep(Duration::from_millis(500));
                }
            }
        }
    }

    // All retries failed: clean up profile temp dir if we created one
    if let Some(ref dir) = profile_temp_dir {
        let _ = std::fs::remove_dir_all(dir);
    }

    Err(last_err)
}

fn try_launch_chrome(chrome_path: &Path, options: &LaunchOptions) -> Result<ChromeProcess, String> {
    let ChromeArgs {
        args,
        user_data_dir,
        temp_user_data_dir,
    } = build_chrome_args(options)?;

    // Mitigate stale DevToolsActivePort risk (e.g., previous crash left it behind).
    // Puppeteer does similar cleanup before spawning.
    let _ = std::fs::remove_file(user_data_dir.join("DevToolsActivePort"));

    let cleanup_temp_dir = |dir: &Option<PathBuf>| {
        if let Some(ref d) = dir {
            let _ = std::fs::remove_dir_all(d);
        }
    };

    let mut cmd = Command::new(chrome_path);
    cmd.args(&args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped());

    // Place Chrome in its own process group so we can kill the entire tree
    // (main process + GPU/renderer/utility/crashpad helpers) with a single
    // killpg(), preventing orphaned processes (issue #1113).
    //
    // NOTE: Do NOT use PR_SET_PDEATHSIG here. Chrome is spawned via
    // tokio::task::spawn_blocking, and PR_SET_PDEATHSIG fires when the
    // *thread* that forked the child exits, not the process. Tokio reaps
    // idle blocking threads after ~10s, which kills Chrome (issue #1157).
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        // SAFETY: pre_exec runs between fork() and exec() in the child.
        // setpgid is async-signal-safe.
        unsafe {
            cmd.pre_exec(|| {
                libc::setpgid(0, 0);
                Ok(())
            });
        }
    }

    let mut child = cmd.spawn().map_err(|e| {
        cleanup_temp_dir(&temp_user_data_dir);
        format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
    })?;

    // Shared overall deadline so we don't double-wait (poll + stderr fallback).
    let deadline = std::time::Instant::now() + Duration::from_secs(30);

    // Primary path: use DevToolsActivePort written into user-data-dir.
    // This is more reliable on Windows than scraping stderr for "DevTools listening on ...",
    // which can be missing/empty depending on how Chrome is launched.
    let ws_url = match wait_for_devtools_active_port(&mut child, &user_data_dir, deadline) {
        Ok(url) => url,
        Err(primary_err) => {
            // Fallback: scrape stderr (legacy behavior) for better diagnostics.
            let stderr = child.stderr.take().ok_or_else(|| {
                let _ = child.kill();
                cleanup_temp_dir(&temp_user_data_dir);
                "Failed to capture Chrome stderr".to_string()
            })?;
            let reader = BufReader::new(stderr);
            match wait_for_ws_url_until(reader, deadline) {
                Ok(url) => url,
                Err(fallback_err) => {
                    let _ = child.kill();
                    cleanup_temp_dir(&temp_user_data_dir);
                    return Err(format!(
                        "{}\n(also tried parsing stderr) {}",
                        primary_err, fallback_err
                    ));
                }
            }
        }
    };

    #[cfg(unix)]
    let pgid = {
        let pid = child.id() as i32;
        // The child called setpgid(0,0) via process_group(0), so its PGID
        // equals its own PID.
        Some(pid)
    };

    Ok(ChromeProcess {
        child,
        ws_url,
        temp_user_data_dir,
        #[cfg(unix)]
        pgid,
    })
}

fn wait_for_devtools_active_port(
    child: &mut Child,
    user_data_dir: &Path,
    deadline: std::time::Instant,
) -> Result<String, String> {
    let poll_interval = Duration::from_millis(50);

    while std::time::Instant::now() <= deadline {
        if let Ok(Some(status)) = child.try_wait() {
            // Chrome exited before writing DevToolsActivePort -- report the
            // exit code so the caller can surface it alongside stderr output.
            let code = status
                .code()
                .map(|c| format!("{}", c))
                .unwrap_or_else(|| "unknown".to_string());
            return Err(format!(
                "Chrome exited early (exit code: {}) without writing DevToolsActivePort",
                code
            ));
        }

        if let Some((port, ws_path)) = read_devtools_active_port(user_data_dir) {
            let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
            return Ok(ws_url);
        }

        std::thread::sleep(poll_interval);
    }

    Err("Timeout waiting for DevToolsActivePort".to_string())
}

fn wait_for_ws_url_until(
    reader: BufReader<std::process::ChildStderr>,
    deadline: std::time::Instant,
) -> Result<String, String> {
    let prefix = "DevTools listening on ";
    let mut stderr_lines: Vec<String> = Vec::new();

    for line in reader.lines() {
        if std::time::Instant::now() > deadline {
            return Err(chrome_launch_error(
                "Timeout waiting for Chrome DevTools URL",
                &stderr_lines,
            ));
        }
        let line = line.map_err(|e| format!("Failed to read Chrome stderr: {}", e))?;
        if let Some(url) = line.strip_prefix(prefix) {
            return Ok(url.trim().to_string());
        }
        stderr_lines.push(line);
    }

    Err(chrome_launch_error(
        "Chrome exited before providing DevTools URL",
        &stderr_lines,
    ))
}

fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
    let relevant: Vec<&String> = stderr_lines
        .iter()
        .filter(|l| {
            let lower = l.to_lowercase();
            lower.contains("error")
                || lower.contains("fatal")
                || lower.contains("sandbox")
                || lower.contains("namespace")
                || lower.contains("permission")
                || lower.contains("cannot")
                || lower.contains("failed")
                || lower.contains("abort")
        })
        .collect();

    if relevant.is_empty() {
        if stderr_lines.is_empty() {
            return format!(
                "{} (no stderr output from Chrome)\nHint: try passing --args \"--no-sandbox\" if Chrome crashes silently in your environment",
                message
            );
        }
        let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
        return format!(
            "{}\nChrome stderr (last {} lines):\n  {}",
            message,
            last_lines.len(),
            last_lines
                .into_iter()
                .rev()
                .map(|s| s.as_str())
                .collect::<Vec<_>>()
                .join("\n  ")
        );
    }

    let hint = if relevant.iter().any(|l| {
        let lower = l.to_lowercase();
        lower.contains("sandbox") || lower.contains("namespace")
    }) {
        "\nHint: try --args \"--no-sandbox\" (required in containers, VMs, and some Linux setups)"
    } else {
        ""
    };

    format!(
        "{}\nChrome stderr:\n  {}{}",
        message,
        relevant
            .iter()
            .map(|s| s.as_str())
            .collect::<Vec<_>>()
            .join("\n  "),
        hint
    )
}

pub fn find_chrome() -> Option<PathBuf> {
    // 1. Check Chrome downloaded by `agent-browser install`
    if let Some(p) = crate::install::find_installed_chrome() {
        return Some(p);
    }

    // If the cache directory exists but no Chrome was found, warn -- this
    // likely means the cache is corrupted or the directory layout is unexpected.
    let cache_dir = crate::install::get_browsers_dir();
    if cache_dir.exists() {
        let _ = writeln!(
            std::io::stderr(),
            "Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \
             Falling back to system Chrome. Run `agent-browser install` to re-download.",
            cache_dir.display()
        );
    }

    // 2. Check system-installed Chrome
    #[cfg(target_os = "macos")]
    {
        let candidates = [
            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
            "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
            "/Applications/Chromium.app/Contents/MacOS/Chromium",
            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
        ];
        for c in &candidates {
            let p = PathBuf::from(c);
            if p.exists() {
                return Some(p);
            }
        }
    }

    #[cfg(target_os = "linux")]
    {
        let candidates = [
            "google-chrome",
            "google-chrome-stable",
            "chromium-browser",
            "chromium",
            "brave-browser",
            "brave-browser-stable",
        ];
        for name in &candidates {
            if let Ok(output) = Command::new("which").arg(name).output() {
                if output.status.success() {
                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
                    if !path.is_empty() {
                        return Some(PathBuf::from(path));
                    }
                }
            }
        }
    }

    #[cfg(target_os = "windows")]
    {
        let candidates = [
            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
        ];
        if let Ok(local) = std::env::var("LOCALAPPDATA") {
            let chrome = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
            if chrome.exists() {
                return Some(chrome);
            }
            let brave =
                PathBuf::from(&local).join(r"BraveSoftware\Brave-Browser\Application\brave.exe");
            if brave.exists() {
                return Some(brave);
            }
        }
        for c in &candidates {
            let p = PathBuf::from(c);
            if p.exists() {
                return Some(p);
            }
        }
    }

    // 3. Fallback: check Puppeteer / Playwright browser caches
    if let Some(p) = find_puppeteer_chrome() {
        return Some(p);
    }
    if let Some(p) = find_playwright_chromium() {
        return Some(p);
    }

    None
}

pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
    let path = user_data_dir.join("DevToolsActivePort");
    let content = std::fs::read_to_string(&path).ok()?;
    let mut lines = content.lines();
    let port: u16 = lines.next()?.trim().parse().ok()?;
    let ws_path = lines
        .next()
        .unwrap_or("/devtools/browser")
        .trim()
        .to_string();
    Some((port, ws_path))
}

/// Remove leftover Chrome temp profile directories from daemons that were
/// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a
/// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB
/// per session under the system temp dir. On daemon startup we sweep them — but
/// ONLY dirs that no running process still references as `--user-data-dir`, so
/// a profile in active use is never deleted.
pub fn cleanup_orphaned_chrome_profiles() {
    let tmp = std::env::temp_dir();
    let Ok(entries) = std::fs::read_dir(&tmp) else {
        return;
    };
    // Snapshot live process command lines once. If we can't determine them,
    // skip cleanup entirely rather than risk deleting an in-use profile.
    let Some(live_cmdlines) = running_process_cmdlines() else {
        return;
    };
    for entry in entries.flatten() {
        let name = entry.file_name();
        if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
            continue;
        }
        let path = entry.path();
        let path_str = path.to_string_lossy();
        let in_use = live_cmdlines
            .iter()
            .any(|cmd| cmd.contains(path_str.as_ref()));
        if !in_use {
            let _ = std::fs::remove_dir_all(&path);
        }
    }
}

#[cfg(unix)]
fn running_process_cmdlines() -> Option<Vec<String>> {
    let output = std::process::Command::new("ps")
        .args(["-axww", "-o", "command="])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    Some(
        String::from_utf8_lossy(&output.stdout)
            .lines()
            .map(|l| l.to_string())
            .collect(),
    )
}

#[cfg(not(unix))]
fn running_process_cmdlines() -> Option<Vec<String>> {
    // Best-effort: skip cleanup where we can't cheaply enumerate full process
    // command lines, to avoid deleting a profile that is still in use.
    None
}

pub async fn auto_connect_cdp() -> Result<String, String> {
    // Prefer the dialog-free `ab-connect` extension relay when it is live.
    // The relay drives the user's REAL Chrome via the extension's
    // `chrome.debugger` permission, which — unlike a raw `--remote-debugging-port`
    // CDP attach — never triggers Chrome 136+'s per-connection
    // "Allow remote debugging?" consent modal. The native-messaging host writes
    // ~/.agent-browser/relay-cdp-url while connected and removes it on exit, so a
    // present URL means the relay is up. This must win over the DevToolsActivePort
    // / :9222 probes below: if the user's Chrome happens to also be listening on a
    // debug port, attaching there would pop the consent dialog and defeat the
    // whole zero-interaction extension path.
    // If the extension is installed, it is the *intended* transport. The relay
    // URL file comes and goes with the MV3 service worker (a Chrome restart or an
    // idle SW briefly drops it), so a single failed probe doesn't mean "no
    // extension" — retry for a few seconds while it reconnects. Crucially, when
    // the extension is set up we must NEVER fall through to the raw :9222 path
    // below: that pops Chrome 136+'s "Allow remote debugging?" dialog, the exact
    // thing the extension exists to avoid.
    // ~15s of retries (500ms apart) when the extension is installed: long enough
    // for the MV3 service worker to wake and reconnect on its own (onStartup
    // after a Chrome restart, or the keepalive alarm) so the relay self-heals
    // with NO user action. The loop re-checks the relay file every iteration, so
    // a recovery mid-wait is picked up immediately — the full window is only ever
    // spent when the extension is genuinely down.
    let host_installed = crate::connect::host_installed();
    let relay_attempts = if host_installed { 30 } else { 1 };
    for attempt in 0..relay_attempts {
        if let Some(relay) = crate::connect::relay_url() {
            // The relay is a local CDP-over-WS endpoint we connect to like Chrome.
            // A bare TCP liveness check (no WS upgrade) confirms it is actually
            // accepting before we commit, mirroring the consent-free probe used
            // for DevToolsActivePort.
            if relay_is_live(&relay).await {
                return Ok(relay);
            }
        }
        if host_installed && attempt + 1 < relay_attempts {
            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        }
    }

    if host_installed {
        return Err(
            "The agent-browser-stealth extension is installed, but its relay \
             isn't connected right now. Wake it up — click the extension's \
             toolbar icon, or reload it at chrome://extensions — then retry. \
             (agent-browser will not attach to a raw --remote-debugging-port \
             while the extension is set up, because that pops Chrome's \"Allow \
             remote debugging?\" dialog. Use --cdp <port> to force the raw path.)"
                .to_string(),
        );
    }

    let user_data_dirs = get_chrome_user_data_dirs();

    for dir in &user_data_dirs {
        if let Some((port, ws_path)) = read_devtools_active_port(dir) {
            if let Ok(ws_url) = resolve_cdp_from_active_port(port, &ws_path).await {
                return Ok(ws_url);
            }
            // Port is dead — remove the stale file so future runs skip it.
            let stale = dir.join("DevToolsActivePort");
            let _ = std::fs::remove_file(&stale);
        }
    }

    // Fallback: probe common ports
    for port in [9222u16, 9229] {
        if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
            return Ok(ws_url);
        }
    }

    Err(
        "No running Chrome with remote debugging found. Remote debugging is a \
         startup flag, not a setting: fully quit Chrome and relaunch it with \
         --remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
         --cdp <port>/--launch."
            .to_string(),
    )
}

/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
///
/// Returns the exact browser WebSocket URL from DevToolsActivePort, gated only
/// by a consent-free TCP liveness check. Falls back to HTTP discovery on the
/// same port for older Chrome layouts.
///
/// Crucially, this does NOT open a throwaway verification WebSocket. On
/// Chrome 136+ the "Allow remote debugging?" consent is granted *per
/// connection*: a probe WebSocket we then close would consume the user's one
/// Allow click, leaving the real connection (opened afterwards) unconsented —
/// which manifests as an endless prompt loop or a hung command. By skipping the
/// probe, the real connection is the single WebSocket the user consents to.
/// (Background: #1210, #1206 duplicate-prompt reports.)
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
    // Consent-free liveness: a bare TCP connect does not trigger the
    // remote-debugging consent flow (that fires on the CDP/WebSocket upgrade),
    // so we can tell "Chrome is listening" from "stale DevToolsActivePort"
    // without burning a prompt.
    if tcp_port_alive(port).await {
        return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path));
    }

    // Port isn't accepting connections (stale file / different layout). Fall
    // back to HTTP discovery for older Chrome before giving up.
    if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
        return Ok(ws_url);
    }

    Err(format!(
        "Cannot connect to Chrome on port {}: port not reachable and HTTP discovery failed",
        port
    ))
}

/// Consent-free check that something is accepting TCP connections on
/// `127.0.0.1:port`. Unlike a CDP/WebSocket probe, a bare TCP connect does not
/// trigger Chrome's "Allow remote debugging?" consent prompt, so it is safe to
/// use for liveness before handing the URL to the single real connection.
async fn tcp_port_alive(port: u16) -> bool {
    let timeout = Duration::from_secs(1);
    matches!(
        tokio::time::timeout(timeout, tokio::net::TcpStream::connect(("127.0.0.1", port)),).await,
        Ok(Ok(_))
    )
}

/// Consent-free liveness for the `ab-connect` relay ws URL (`ws://127.0.0.1:<port>/…`).
/// Parses the port and does a bare TCP connect — a stale relay-cdp-url file
/// (host exited without cleanup) must not divert auto-connect away from the
/// working port path.
async fn relay_is_live(ws_url: &str) -> bool {
    let port = ws_url
        .strip_prefix("ws://")
        .and_then(|rest| rest.split('/').next())
        .and_then(|hostport| hostport.rsplit(':').next())
        .and_then(|p| p.parse::<u16>().ok());
    match port {
        Some(p) => tcp_port_alive(p).await,
        None => false,
    }
}

/// Returns the default Chrome user-data directory paths for the current platform.
/// Includes Chrome, Chrome Canary, Chromium, and Brave.
pub fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();

    #[cfg(target_os = "macos")]
    {
        if let Some(home) = dirs::home_dir() {
            let base = home.join("Library/Application Support");
            for name in [
                "Google/Chrome",
                "Google/Chrome Canary",
                "Chromium",
                "BraveSoftware/Brave-Browser",
            ] {
                dirs.push(base.join(name));
            }
        }
    }

    #[cfg(target_os = "linux")]
    {
        if let Some(home) = dirs::home_dir() {
            let config = home.join(".config");
            for name in [
                "google-chrome",
                "google-chrome-unstable",
                "chromium",
                "BraveSoftware/Brave-Browser",
            ] {
                dirs.push(config.join(name));
            }
        }
    }

    #[cfg(target_os = "windows")]
    {
        if let Ok(local) = std::env::var("LOCALAPPDATA") {
            let base = PathBuf::from(local);
            for name in [
                r"Google\Chrome\User Data",
                r"Google\Chrome SxS\User Data",
                r"Chromium\User Data",
                r"BraveSoftware\Brave-Browser\User Data",
            ] {
                dirs.push(base.join(name));
            }
        }
    }

    dirs
}

/// Returns true if the given string looks like a Chrome profile name rather than
/// a file path. A profile name contains no `/`, `\`, or `~` characters.
pub fn is_chrome_profile_name(s: &str) -> bool {
    !s.contains('/') && !s.contains('\\') && !s.contains('~')
}

/// Returns the first existing Chrome user-data directory that contains a
/// `Local State` file.
pub fn find_chrome_user_data_dir() -> Option<PathBuf> {
    get_chrome_user_data_dirs()
        .into_iter()
        .find(|dir| dir.join("Local State").is_file())
}

/// A Chrome profile entry parsed from `Local State`.
#[derive(Debug, Clone)]
pub struct ChromeProfile {
    /// The directory name (e.g., "Default", "Profile 1").
    pub directory: String,
    /// The user-visible display name (e.g., "Person 1").
    pub name: String,
}

/// Lists all Chrome profiles found in the given user-data directory by reading
/// the `Local State` JSON file. Returns an empty vec if the file is missing,
/// malformed, or lacks the expected `profile.info_cache` key.
pub fn list_chrome_profiles(user_data_dir: &Path) -> Vec<ChromeProfile> {
    let local_state_path = user_data_dir.join("Local State");
    let content = match std::fs::read_to_string(&local_state_path) {
        Ok(c) => c,
        Err(_) => return Vec::new(),
    };
    let json: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return Vec::new(),
    };
    let info_cache = match json.get("profile").and_then(|p| p.get("info_cache")) {
        Some(obj) if obj.is_object() => obj.as_object().unwrap(),
        _ => return Vec::new(),
    };

    let mut profiles: Vec<ChromeProfile> = info_cache
        .iter()
        .map(|(dir_name, info)| {
            let display_name = info
                .get("name")
                .and_then(|n| n.as_str())
                .unwrap_or(dir_name)
                .to_string();
            ChromeProfile {
                directory: dir_name.clone(),
                name: display_name,
            }
        })
        .collect();
    profiles.sort_by(|a, b| a.directory.cmp(&b.directory));
    profiles
}

/// Resolves a profile input string to a Chrome profile directory name using
/// three-tier matching:
/// 1. Exact directory name match
/// 2. Case-insensitive display name match (error if ambiguous)
/// 3. Case-insensitive directory name match
///
/// Returns the resolved directory name, or an error with available profiles.
/// Read `profile.last_used` (the directory name of the profile Chrome opened
/// most recently) from a user-data dir's `Local State`. Used to resolve
/// `--profile auto`.
fn read_last_used_profile(user_data_dir: &Path) -> Option<String> {
    let content = std::fs::read_to_string(user_data_dir.join("Local State")).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
    json.get("profile")?
        .get("last_used")?
        .as_str()
        .map(String::from)
}

pub fn resolve_chrome_profile(user_data_dir: &Path, input: &str) -> Result<String, String> {
    let profiles = list_chrome_profiles(user_data_dir);

    if profiles.is_empty() {
        return Err(format!(
            "No Chrome profiles found in {}.\n\
             If you meant a directory path, use a full path (e.g., /path/to/profile).",
            user_data_dir.display()
        ));
    }

    // "auto": pick the profile Chrome last used (else "Default", else the first
    // one), so `--profile auto` reuses the real logged-in profile without the
    // user having to name it explicitly.
    if input.eq_ignore_ascii_case("auto") {
        if let Some(lu) = read_last_used_profile(user_data_dir) {
            if let Some(p) = profiles.iter().find(|p| p.directory == lu) {
                return Ok(p.directory.clone());
            }
        }
        if let Some(p) = profiles.iter().find(|p| p.directory == "Default") {
            return Ok(p.directory.clone());
        }
        return Ok(profiles[0].directory.clone());
    }

    // Tier 1: exact directory name match
    if let Some(p) = profiles.iter().find(|p| p.directory == input) {
        return Ok(p.directory.clone());
    }

    // Tier 2: case-insensitive display name match
    let input_lower = input.to_lowercase();
    let display_matches: Vec<&ChromeProfile> = profiles
        .iter()
        .filter(|p| p.name.to_lowercase() == input_lower)
        .collect();
    match display_matches.len() {
        1 => return Ok(display_matches[0].directory.clone()),
        n if n > 1 => {
            return Err(format!(
                "Ambiguous profile name \"{}\". Multiple profiles match:\n{}\n\
                 Use the directory name instead.",
                input,
                format_profile_list(&display_matches)
            ));
        }
        _ => {}
    }

    // Tier 3: case-insensitive directory name match
    if let Some(p) = profiles
        .iter()
        .find(|p| p.directory.to_lowercase() == input_lower)
    {
        return Ok(p.directory.clone());
    }

    let all_profiles: Vec<&ChromeProfile> = profiles.iter().collect();
    Err(format!(
        "Chrome profile \"{}\" not found. Available profiles:\n{}\n\
         If you meant a directory path, use a full path (e.g., /path/to/profile).",
        input,
        format_profile_list(&all_profiles)
    ))
}

fn format_profile_list(profiles: &[&ChromeProfile]) -> String {
    profiles
        .iter()
        .map(|p| format!("  {} ({})", p.directory, p.name))
        .collect::<Vec<_>>()
        .join("\n")
}

/// Directories to exclude when copying a Chrome profile. These are large
/// non-auth directories that are not needed for reusing login state.
const PROFILE_COPY_EXCLUDE_DIRS: &[&str] = &[
    "Cache",
    "Code Cache",
    "GPUCache",
    "Service Worker",
    "blob_storage",
    "File System",
    "GCM Store",
    "optimization_guide",
    "ShaderCache",
    "component_crx_cache",
];

/// Copies a Chrome profile subdirectory and `Local State` to a temp directory
/// with a two-level structure suitable for `--user-data-dir`. Returns the temp
/// directory path on success.
///
/// The copy is best-effort: individual file failures (e.g., `SingletonLock`)
/// are skipped with a warning. If the source profile directory is missing or
/// the temp dir cannot be created, returns an error after cleaning up.
pub fn copy_chrome_profile(
    user_data_dir: &Path,
    profile_directory: &str,
) -> Result<PathBuf, String> {
    let temp_dir =
        std::env::temp_dir().join(format!("agent-browser-profile-{}", uuid::Uuid::new_v4()));
    std::fs::create_dir_all(&temp_dir)
        .map_err(|e| format!("Failed to create temp profile dir: {}", e))?;

    // Copy Local State (non-fatal if missing or unreadable)
    let local_state_src = user_data_dir.join("Local State");
    if let Err(e) = std::fs::copy(&local_state_src, temp_dir.join("Local State")) {
        let _ = writeln!(
            std::io::stderr(),
            "Warning: could not copy Local State from {}: {}",
            local_state_src.display(),
            e
        );
    }

    // Copy profile subdirectory
    let src_profile = user_data_dir.join(profile_directory);
    if !src_profile.is_dir() {
        let _ = std::fs::remove_dir_all(&temp_dir);
        return Err(format!(
            "Profile directory not found: {}",
            src_profile.display()
        ));
    }
    let dst_profile = temp_dir.join(profile_directory);
    if let Err(e) = copy_dir_recursive(&src_profile, &dst_profile) {
        let _ = std::fs::remove_dir_all(&temp_dir);
        return Err(format!("Failed to copy profile: {}", e));
    }

    Ok(temp_dir)
}

/// Recursively copies a directory, skipping entries in [`PROFILE_COPY_EXCLUDE_DIRS`].
/// Individual file copy failures are logged to stderr but do not fail the operation.
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
    std::fs::create_dir_all(dst)
        .map_err(|e| format!("Failed to create directory {}: {}", dst.display(), e))?;

    let entries = std::fs::read_dir(src)
        .map_err(|e| format!("Failed to read directory {}: {}", src.display(), e))?;

    for entry in entries {
        let entry = match entry {
            Ok(e) => e,
            Err(e) => {
                let _ = writeln!(
                    std::io::stderr(),
                    "Warning: failed to read entry in {}: {}",
                    src.display(),
                    e
                );
                continue;
            }
        };

        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        let src_path = entry.path();
        let dst_path = dst.join(&name);

        let file_type = match entry.file_type() {
            Ok(ft) => ft,
            Err(e) => {
                let _ = writeln!(
                    std::io::stderr(),
                    "Warning: failed to get file type for {}: {}",
                    src_path.display(),
                    e
                );
                continue;
            }
        };

        if file_type.is_dir() {
            if PROFILE_COPY_EXCLUDE_DIRS.contains(&name_str.as_ref()) {
                continue;
            }
            copy_dir_recursive(&src_path, &dst_path)?;
        } else if let Err(e) = std::fs::copy(&src_path, &dst_path) {
            let _ = writeln!(
                std::io::stderr(),
                "Warning: failed to copy {}: {}",
                src_path.display(),
                e
            );
        }
    }

    Ok(())
}

/// Returns true if Chrome's sandbox should be disabled because the environment
/// doesn't support it (containers, VMs, CI runners, running as root).
fn should_disable_sandbox(existing_args: &[String]) -> bool {
    if existing_args.iter().any(|a| a == "--no-sandbox") {
        return false; // already set by user
    }

    // CI environments (GitHub Actions, GitLab CI, etc.) often lack user namespace
    // support due to AppArmor or kernel restrictions.
    if std::env::var("CI").is_ok() {
        return true;
    }

    #[cfg(unix)]
    {
        // Root user -- standard container default, Chrome sandbox requires non-root
        if unsafe { libc::geteuid() } == 0 {
            return true;
        }

        // Docker container
        if Path::new("/.dockerenv").exists() {
            return true;
        }

        // Podman container
        if Path::new("/run/.containerenv").exists() {
            return true;
        }

        // Generic container detection: cgroup contains docker/kubepods/lxc
        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
            if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
                return true;
            }
        }
    }

    false
}

/// Returns true if Chrome should use disk instead of /dev/shm for shared memory.
/// On CI runners and containers, /dev/shm is often too small (64MB default),
/// which causes Chrome to crash mid-session.
fn should_disable_dev_shm(existing_args: &[String]) -> bool {
    if existing_args.iter().any(|a| a == "--disable-dev-shm-usage") {
        return false;
    }

    if std::env::var("CI").is_ok() {
        return true;
    }

    #[cfg(unix)]
    {
        if unsafe { libc::geteuid() } == 0 {
            return true;
        }
        if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
            return true;
        }
        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
            if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
                return true;
            }
        }
    }

    false
}

/// Search Puppeteer's browser cache for a Chrome binary.
/// Puppeteer v19+ stores Chrome in ~/.cache/puppeteer/chrome/<platform>-<version>/
fn find_puppeteer_chrome() -> Option<PathBuf> {
    let mut search_dirs = Vec::new();

    if let Ok(custom) = std::env::var("PUPPETEER_CACHE_DIR") {
        search_dirs.push(PathBuf::from(custom).join("chrome"));
    }

    if let Some(home) = dirs::home_dir() {
        search_dirs.push(home.join(".cache/puppeteer/chrome"));
    }

    for dir in &search_dirs {
        if !dir.is_dir() {
            continue;
        }
        if let Ok(entries) = std::fs::read_dir(dir) {
            let mut matches: Vec<PathBuf> = entries
                .filter_map(|e| e.ok())
                .filter(|e| e.path().is_dir())
                .filter_map(|e| {
                    let candidate = build_puppeteer_binary_path(&e.path());
                    if candidate.exists() {
                        Some(candidate)
                    } else {
                        None
                    }
                })
                .collect();
            matches.sort();
            matches.reverse();
            if let Some(p) = matches.into_iter().next() {
                return Some(p);
            }
        }
    }

    None
}

#[cfg(target_os = "linux")]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
    version_dir.join("chrome-linux64/chrome")
}

#[cfg(target_os = "macos")]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
    // Puppeteer uses chrome-mac-arm64 or chrome-mac-x64 depending on arch
    let arm = version_dir.join(
        "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
    );
    if arm.exists() {
        return arm;
    }
    version_dir.join(
        "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
    )
}

#[cfg(target_os = "windows")]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
    version_dir.join(r"chrome-win64\chrome.exe")
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
    version_dir.join("chrome")
}

/// Search Playwright's browser cache for a Chromium binary.
/// Legacy fallback for users who previously installed Chromium via Playwright.
fn find_playwright_chromium() -> Option<PathBuf> {
    let mut search_dirs = Vec::new();

    if let Ok(custom) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
        search_dirs.push(PathBuf::from(custom));
    }

    if let Some(home) = dirs::home_dir() {
        search_dirs.push(home.join(".cache/ms-playwright"));
    }

    for dir in &search_dirs {
        if !dir.is_dir() {
            continue;
        }
        if let Ok(entries) = std::fs::read_dir(dir) {
            let mut matches: Vec<PathBuf> = entries
                .filter_map(|e| e.ok())
                .filter(|e| {
                    e.file_name()
                        .to_str()
                        .map(|n| n.starts_with("chromium-"))
                        .unwrap_or(false)
                })
                .filter_map(|e| {
                    let candidate = build_playwright_binary_path(&e.path());
                    if candidate.exists() {
                        Some(candidate)
                    } else {
                        None
                    }
                })
                .collect();
            // Sort descending so the newest version wins
            matches.sort();
            matches.reverse();
            if let Some(p) = matches.into_iter().next() {
                return Some(p);
            }
        }
    }

    None
}

#[cfg(target_os = "linux")]
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
    chromium_dir.join("chrome-linux64/chrome")
}

#[cfg(target_os = "macos")]
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
    chromium_dir.join("chrome-mac/Chromium.app/Contents/MacOS/Chromium")
}

#[cfg(target_os = "windows")]
fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
    chromium_dir.join("chrome-win/chrome.exe")
}

fn expand_tilde(path: &str) -> String {
    if let Some(rest) = path.strip_prefix('~') {
        if let Some(home) = dirs::home_dir() {
            return home
                .join(rest.strip_prefix('/').unwrap_or(rest))
                .to_string_lossy()
                .to_string();
        }
    }
    path.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_utils::EnvGuard;

    #[test]
    fn webrtc_policy_forces_proxy_when_proxy_set() {
        let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);
        g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
        // Proxy set, no env: always force WebRTC through the proxy.
        assert_eq!(
            webrtc_ip_handling_policy(true),
            Some("disable_non_proxied_udp")
        );
        // No proxy, no env: leave WebRTC at Chrome's default.
        assert_eq!(webrtc_ip_handling_policy(false), None);
    }

    #[test]
    fn webrtc_policy_opt_in_and_opt_out() {
        let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);

        g.set("AGENT_BROWSER_BLOCK_WEBRTC", "1");
        assert_eq!(
            webrtc_ip_handling_policy(false),
            Some("default_public_interface_only")
        );

        // Explicit opt-out wins even when a proxy is set.
        g.set("AGENT_BROWSER_BLOCK_WEBRTC", "0");
        assert_eq!(webrtc_ip_handling_policy(true), None);
        assert_eq!(webrtc_ip_handling_policy(false), None);

        g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
    }

    #[cfg(unix)]
    fn spawn_noop_child() -> Child {
        Command::new("/bin/sh")
            .args(["-c", "exit 0"])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .unwrap()
    }

    #[cfg(windows)]
    fn spawn_noop_child() -> Child {
        Command::new("cmd.exe")
            .args(["/C", "exit 0"])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .unwrap()
    }

    #[test]
    fn test_find_chrome_returns_some_on_host() {
        // This test only makes sense on systems with Chrome installed
        if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
            let result = find_chrome();
            // Don't assert Some -- CI may not have Chrome
            if let Some(path) = result {
                assert!(path.exists());
            }
        }
    }

    #[test]
    fn test_expand_tilde() {
        let expanded = expand_tilde("~/test/path");
        assert!(!expanded.starts_with('~'));
        assert!(expanded.ends_with("test/path"));
    }

    #[test]
    fn test_expand_tilde_no_tilde() {
        assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
    }

    #[test]
    fn test_read_devtools_active_port_missing() {
        let result = read_devtools_active_port(Path::new("/nonexistent"));
        assert!(result.is_none());
    }

    #[test]
    fn test_should_disable_sandbox_skips_if_already_set() {
        let args = vec!["--headless=new".to_string(), "--no-sandbox".to_string()];
        assert!(!should_disable_sandbox(&args));
    }

    #[test]
    fn test_chrome_launch_error_no_stderr() {
        let msg = chrome_launch_error("Chrome exited", &[]);
        assert!(msg.contains("no stderr output"));
        assert!(msg.contains("Hint:"));
        assert!(msg.contains("--no-sandbox"));
    }

    #[test]
    fn test_chrome_launch_error_with_sandbox_hint() {
        let lines = vec![
            "some log line".to_string(),
            "Failed to move to new namespace: sandbox error".to_string(),
        ];
        let msg = chrome_launch_error("Chrome exited", &lines);
        assert!(msg.contains("sandbox error"));
        assert!(msg.contains("Hint:"));
        assert!(msg.contains("--no-sandbox"));
    }

    #[test]
    fn test_chrome_launch_error_generic() {
        let lines = vec!["info line".to_string(), "another info line".to_string()];
        let msg = chrome_launch_error("Chrome exited", &lines);
        assert!(msg.contains("last 2 lines"));
    }

    #[test]
    fn test_find_playwright_chromium_nonexistent() {
        let guard = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH", "HOME", "USERPROFILE"]);
        guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");

        let temp_home = std::env::temp_dir().join(format!(
            "agent-browser-test-home-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("system clock should be after unix epoch")
                .as_nanos()
        ));
        std::fs::create_dir_all(&temp_home).expect("temp home should be created");
        let temp_home = temp_home.to_string_lossy().to_string();
        guard.set("HOME", &temp_home);
        guard.set("USERPROFILE", &temp_home);

        let result = find_playwright_chromium();
        assert!(result.is_none());
    }

    #[test]
    fn test_build_args_forbids_headless_by_default() {
        // Stealth fork: headless is FORBIDDEN. `headless: true` is ignored — the
        // launch is always headed (no --headless / swiftshader / forced size).
        let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]);
        g.remove("AGENT_BROWSER_ALLOW_HEADLESS");
        let opts = LaunchOptions {
            headless: true,
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(
            !result.args.iter().any(|a| a.contains("--headless")),
            "headless must be forbidden even when the headless option is true"
        );
        assert!(!result
            .args
            .iter()
            .any(|a| a == "--enable-unsafe-swiftshader"));
        if let Some(dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(&dir);
        }
    }

    #[test]
    fn test_build_args_allow_headless_escape() {
        // The only way back to headless: an explicit opt-in for display-less servers.
        let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]);
        g.set("AGENT_BROWSER_ALLOW_HEADLESS", "1");
        let opts = LaunchOptions {
            headless: true,
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(result.args.iter().any(|a| a == "--headless=new"));
        assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
        if let Some(dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(&dir);
        }
    }

    #[test]
    fn test_build_args_headed_no_headless_flag() {
        let opts = LaunchOptions {
            headless: false,
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(!result.args.iter().any(|a| a.contains("--headless")));
        assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
        assert!(!result
            .args
            .iter()
            .any(|a| a == "--enable-unsafe-swiftshader"));
        assert!(!result.args.iter().any(|a| a.starts_with("--window-size=")));
        // Temp dir created when no profile
        assert!(result.temp_user_data_dir.is_some());
        let dir = result.temp_user_data_dir.unwrap();
        assert!(dir.exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_build_args_temp_user_data_dir_created() {
        let opts = LaunchOptions::default();
        let result = build_chrome_args(&opts).unwrap();
        let dir = result.temp_user_data_dir.as_ref().unwrap();
        assert!(dir.exists());
        assert!(result
            .args
            .iter()
            .any(|a| a.starts_with("--user-data-dir=")));
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn test_build_args_profile_no_temp_dir() {
        let opts = LaunchOptions {
            profile: Some("/tmp/my-profile".to_string()),
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(result.temp_user_data_dir.is_none());
        assert!(result
            .args
            .iter()
            .any(|a| a == "--user-data-dir=/tmp/my-profile"));
    }

    #[test]
    fn test_build_args_custom_window_size_not_overridden() {
        let opts = LaunchOptions {
            headless: true,
            args: vec!["--window-size=1920,1080".to_string()],
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
        assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() {
        let opts = LaunchOptions {
            headless: true,
            hide_scrollbars: false,
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(
            !result.args.iter().any(|a| a == "--hide-scrollbars"),
            "--hide-scrollbars false should suppress agent-browser's default hide switch"
        );
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_start_maximized_suppresses_default_window_size() {
        let opts = LaunchOptions {
            headless: true,
            args: vec!["--start-maximized".to_string()],
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
        assert!(result.args.iter().any(|a| a == "--start-maximized"));
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_disables_translate() {
        let opts = LaunchOptions::default();
        let result = build_chrome_args(&opts).unwrap();
        assert!(result
            .args
            .iter()
            .any(|a| a.contains("--disable-features") && a.contains("Translate")));
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_headless_with_extensions_skips_headless_flag() {
        let opts = LaunchOptions {
            headless: true,
            extensions: Some(vec!["/tmp/my-ext".to_string()]),
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(
            !result.args.iter().any(|a| a.contains("--headless")),
            "headless flag should be omitted when extensions are present"
        );
        assert!(
            !result.args.iter().any(|a| a == "--hide-scrollbars"),
            "scrollbars should remain visible when extensions force headed mode"
        );
        assert!(
            !result.args.iter().any(|a| a.contains("--window-size")),
            "window-size should be omitted when extensions force headed mode"
        );
        assert!(result
            .args
            .iter()
            .any(|a| a.starts_with("--load-extension=")));
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_headed_with_extensions_no_headless_flag() {
        let opts = LaunchOptions {
            headless: false,
            extensions: Some(vec!["/tmp/my-ext".to_string()]),
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(
            !result.args.iter().any(|a| a.contains("--headless")),
            "headless flag should not be present in headed mode"
        );
        assert!(result
            .args
            .iter()
            .any(|a| a.starts_with("--load-extension=")));
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_ignore_https_errors_includes_flag() {
        let opts = LaunchOptions {
            ignore_https_errors: true,
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(result
            .args
            .iter()
            .any(|a| a == "--ignore-certificate-errors"));
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_ignore_https_errors_default_no_flag() {
        let opts = LaunchOptions::default();
        let result = build_chrome_args(&opts).unwrap();
        assert!(!result
            .args
            .iter()
            .any(|a| a == "--ignore-certificate-errors"));
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_chrome_process_drop_cleans_temp_dir() {
        let dir = std::env::temp_dir().join(format!(
            "agent-browser-chrome-drop-test-{}",
            uuid::Uuid::new_v4()
        ));
        let _ = std::fs::create_dir_all(&dir);
        assert!(dir.exists());

        {
            // Simulate a ChromeProcess with a temp dir but a dummy child.
            // We can't actually spawn Chrome here, but we can verify the Drop
            // logic by creating a small helper process.
            let child = spawn_noop_child();
            let _process = ChromeProcess {
                child,
                ws_url: String::new(),
                temp_user_data_dir: Some(dir.clone()),
                #[cfg(unix)]
                pgid: None,
            };
            // _process dropped here
        }

        assert!(!dir.exists(), "Temp dir should be cleaned up on drop");
    }

    #[test]
    fn test_is_chrome_profile_name_simple() {
        assert!(is_chrome_profile_name("Default"));
        assert!(is_chrome_profile_name("Profile 1"));
        assert!(is_chrome_profile_name(""));
    }

    #[test]
    fn test_is_chrome_profile_name_paths() {
        assert!(!is_chrome_profile_name("/tmp/dir"));
        assert!(!is_chrome_profile_name("~/my-profile"));
        assert!(!is_chrome_profile_name("C:\\Users\\foo"));
        assert!(!is_chrome_profile_name("relative/path"));
    }

    #[test]
    fn test_resolve_chrome_profile_auto_prefers_last_used() {
        let tmp = std::env::temp_dir().join("ab-auto-lastused-test");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        let local_state = serde_json::json!({
            "profile": {
                "last_used": "Profile 2",
                "info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} }
            }
        });
        std::fs::write(
            tmp.join("Local State"),
            serde_json::to_string(&local_state).unwrap(),
        )
        .unwrap();
        assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Profile 2");
        assert_eq!(resolve_chrome_profile(&tmp, "AUTO").unwrap(), "Profile 2");
        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn test_resolve_chrome_profile_auto_falls_back_to_default() {
        let tmp = std::env::temp_dir().join("ab-auto-default-test");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        let local_state = serde_json::json!({
            "profile": { "info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} } }
        });
        std::fs::write(
            tmp.join("Local State"),
            serde_json::to_string(&local_state).unwrap(),
        )
        .unwrap();
        assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Default");
        let _ = std::fs::remove_dir_all(&tmp);
    }

    /// Helper to create a fake Chrome user-data dir with a `Local State` file.
    fn create_fake_local_state(base: &Path, profiles: &[(&str, &str)]) {
        let mut info_cache = serde_json::Map::new();
        for (dir_name, display_name) in profiles {
            let mut entry = serde_json::Map::new();
            entry.insert(
                "name".to_string(),
                serde_json::Value::String(display_name.to_string()),
            );
            info_cache.insert(dir_name.to_string(), serde_json::Value::Object(entry));
        }

        let local_state = serde_json::json!({
            "profile": {
                "info_cache": info_cache
            }
        });

        std::fs::create_dir_all(base).unwrap();
        std::fs::write(
            base.join("Local State"),
            serde_json::to_string_pretty(&local_state).unwrap(),
        )
        .unwrap();
    }

    /// RAII guard that removes the temp directory on drop (even on panic).
    struct TempDir(PathBuf);

    impl TempDir {
        fn new(name: &str) -> Self {
            Self(std::env::temp_dir().join(format!(
                "agent-browser-test-{}-{}-{}",
                name,
                std::process::id(),
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap()
                    .as_nanos()
            )))
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    impl std::ops::Deref for TempDir {
        type Target = PathBuf;
        fn deref(&self) -> &PathBuf {
            &self.0
        }
    }

    #[test]
    fn test_list_chrome_profiles_valid() {
        let dir = TempDir::new("list-profiles");
        create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]);

        let profiles = list_chrome_profiles(&dir);
        assert_eq!(profiles.len(), 2);
        assert_eq!(profiles[0].directory, "Default");
        assert_eq!(profiles[0].name, "Person 1");
        assert_eq!(profiles[1].directory, "Profile 1");
        assert_eq!(profiles[1].name, "Work");
    }

    #[test]
    fn test_list_chrome_profiles_missing_local_state() {
        let dir = TempDir::new("list-profiles-missing");
        std::fs::create_dir_all(&*dir).unwrap();
        let profiles = list_chrome_profiles(&dir);
        assert!(profiles.is_empty());
    }

    #[test]
    fn test_list_chrome_profiles_malformed_json() {
        let dir = TempDir::new("list-profiles-malformed");
        std::fs::create_dir_all(&*dir).unwrap();
        std::fs::write(dir.join("Local State"), "not json").unwrap();
        let profiles = list_chrome_profiles(&dir);
        assert!(profiles.is_empty());
    }

    #[test]
    fn test_list_chrome_profiles_missing_info_cache() {
        let dir = TempDir::new("list-profiles-no-cache");
        std::fs::create_dir_all(&*dir).unwrap();
        std::fs::write(dir.join("Local State"), r#"{"profile": {}}"#).unwrap();
        let profiles = list_chrome_profiles(&dir);
        assert!(profiles.is_empty());
    }

    #[test]
    fn test_resolve_chrome_profile_exact_directory() {
        let dir = TempDir::new("resolve-exact");
        create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]);

        let result = resolve_chrome_profile(&dir, "Default");
        assert_eq!(result.unwrap(), "Default");
    }

    #[test]
    fn test_resolve_chrome_profile_display_name_case_insensitive() {
        let dir = TempDir::new("resolve-display");
        create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]);

        let result = resolve_chrome_profile(&dir, "work");
        assert_eq!(result.unwrap(), "Profile 1");
    }

    #[test]
    fn test_resolve_chrome_profile_directory_name_case_insensitive() {
        let dir = TempDir::new("resolve-dir-ci");
        create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]);

        let result = resolve_chrome_profile(&dir, "default");
        assert_eq!(result.unwrap(), "Default");
    }

    #[test]
    fn test_resolve_chrome_profile_not_found() {
        let dir = TempDir::new("resolve-notfound");
        create_fake_local_state(&dir, &[("Default", "Person 1")]);

        let result = resolve_chrome_profile(&dir, "Nonexistent");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.contains("not found"));
        assert!(err.contains("Default"));
        assert!(err.contains("full path"));
    }

    #[test]
    fn test_resolve_chrome_profile_ambiguous_display_name() {
        let dir = TempDir::new("resolve-ambiguous");
        create_fake_local_state(&dir, &[("Default", "Work"), ("Profile 1", "Work")]);

        let result = resolve_chrome_profile(&dir, "Work");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.contains("Ambiguous"));
        assert!(err.contains("Default"));
        assert!(err.contains("Profile 1"));
    }

    /// Helper to create a fake Chrome profile directory with some files.
    fn create_fake_profile(user_data_dir: &Path, profile_dir: &str) {
        let profile_path = user_data_dir.join(profile_dir);
        std::fs::create_dir_all(profile_path.join("Local Storage/leveldb")).unwrap();
        std::fs::write(profile_path.join("Cookies"), "fake-cookies").unwrap();
        std::fs::write(
            profile_path.join("Local Storage/leveldb/CURRENT"),
            "fake-leveldb",
        )
        .unwrap();
        // Create an excluded directory to verify it's skipped
        std::fs::create_dir_all(profile_path.join("Cache")).unwrap();
        std::fs::write(profile_path.join("Cache/data_0"), "cache-data").unwrap();
    }

    #[test]
    fn test_copy_chrome_profile_structure() {
        let src = TempDir::new("copy-src");
        create_fake_local_state(&src, &[("Default", "Person 1")]);
        create_fake_profile(&src, "Default");

        let temp_path = copy_chrome_profile(&src, "Default").unwrap();
        let temp = TempDir(temp_path);

        assert!(temp.join("Local State").is_file());
        assert!(temp.join("Default/Cookies").is_file());
        assert!(temp.join("Default/Local Storage/leveldb/CURRENT").is_file());
        assert_eq!(
            std::fs::read_to_string(temp.join("Default/Cookies")).unwrap(),
            "fake-cookies"
        );
        assert_eq!(
            std::fs::read_to_string(temp.join("Default/Local Storage/leveldb/CURRENT")).unwrap(),
            "fake-leveldb"
        );
        assert!(!temp.join("Default/Cache").exists());
    }

    #[test]
    fn test_copy_chrome_profile_missing_source() {
        let src = TempDir::new("copy-missing-src");
        std::fs::create_dir_all(&*src).unwrap();

        let result = copy_chrome_profile(&src, "Nonexistent");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Profile directory not found"));
    }

    #[test]
    fn test_copy_chrome_profile_missing_local_state() {
        let src = TempDir::new("copy-no-ls");
        let profile_path = src.join("Default");
        std::fs::create_dir_all(&profile_path).unwrap();
        std::fs::write(profile_path.join("Cookies"), "data").unwrap();

        let temp_path = copy_chrome_profile(&src, "Default").unwrap();
        let temp = TempDir(temp_path);

        assert!(!temp.join("Local State").exists());
        assert!(temp.join("Default/Cookies").is_file());
    }

    #[test]
    fn test_copy_dir_recursive_excludes() {
        let src = TempDir::new("copy-excludes-src");
        let dst = TempDir::new("copy-excludes-dst");
        std::fs::create_dir_all(src.join("keep")).unwrap();
        std::fs::write(src.join("keep/data"), "keep-data").unwrap();
        for excluded in PROFILE_COPY_EXCLUDE_DIRS {
            std::fs::create_dir_all(src.join(excluded)).unwrap();
            std::fs::write(src.join(excluded).join("file"), "excluded").unwrap();
        }

        copy_dir_recursive(&src, &dst).unwrap();

        assert!(dst.join("keep/data").is_file());
        for excluded in PROFILE_COPY_EXCLUDE_DIRS {
            assert!(
                !dst.join(excluded).exists(),
                "{} should be excluded",
                excluded
            );
        }
    }

    #[test]
    fn test_build_args_use_real_keychain_true() {
        let opts = LaunchOptions {
            use_real_keychain: true,
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(
            !result.args.iter().any(|a| a == "--password-store=basic"),
            "should NOT have --password-store=basic when use_real_keychain is true"
        );
        assert!(
            !result.args.iter().any(|a| a == "--use-mock-keychain"),
            "should NOT have --use-mock-keychain when use_real_keychain is true"
        );
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_use_real_keychain_false_default() {
        let opts = LaunchOptions::default();
        let result = build_chrome_args(&opts).unwrap();
        assert!(
            result.args.iter().any(|a| a == "--password-store=basic"),
            "should have --password-store=basic by default"
        );
        assert!(
            result.args.iter().any(|a| a == "--use-mock-keychain"),
            "should have --use-mock-keychain by default"
        );
        if let Some(ref dir) = result.temp_user_data_dir {
            let _ = std::fs::remove_dir_all(dir);
        }
    }

    #[test]
    fn test_build_args_profile_path_preserves_keychain_flags() {
        let opts = LaunchOptions {
            profile: Some("/tmp/my-profile".to_string()),
            ..Default::default()
        };
        let result = build_chrome_args(&opts).unwrap();
        assert!(result
            .args
            .iter()
            .any(|a| a == "--user-data-dir=/tmp/my-profile"));
        assert!(
            result.args.iter().any(|a| a == "--password-store=basic"),
            "profile path should keep keychain flags"
        );
    }

    // -------------------------------------------------------------------
    // auto_connect_cdp discovery-order tests (#1210, #1206)
    // -------------------------------------------------------------------

    /// When the port is live, `resolve_cdp_from_active_port` returns the exact
    /// DevToolsActivePort ws_path URL via a consent-free TCP check — it does NOT
    /// probe with a verification WebSocket (which would burn Chrome 136+'s
    /// per-connection remote-debugging consent on a throwaway socket).
    #[tokio::test]
    async fn test_resolve_cdp_from_active_port_returns_ws_path_without_probe() {
        // A bound listener makes the port TCP-reachable. We do NOT accept/serve
        // any WebSocket — resolve must succeed from the bare TCP check alone.
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let ws_path = "/devtools/browser/test-uuid-1234";

        let result = resolve_cdp_from_active_port(port, ws_path).await;
        assert!(
            result.is_ok(),
            "should succeed when port is live: {:?}",
            result
        );
        assert_eq!(
            result.unwrap(),
            format!("ws://127.0.0.1:{}{}", port, ws_path),
            "should return the exact DevToolsActivePort URL untouched"
        );
        drop(listener);
    }

    /// Regression guard for the consent storm: resolving the URL must only do a
    /// bare TCP connect, never a WebSocket/CDP handshake. On Chrome 136+ a
    /// handshake on a throwaway socket consumes the user's one "Allow remote
    /// debugging?" click, leaving the real connection unconsented (endless
    /// prompts / hang).
    #[tokio::test]
    async fn test_resolve_cdp_from_active_port_does_not_open_websocket() {
        use tokio::io::AsyncReadExt;

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();

        let server = tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.unwrap();
            // The liveness check connects then drops without writing anything.
            // Assert we receive no WebSocket upgrade bytes (EOF / no data).
            let mut buf = [0u8; 128];
            let read =
                tokio::time::timeout(Duration::from_millis(500), stream.read(&mut buf)).await;
            match read {
                Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
                Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
            }
        });

        let result = resolve_cdp_from_active_port(port, "/devtools/browser/abc").await;
        assert_eq!(
            result.unwrap(),
            format!("ws://127.0.0.1:{}/devtools/browser/abc", port)
        );
        server.await.unwrap();
    }

    /// When neither ws_path nor HTTP discovery works, return an error.
    #[tokio::test]
    async fn test_resolve_cdp_from_active_port_both_fail() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener);

        let result = resolve_cdp_from_active_port(port, "/devtools/browser/dead").await;
        assert!(result.is_err(), "should fail when nothing is listening");
    }

    #[tokio::test]
    async fn test_relay_is_live_true_when_listening() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let url = format!("ws://127.0.0.1:{}/abc-guid", port);
        assert!(
            relay_is_live(&url).await,
            "relay_is_live should be true while the port is accepting"
        );
    }

    #[tokio::test]
    async fn test_relay_is_live_false_when_dead() {
        // Bind to grab a free port, then drop so nothing is listening.
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener);
        let url = format!("ws://127.0.0.1:{}/abc-guid", port);
        assert!(
            !relay_is_live(&url).await,
            "relay_is_live must be false for a stale relay-cdp-url (host exited)"
        );
    }

    #[tokio::test]
    async fn test_relay_is_live_false_on_malformed_url() {
        assert!(!relay_is_live("not-a-ws-url").await);
        assert!(!relay_is_live("ws://127.0.0.1/no-port").await);
        assert!(!relay_is_live("ws://127.0.0.1:notaport/x").await);
    }
}
