use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use tokio::sync::{broadcast, oneshot, RwLock};

use crate::connection::{get_restore_url_path, get_socket_dir};

use super::auth;
use super::browser::{should_track_target, BrowserManager, WaitUntil};
use super::cdp::chrome::LaunchOptions;
use super::cdp::client::CdpClient;
use super::cdp::types::{
    AttachToTargetParams, AttachToTargetResult, CdpEvent, CreateTargetResult,
    DispatchMouseEventParams, ExceptionThrownEvent, JavascriptDialogOpeningEvent,
    TargetCreatedEvent, TargetDestroyedEvent, TargetInfoChangedEvent,
};
use super::cookies;
use super::diff;
use super::element::RefMap;
use super::humanize;
use super::inspect_server::InspectServer;
use super::interaction;
use super::network::{self, DomainFilter, EventTracker};
use super::policy::{ActionPolicy, ConfirmActions, PolicyResult};
use super::providers;
use super::react;
use super::recording::{self, RecordingState};
use super::screenshot::{self, ScreenshotOptions};
use super::snapshot::{self, SnapshotOptions};
use super::state;
use super::stealth;
use super::storage;
use super::stream::{self, StreamServer};
use super::tracing::{self as native_tracing, TracingState};
use super::webdriver::appium::AppiumManager;
use super::webdriver::backend::{BrowserBackend, WebDriverBackend, WEBDRIVER_UNSUPPORTED_ACTIONS};
use super::webdriver::ios;
use super::webdriver::safari;

/// Wait strategy used by `auth_login` when navigating to the login page.
///
/// We intentionally use `Load` (instead of `NetworkIdle`) because many modern
/// apps keep background requests active indefinitely (polling, analytics,
/// websockets), which can prevent network-idle from ever resolving.
///
/// After navigation completes, `auth_login` explicitly waits for form selectors
/// to appear before filling/clicking.
pub const AUTH_LOGIN_WAIT_UNTIL: WaitUntil = WaitUntil::Load;

/// Poll interval used while waiting for auth form selectors to appear.
const AUTH_LOGIN_SELECTOR_POLL_INTERVAL_MS: u64 = 100;

/// Time spent trying targeted username selectors before broad text-input
/// fallback selectors are allowed.
const AUTH_LOGIN_PREFERRED_SELECTOR_WINDOW_MS: u64 = 5_000;

pub struct PendingConfirmation {
    pub action: String,
    pub cmd: Value,
}

/// Captured request/response metadata used to export HAR 1.2 files.
pub struct HarEntry {
    pub request_id: String,
    /// Seconds since Unix epoch (CDP `wallTime`), with sub-second precision.
    pub wall_time: f64,
    // Request fields
    pub method: String,
    pub url: String,
    pub request_headers: Vec<(String, String)>,
    pub post_data: Option<String>,
    pub request_body_size: i64,
    pub resource_type: String,
    // Response fields — populated by `Network.responseReceived`
    pub status: Option<i64>,
    pub status_text: String,
    /// Normalised from CDP `response.protocol` (e.g. `"h2"` → `"HTTP/2.0"`).
    pub http_version: String,
    pub response_headers: Vec<(String, String)>,
    pub mime_type: String,
    pub redirect_url: String,
    /// Updated by `Network.loadingFinished` for final accuracy.
    pub response_body_size: i64,
    /// Raw CDP `ResourceTiming` object from `Network.responseReceived`.
    pub cdp_timing: Option<Value>,
    /// Monotonic timestamp (seconds) from `Network.loadingFinished`; used to
    /// compute the `receive` timing phase.
    pub loading_finished_timestamp: Option<f64>,
}

pub struct RouteEntry {
    pub url_pattern: String,
    pub response: Option<RouteResponse>,
    pub abort: bool,
    /// When non-empty, only requests whose `resourceType` (as reported by
    /// CDP Fetch.requestPaused) is in this list are matched. Values are
    /// compared case-insensitively. Empty means "match any resource type".
    pub resource_types: Vec<String>,
}

pub struct RouteResponse {
    pub status: Option<u16>,
    pub body: Option<String>,
    pub content_type: Option<String>,
    pub headers: Option<HashMap<String, String>>,
}

#[derive(Clone, serde::Serialize)]
pub struct TrackedRequest {
    pub url: String,
    pub method: String,
    pub headers: Value,
    pub timestamp: u64,
    #[serde(rename = "resourceType")]
    pub resource_type: String,
    #[serde(rename = "requestId")]
    pub request_id: String,
    #[serde(rename = "postData", skip_serializing_if = "Option::is_none")]
    pub post_data: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<i64>,
    #[serde(rename = "responseHeaders", skip_serializing_if = "Option::is_none")]
    pub response_headers: Option<Value>,
    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
    pub mime_type: Option<String>,
}

pub struct FetchPausedRequest {
    pub request_id: String,
    pub url: String,
    pub resource_type: String,
    pub session_id: String,
    /// Original request headers from the Fetch.requestPaused event, needed
    /// because Fetch.continueRequest replaces (not merges) headers.
    pub request_headers: Option<serde_json::Map<String, Value>>,
}

pub enum BackendType {
    Cdp,
    WebDriver,
}

#[derive(Debug, Clone, Default)]
pub struct PendingDialog {
    pub dialog_type: String,
    pub message: String,
    pub url: String,
    pub default_prompt: Option<String>,
}

#[derive(Debug, Clone, Copy, Default)]
pub struct MouseState {
    pub x: f64,
    pub y: f64,
    pub buttons: i32,
}

#[derive(Default)]
struct DrainedEvents {
    pending_acks: Vec<i64>,
    new_targets: Vec<TargetCreatedEvent>,
    changed_targets: Vec<TargetInfoChangedEvent>,
    destroyed_targets: Vec<String>,
    /// Cross-origin iframe (frame_id, session_id) pairs from Target.attachedToTarget.
    attached_iframe_sessions: Vec<(String, String)>,
    /// Session IDs from Target.detachedFromTarget.
    detached_iframe_sessions: Vec<String>,
}

/// Compute a hash of the [`LaunchOptions`] fields that require a browser
/// relaunch when changed (baked into the Chrome process at startup).
///
/// Fields NOT hashed:
/// ignore_https_errors, color_scheme, download_path
///
/// `storage_state` is handled separately in `handle_launch()`: explicit
/// `storageState` launches always require a clean local browser so the loaded
/// state replaces the prior session instead of merging into it.
fn launch_hash(opts: &LaunchOptions) -> u64 {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let mut h = DefaultHasher::new();
    opts.headless.hash(&mut h);
    opts.extensions.hash(&mut h);
    opts.profile.hash(&mut h);
    opts.executable_path.hash(&mut h);
    opts.args.hash(&mut h);
    opts.proxy.hash(&mut h);
    opts.proxy_bypass.hash(&mut h);
    opts.proxy_username.hash(&mut h);
    opts.proxy_password.hash(&mut h);
    opts.user_agent.hash(&mut h);
    opts.allow_file_access.hash(&mut h);
    opts.hide_scrollbars.hash(&mut h);
    h.finish()
}

pub struct DaemonState {
    pub browser: Option<BrowserManager>,
    pub appium: Option<AppiumManager>,
    pub safari_driver: Option<safari::SafariDriverProcess>,
    pub webdriver_backend: Option<super::webdriver::backend::WebDriverBackend>,
    pub backend_type: BackendType,
    pub ref_map: RefMap,
    pub domain_filter: Arc<RwLock<Option<DomainFilter>>>,
    pub event_tracker: EventTracker,
    pub session_name: Option<String>,
    pub session_id: String,
    pub tracing_state: TracingState,
    pub recording_state: RecordingState,
    event_rx: Option<broadcast::Receiver<CdpEvent>>,
    pub screencasting: bool,
    pub policy: Option<ActionPolicy>,
    pub pending_confirmation: Option<PendingConfirmation>,
    pub har_recording: bool,
    pub har_entries: Vec<HarEntry>,
    pub confirm_actions: Option<ConfirmActions>,
    pub inspect_server: Option<InspectServer>,
    pub routes: Arc<RwLock<Vec<RouteEntry>>>,
    pub tracked_requests: Vec<TrackedRequest>,
    pub request_tracking: bool,
    pub active_frame_id: Option<String>,
    /// Cross-origin iframe frame_id → dedicated CDP session_id.
    /// Populated by Target.attachedToTarget events from Target.setAutoAttach.
    pub iframe_sessions: HashMap<String, String>,
    /// Origin-scoped extra HTTP headers set via `--headers` on navigate.
    /// Key is the origin (scheme + host + port), value is the headers map.
    /// Wrapped in Arc<RwLock<>> so the background Fetch handler can read it.
    pub origin_headers: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
    /// Proxy authentication credentials (username, password) for handling
    /// Fetch.authRequired events from authenticated proxies.
    pub proxy_credentials: Arc<RwLock<Option<(String, String)>>>,
    /// Background task that processes Fetch.requestPaused events in real-time,
    /// handling domain filtering, route interception, and origin-scoped headers
    /// without deadlocking navigation/evaluate.
    fetch_handler_task: Option<tokio::task::JoinHandle<()>>,
    /// Background task that auto-accepts `alert` and `beforeunload` dialogs
    /// so they never block the agent.
    dialog_handler_task: Option<tokio::task::JoinHandle<()>>,
    pub mouse_state: MouseState,
    /// Tracks the currently open JavaScript dialog (alert/confirm/prompt), if any.
    pub pending_dialog: Option<PendingDialog>,
    /// When true, automatically dismiss `beforeunload` dialogs and accept `alert`
    /// dialogs so they never block the agent.  Enabled by default.
    pub auto_dialog: bool,
    /// Shared slot for stream server to receive CDP client when browser launches.
    pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
    /// Stream server instance kept alive so the broadcast channel remains open.
    pub stream_server: Option<Arc<StreamServer>>,
    /// Hash of launch options used for the current browser, for relaunch detection.
    launch_hash: Option<u64>,
    /// Browser engine name (e.g. "chrome", "lightpanda") for observability.
    pub engine: String,
    /// Default timeout for wait operations, from AGENT_BROWSER_DEFAULT_TIMEOUT env var.
    pub default_timeout_ms: u64,
    /// Last viewport settings (width, height, deviceScaleFactor, mobile),
    /// re-applied to new contexts (e.g., recording).
    pub viewport: Option<(i32, i32, f64, bool)>,
}

impl DaemonState {
    pub fn new() -> Self {
        Self {
            browser: None,
            appium: None,
            safari_driver: None,
            webdriver_backend: None,
            backend_type: BackendType::Cdp,
            ref_map: RefMap::new(),
            domain_filter: Arc::new(RwLock::new(
                env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
                    .ok()
                    .filter(|s| !s.is_empty())
                    .map(|s| DomainFilter::new(&s)),
            )),
            event_tracker: EventTracker::new(),
            session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(),
            session_id: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
            tracing_state: TracingState::new(),
            recording_state: RecordingState::new(),
            event_rx: None,
            screencasting: false,
            policy: ActionPolicy::load_if_exists(),
            pending_confirmation: None,
            har_recording: false,
            har_entries: Vec::new(),
            confirm_actions: ConfirmActions::from_env(),
            inspect_server: None,
            routes: Arc::new(RwLock::new(Vec::new())),
            tracked_requests: Vec::new(),
            request_tracking: false,
            active_frame_id: None,
            iframe_sessions: HashMap::new(),
            origin_headers: Arc::new(RwLock::new(HashMap::new())),
            proxy_credentials: Arc::new(RwLock::new(None)),
            fetch_handler_task: None,
            dialog_handler_task: None,
            mouse_state: MouseState::default(),
            pending_dialog: None,
            auto_dialog: !matches!(
                env::var("AGENT_BROWSER_NO_AUTO_DIALOG").as_deref(),
                Ok("1" | "true" | "yes")
            ),
            stream_client: None,
            stream_server: None,
            launch_hash: None,
            engine: env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()),
            default_timeout_ms: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT")
                .ok()
                .and_then(|s| s.parse::<u64>().ok())
                .unwrap_or(30_000),
            viewport: None,
        }
    }

    /// Extract the timeout from a command JSON, falling back to the
    /// configured `default_timeout_ms` (from `AGENT_BROWSER_DEFAULT_TIMEOUT`).
    /// All wait-family handlers should use this instead of reading the
    /// timeout field and providing their own fallback.
    fn timeout_ms(&self, cmd: &Value) -> u64 {
        cmd.get("timeout")
            .and_then(|v| v.as_u64())
            .unwrap_or(self.default_timeout_ms)
    }

    fn reset_input_state(&mut self) {
        self.mouse_state = MouseState::default();
    }

    /// Create state with an optional stream client slot and server instance
    /// (for daemon startup with stream server).
    pub fn new_with_stream(
        stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
        stream_server: Option<Arc<StreamServer>>,
    ) -> Self {
        let mut s = Self::new();
        if stream_server.is_some() {
            s.request_tracking = true;
        }
        s.stream_client = stream_client;
        s.stream_server = stream_server;
        s
    }

    fn subscribe_to_browser_events(&mut self) {
        if let Some(ref browser) = self.browser {
            self.event_rx = Some(browser.client.subscribe());
        }
    }

    /// Start the background task that processes Fetch.requestPaused and
    /// Fetch.authRequired events in real-time (domain filtering, route
    /// interception, origin-scoped headers, proxy authentication).
    /// Must be called after the browser is set and events are subscribed.
    fn start_fetch_handler(&mut self) {
        // Abort any existing handler.
        if let Some(task) = self.fetch_handler_task.take() {
            task.abort();
        }

        let Some(ref browser) = self.browser else {
            return;
        };

        let client = browser.client.clone();
        let mut rx = browser.client.subscribe();
        let domain_filter = self.domain_filter.clone();
        let routes = self.routes.clone();
        let origin_headers = self.origin_headers.clone();
        let proxy_credentials = self.proxy_credentials.clone();

        self.fetch_handler_task = Some(tokio::spawn(async move {
            loop {
                match rx.recv().await {
                    Ok(event) if event.method == "Fetch.authRequired" => {
                        let request_id = event
                            .params
                            .get("requestId")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let sid = event.session_id.clone().unwrap_or_default();
                        let creds = proxy_credentials.read().await;
                        if let Some((ref user, ref pass)) = *creds {
                            let _ = client
                                .send_command(
                                    "Fetch.continueWithAuth",
                                    Some(json!({
                                        "requestId": request_id,
                                        "authChallengeResponse": {
                                            "response": "ProvideCredentials",
                                            "username": user,
                                            "password": pass,
                                        }
                                    })),
                                    Some(&sid),
                                )
                                .await;
                        } else {
                            let _ = client
                                .send_command(
                                    "Fetch.continueWithAuth",
                                    Some(json!({
                                        "requestId": request_id,
                                        "authChallengeResponse": {
                                            "response": "CancelAuth",
                                        }
                                    })),
                                    Some(&sid),
                                )
                                .await;
                        }
                    }
                    Ok(event) if event.method == "Fetch.requestPaused" => {
                        let request_id = event
                            .params
                            .get("requestId")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let request_url = event
                            .params
                            .get("request")
                            .and_then(|r| r.get("url"))
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let resource_type = event
                            .params
                            .get("resourceType")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let request_headers = event
                            .params
                            .get("request")
                            .and_then(|r| r.get("headers"))
                            .and_then(|h| h.as_object())
                            .cloned();
                        let sid = event.session_id.clone().unwrap_or_default();

                        let paused = FetchPausedRequest {
                            request_id,
                            url: request_url,
                            resource_type,
                            session_id: sid,
                            request_headers,
                        };

                        let df = domain_filter.read().await;
                        let rt = routes.read().await;
                        let oh = origin_headers.read().await;

                        resolve_fetch_paused(&client, df.as_ref(), &rt, &oh, &paused).await;
                    }
                    Ok(_) => continue,
                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(_) => break,
                }
            }
        }));
    }

    /// Start the background task that auto-accepts `alert` and `beforeunload`
    /// dialogs so they never block the agent. `confirm` and `prompt` dialogs
    /// are left for the agent to handle explicitly.
    fn start_dialog_handler(&mut self) {
        if let Some(task) = self.dialog_handler_task.take() {
            task.abort();
        }

        if !self.auto_dialog {
            return;
        }

        let Some(ref browser) = self.browser else {
            return;
        };

        let client = browser.client.clone();
        let mut rx = browser.client.subscribe();

        self.dialog_handler_task = Some(tokio::spawn(async move {
            loop {
                match rx.recv().await {
                    Ok(event) if event.method == "Page.javascriptDialogOpening" => {
                        let dialog_type = event
                            .params
                            .get("type")
                            .and_then(|v| v.as_str())
                            .unwrap_or("");
                        if matches!(dialog_type, "beforeunload" | "alert") {
                            let message = event
                                .params
                                .get("message")
                                .and_then(|v| v.as_str())
                                .unwrap_or("");
                            eprintln!("[auto-dismiss] {} dialog: {}", dialog_type, message);
                            let sid = event.session_id.clone().unwrap_or_default();
                            if let Err(e) = client
                                .send_command(
                                    "Page.handleJavaScriptDialog",
                                    Some(json!({ "accept": true })),
                                    Some(&sid),
                                )
                                .await
                            {
                                eprintln!(
                                    "[auto-dismiss] failed to dismiss {} dialog: {}",
                                    dialog_type, e
                                );
                            }
                        }
                    }
                    Ok(_) => continue,
                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(_) => break,
                }
            }
        }));
    }

    /// Update the stream server's CDP client slot when browser is set or cleared.
    pub async fn update_stream_client(&self) {
        if let Some(ref slot) = self.stream_client {
            let mut guard = slot.write().await;
            *guard = self.browser.as_ref().map(|m| Arc::clone(&m.client));
        }
        if let Some(ref server) = self.stream_server {
            // Update the CDP page session ID so screencast commands target the right page
            let session_id = self
                .browser
                .as_ref()
                .and_then(|m| m.active_session_id().ok().map(|s| s.to_string()));
            server.set_cdp_session_id(session_id).await;

            // Broadcast connection status change to WebSocket clients
            let connected = self.browser.is_some();
            let sc = server.is_screencasting().await;
            let (vw, vh) = server.viewport().await;
            server
                .broadcast_status(connected, sc, vw, vh, &self.engine)
                .await;
            if let Some(ref mgr) = self.browser {
                server.broadcast_tabs(&mgr.tab_list()).await;
            } else {
                server.broadcast_tabs(&[]).await;
            }
            // Notify the background CDP event loop that the client changed
            server.notify_client_changed();
        }
    }

    /// Spawn a background task that polls screenshots and pipes them to ffmpeg.
    async fn start_recording_task(
        &mut self,
        client: Arc<CdpClient>,
        session_id: String,
    ) -> Result<(), String> {
        let shared_count = Arc::new(AtomicU64::new(0));
        let (cancel_tx, cancel_rx) = oneshot::channel();
        let handle = recording::spawn_recording_task(
            client,
            session_id,
            self.recording_state.output_path.clone(),
            shared_count.clone(),
            cancel_rx,
        );
        self.recording_state.capture_task = Some(handle);
        self.recording_state.shared_frame_count = Some(shared_count);
        self.recording_state.cancel_tx = Some(cancel_tx);
        Ok(())
    }

    async fn stop_recording_task(&mut self) -> Result<(), String> {
        recording::stop_recording_task(&mut self.recording_state).await
    }

    pub async fn drain_cdp_events_background(&mut self) {
        let drained = self.drain_cdp_events();
        self.apply_drained_events(drained).await;
    }

    async fn apply_drained_events(&mut self, drained: DrainedEvents) {
        // ACK screencast frames
        if !drained.pending_acks.is_empty() {
            if let Some(ref browser) = self.browser {
                if let Ok(session_id) = browser.active_session_id() {
                    for ack_sid in drained.pending_acks {
                        let _ = stream::ack_screencast_frame(&browser.client, session_id, ack_sid)
                            .await;
                    }
                }
            }
        }

        // Remove destroyed targets
        for target_id in &drained.destroyed_targets {
            if let Some(ref mut mgr) = self.browser {
                mgr.remove_page_by_target_id(target_id);
            }
        }

        // Track cross-origin iframe sessions
        for (frame_id, iframe_sid) in &drained.attached_iframe_sessions {
            self.iframe_sessions
                .insert(frame_id.clone(), iframe_sid.clone());
            if let Some(ref mgr) = self.browser {
                let _ = mgr
                    .client
                    .send_command_no_params(
                        "Runtime.runIfWaitingForDebugger",
                        Some(iframe_sid.as_str()),
                    )
                    .await;
                let _ = mgr
                    .client
                    .send_command_no_params("DOM.enable", Some(iframe_sid.as_str()))
                    .await;
                let _ = mgr
                    .client
                    .send_command_no_params("Accessibility.enable", Some(iframe_sid.as_str()))
                    .await;
                if self.har_recording || self.request_tracking {
                    let _ = mgr
                        .client
                        .send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
                        .await;
                }
                // Hide automation markers in this cross-origin iframe session too.
                apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await;
            }
        }
        for sid in &drained.detached_iframe_sessions {
            self.iframe_sessions.retain(|_, v| v != sid);
        }

        // Attach and register new targets
        for te in &drained.new_targets {
            if let Some(ref mut mgr) = self.browser {
                let attach_result: Result<AttachToTargetResult, String> = mgr
                    .client
                    .send_command_typed(
                        "Target.attachToTarget",
                        &AttachToTargetParams {
                            target_id: te.target_info.target_id.clone(),
                            flatten: true,
                        },
                        None,
                    )
                    .await;
                if let Ok(attach) = attach_result {
                    let _ = mgr.enable_domains_pub(&attach.session_id).await;

                    // Install domain filter on new pages
                    let df = self.domain_filter.read().await;
                    if let Some(ref filter) = *df {
                        let has_proxy_creds = self.proxy_credentials.read().await.is_some();
                        let _ = network::install_domain_filter(
                            &mgr.client,
                            &attach.session_id,
                            &filter.allowed_domains,
                            has_proxy_creds,
                        )
                        .await;
                    }

                    let tab_id = mgr.assign_tab_id();
                    // Passively discovered (event-driven) — must NOT steal the
                    // active tab, or a foreign/user/other-session tab opening
                    // hijacks this session's eval/screenshot target.
                    mgr.add_background_page(super::browser::PageInfo {
                        tab_id,
                        label: None,
                        target_id: te.target_info.target_id.clone(),
                        session_id: attach.session_id,
                        url: te.target_info.url.clone(),
                        title: te.target_info.title.clone(),
                        target_type: te.target_info.target_type.clone(),
                    });
                }
            }
        }

        // Update changed targets
        for te in &drained.changed_targets {
            if let Some(ref mut mgr) = self.browser {
                mgr.update_page_target_info(&te.target_info);
            }
        }
    }

    fn drain_cdp_events(&mut self) -> DrainedEvents {
        let rx = match self.event_rx.as_mut() {
            Some(rx) => rx,
            None => return DrainedEvents::default(),
        };

        let mut pending_acks: Vec<i64> = Vec::new();
        let mut new_targets: Vec<TargetCreatedEvent> = Vec::new();
        let mut new_target_ids: HashSet<String> = HashSet::new();
        let mut changed_targets: Vec<TargetInfoChangedEvent> = Vec::new();
        let mut destroyed_targets: Vec<String> = Vec::new();
        let mut attached_iframe_sessions: Vec<(String, String)> = Vec::new();
        let mut detached_iframe_sessions: Vec<String> = Vec::new();

        loop {
            match rx.try_recv() {
                Ok(event) => {
                    // Target events are not session-scoped; handle them first
                    match event.method.as_str() {
                        "Target.targetCreated" => {
                            if let Ok(te) =
                                serde_json::from_value::<TargetCreatedEvent>(event.params.clone())
                            {
                                if should_track_target(&te.target_info) {
                                    let already_tracked = self
                                        .browser
                                        .as_ref()
                                        .is_none_or(|b| b.has_target(&te.target_info.target_id));
                                    if !already_tracked {
                                        new_target_ids.insert(te.target_info.target_id.clone());
                                        new_targets.push(te);
                                    }
                                }
                            }
                            continue;
                        }
                        "Target.targetInfoChanged" => {
                            if let Ok(te) = serde_json::from_value::<TargetInfoChangedEvent>(
                                event.params.clone(),
                            ) {
                                if should_track_target(&te.target_info) {
                                    // If this target is not yet tracked (e.g. it was
                                    // initially filtered because its URL was
                                    // chrome://newtab/), promote it to a new target
                                    // so it gets attached and added to `pages`.
                                    let already_tracked = self
                                        .browser
                                        .as_ref()
                                        .is_some_and(|b| b.has_target(&te.target_info.target_id));
                                    if already_tracked
                                        || new_target_ids.contains(&te.target_info.target_id)
                                    {
                                        changed_targets.push(te);
                                    } else {
                                        new_target_ids.insert(te.target_info.target_id.clone());
                                        new_targets.push(TargetCreatedEvent {
                                            target_info: te.target_info,
                                        });
                                    }
                                }
                            }
                            continue;
                        }
                        "Target.targetDestroyed" => {
                            if let Ok(te) =
                                serde_json::from_value::<TargetDestroyedEvent>(event.params.clone())
                            {
                                destroyed_targets.push(te.target_id);
                            }
                            continue;
                        }
                        "Target.attachedToTarget" => {
                            if let (Some(sid), Some(target_info)) = (
                                event.params.get("sessionId").and_then(|v| v.as_str()),
                                event.params.get("targetInfo"),
                            ) {
                                let target_type = target_info
                                    .get("type")
                                    .and_then(|v| v.as_str())
                                    .unwrap_or("");
                                if target_type == "iframe" {
                                    // For OOPIF targets, Chrome uses the frameId as
                                    // the targetId, so we can key iframe_sessions by it.
                                    if let Some(target_id) =
                                        target_info.get("targetId").and_then(|v| v.as_str())
                                    {
                                        attached_iframe_sessions
                                            .push((target_id.to_string(), sid.to_string()));
                                    }
                                }
                            }
                            continue;
                        }
                        "Target.detachedFromTarget" => {
                            if let Some(sid) =
                                event.params.get("sessionId").and_then(|v| v.as_str())
                            {
                                detached_iframe_sessions.push(sid.to_string());
                            }
                            continue;
                        }
                        _ => {}
                    }

                    let session_matches = if let Some(ref browser) = self.browser {
                        event.session_id.as_deref() == browser.active_session_id().ok()
                    } else {
                        false
                    };

                    // Allow Network events from cross-origin iframe sessions
                    // when HAR recording or request tracking is active.
                    let iframe_network_event = !session_matches
                        && (self.har_recording || self.request_tracking)
                        && event.method.starts_with("Network.")
                        && event
                            .session_id
                            .as_ref()
                            .is_some_and(|sid| self.iframe_sessions.values().any(|v| v == sid));

                    if !session_matches && !iframe_network_event {
                        continue;
                    }

                    match event.method.as_str() {
                        "Runtime.consoleAPICalled" => {
                            let level = event
                                .params
                                .get("type")
                                .and_then(|v| v.as_str())
                                .unwrap_or("log");
                            let raw_args: Vec<Value> = event
                                .params
                                .get("args")
                                .and_then(|v| v.as_array())
                                .cloned()
                                .unwrap_or_default();
                            let text = network::format_console_args(&raw_args);
                            if let Some(ref server) = self.stream_server {
                                server.broadcast_console(level, &text, &raw_args);
                            }
                            self.event_tracker.add_console(level, &text, raw_args);
                        }
                        "Runtime.exceptionThrown" => {
                            if let Ok(ex_event) =
                                serde_json::from_value::<ExceptionThrownEvent>(event.params.clone())
                            {
                                let details = &ex_event.exception_details;
                                let text = details
                                    .exception
                                    .as_ref()
                                    .and_then(|e| e.description.as_deref())
                                    .unwrap_or(&details.text);
                                self.event_tracker.add_error(
                                    text,
                                    None,
                                    details.line_number,
                                    details.column_number,
                                );
                                if let Some(ref server) = self.stream_server {
                                    server.broadcast_page_error(
                                        text,
                                        details.line_number,
                                        details.column_number,
                                    );
                                }
                            }
                        }
                        "Network.requestWillBeSent"
                            if self.har_recording || self.request_tracking =>
                        {
                            if let Some(request) = event.params.get("request") {
                                let method = request
                                    .get("method")
                                    .and_then(|v| v.as_str())
                                    .unwrap_or("GET")
                                    .to_string();
                                let url = request
                                    .get("url")
                                    .and_then(|v| v.as_str())
                                    .unwrap_or("")
                                    .to_string();
                                let request_id = event
                                    .params
                                    .get("requestId")
                                    .and_then(|v| v.as_str())
                                    .unwrap_or("")
                                    .to_string();
                                if self.har_recording {
                                    let wall_time = event
                                        .params
                                        .get("wallTime")
                                        .and_then(|v| v.as_f64())
                                        .unwrap_or(0.0);
                                    let request_headers =
                                        har_extract_headers(request.get("headers"));
                                    let post_data = request
                                        .get("postData")
                                        .and_then(|v| v.as_str())
                                        .map(String::from);
                                    let request_body_size =
                                        post_data.as_ref().map(|s| s.len() as i64).unwrap_or(0);
                                    let resource_type = event
                                        .params
                                        .get("type")
                                        .and_then(|v| v.as_str())
                                        .unwrap_or("Other")
                                        .to_string();
                                    self.har_entries.push(HarEntry {
                                        request_id: request_id.clone(),
                                        wall_time,
                                        method: method.clone(),
                                        url: url.clone(),
                                        request_headers,
                                        post_data,
                                        request_body_size,
                                        resource_type,
                                        status: None,
                                        status_text: String::new(),
                                        http_version: "HTTP/1.1".to_string(),
                                        response_headers: Vec::new(),
                                        mime_type: String::new(),
                                        redirect_url: String::new(),
                                        response_body_size: -1,
                                        cdp_timing: None,
                                        loading_finished_timestamp: None,
                                    });
                                }
                                if self.request_tracking {
                                    let headers =
                                        request.get("headers").cloned().unwrap_or(json!({}));
                                    let resource_type = event
                                        .params
                                        .get("type")
                                        .and_then(|v| v.as_str())
                                        .unwrap_or("Other")
                                        .to_string();
                                    let timestamp = std::time::SystemTime::now()
                                        .duration_since(std::time::UNIX_EPOCH)
                                        .map(|d| d.as_millis() as u64)
                                        .unwrap_or(0);
                                    self.tracked_requests.push(TrackedRequest {
                                        url,
                                        method,
                                        headers,
                                        timestamp,
                                        resource_type,
                                        request_id,
                                        post_data: request
                                            .get("postData")
                                            .and_then(|v| v.as_str())
                                            .map(String::from),
                                        status: None,
                                        response_headers: None,
                                        mime_type: None,
                                    });
                                }
                            }
                        }
                        "Network.responseReceived"
                            if self.har_recording || self.request_tracking =>
                        {
                            if let Some(response) = event.params.get("response") {
                                let request_id = event
                                    .params
                                    .get("requestId")
                                    .and_then(|v| v.as_str())
                                    .unwrap_or("");
                                let status = response.get("status").and_then(|v| v.as_i64());
                                let status_text = response
                                    .get("statusText")
                                    .and_then(|v| v.as_str())
                                    .unwrap_or("")
                                    .to_string();
                                let mime_type = response
                                    .get("mimeType")
                                    .and_then(|v| v.as_str())
                                    .unwrap_or("")
                                    .to_string();
                                let http_version = response
                                    .get("protocol")
                                    .and_then(|v| v.as_str())
                                    .map(har_cdp_protocol_to_http_version)
                                    .unwrap_or_else(|| "HTTP/1.1".to_string());
                                let response_headers = har_extract_headers(response.get("headers"));
                                let redirect_url = response_headers
                                    .iter()
                                    .find(|(k, _)| k.eq_ignore_ascii_case("location"))
                                    .map(|(_, v)| v.clone())
                                    .unwrap_or_default();
                                let encoded_data_length = response
                                    .get("encodedDataLength")
                                    .and_then(|v| v.as_i64())
                                    .unwrap_or(-1);
                                let cdp_timing = response.get("timing").cloned();
                                if self.har_recording {
                                    if let Some(entry) = self
                                        .har_entries
                                        .iter_mut()
                                        .rev()
                                        .find(|e| e.request_id == request_id)
                                    {
                                        entry.status = status;
                                        entry.status_text = status_text;
                                        entry.mime_type = mime_type;
                                        entry.http_version = http_version;
                                        entry.response_headers = response_headers;
                                        entry.redirect_url = redirect_url;
                                        entry.response_body_size = encoded_data_length;
                                        entry.cdp_timing = cdp_timing;
                                    }
                                }
                                if self.request_tracking {
                                    let resp_headers = response.get("headers").cloned();
                                    let resp_mime = response
                                        .get("mimeType")
                                        .and_then(|v| v.as_str())
                                        .map(String::from);
                                    if let Some(entry) = self
                                        .tracked_requests
                                        .iter_mut()
                                        .rev()
                                        .find(|e| e.request_id == request_id)
                                    {
                                        entry.status = status;
                                        entry.mime_type = resp_mime;
                                        entry.response_headers = resp_headers;
                                    }
                                }
                            }
                        }
                        "Network.loadingFinished" if self.har_recording => {
                            let request_id = event
                                .params
                                .get("requestId")
                                .and_then(|v| v.as_str())
                                .unwrap_or("");
                            let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64());
                            let encoded_data_length = event
                                .params
                                .get("encodedDataLength")
                                .and_then(|v| v.as_i64());
                            if let Some(entry) = self
                                .har_entries
                                .iter_mut()
                                .rev()
                                .find(|e| e.request_id == request_id)
                            {
                                if let Some(ts) = timestamp {
                                    entry.loading_finished_timestamp = Some(ts);
                                }
                                if let Some(len) = encoded_data_length {
                                    entry.response_body_size = len;
                                }
                            }
                        }
                        "Network.loadingFailed" if self.har_recording => {
                            let request_id = event
                                .params
                                .get("requestId")
                                .and_then(|v| v.as_str())
                                .unwrap_or("");
                            let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64());
                            let error_text = event
                                .params
                                .get("errorText")
                                .and_then(|v| v.as_str())
                                .unwrap_or("Failed");
                            if let Some(entry) = self
                                .har_entries
                                .iter_mut()
                                .rev()
                                .find(|e| e.request_id == request_id)
                            {
                                if entry.status.is_none() {
                                    entry.status = Some(0);
                                    entry.status_text = error_text.to_string();
                                }
                                if let Some(ts) = timestamp {
                                    entry.loading_finished_timestamp = Some(ts);
                                }
                            }
                        }
                        // Frame broadcasting and acks are handled in real-time by the
                        // stream server's background CDP event loop. Here we just
                        // collect acks as a fallback for non-streaming mode.
                        "Page.screencastFrame" if self.stream_server.is_none() => {
                            if let Some(sid) =
                                event.params.get("sessionId").and_then(|v| v.as_i64())
                            {
                                pending_acks.push(sid);
                            }
                        }
                        "Page.javascriptDialogOpening" => {
                            if let Ok(dialog_event) =
                                serde_json::from_value::<JavascriptDialogOpeningEvent>(
                                    event.params.clone(),
                                )
                            {
                                // When auto_dialog is enabled, alert and beforeunload
                                // dialogs are handled by the background dialog_handler_task.
                                // Skip tracking them to avoid a stale warning.
                                let auto_handled = self.auto_dialog
                                    && matches!(
                                        dialog_event.dialog_type.as_str(),
                                        "beforeunload" | "alert"
                                    );
                                if !auto_handled {
                                    self.pending_dialog = Some(PendingDialog {
                                        dialog_type: dialog_event.dialog_type,
                                        message: dialog_event.message,
                                        url: dialog_event.url,
                                        default_prompt: dialog_event.default_prompt,
                                    });
                                }
                            }
                        }
                        "Page.javascriptDialogClosed" => {
                            self.pending_dialog = None;
                        }
                        // Fetch.requestPaused is handled by the background
                        // fetch_handler_task — no need to collect here.
                        _ => {}
                    }
                }
                Err(broadcast::error::TryRecvError::Empty) => break,
                Err(broadcast::error::TryRecvError::Lagged(n)) => {
                    eprintln!("[agent-browser] Warning: CDP event buffer overflowed, {} events dropped. Network requests may be missing from HAR output.", n);
                    continue;
                }
                Err(broadcast::error::TryRecvError::Closed) => {
                    self.event_rx = None;
                    break;
                }
            }
        }

        DrainedEvents {
            pending_acks,
            new_targets,
            changed_targets,
            destroyed_targets,
            attached_iframe_sessions,
            detached_iframe_sessions,
        }
    }
}

impl Drop for DaemonState {
    fn drop(&mut self) {
        // The background fetch handler sits in rx.recv().await indefinitely.
        // Without aborting it, the tokio runtime won't shut down (tests hang).
        if let Some(task) = self.fetch_handler_task.take() {
            task.abort();
        }
        if let Some(task) = self.dialog_handler_task.take() {
            task.abort();
        }
    }
}

pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
    let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
    let id = cmd
        .get("id")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();

    let cmd_start = std::time::Instant::now();

    if let Some(ref server) = state.stream_server {
        server.broadcast_command(action, &id, cmd);
    }

    // Drain and apply pending CDP events (console, errors, screencast frames, target lifecycle)
    state.drain_cdp_events_background().await;

    // Hot-reload and check action policy
    if let Some(ref mut policy) = state.policy {
        let _ = policy.reload();
        match policy.check(action) {
            PolicyResult::Allow => {}
            PolicyResult::Deny(reason) => {
                return error_response(
                    &id,
                    &format!("Action '{}' denied by policy: {}", action, reason),
                );
            }
            PolicyResult::RequiresConfirmation => {
                state.pending_confirmation = Some(PendingConfirmation {
                    action: action.to_string(),
                    cmd: cmd.clone(),
                });
                return json!({
                    "id": id,
                    "success": true,
                    "data": { "confirmation_required": true, "action": action },
                });
            }
        }
    }

    // Check AGENT_BROWSER_CONFIRM_ACTIONS (category-based, independent of policy file)
    if action != "confirm" && action != "deny" {
        if let Some(ref ca) = state.confirm_actions {
            if ca.requires_confirmation(action) {
                state.pending_confirmation = Some(PendingConfirmation {
                    action: action.to_string(),
                    cmd: cmd.clone(),
                });
                return json!({
                    "id": id,
                    "success": true,
                    "data": {
                        "confirmation_required": true,
                        "confirmation_id": id,
                        "action": action,
                    },
                });
            }
        }
    }

    let skip_launch = matches!(
        action,
        "" | "launch"
            | "close"
            | "har_stop"
            | "credentials_set"
            | "credentials_get"
            | "credentials_delete"
            | "credentials_list"
            | "auth_save"
            | "auth_show"
            | "auth_delete"
            | "auth_list"
            | "state_list"
            | "state_show"
            | "state_clear"
            | "state_clean"
            | "state_rename"
            | "device_list"
            | "stream_enable"
            | "stream_disable"
            | "stream_status"
    );
    if !skip_launch {
        // Check if existing connection is stale and needs re-launch.
        // First do a fast, non-blocking check: did the browser process crash/exit?
        // This avoids a 3-second CDP timeout when Chrome is already dead.
        let needs_launch = if let Some(ref mut mgr) = state.browser {
            mgr.has_process_exited() || !mgr.is_connection_alive().await
        } else {
            true
        };

        if needs_launch {
            if state.browser.is_some() {
                if let Some(ref mut mgr) = state.browser {
                    let _ = mgr.close().await;
                }
                state.browser = None;
                state.screencasting = false;
                state.reset_input_state();
                state.update_stream_client().await;
            }
            if let Err(e) = auto_launch(state).await {
                return error_response(&id, &format!("Auto-launch failed: {}", e));
            }
        }

        if let Some(ref mut mgr) = state.browser {
            if mgr.page_count() == 0 {
                let _ = mgr.ensure_page().await;
            }
        }
    }

    // WebDriver backend: reject unsupported CDP-only actions
    if matches!(state.backend_type, BackendType::WebDriver)
        && WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action)
    {
        return error_response(
            &id,
            &format!(
                "Action '{}' is not supported on the WebDriver backend",
                action
            ),
        );
    }

    let result = match action {
        "launch" => handle_launch(cmd, state).await,
        "navigate" => handle_navigate(cmd, state).await,
        "url" => handle_url(state).await,
        "cdp_url" => handle_cdp_url(state),
        "inspect" => handle_inspect(state).await,
        "title" => handle_title(state).await,
        "content" => handle_content(state).await,
        "evaluate" => handle_evaluate(cmd, state).await,
        "close" => handle_close(state).await,
        "snapshot" => handle_snapshot(cmd, state).await,
        "screenshot" => handle_screenshot(cmd, state).await,
        "click" => handle_click(cmd, state).await,
        "dblclick" => handle_dblclick(cmd, state).await,
        "fill" => handle_fill(cmd, state).await,
        "type" => handle_type(cmd, state).await,
        "press" => handle_press(cmd, state).await,
        "hover" => handle_hover(cmd, state).await,
        "scroll" => handle_scroll(cmd, state).await,
        "select" => handle_select(cmd, state).await,
        "check" => handle_check(cmd, state).await,
        "uncheck" => handle_uncheck(cmd, state).await,
        "wait" => handle_wait(cmd, state).await,
        "gettext" => handle_gettext(cmd, state).await,
        "getattribute" => handle_getattribute(cmd, state).await,
        "isvisible" => handle_isvisible(cmd, state).await,
        "isenabled" => handle_isenabled(cmd, state).await,
        "ischecked" => handle_ischecked(cmd, state).await,
        "back" => handle_back(state).await,
        "forward" => handle_forward(state).await,
        "reload" => handle_reload(state).await,
        "cookies_get" => handle_cookies_get(cmd, state).await,
        "cookies_set" => handle_cookies_set(cmd, state).await,
        "cookies_clear" => handle_cookies_clear(state).await,
        "storage_get" => handle_storage_get(cmd, state).await,
        "storage_set" => handle_storage_set(cmd, state).await,
        "storage_clear" => handle_storage_clear(cmd, state).await,
        "setcontent" => handle_setcontent(cmd, state).await,
        "headers" => handle_headers(cmd, state).await,
        "offline" => handle_offline(cmd, state).await,
        "console" => handle_console(cmd, state).await,
        "errors" => handle_errors(state).await,
        "state_save" => handle_state_save(cmd, state).await,
        "state_load" => handle_state_load(cmd, state).await,
        "state_list" | "state_show" | "state_clear" | "state_clean" | "state_rename" => {
            state::dispatch_state_command(cmd)
                .expect("dispatch_state_command must handle all state_* actions matched here")
        }
        "trace_start" => handle_trace_start(state).await,
        "trace_stop" => handle_trace_stop(cmd, state).await,
        "profiler_start" => handle_profiler_start(cmd, state).await,
        "profiler_stop" => handle_profiler_stop(cmd, state).await,
        "recording_start" => handle_recording_start(cmd, state).await,
        "recording_stop" => handle_recording_stop(state).await,
        "recording_restart" => handle_recording_restart(cmd, state).await,
        "pdf" => handle_pdf(cmd, state).await,
        "tab_list" => handle_tab_list(state).await,
        "tab_new" => handle_tab_new(cmd, state).await,
        "tab_switch" => handle_tab_switch(cmd, state).await,
        "tab_close" => handle_tab_close(cmd, state).await,
        "viewport" => handle_viewport(cmd, state).await,
        "useragent" | "user_agent" => handle_user_agent(cmd, state).await,
        "set_media" => handle_set_media(cmd, state).await,
        "download" => handle_download(cmd, state).await,
        "diff_snapshot" => handle_diff_snapshot(cmd, state).await,
        "diff_url" => handle_diff_url(cmd, state).await,
        "credentials_set" => handle_credentials_set(cmd).await,
        "credentials_get" => handle_credentials_get(cmd).await,
        "credentials_delete" => handle_credentials_delete(cmd).await,
        "credentials_list" => handle_credentials_list().await,
        "mouse" => handle_mouse(cmd, state).await,
        "keyboard" => handle_keyboard(cmd, state).await,
        "focus" => handle_focus(cmd, state).await,
        "clear" => handle_clear(cmd, state).await,
        "selectall" => handle_selectall(cmd, state).await,
        "scrollintoview" => handle_scrollintoview(cmd, state).await,
        "dispatch" => handle_dispatch(cmd, state).await,
        "highlight" => handle_highlight(cmd, state).await,
        "tap" => handle_tap(cmd, state).await,
        "boundingbox" => handle_boundingbox(cmd, state).await,
        "innertext" => handle_innertext(cmd, state).await,
        "innerhtml" => handle_innerhtml(cmd, state).await,
        "inputvalue" => handle_inputvalue(cmd, state).await,
        "setvalue" => handle_setvalue(cmd, state).await,
        "count" => handle_count(cmd, state).await,
        "styles" => handle_styles(cmd, state).await,
        "bringtofront" => handle_bringtofront(state).await,
        "timezone" => handle_timezone(cmd, state).await,
        "locale" => handle_locale(cmd, state).await,
        "geolocation" => handle_geolocation(cmd, state).await,
        "permissions" => handle_permissions(cmd, state).await,
        "dialog" => handle_dialog(cmd, state).await,
        "upload" => handle_upload(cmd, state).await,
        "addscript" => handle_addscript(cmd, state).await,
        "addinitscript" => handle_addinitscript(cmd, state).await,
        "removeinitscript" => handle_removeinitscript(cmd, state).await,
        "addstyle" => handle_addstyle(cmd, state).await,
        "react_tree" => handle_react_tree(cmd, state).await,
        "react_inspect" => handle_react_inspect(cmd, state).await,
        "react_renders_start" => handle_react_renders_start(cmd, state).await,
        "react_renders_stop" => handle_react_renders_stop(cmd, state).await,
        "react_suspense" => handle_react_suspense(cmd, state).await,
        "vitals" => handle_vitals(cmd, state).await,
        "pushstate" => handle_pushstate(cmd, state).await,
        "clipboard" => handle_clipboard(cmd, state).await,
        "wheel" => handle_wheel(cmd, state).await,
        "device" => handle_device(cmd, state).await,
        "screencast_start" => handle_screencast_start(cmd, state).await,
        "screencast_stop" => handle_screencast_stop(state).await,
        "stream_enable" => handle_stream_enable(cmd, state).await,
        "stream_disable" => handle_stream_disable(state).await,
        "stream_status" => handle_stream_status(state).await,
        "waitforurl" => handle_waitforurl(cmd, state).await,
        "waitforloadstate" => handle_waitforloadstate(cmd, state).await,
        "waitforfunction" => handle_waitforfunction(cmd, state).await,
        "frame" => handle_frame(cmd, state).await,
        "mainframe" => handle_mainframe(state).await,
        "getbyrole" => handle_getbyrole(cmd, state).await,
        "getbytext" => handle_getbytext(cmd, state).await,
        "getbylabel" => handle_getbylabel(cmd, state).await,
        "getbyplaceholder" => handle_getbyplaceholder(cmd, state).await,
        "getbyalttext" => handle_getbyalttext(cmd, state).await,
        "getbytitle" => handle_getbytitle(cmd, state).await,
        "getbytestid" => handle_getbytestid(cmd, state).await,
        "nth" => handle_nth(cmd, state).await,
        "find" => handle_find(cmd, state).await,
        "evalhandle" => handle_evalhandle(cmd, state).await,
        "drag" => handle_drag(cmd, state).await,
        "expose" => handle_expose(cmd, state).await,
        "pause" => handle_pause(state).await,
        "multiselect" => handle_multiselect(cmd, state).await,
        "responsebody" => handle_responsebody(cmd, state).await,
        "waitfordownload" => handle_waitfordownload(cmd, state).await,
        "window_new" => handle_window_new(cmd, state).await,
        "diff_screenshot" => handle_diff_screenshot(cmd, state).await,
        "video_start" => handle_video_start(cmd, state).await,
        "video_stop" => handle_video_stop(state).await,
        "har_start" => handle_har_start(state).await,
        "har_stop" => handle_har_stop(cmd, state).await,
        "route" => handle_route(cmd, state).await,
        "unroute" => handle_unroute(cmd, state).await,
        "requests" => handle_requests(cmd, state).await,
        "request_detail" => handle_request_detail(cmd, state).await,
        "credentials" => handle_http_credentials(cmd, state).await,
        "emulatemedia" => handle_set_media(cmd, state).await,
        "auth_save" => handle_auth_save(cmd).await,
        "auth_login" => handle_auth_login(cmd, state).await,
        "auth_list" => handle_credentials_list().await,
        "auth_delete" => handle_credentials_delete(cmd).await,
        "auth_show" => handle_auth_show(cmd).await,
        "confirm" => handle_confirm(cmd, state).await,
        "deny" => handle_deny(cmd, state).await,
        "swipe" => handle_swipe(cmd, state).await,
        "device_list" => handle_device_list().await,
        "input_mouse" => handle_input_mouse(cmd, state).await,
        "input_keyboard" => handle_input_keyboard(cmd, state).await,
        "input_touch" => handle_input_touch(cmd, state).await,
        "keydown" => handle_keydown(cmd, state).await,
        "keyup" => handle_keyup(cmd, state).await,
        "inserttext" => handle_inserttext(cmd, state).await,
        "mousemove" => handle_mousemove(cmd, state).await,
        "mousedown" => handle_mousedown(cmd, state).await,
        "mouseup" => handle_mouseup(cmd, state).await,
        _ => Err(format!("Not yet implemented: {}", action)),
    };

    let mut resp = match result {
        Ok(data) => success_response(&id, data),
        Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)),
    };

    // Auto-report pending JavaScript dialog so agents know why commands may hang
    if action != "dialog" {
        if let Some(ref dialog) = state.pending_dialog {
            if let Some(obj) = resp.as_object_mut() {
                obj.insert(
                    "warning".to_string(),
                    json!(format!(
                        "A JavaScript {} dialog is blocking the page: \"{}\" — use `dialog accept` or `dialog dismiss` to resolve it",
                        dialog.dialog_type, dialog.message
                    )),
                );
            }
        }
    }

    if let Some(ref server) = state.stream_server {
        let duration_ms = cmd_start.elapsed().as_millis() as u64;
        let success = resp
            .get("status")
            .and_then(|v| v.as_str())
            .is_some_and(|s| s == "success");
        let data = resp.get("data").cloned().unwrap_or(Value::Null);
        server.broadcast_result(&id, action, success, &data, duration_ms);

        if let Some(ref mgr) = state.browser {
            server.broadcast_tabs(&mgr.tab_list()).await;

            // Keep the stream server's CDP session in sync with the active tab
            // so screencasting always targets the correct page.
            if matches!(
                action,
                "tab_new" | "tab_switch" | "tab_close" | "open" | "navigate"
            ) {
                let session_id = mgr.active_session_id().ok().map(|s| s.to_string());
                server.set_cdp_session_id(session_id).await;
                server.notify_client_changed();
            }
        }
    }

    resp
}

// ---------------------------------------------------------------------------
// Auto-launch
// ---------------------------------------------------------------------------

/// Connect to a running Chrome via auto-discovery and open a fresh tab so
/// subsequent navigations don't hijack the user's existing tabs.
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
    let mut mgr = BrowserManager::connect_auto().await?;
    // tab_new creates the tab in the background (CreateTargetParams.background),
    // so attaching to the user's Chrome never steals their foreground tab. We
    // deliberately do NOT bring it to front — silent operation.
    mgr.tab_new(None, None).await?;
    let session_id = mgr.active_session_id()?.to_string();

    // Liveness probe: confirm the CDP session can actually round-trip
    // before returning success. Without this, a zombie CDP socket (process
    // alive, websocket dead) would let `connect_auto` and `tab_new` succeed,
    // we'd return Ok, the next user command would silently no-op, and
    // `agent-browser open URL` would exit 0 with the browser still on
    // about:blank. Failing here lets the caller surface the real error.
    if let Err(e) = mgr
        .client
        .send_command(
            "Runtime.evaluate",
            Some(serde_json::json!({
                "expression": "1",
                "returnByValue": true,
            })),
            Some(&session_id),
        )
        .await
    {
        return Err(format!(
            "CDP session is unresponsive after attaching ({}). \
             The browser may have lost its DevTools connection. \
             Try: agent-browser close, then re-run.",
            e
        ));
    }
    Ok(mgr)
}

async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
    let mut options = launch_options_from_env();

    // Use the stream server's viewport dimensions for --window-size so the
    // content area matches the desired viewport from the start.
    if let Some(ref server) = state.stream_server {
        options.viewport_size = Some(server.viewport().await);
    }
    let engine = env::var("AGENT_BROWSER_ENGINE").ok();

    // Extract storage_state before options is moved into BrowserManager::launch.
    let storage_state_path = options.storage_state.clone();

    // Store proxy credentials for Fetch.authRequired handling
    let has_proxy_auth = options.proxy_username.is_some();
    if has_proxy_auth {
        let mut creds = state.proxy_credentials.write().await;
        *creds = Some((
            options.proxy_username.clone().unwrap_or_default(),
            options.proxy_password.clone().unwrap_or_default(),
        ));
    }

    state.engine = engine.as_deref().unwrap_or("chrome").to_string();
    write_engine_file(&state.session_id, &state.engine);
    write_extensions_file(&state.session_id);

    if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
        let mgr = BrowserManager::connect_cdp(&cdp).await?;
        state.reset_input_state();
        state.browser = Some(mgr);
        state.subscribe_to_browser_events();
        state.start_fetch_handler();
        state.start_dialog_handler();
        state.update_stream_client().await;
        apply_launch_init_scripts(state).await;
        try_auto_restore_state(state).await;
        try_load_storage_state(state, &storage_state_path).await;
        apply_stealth_to_browser(state).await;
        try_restore_navigation(state).await;
        return Ok(());
    }

    let force_launch = env::var("AGENT_BROWSER_FORCE_LAUNCH").is_ok();

    // Default behavior: try to connect to the user's existing Chrome first.
    // This shares cookies/sessions so the agent can reuse logged-in state.
    // Skip if --launch/--new was passed or running in CI.
    if env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok() && !force_launch {
        match connect_auto_with_fresh_tab().await {
            Ok(mgr) => {
                state.reset_input_state();
                state.browser = Some(mgr);
                state.subscribe_to_browser_events();
                state.start_fetch_handler();
                state.start_dialog_handler();
                state.update_stream_client().await;
                apply_launch_init_scripts(state).await;
                try_auto_restore_state(state).await;
                try_load_storage_state(state, &storage_state_path).await;
                apply_stealth_to_browser(state).await;
                try_restore_navigation(state).await;
                return Ok(());
            }
            Err(_e) => {
                // Could not find a running Chrome with CDP enabled.
                // Return a helpful error guiding the user to enable it.
                return Err(format!(
                    "Could not connect to your Chrome browser.\n\n\
                     If Chrome showed an \"Allow remote debugging?\" dialog, click \
                     Allow and re-run — that consent is what lets agent-browser attach.\n\n\
                     Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
                     {}\n\n\
                     Or launch a separate browser that KEEPS your login state:\n  \
                     agent-browser --launch --profile auto open <url>\n\
                     (plain `--launch` alone uses a temporary EMPTY profile — no cookies, \
                     no logged-in sessions.)\n\n\
                     Note: remote debugging is a startup flag, not a Chrome setting — \
                     chrome://inspect/#remote-debugging only enables target discovery and \
                     does NOT expose the CDP HTTP API on /json/version. \
                     A full restart with --remote-debugging-port=<port> is required.",
                    chrome_relaunch_hint(),
                ));
            }
        }
    }

    // Cloud provider: when AGENT_BROWSER_PROVIDER is set, connect via the
    // provider API instead of launching a local Chrome instance.  This mirrors
    // the logic in handle_launch() so that auto_launch (triggered by any
    // command arriving before an explicit "launch") honours the provider env.
    if let Ok(provider) = env::var("AGENT_BROWSER_PROVIDER") {
        let p = provider.to_lowercase();
        // ios/safari are device providers handled via explicit launch command
        if !p.is_empty() && p != "ios" && p != "safari" {
            let conn = providers::connect_provider(&p).await?;
            let ws_headers = if p == "agentcore" {
                providers::take_agentcore_ws_headers()
            } else {
                None
            };
            let connect_result = if conn.direct_page {
                BrowserManager::connect_cdp_direct(&conn.ws_url).await
            } else if ws_headers.is_some() {
                BrowserManager::connect_cdp_with_headers(&conn.ws_url, ws_headers).await
            } else {
                BrowserManager::connect_cdp(&conn.ws_url).await
            };
            match connect_result {
                Ok(mgr) => {
                    state.reset_input_state();
                    state.browser = Some(mgr);
                    state.subscribe_to_browser_events();
                    state.start_fetch_handler();
                    state.start_dialog_handler();
                    state.update_stream_client().await;
                    write_provider_file(&state.session_id, &p);
                    apply_launch_init_scripts(state).await;
                    try_auto_restore_state(state).await;
                    try_load_storage_state(state, &storage_state_path).await;
                    return Ok(());
                }
                Err(e) => {
                    if let Some(ref ps) = conn.session {
                        providers::close_provider_session(ps).await;
                    }
                    return Err(format!("Provider '{}' connection failed: {}", p, e));
                }
            }
        }
    }

    let hash = launch_hash(&options);
    let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
    state.reset_input_state();
    state.browser = Some(mgr);
    state.launch_hash = Some(hash);
    state.subscribe_to_browser_events();
    state.start_fetch_handler();
    state.start_dialog_handler();
    state.update_stream_client().await;

    // Enable Fetch with handleAuthRequests for proxy authentication
    if has_proxy_auth {
        if let Some(ref mgr) = state.browser {
            if let Ok(session_id) = mgr.active_session_id() {
                let _ = network::install_domain_filter_fetch(&mgr.client, session_id, true).await;
            }
        }
    }

    apply_launch_init_scripts(state).await;
    try_auto_restore_state(state).await;
    try_load_storage_state(state, &storage_state_path).await;
    // Apply stealth anti-detection patches after browser is ready
    apply_stealth_to_browser(state).await;
    try_restore_navigation(state).await;
    Ok(())
}

/// Apply AGENT_BROWSER_ENABLE (built-in init scripts like `react-devtools`)
/// and AGENT_BROWSER_INIT_SCRIPTS (user-provided files) to the browser so the
/// scripts are registered before any page JS runs on the next navigation.
/// Also evaluates each script on the current page (if any) so the effect is
/// immediate for already-loaded pages.
async fn apply_launch_init_scripts(state: &DaemonState) {
    let Some(mgr) = state.browser.as_ref() else {
        return;
    };

    // Built-in features via --enable / AGENT_BROWSER_ENABLE.
    if let Ok(raw) = env::var("AGENT_BROWSER_ENABLE") {
        for feature in raw
            .split([',', '\n'])
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
        {
            match feature {
                "react-devtools" | "react" => {
                    let _ = mgr.add_script_to_evaluate(react::INSTALL_HOOK_JS).await;
                }
                other => {
                    eprintln!("warning: unknown --enable feature '{}'", other);
                }
            }
        }
    }

    // User init scripts via --init-script / AGENT_BROWSER_INIT_SCRIPTS.
    if let Ok(raw) = env::var("AGENT_BROWSER_INIT_SCRIPTS") {
        for path in raw
            .split([',', '\n'])
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
        {
            match fs::read_to_string(path) {
                Ok(source) => {
                    let _ = mgr.add_script_to_evaluate(&source).await;
                }
                Err(e) => {
                    eprintln!("warning: failed to read --init-script '{}': {}", path, e);
                }
            }
        }
    }
}

/// Inject stealth scripts into the active browser session.
/// Platform-specific hint for relaunching Chrome with CDP enabled.
fn chrome_relaunch_hint() -> &'static str {
    if cfg!(target_os = "macos") {
        "  1. Quit Chrome completely\n\
         2. Run:  open -a \"Google Chrome\" --args --remote-debugging-port=9222\n\
         3. Then retry your agent-browser command"
    } else if cfg!(target_os = "windows") {
        "  1. Close Chrome completely\n\
         2. Run:  start chrome --remote-debugging-port=9222\n\
         3. Then retry your agent-browser command"
    } else {
        "  1. Close Chrome completely\n\
         2. Run:  google-chrome --remote-debugging-port=9222\n\
         3. Then retry your agent-browser command"
    }
}

/// Called after every successful launch / CDP connect / auto-connect.
/// Uses `CdpAttach` mode for external connections (minimal patches) and
/// `FullLaunch` mode for newly launched Chrome (all patches).
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
fn stealth_enabled() -> bool {
    !env::var("AGENT_BROWSER_STEALTH")
        .map(|v| v == "0")
        .unwrap_or(false)
}

/// Apply stealth patches to ONE CDP session of the given browser.
///
/// Stealth scripts are registered per-session via
/// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new
/// tabs or cross-origin iframe sessions created after the initial page. We must
/// re-apply to every session the user can touch, otherwise automation markers
/// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces.
async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) {
    if !stealth_enabled() {
        return;
    }
    // Determine mode: an external attach uses minimal patches (the user's real
    // Chrome already has a genuine fingerprint — heavy patches create detectable
    // "lies" like creepjs hasIframeProxy); a fresh launch uses the full set.
    let mode = if mgr.is_cdp_connection() {
        stealth::StealthMode::CdpAttach
    } else {
        stealth::StealthMode::FullLaunch
    };
    let locale = env::var("AGENT_BROWSER_LOCALE").ok();
    if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
        eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
    }
    // Also inject into the current page (already loaded before our init script).
    if let Err(e) =
        stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
            .await
    {
        eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
    }
}

/// Apply stealth to a specific session of the active browser (no-op if no
/// browser or stealth disabled).
async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
    if let Some(ref mgr) = state.browser {
        apply_stealth_via_mgr(mgr, session_id).await;
    }
}

/// Apply stealth to the active page session (initial connect/launch).
async fn apply_stealth_to_browser(state: &DaemonState) {
    let session_id = match state
        .browser
        .as_ref()
        .and_then(|m| m.active_session_id().ok())
    {
        Some(sid) => sid.to_string(),
        None => return,
    };
    apply_stealth_to_session(state, &session_id).await;
}

/// If the previous daemon left a `.restore-url` sidecar (because it was killed
/// by a version-mismatch restart), navigate the freshly-connected browser to
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
/// the page the user was on. Read-and-delete: the file is removed regardless
/// of whether navigation succeeds, so a stale sidecar can't haunt later
/// auto-launches.
async fn try_restore_navigation(state: &mut DaemonState) {
    let path = get_restore_url_path(&state.session_id);
    let url = match fs::read_to_string(&path) {
        Ok(s) => s.trim().to_string(),
        Err(_) => return,
    };
    let _ = fs::remove_file(&path);
    if url.is_empty() {
        return;
    }
    let Some(mgr) = state.browser.as_mut() else {
        return;
    };
    state.ref_map.clear();
    state.iframe_sessions.clear();
    state.active_frame_id = None;
    match mgr.navigate(&url, super::browser::WaitUntil::Load).await {
        Ok(_) => {
            eprintln!(
                "{} Restored previous URL: {}",
                crate::color::warning_indicator(),
                url
            );
        }
        Err(e) => {
            eprintln!(
                "{} Could not restore previous URL ({}): {}",
                crate::color::warning_indicator(),
                url,
                e
            );
        }
    }
}

fn launch_options_from_env() -> LaunchOptions {
    let headed = env::var("AGENT_BROWSER_HEADED")
        .map(|v| v == "1" || v == "true")
        .unwrap_or(false);

    let extensions: Option<Vec<String>> = env::var("AGENT_BROWSER_EXTENSIONS").ok().map(|v| {
        v.split([',', '\n'])
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect()
    });

    LaunchOptions {
        headless: !headed,
        executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
        proxy: env::var("AGENT_BROWSER_PROXY").ok(),
        proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(),
        proxy_username: env::var("AGENT_BROWSER_PROXY_USERNAME").ok(),
        proxy_password: env::var("AGENT_BROWSER_PROXY_PASSWORD").ok(),
        profile: env::var("AGENT_BROWSER_PROFILE").ok(),
        allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS")
            .map(|v| v == "1" || v == "true")
            .unwrap_or(false),
        args: env::var("AGENT_BROWSER_ARGS")
            .map(|v| {
                v.split([',', '\n'])
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect()
            })
            .unwrap_or_default(),
        extensions,
        storage_state: env::var("AGENT_BROWSER_STATE").ok(),
        user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(),
        ignore_https_errors: env::var("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
            .map(|v| v == "1" || v == "true")
            .unwrap_or(false),
        color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
        download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
        hide_scrollbars: hide_scrollbars_from_env(),
        viewport_size: None,
        use_real_keychain: false,
    }
}

fn hide_scrollbars_from_env() -> bool {
    env::var("AGENT_BROWSER_HIDE_SCROLLBARS")
        .map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | ""))
        .unwrap_or(true)
}

fn hide_scrollbars_from_launch_cmd(cmd: &Value) -> bool {
    cmd.get("hideScrollbars")
        .and_then(|v| v.as_bool())
        .unwrap_or_else(hide_scrollbars_from_env)
}

async fn try_auto_restore_state(state: &mut DaemonState) {
    let session_name = match state.session_name.as_deref() {
        Some(n) if !n.is_empty() => n.to_string(),
        _ => return,
    };
    if let Some(path) = state::find_auto_state_file(&session_name) {
        if let Some(ref mgr) = state.browser {
            if let Ok(session_id) = mgr.active_session_id() {
                let _ = state::load_state(&mgr.client, session_id, &path).await;
            }
        }
    }
}

/// Load storage state if a path is configured.
///
/// Explicit launch should surface this error. Best-effort callers can ignore
/// the returned `Result` and keep their previous behavior.
async fn load_storage_state(state: &DaemonState, path: &Option<String>) -> Result<(), String> {
    if let Some(ref path) = path {
        if let Some(ref mgr) = state.browser {
            if let Ok(session_id) = mgr.active_session_id() {
                state::load_state(&mgr.client, session_id, path).await?;
            }
        }
    }

    Ok(())
}

async fn rollback_failed_launch(state: &mut DaemonState) -> Result<(), String> {
    let close_error = if let Some(mut mgr) = state.browser.take() {
        mgr.close().await.err()
    } else {
        None
    };

    state.launch_hash = None;
    state.screencasting = false;
    state.reset_input_state();
    state.ref_map.clear();
    state.update_stream_client().await;

    if let Some(err) = close_error {
        return Err(err);
    }

    Ok(())
}

async fn load_storage_state_or_rollback(
    state: &mut DaemonState,
    path: &Option<String>,
) -> Result<(), String> {
    if let Err(err) = load_storage_state(state, path).await {
        if let Err(close_err) = rollback_failed_launch(state).await {
            return Err(format!(
                "{} (also failed to roll back browser after launch: {})",
                err, close_err
            ));
        }
        return Err(err);
    }

    Ok(())
}

/// Load storage state from AGENT_BROWSER_STATE if set.
async fn try_load_storage_state(state: &DaemonState, path: &Option<String>) {
    let _ = load_storage_state(state, path).await;
}

// ---------------------------------------------------------------------------
// Phase 1 handlers
// ---------------------------------------------------------------------------

async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let headless = cmd
        .get("headless")
        .and_then(|v| v.as_bool())
        .unwrap_or(true);
    let cdp_url = cmd.get("cdpUrl").and_then(|v| v.as_str());
    let cdp_port = cmd.get("cdpPort").and_then(|v| v.as_u64());
    let auto_connect = cmd
        .get("autoConnect")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    let extensions: Option<Vec<String>> =
        cmd.get("extensions").and_then(|v| v.as_array()).map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        });
    let storage_state = cmd.get("storageState").and_then(|v| v.as_str());
    let storage_state_owned = storage_state.map(|s| s.to_string());

    let launch_options = LaunchOptions {
        headless,
        executable_path: cmd
            .get("executablePath")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string())
            .or_else(|| env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok()),
        proxy: cmd.get("proxy").and_then(|v| {
            v.as_str().map(|s| s.to_string()).or_else(|| {
                v.get("server")
                    .and_then(|s| s.as_str())
                    .map(|s| s.to_string())
            })
        }),
        proxy_bypass: cmd
            .get("proxy")
            .and_then(|v| v.get("bypass"))
            .and_then(|v| v.as_str())
            .map(String::from),
        proxy_username: cmd
            .get("proxy")
            .and_then(|v| v.get("username"))
            .and_then(|v| v.as_str())
            .map(String::from)
            .or_else(|| env::var("AGENT_BROWSER_PROXY_USERNAME").ok()),
        proxy_password: cmd
            .get("proxy")
            .and_then(|v| v.get("password"))
            .and_then(|v| v.as_str())
            .map(String::from)
            .or_else(|| env::var("AGENT_BROWSER_PROXY_PASSWORD").ok()),
        profile: cmd
            .get("profile")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string()),
        allow_file_access: cmd
            .get("allowFileAccess")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        args: cmd
            .get("args")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
                    .collect()
            })
            .unwrap_or_default(),
        extensions,
        storage_state: storage_state.map(String::from),
        user_agent: cmd
            .get("userAgent")
            .and_then(|v| v.as_str())
            .map(String::from),
        ignore_https_errors: cmd
            .get("ignoreHTTPSErrors")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        color_scheme: cmd
            .get("colorScheme")
            .and_then(|v| v.as_str())
            .map(String::from),
        download_path: cmd
            .get("downloadPath")
            .and_then(|v| v.as_str())
            .map(String::from),
        hide_scrollbars: hide_scrollbars_from_launch_cmd(cmd),
        viewport_size: None,
        use_real_keychain: false,
    };

    let new_hash = launch_hash(&launch_options);

    // Hash comparison and fast process-exit check are evaluated before the
    // async is_connection_alive to skip the expensive CDP liveness probe
    // when a relaunch is already certain.
    let needs_relaunch = if let Some(ref mut mgr) = state.browser {
        let is_external = cdp_url.is_some() || cdp_port.is_some() || auto_connect;
        let was_external = mgr.is_cdp_connection();
        let hash_changed = !is_external && state.launch_hash != Some(new_hash);
        let storage_state_requires_clean_launch = storage_state_owned.is_some() && !is_external;
        is_external != was_external
            || hash_changed
            || storage_state_requires_clean_launch
            || mgr.has_process_exited()
            || !mgr.is_connection_alive().await
    } else {
        true
    };

    if needs_relaunch {
        if let Some(ref mut b) = state.browser {
            b.close().await?;
            state.browser = None;
            state.launch_hash = None;
            state.screencasting = false;
            state.reset_input_state();
            state.update_stream_client().await;
        }
    } else {
        load_storage_state(state, &storage_state_owned).await?;
        return Ok(json!({ "launched": true, "reused": true }));
    }
    state.ref_map.clear();

    let has_cdp = cdp_url.is_some() || cdp_port.is_some();
    super::browser::validate_launch_options(
        launch_options.extensions.as_deref(),
        has_cdp,
        launch_options.profile.as_deref(),
        storage_state,
        launch_options.allow_file_access,
        launch_options.executable_path.as_deref(),
    )?;

    if let Some(url) = cdp_url {
        state.reset_input_state();
        state.browser = Some(BrowserManager::connect_cdp(url).await?);
        state.subscribe_to_browser_events();
        state.start_fetch_handler();
        state.start_dialog_handler();
        state.update_stream_client().await;
        load_storage_state_or_rollback(state, &storage_state_owned).await?;
        apply_launch_init_scripts(state).await;
        return Ok(json!({ "launched": true }));
    }

    if let Some(port) = cdp_port {
        state.reset_input_state();
        state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
        state.subscribe_to_browser_events();
        state.start_fetch_handler();
        state.start_dialog_handler();
        state.update_stream_client().await;
        load_storage_state_or_rollback(state, &storage_state_owned).await?;
        apply_launch_init_scripts(state).await;
        return Ok(json!({ "launched": true }));
    }

    if auto_connect {
        match connect_auto_with_fresh_tab().await {
            Ok(mgr) => {
                state.reset_input_state();
                state.browser = Some(mgr);
                state.subscribe_to_browser_events();
                state.start_fetch_handler();
                state.start_dialog_handler();
                state.update_stream_client().await;
                load_storage_state_or_rollback(state, &storage_state_owned).await?;
                apply_launch_init_scripts(state).await;
                apply_stealth_to_browser(state).await;
                return Ok(json!({ "launched": true }));
            }
            Err(_e) => {
                return Err(format!(
                    "Could not connect to your Chrome browser.\n\n\
                     If Chrome showed an \"Allow remote debugging?\" dialog, click \
                     Allow and re-run — that consent is what lets agent-browser attach.\n\n\
                     Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
                     {}\n\n\
                     Or launch a separate browser that KEEPS your login state:\n  \
                     agent-browser --launch --profile auto open <url>\n\
                     (plain `--launch` alone uses a temporary EMPTY profile — no cookies, \
                     no logged-in sessions.)\n\n\
                     Note: remote debugging is a startup flag, not a Chrome setting — \
                     chrome://inspect/#remote-debugging only enables target discovery and \
                     does NOT expose the CDP HTTP API on /json/version. \
                     A full restart with --remote-debugging-port=<port> is required.",
                    chrome_relaunch_hint(),
                ));
            }
        }
    }

    if let Some(provider) = cmd.get("provider").and_then(|v| v.as_str()) {
        match provider.to_lowercase().as_str() {
            "ios" => {
                return launch_ios(cmd, state).await;
            }
            "safari" => {
                return launch_safari(cmd, state).await;
            }
            _ => {
                let conn = providers::connect_provider(provider).await?;

                let ws_headers = if provider.eq_ignore_ascii_case("agentcore") {
                    providers::take_agentcore_ws_headers()
                } else {
                    None
                };

                let connect_result = if conn.direct_page {
                    BrowserManager::connect_cdp_direct(&conn.ws_url).await
                } else if ws_headers.is_some() {
                    BrowserManager::connect_cdp_with_headers(&conn.ws_url, ws_headers).await
                } else {
                    BrowserManager::connect_cdp(&conn.ws_url).await
                };
                match connect_result {
                    Ok(mgr) => {
                        state.reset_input_state();
                        state.browser = Some(mgr);
                        state.subscribe_to_browser_events();
                        state.start_fetch_handler();
                        state.start_dialog_handler();
                        state.update_stream_client().await;
                        write_provider_file(&state.session_id, provider);
                        load_storage_state_or_rollback(state, &storage_state_owned).await?;
                        apply_launch_init_scripts(state).await;

                        if let Some(info) = providers::get_agentcore_info() {
                            return Ok(json!({
                                "launched": true,
                                "provider": provider,
                                "agentCoreSessionId": info.session_id,
                                "agentCoreLiveViewUrl": info.live_view_url
                            }));
                        }

                        return Ok(json!({ "launched": true, "provider": provider }));
                    }
                    Err(e) => {
                        if let Some(ref ps) = conn.session {
                            providers::close_provider_session(ps).await;
                        }
                        return Err(e);
                    }
                }
            }
        }
    }

    let engine = cmd
        .get("engine")
        .and_then(|v| v.as_str())
        .map(String::from)
        .or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());

    // Store proxy credentials for Fetch.authRequired handling
    let has_proxy_auth = launch_options.proxy_username.is_some();
    if has_proxy_auth {
        let mut creds = state.proxy_credentials.write().await;
        *creds = Some((
            launch_options.proxy_username.clone().unwrap_or_default(),
            launch_options.proxy_password.clone().unwrap_or_default(),
        ));
    }

    if let Some(ref domains) = cmd
        .get("allowedDomains")
        .and_then(|v| v.as_str())
        .map(String::from)
    {
        let mut df = state.domain_filter.write().await;
        *df = Some(DomainFilter::new(domains));
    }

    state.engine = engine.as_deref().unwrap_or("chrome").to_string();
    write_engine_file(&state.session_id, &state.engine);
    write_extensions_file(&state.session_id);
    state.reset_input_state();
    state.browser = Some(BrowserManager::launch(launch_options, engine.as_deref()).await?);
    state.launch_hash = Some(new_hash);
    state.subscribe_to_browser_events();
    state.start_fetch_handler();
    state.start_dialog_handler();
    state.update_stream_client().await;

    // Enable Fetch interception (domain filtering and/or proxy auth).
    // Only call Fetch.enable once to avoid overwriting handleAuthRequests.
    {
        let df = state.domain_filter.read().await;
        let has_domain_filter = df.is_some();

        if has_domain_filter || has_proxy_auth {
            if let Some(ref mgr) = state.browser {
                if let Ok(session_id) = mgr.active_session_id() {
                    if let Some(ref filter) = *df {
                        let _ = network::install_domain_filter(
                            &mgr.client,
                            session_id,
                            &filter.allowed_domains,
                            has_proxy_auth,
                        )
                        .await;
                        network::sanitize_existing_pages(&mgr.client, &mgr.pages_list(), filter)
                            .await;
                    } else {
                        // No domain filter, but proxy auth needs Fetch.enable
                        let _ = network::install_domain_filter_fetch(
                            &mgr.client,
                            session_id,
                            has_proxy_auth,
                        )
                        .await;
                    }
                }
            }
        }
    }

    // Load storage state only after Fetch interception is active so replayed
    // origin navigations go through the same domain and proxy handling as
    // normal browser traffic.
    load_storage_state_or_rollback(state, &storage_state_owned).await?;

    apply_launch_init_scripts(state).await;
    // Apply stealth patches (the 32 JS patches + HeadlessChrome UA strip in
    // FullLaunch mode). The fresh-launch path was missing this — only the launch
    // FLAGS (e.g. --disable-blink-features) were applied, so the JS patches never
    // ran and navigator.userAgent kept the HeadlessChrome marker.
    apply_stealth_to_browser(state).await;

    Ok(json!({ "launched": true }))
}

async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let device_name = cmd.get("deviceName").and_then(|v| v.as_str());
    let device_udid = cmd.get("udid").and_then(|v| v.as_str());
    let platform_version = cmd.get("platformVersion").and_then(|v| v.as_str());

    // Select device (or use default)
    let device = ios::select_device(device_name, device_udid)?;

    // Boot simulator if it's not real and not already booted
    if !device.is_real && device.state != "Booted" {
        ios::boot_simulator(&device.udid)?;
    }

    // Start Appium
    let mut appium = AppiumManager::connect_or_launch(Some(&device.udid)).await?;

    // Create iOS Safari session
    appium
        .create_ios_session(Some(&device.name), platform_version)
        .await?;

    // Create a WebDriverBackend from the Appium session for common commands
    if let Some(sid) = appium.client.session_id_pub().map(String::from) {
        let wd_client = super::webdriver::client::WebDriverClient::new_with_session(4723, sid);
        state.webdriver_backend = Some(WebDriverBackend::new(wd_client));
    }

    state.appium = Some(appium);
    state.backend_type = BackendType::WebDriver;
    state.engine = "safari".to_string();
    write_engine_file(&state.session_id, &state.engine);
    write_provider_file(&state.session_id, "ios");
    write_extensions_file(&state.session_id);
    state.reset_input_state();

    Ok(json!({
        "launched": true,
        "provider": "ios",
        "device": device.name,
        "udid": device.udid,
        "backend": "webdriver",
    }))
}

async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let port: u16 = cmd
        .get("port")
        .and_then(|v| v.as_u64())
        .map(|p| p as u16)
        .unwrap_or(0);
    let driver_port = if port > 0 { port } else { 0 };

    // Find a free port if none specified
    let actual_port = if driver_port > 0 {
        driver_port
    } else {
        // Use any available high port
        let listener = std::net::TcpListener::bind("127.0.0.1:0")
            .map_err(|e| format!("Failed to find free port: {}", e))?;
        listener
            .local_addr()
            .map_err(|e| format!("Failed to get local address: {}", e))?
            .port()
    };

    let driver = safari::launch_safaridriver(actual_port)?;
    let mut client = super::webdriver::client::WebDriverClient::new(actual_port);

    client
        .create_session(serde_json::json!({
            "browserName": "safari",
        }))
        .await?;

    state.safari_driver = Some(driver);
    state.webdriver_backend = Some(WebDriverBackend::new(client));
    state.backend_type = BackendType::WebDriver;
    state.engine = "safari".to_string();
    write_engine_file(&state.session_id, &state.engine);
    write_provider_file(&state.session_id, "safari");
    write_extensions_file(&state.session_id);
    state.reset_input_state();

    Ok(json!({
        "launched": true,
        "provider": "safari",
        "port": actual_port,
        "backend": "webdriver",
    }))
}

async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let url = cmd
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url' parameter")?;

    {
        let df = state.domain_filter.read().await;
        if let Some(ref filter) = *df {
            filter.check_url(url)?;
        }
    }

    // WebDriver backend path
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            state.ref_map.clear();
            wb.navigate(url).await?;
            let new_url = wb.get_url().await.unwrap_or_else(|_| url.to_string());
            let title = wb.get_title().await.unwrap_or_default();
            return Ok(json!({ "url": new_url, "title": title }));
        }
    }

    let mgr = state.browser.as_mut().ok_or("Browser not launched")?;

    let wait_until = cmd
        .get("waitUntil")
        .and_then(|v| v.as_str())
        .map(WaitUntil::from_str)
        .unwrap_or(WaitUntil::Load);

    // If --headers was passed, store them keyed by origin and enable Fetch
    // interception. The background fetch_handler_task (started on launch)
    // injects them into matching requests in real-time.
    let scoped_headers = cmd
        .get("headers")
        .and_then(|v| v.as_object())
        .filter(|m| !m.is_empty());

    if let Some(headers_map) = scoped_headers {
        if let Some(origin) = url::Url::parse(url)
            .ok()
            .map(|u| u.origin().ascii_serialization())
        {
            let headers: HashMap<String, String> = headers_map
                .iter()
                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                .collect();

            let first_origin_header = {
                let mut map = state.origin_headers.write().await;
                let first = map.is_empty();
                map.insert(origin, headers);
                first
            };

            // Enable Fetch interception the first time --headers is used.
            // Fetch.enable is idempotent — safe even if domain filter or
            // routes already enabled it. Wildcard ensures we see all requests.
            if first_origin_header {
                let session_id = mgr.active_session_id()?.to_string();
                let has_proxy_creds = state.proxy_credentials.read().await.is_some();
                let mut params = json!({ "patterns": [{ "urlPattern": "*" }] });
                if has_proxy_creds {
                    params["handleAuthRequests"] = json!(true);
                }
                mgr.client
                    .send_command("Fetch.enable", Some(params), Some(&session_id))
                    .await?;
            }
        }
    }

    state.ref_map.clear();
    state.iframe_sessions.clear();
    state.active_frame_id = None;
    let result = mgr.navigate(url, wait_until).await?;
    // Adaptive humanize: sample the freshly loaded page for known behavioural
    // anti-bot vendors and escalate this session to Human if any are present.
    detect_and_set_humanize(mgr).await;
    Ok(result)
}

/// After navigation, probe the page for known anti-bot vendor fingerprints
/// (cookies / script URLs / `window` globals) and set this session's humanize
/// level accordingly — `Human` when a vendor is detected, else the `Off`
/// baseline. Best-effort: any failure leaves the level unchanged. Skipped when
/// `AGENT_BROWSER_HUMANIZE` is set, since the override always wins and the probe
/// would be wasted work.
async fn detect_and_set_humanize(mgr: &BrowserManager) {
    if std::env::var("AGENT_BROWSER_HUMANIZE").is_ok() {
        return;
    }
    let js = r#"(() => { try {
        const cookies = document.cookie.split(';').map(c => c.trim().split('=')[0]).filter(Boolean);
        const scripts = Array.from(document.scripts, s => s.src || '').filter(Boolean);
        const re = /_px|bmak|_abck|datadome|reese84|kpsdk|incap_ses|visid_incap|akam/i;
        const globals = Object.getOwnPropertyNames(window).filter(k => re.test(k));
        return { cookies, scripts, globals };
    } catch (e) { return {}; } })()"#;
    let Ok(val) = mgr.evaluate(js, None).await else {
        return;
    };
    let to_strings = |v: Option<&Value>| -> Vec<String> {
        v.and_then(|v| v.as_array())
            .map(|a| {
                a.iter()
                    .filter_map(|x| x.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default()
    };
    let signals = humanize::DetectSignals {
        cookie_names: to_strings(val.get("cookies")),
        script_urls: to_strings(val.get("scripts")),
        window_globals: to_strings(val.get("globals")),
    };
    let level = humanize::detect_level(&signals, humanize::HumanizeLevel::Off);
    humanize::set_detected_level(level);
}

async fn handle_url(state: &DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            let url = wb.get_url().await?;
            return Ok(json!({ "url": url }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let url = mgr.get_url().await?;
    Ok(json!({ "url": url }))
}

fn handle_cdp_url(state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    Ok(json!({ "cdpUrl": mgr.get_cdp_url() }))
}

async fn handle_inspect(state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;

    // Shut down any existing inspect server so we always target the current page
    if let Some(server) = state.inspect_server.take() {
        server.shutdown();
    }

    let target_id = mgr.active_target_id()?.to_string();
    let chrome_hp = mgr.chrome_host_port().to_string();
    let proxy_handle = mgr.client.inspect_handle();

    let server = InspectServer::start(proxy_handle, target_id, chrome_hp).await?;
    let url = format!("http://127.0.0.1:{}", server.port());
    open_url_in_browser(&url);

    state.inspect_server = Some(server);
    Ok(json!({ "opened": true, "url": url }))
}

fn open_url_in_browser(url: &str) {
    #[cfg(target_os = "macos")]
    let result = std::process::Command::new("open").arg(url).spawn();
    #[cfg(target_os = "linux")]
    let result = std::process::Command::new("xdg-open").arg(url).spawn();
    #[cfg(target_os = "windows")]
    let result = std::process::Command::new("cmd")
        .args(["/c", "start", "", url])
        .spawn();
    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
    let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
        std::io::ErrorKind::Unsupported,
        "unsupported platform",
    ));
    if let Err(e) = result {
        let _ = writeln!(std::io::stderr(), "[inspect] Failed to open browser: {}", e);
    }
}

async fn handle_title(state: &DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            let title = wb.get_title().await?;
            return Ok(json!({ "title": title }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let title = mgr.get_title().await?;
    Ok(json!({ "title": title }))
}

async fn handle_content(state: &DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            let html = wb.get_content().await?;
            let url = wb.get_url().await.unwrap_or_default();
            return Ok(json!({ "html": html, "origin": url }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let html = mgr.get_content().await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "html": html, "origin": url }))
}

async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            let script = cmd
                .get("script")
                .and_then(|v| v.as_str())
                .ok_or("Missing 'script' parameter")?;
            let result = wb.evaluate(script).await?;
            let url = wb.get_url().await.unwrap_or_default();
            return Ok(json!({ "result": result, "origin": url }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let script = cmd
        .get("script")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'script' parameter")?;

    let result = mgr.evaluate(script, None).await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "result": result, "origin": url }))
}

async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
    if let Some(ref mgr) = state.browser {
        if let Some(ref session_name) = state.session_name {
            if let Ok(session_id) = mgr.active_session_id() {
                let _ = state::save_state(
                    &mgr.client,
                    session_id,
                    None,
                    Some(session_name.as_str()),
                    &state.session_id,
                    mgr.visited_origins(),
                )
                .await;
            }
        }
    }
    if let Some(ref mut mgr) = state.browser {
        mgr.close().await?;
    }
    state.browser = None;
    state.launch_hash = None;
    state.screencasting = false;
    state.reset_input_state();
    state.update_stream_client().await;

    // Stop background Fetch handler
    if let Some(task) = state.fetch_handler_task.take() {
        task.abort();
    }
    {
        let mut map = state.origin_headers.write().await;
        map.clear();
    }

    // Close WebDriver sessions
    if let Some(ref mut wb) = state.webdriver_backend {
        let _ = wb.close().await;
    }
    state.webdriver_backend = None;
    if let Some(ref mut appium) = state.appium {
        let _ = appium.close().await;
    }
    state.appium = None;
    if let Some(ref mut driver) = state.safari_driver {
        driver.kill();
    }
    state.safari_driver = None;
    state.backend_type = BackendType::Cdp;

    if let Some(server) = state.inspect_server.take() {
        server.shutdown();
    }

    state.ref_map.clear();
    Ok(json!({ "closed": true }))
}

// ---------------------------------------------------------------------------
// Phase 2 handlers
// ---------------------------------------------------------------------------

async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let options = SnapshotOptions {
        selector: cmd
            .get("selector")
            .and_then(|v| v.as_str())
            .map(String::from),
        interactive: cmd
            .get("interactive")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        compact: cmd
            .get("compact")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        depth: cmd
            .get("maxDepth")
            .and_then(|v| v.as_u64())
            .map(|d| d as usize),
        urls: cmd.get("urls").and_then(|v| v.as_bool()).unwrap_or(false),
    };

    state.ref_map.clear();
    let tree = snapshot::take_snapshot(
        &mgr.client,
        &session_id,
        &options,
        &mut state.ref_map,
        state.active_frame_id.as_deref(),
        &state.iframe_sessions,
    )
    .await?;

    let url = mgr.get_url().await.unwrap_or_default();

    let refs: serde_json::Map<String, Value> = state
        .ref_map
        .entries_sorted()
        .into_iter()
        .map(|(ref_id, entry)| {
            let mut obj = serde_json::Map::new();
            obj.insert("role".into(), Value::String(entry.role));
            obj.insert("name".into(), Value::String(entry.name));
            (ref_id, Value::Object(obj))
        })
        .collect();

    Ok(json!({ "snapshot": tree, "origin": url, "refs": refs }))
}

async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let annotate = cmd
        .get("annotate")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            if annotate {
                return Err(
                    "Annotated screenshots are not yet implemented on the WebDriver backend"
                        .to_string(),
                );
            }

            let base64_data = wb.screenshot().await?;
            let path = cmd.get("path").and_then(|v| v.as_str());
            if let Some(p) = path {
                let bytes = base64::Engine::decode(
                    &base64::engine::general_purpose::STANDARD,
                    &base64_data,
                )
                .map_err(|e| format!("Base64 decode error: {}", e))?;
                std::fs::write(p, bytes)
                    .map_err(|e| format!("Failed to write screenshot: {}", e))?;
                return Ok(json!({ "path": p }));
            }
            let tmp = format!(
                "/tmp/screenshot-{}.png",
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_millis())
                    .unwrap_or(0)
            );
            let bytes =
                base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &base64_data)
                    .map_err(|e| format!("Base64 decode error: {}", e))?;
            std::fs::write(&tmp, bytes)
                .map_err(|e| format!("Failed to write screenshot: {}", e))?;
            return Ok(json!({ "path": tmp }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let format = cmd
        .get("format")
        .or_else(|| cmd.get("type"))
        .and_then(|v| v.as_str())
        .unwrap_or("png")
        .to_string();

    let options = ScreenshotOptions {
        selector: cmd
            .get("selector")
            .and_then(|v| v.as_str())
            .map(String::from),
        path: cmd.get("path").and_then(|v| v.as_str()).map(String::from),
        full_page: cmd
            .get("fullPage")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        format,
        quality: cmd
            .get("quality")
            .and_then(|v| v.as_i64())
            .map(|q| q as i32),
        annotate,
        output_dir: cmd
            .get("screenshotDir")
            .and_then(|v| v.as_str())
            .map(String::from),
    };

    if annotate {
        state.ref_map.clear();
        let _ = snapshot::take_snapshot(
            &mgr.client,
            &session_id,
            &SnapshotOptions {
                interactive: true,
                ..SnapshotOptions::default()
            },
            &mut state.ref_map,
            state.active_frame_id.as_deref(),
            &state.iframe_sessions,
        )
        .await?;
    }

    let result = screenshot::take_screenshot(
        &mgr.client,
        &session_id,
        &state.ref_map,
        &options,
        &state.iframe_sessions,
    )
    .await?;

    let mut response = json!({ "path": result.path });
    if !result.annotations.is_empty() {
        response["annotations"] = serde_json::to_value(&result.annotations)
            .map_err(|e| format!("Failed to serialize annotations: {}", e))?;
    }

    Ok(response)
}

async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            wb.click(selector).await?;
            return Ok(json!({ "clicked": selector }));
        }
    }

    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let new_tab = cmd.get("newTab").and_then(|v| v.as_bool()).unwrap_or(false);

    if new_tab {
        use super::element::resolve_element_object_id;
        let (object_id, effective_session_id) = resolve_element_object_id(
            &mgr.client,
            &session_id,
            &state.ref_map,
            selector,
            &state.iframe_sessions,
        )
        .await?;
        let call_params = json!({
            "objectId": object_id,
            "functionDeclaration": "function() { var h = this.getAttribute('href'); if (!h) return null; try { return new URL(h, document.baseURI).toString(); } catch(e) { return null; } }",
            "returnByValue": true
        });
        let call_result = mgr
            .client
            .send_command(
                "Runtime.callFunctionOn",
                Some(call_params),
                Some(&effective_session_id),
            )
            .await?;
        let href = call_result
            .get("result")
            .and_then(|r| r.get("value"))
            .and_then(|v| v.as_str())
            .ok_or_else(|| {
                format!(
                    "Element '{}' does not have an href attribute. --new-tab only works on links.",
                    selector
                )
            })?
            .to_string();

        let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
        state.ref_map.clear();
        mgr.tab_new(Some(&href), None).await?;

        return Ok(json!({ "clicked": selector, "newTab": true, "url": href }));
    }

    let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
    let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32;

    interaction::click(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        button,
        click_count,
        &state.iframe_sessions,
    )
    .await?;

    Ok(json!({ "clicked": selector }))
}

async fn handle_dblclick(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::dblclick(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "clicked": selector }))
}

async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let value = cmd
        .get("value")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'value' parameter")?;

    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            wb.fill(selector, value).await?;
            return Ok(json!({ "filled": selector }));
        }
    }

    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    interaction::fill(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        value,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "filled": selector }))
}

async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let text = cmd
        .get("text")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'text' parameter")?;
    let clear = cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false);
    let delay = cmd.get("delay").and_then(|v| v.as_u64());

    interaction::type_text(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        text,
        clear,
        delay,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "typed": text }))
}

async fn handle_press(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let key = cmd
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'key' parameter")?;

    // Parse modifier+key chords like "Control+a", "Shift+Enter", "Control+Shift+a"
    let (actual_key, modifiers) = parse_key_chord(key);

    interaction::press_key_with_modifiers(&mgr.client, &session_id, &actual_key, modifiers).await?;
    Ok(json!({ "pressed": key }))
}

/// Parse a key chord string like "Control+a" or "Control+Shift+Enter" into
/// the actual key name and an optional CDP modifier bitmask.
///
/// CDP modifier values: 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift.
fn parse_key_chord(input: &str) -> (String, Option<i32>) {
    let parts: Vec<&str> = input.split('+').collect();
    if parts.len() < 2 {
        return (input.to_string(), None);
    }

    let mut modifiers = 0i32;
    let mut key_parts: Vec<&str> = Vec::new();

    for part in &parts {
        match part.to_lowercase().as_str() {
            "alt" => modifiers |= 1,
            "control" | "ctrl" => modifiers |= 2,
            "meta" | "cmd" | "command" => modifiers |= 4,
            "shift" => modifiers |= 8,
            _ => key_parts.push(part),
        }
    }

    // If no modifiers were found, the '+' was part of the key name (e.g. "+")
    // or the input was something unexpected — treat the whole string as the key.
    if modifiers == 0 {
        return (input.to_string(), None);
    }

    // The actual key is whatever remains after stripping modifiers.
    // If nothing remains (e.g. "Control+"), treat the whole string as-is.
    let actual_key = if key_parts.is_empty() {
        input.to_string()
    } else {
        key_parts.join("+")
    };

    (actual_key, Some(modifiers))
}

async fn handle_hover(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::hover(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "hovered": selector }))
}

async fn handle_scroll(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd.get("selector").and_then(|v| v.as_str());

    let (mut dx, mut dy) = (
        cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
        cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
    );

    if let Some(direction) = cmd.get("direction").and_then(|v| v.as_str()) {
        let amount = cmd.get("amount").and_then(|v| v.as_f64()).unwrap_or(300.0);
        match direction {
            "up" => dy = -amount,
            "down" => dy = amount,
            "left" => dx = -amount,
            "right" => dx = amount,
            _ => {}
        }
    }

    interaction::scroll(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        dx,
        dy,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "scrolled": true }))
}

async fn handle_select(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let values: Vec<String> = match cmd.get("values") {
        Some(Value::Array(arr)) => arr
            .iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect(),
        Some(Value::String(s)) => vec![s.clone()],
        _ => cmd
            .get("value")
            .and_then(|v| v.as_str())
            .map(|s| vec![s.to_string()])
            .unwrap_or_default(),
    };

    interaction::select_option(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &values,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "selected": values }))
}

async fn handle_check(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::check(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "checked": selector }))
}

async fn handle_uncheck(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::uncheck(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "unchecked": selector }))
}

async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let timeout_ms = state.timeout_ms(cmd);

    if let Some(text) = cmd.get("text").and_then(|v| v.as_str()) {
        wait_for_text(&mgr.client, &session_id, text, timeout_ms).await?;
        return Ok(json!({ "waited": "text", "text": text }));
    }

    if let Some(selector) = cmd.get("selector").and_then(|v| v.as_str()) {
        let state_str = cmd
            .get("state")
            .and_then(|v| v.as_str())
            .unwrap_or("visible");
        // @-ref support: if the selector is `@e12` style, poll the ref map +
        // accessibility tree instead of `document.querySelector`. This makes
        // `wait @e8 --gone` a usable "assert modal still mounted" primitive
        // for SPA flows where the only stable identity is the AX role+name
        // captured at snapshot time.
        if selector.starts_with('@') {
            wait_for_ref(state, selector, state_str, timeout_ms).await?;
            return Ok(json!({ "waited": "ref", "ref": selector, "state": state_str }));
        }
        wait_for_selector(&mgr.client, &session_id, selector, state_str, timeout_ms).await?;
        return Ok(json!({ "waited": "selector", "selector": selector }));
    }

    if let Some(url_pattern) = cmd.get("url").and_then(|v| v.as_str()) {
        wait_for_url(&mgr.client, &session_id, url_pattern, timeout_ms).await?;
        return Ok(json!({ "waited": "url", "url": url_pattern }));
    }

    if let Some(fn_str) = cmd.get("function").and_then(|v| v.as_str()) {
        wait_for_function(&mgr.client, &session_id, fn_str, timeout_ms).await?;
        return Ok(json!({ "waited": "function" }));
    }

    if let Some(load_state) = cmd.get("loadState").and_then(|v| v.as_str()) {
        let wait_until = WaitUntil::from_str(load_state);
        mgr.wait_for_lifecycle_external(wait_until, &session_id)
            .await?;
        return Ok(json!({ "waited": "load", "state": load_state }));
    }

    // Just a timeout wait
    tokio::time::sleep(tokio::time::Duration::from_millis(timeout_ms)).await;
    Ok(json!({ "waited": "timeout", "ms": timeout_ms }))
}

async fn handle_gettext(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let text = super::element::get_element_text(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "text": text, "origin": url }))
}

async fn handle_getattribute(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let attribute = cmd
        .get("attribute")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'attribute' parameter")?;

    let value = super::element::get_element_attribute(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        attribute,
        &state.iframe_sessions,
    )
    .await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "value": value, "origin": url }))
}

async fn handle_isvisible(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let visible = super::element::is_element_visible(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "visible": visible, "origin": url }))
}

async fn handle_isenabled(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let enabled = super::element::is_element_enabled(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "enabled": enabled, "origin": url }))
}

async fn handle_ischecked(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let checked = super::element::is_element_checked(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "checked": checked, "origin": url }))
}

async fn handle_back(state: &mut DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            wb.back().await?;
            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
            let url = wb.get_url().await.unwrap_or_default();
            state.ref_map.clear();
            return Ok(json!({ "url": url }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    mgr.evaluate("history.back()", None).await?;
    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    let url = mgr.get_url().await.unwrap_or_default();
    state.ref_map.clear();
    Ok(json!({ "url": url }))
}

async fn handle_forward(state: &mut DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            wb.forward().await?;
            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
            let url = wb.get_url().await.unwrap_or_default();
            state.ref_map.clear();
            return Ok(json!({ "url": url }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    mgr.evaluate("history.forward()", None).await?;
    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    let url = mgr.get_url().await.unwrap_or_default();
    state.ref_map.clear();
    Ok(json!({ "url": url }))
}

async fn handle_reload(state: &mut DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            wb.reload().await?;
            tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
            let url = wb.get_url().await.unwrap_or_default();
            state.ref_map.clear();
            return Ok(json!({ "url": url }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    mgr.client
        .send_command_no_params("Page.reload", Some(&session_id))
        .await?;

    let mut rx = mgr.client.subscribe();
    let _ = tokio::time::timeout(tokio::time::Duration::from_secs(10), async {
        loop {
            match rx.recv().await {
                Ok(event) => {
                    if event.method == "Page.loadEventFired"
                        && event.session_id.as_deref() == Some(&session_id)
                    {
                        return;
                    }
                }
                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                Err(_) => break,
            }
        }
    })
    .await;

    let url = mgr.get_url().await.unwrap_or_default();
    state.ref_map.clear();
    Ok(json!({ "url": url }))
}

// ---------------------------------------------------------------------------
// Wait helpers
// ---------------------------------------------------------------------------

/// Poll-based wait for a ref-identified element. Resolves the @-ref by
/// re-running the ref-identity verification each iteration. The supported
/// states mirror selector-based waits:
///
///   - "visible" / "attached" — succeed when the ref resolves to a node
///     whose AX role + name still match the snapshot entry
///   - "detached" / "hidden" — succeed when the ref no longer matches
///     (node removed OR re-textified to something else)
///
/// Times out with a "ref X did not become {state}" error.
async fn wait_for_ref(
    state: &mut DaemonState,
    ref_selector: &str,
    desired_state: &str,
    timeout_ms: u64,
) -> Result<(), String> {
    let want_present = !matches!(desired_state, "detached" | "hidden");
    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
    loop {
        let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
        let session_id = mgr.active_session_id()?.to_string();
        let resolved = super::element::resolve_element_object_id(
            &mgr.client,
            &session_id,
            &state.ref_map,
            ref_selector,
            &state.iframe_sessions,
        )
        .await;
        let present = resolved.is_ok();
        if present == want_present {
            return Ok(());
        }
        if std::time::Instant::now() >= deadline {
            return Err(format!(
                "Timeout: ref {} did not become {} within {}ms",
                ref_selector, desired_state, timeout_ms
            ));
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
    }
}

async fn wait_for_selector(
    client: &super::cdp::client::CdpClient,
    session_id: &str,
    selector: &str,
    state: &str,
    timeout_ms: u64,
) -> Result<(), String> {
    let check_fn = match state {
        "attached" => format!(
            "!!document.querySelector({})",
            serde_json::to_string(selector).unwrap_or_default()
        ),
        "detached" => format!(
            "!document.querySelector({})",
            serde_json::to_string(selector).unwrap_or_default()
        ),
        "hidden" => format!(
            r#"(() => {{
                const el = document.querySelector({sel});
                if (!el) return true;
                const s = window.getComputedStyle(el);
                return s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0;
            }})()"#,
            sel = serde_json::to_string(selector).unwrap_or_default()
        ),
        _ => format!(
            r#"(() => {{
                const el = document.querySelector({sel});
                if (!el) return false;
                const r = el.getBoundingClientRect();
                const s = window.getComputedStyle(el);
                return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
            }})()"#,
            sel = serde_json::to_string(selector).unwrap_or_default()
        ),
    };

    poll_until_true(client, session_id, &check_fn, timeout_ms).await
}

/// Convert a URL glob (Playwright-style: `*` matches within a path segment,
/// `**` matches across segments, `?` matches one char) to an anchored regex.
fn url_glob_to_regex(glob: &str) -> String {
    let mut re = String::from("^");
    let mut chars = glob.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '*' => {
                if chars.peek() == Some(&'*') {
                    chars.next();
                    re.push_str(".*"); // ** — any chars incl. '/'
                } else {
                    re.push_str("[^/]*"); // * — any chars except '/'
                }
            }
            '?' => re.push('.'),
            '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
                re.push('\\');
                re.push(c);
            }
            _ => re.push(c),
        }
    }
    re.push('$');
    re
}

async fn wait_for_url(
    client: &super::cdp::client::CdpClient,
    session_id: &str,
    pattern: &str,
    timeout_ms: u64,
) -> Result<(), String> {
    // A pattern with glob metacharacters is matched as a glob (the core skill
    // documents `wait --url "**/dashboard"`); otherwise it's a plain substring
    // so exact / partial URLs keep working.
    let check_fn = if pattern.contains('*') || pattern.contains('?') {
        format!(
            "(()=>{{try{{return new RegExp({}).test(location.href)}}catch(e){{return false}}}})()",
            serde_json::to_string(&url_glob_to_regex(pattern)).unwrap_or_default()
        )
    } else {
        format!(
            "location.href.includes({})",
            serde_json::to_string(pattern).unwrap_or_default()
        )
    };
    poll_until_true(client, session_id, &check_fn, timeout_ms).await
}

async fn wait_for_text(
    client: &super::cdp::client::CdpClient,
    session_id: &str,
    text: &str,
    timeout_ms: u64,
) -> Result<(), String> {
    let check_fn = format!(
        "(document.body.innerText || '').includes({})",
        serde_json::to_string(text).unwrap_or_default()
    );
    poll_until_true(client, session_id, &check_fn, timeout_ms).await
}

async fn wait_for_function(
    client: &super::cdp::client::CdpClient,
    session_id: &str,
    fn_str: &str,
    timeout_ms: u64,
) -> Result<(), String> {
    let check_fn = format!("!!({})", fn_str);
    poll_until_true(client, session_id, &check_fn, timeout_ms).await
}

async fn poll_until_true(
    client: &super::cdp::client::CdpClient,
    session_id: &str,
    expression: &str,
    timeout_ms: u64,
) -> Result<(), String> {
    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);

    loop {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            return Err(format!("Wait timed out after {}ms", timeout_ms));
        }

        // Bound each probe. A `Runtime.evaluate` issued while the page is
        // navigating can hang (the execution context is being torn down); without
        // a cap the `.await` would block past the deadline forever and wedge the
        // daemon's request loop. Cap at the remaining budget (max 2s per probe).
        let probe_cap = remaining.min(tokio::time::Duration::from_secs(2));
        let probe = tokio::time::timeout(
            probe_cap,
            client.send_command_typed::<_, super::cdp::types::EvaluateResult>(
                "Runtime.evaluate",
                &super::cdp::types::EvaluateParams {
                    expression: expression.to_string(),
                    return_by_value: Some(true),
                    await_promise: Some(true),
                },
                Some(session_id),
            ),
        )
        .await;

        // A probe that timed out or errored (e.g. the execution context was
        // replaced mid-navigation) is transient — keep polling until the
        // deadline rather than failing or hanging.
        if let Ok(Ok(result)) = probe {
            if result
                .result
                .value
                .as_ref()
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                return Ok(());
            }
        }

        let nap = tokio::time::Duration::from_millis(100)
            .min(deadline.saturating_duration_since(tokio::time::Instant::now()));
        if nap.is_zero() {
            return Err(format!("Wait timed out after {}ms", timeout_ms));
        }
        tokio::time::sleep(nap).await;
    }
}

// ---------------------------------------------------------------------------
// Phase 3 handlers
// ---------------------------------------------------------------------------

async fn handle_cookies_get(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    if let Some(ref wb) = state.webdriver_backend {
        if state.browser.is_none() {
            let cookies_list = wb.get_cookies().await?;
            return Ok(json!({ "cookies": cookies_list }));
        }
    }
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let urls = cmd.get("urls").and_then(|v| v.as_array()).map(|arr| {
        arr.iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect()
    });

    let cookies_list = cookies::get_cookies(&mgr.client, &session_id, urls).await?;
    Ok(json!({ "cookies": cookies_list }))
}

async fn handle_cookies_set(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let url = mgr.get_url().await.ok();

    let cookie_values = if let Some(arr) = cmd.get("cookies").and_then(|v| v.as_array()) {
        arr.clone()
    } else {
        let mut cookie = serde_json::Map::new();
        for key in &[
            "name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite", "url",
        ] {
            if let Some(v) = cmd.get(*key) {
                if !v.is_null() {
                    cookie.insert(key.to_string(), v.clone());
                }
            }
        }
        vec![Value::Object(cookie)]
    };

    cookies::set_cookies(&mgr.client, &session_id, cookie_values, url.as_deref()).await?;
    Ok(json!({ "set": true }))
}

async fn handle_cookies_clear(state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    cookies::clear_cookies(&mgr.client, &session_id).await?;
    Ok(json!({ "cleared": true }))
}

async fn handle_storage_get(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let storage_type = cmd.get("type").and_then(|v| v.as_str()).unwrap_or("local");
    let key = cmd.get("key").and_then(|v| v.as_str());
    storage::storage_get(&mgr.client, &session_id, storage_type, key).await
}

async fn handle_storage_set(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let storage_type = cmd.get("type").and_then(|v| v.as_str()).unwrap_or("local");
    let key = cmd
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'key' parameter")?;
    let value = cmd
        .get("value")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'value' parameter")?;
    storage::storage_set(&mgr.client, &session_id, storage_type, key, value).await?;
    Ok(json!({ "set": true }))
}

async fn handle_storage_clear(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let storage_type = cmd.get("type").and_then(|v| v.as_str()).unwrap_or("local");
    storage::storage_clear(&mgr.client, &session_id, storage_type).await?;
    Ok(json!({ "cleared": true }))
}

async fn handle_setcontent(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let html = cmd
        .get("html")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'html' parameter")?;
    network::set_content(&mgr.client, &session_id, html).await?;
    Ok(json!({ "set": true }))
}

async fn handle_headers(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let headers_value = cmd.get("headers").ok_or("Missing 'headers' parameter")?;

    let headers: HashMap<String, String> = headers_value
        .as_object()
        .map(|m| {
            m.iter()
                .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
                .collect()
        })
        .unwrap_or_default();

    network::set_extra_headers(&mgr.client, &session_id, &headers).await?;
    Ok(json!({ "set": true }))
}

async fn handle_offline(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let offline = cmd.get("offline").and_then(|v| v.as_bool()).unwrap_or(true);
    network::set_offline(&mgr.client, &session_id, offline).await?;
    Ok(json!({ "offline": offline }))
}

async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let clear = cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false);
    if clear {
        state.event_tracker.clear_console();
        Ok(json!({ "cleared": true }))
    } else {
        let mut result = state.event_tracker.get_console_json();
        if !console_capture_active(state) {
            if let Some(obj) = result.as_object_mut() {
                obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
            }
        }
        Ok(result)
    }
}

/// Whether the active browser session has the CDP `Runtime` domain enabled
/// (required to receive console/error events). OFF by default for stealth.
fn console_capture_active(state: &DaemonState) -> bool {
    state
        .browser
        .as_ref()
        .map(|b| b.capture_console)
        .unwrap_or(false)
}

const CONSOLE_DISABLED_HINT: &str =
    "console/error capture is disabled for stealth (Runtime.enable is a detectable CDP \
     signal). Restart the session with AGENT_BROWSER_CAPTURE_CONSOLE=1 to capture page output.";

async fn handle_errors(state: &DaemonState) -> Result<Value, String> {
    let mut result = state.event_tracker.get_errors_json();
    if !console_capture_active(state) {
        if let Some(obj) = result.as_object_mut() {
            obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
        }
    }
    Ok(result)
}

async fn handle_state_save(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let path = cmd.get("path").and_then(|v| v.as_str());

    let saved_path = state::save_state(
        &mgr.client,
        &session_id,
        path,
        state.session_name.as_deref(),
        &state.session_id,
        mgr.visited_origins(),
    )
    .await?;

    Ok(json!({ "saved": true, "path": saved_path }))
}

async fn handle_state_load(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let path = cmd
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'path' parameter")?;

    state::load_state(&mgr.client, &session_id, path).await?;
    Ok(json!({ "loaded": true, "path": path }))
}

// ---------------------------------------------------------------------------
// Phase 6 handlers
// ---------------------------------------------------------------------------

async fn handle_diff_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let compact = cmd
        .get("compact")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let max_depth = cmd
        .get("maxDepth")
        .and_then(|v| v.as_u64())
        .map(|d| d as usize);
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .map(String::from);

    let options = SnapshotOptions {
        compact,
        depth: max_depth,
        selector,
        ..SnapshotOptions::default()
    };
    let current = snapshot::take_snapshot(
        &mgr.client,
        &session_id,
        &options,
        &mut state.ref_map,
        state.active_frame_id.as_deref(),
        &state.iframe_sessions,
    )
    .await?;

    let baseline = cmd.get("baseline").and_then(|v| v.as_str());

    let baseline_text = match baseline {
        Some(b) if std::path::Path::new(b).exists() => {
            std::fs::read_to_string(b).map_err(|e| format!("Failed to read baseline: {}", e))?
        }
        Some(b) => b.to_string(),
        None => String::new(),
    };

    let result = diff::diff_snapshots(&baseline_text, &current);
    Ok(json!({
        "diff": result.diff,
        "additions": result.additions,
        "removals": result.removals,
        "unchanged": result.unchanged,
        "changed": result.changed,
    }))
}

async fn handle_diff_url(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_mut().ok_or("Browser not launched")?;

    let url1 = cmd
        .get("url1")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url1' parameter")?;
    let url2 = cmd
        .get("url2")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url2' parameter")?;

    let wait_until = cmd
        .get("waitUntil")
        .and_then(|v| v.as_str())
        .map(WaitUntil::from_str)
        .unwrap_or(WaitUntil::Load);

    // Navigate to URL1 and snapshot
    mgr.navigate(url1, wait_until).await?;
    let session_id = mgr.active_session_id()?.to_string();
    let options = SnapshotOptions::default();
    let snap1 = snapshot::take_snapshot(
        &mgr.client,
        &session_id,
        &options,
        &mut state.ref_map,
        None,
        &state.iframe_sessions,
    )
    .await?;

    // Navigate to URL2 and snapshot
    mgr.navigate(url2, wait_until).await?;
    state.ref_map.clear();
    let snap2 = snapshot::take_snapshot(
        &mgr.client,
        &session_id,
        &options,
        &mut state.ref_map,
        None,
        &state.iframe_sessions,
    )
    .await?;

    let result = diff::diff_text(&snap1, &snap2);
    Ok(json!({
        "diff": result,
        "url1": url1,
        "url2": url2,
        "snapshot1": snap1,
        "snapshot2": snap2,
    }))
}

async fn handle_credentials_set(cmd: &Value) -> Result<Value, String> {
    let name = cmd
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name'")?;
    let username = cmd
        .get("username")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'username'")?;
    let password = cmd
        .get("password")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'password'")?;
    let url = cmd.get("url").and_then(|v| v.as_str());
    auth::credentials_set(name, username, password, url)
}

async fn handle_credentials_get(cmd: &Value) -> Result<Value, String> {
    let name = cmd
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name'")?;
    auth::credentials_get(name)
}

async fn handle_credentials_delete(cmd: &Value) -> Result<Value, String> {
    let name = cmd
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name'")?;
    auth::credentials_delete(name)
}

async fn handle_credentials_list() -> Result<Value, String> {
    auth::credentials_list()
}

async fn handle_auth_show(cmd: &Value) -> Result<Value, String> {
    let name = cmd
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name'")?;
    auth::auth_show(name)
}

async fn handle_mouse(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let event_type = cmd
        .get("eventType")
        .and_then(|v| v.as_str())
        .unwrap_or("mouseMoved");
    let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("none");
    let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(0);

    mgr.client
        .send_command(
            "Input.dispatchMouseEvent",
            Some(json!({
                "type": event_type,
                "x": x,
                "y": y,
                "button": button,
                "clickCount": click_count,
            })),
            Some(&session_id),
        )
        .await?;

    Ok(json!({ "dispatched": event_type }))
}

async fn handle_keyboard(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    match cmd.get("subaction").and_then(|v| v.as_str()) {
        Some("type") => {
            let text = cmd
                .get("text")
                .and_then(|v| v.as_str())
                .ok_or("Missing 'text' parameter")?;
            interaction::type_text_into_active_context(&mgr.client, &session_id, text, None)
                .await?;
            return Ok(json!({ "typed": text }));
        }
        Some("insertText") => {
            let text = cmd
                .get("text")
                .and_then(|v| v.as_str())
                .ok_or("Missing 'text' parameter")?;
            mgr.client
                .send_command(
                    "Input.insertText",
                    Some(json!({ "text": text })),
                    Some(&session_id),
                )
                .await?;
            return Ok(json!({ "inserted": true }));
        }
        _ => {}
    }

    let event_type = cmd
        .get("eventType")
        .and_then(|v| v.as_str())
        .unwrap_or("keyDown");
    let key = cmd.get("key").and_then(|v| v.as_str());
    let code = cmd.get("code").and_then(|v| v.as_str());
    let text = cmd.get("text").and_then(|v| v.as_str());

    let mut params = json!({ "type": event_type });
    if let Some(k) = key {
        params["key"] = Value::String(k.to_string());
    }
    if let Some(c) = code {
        params["code"] = Value::String(c.to_string());
    }
    if let Some(t) = text {
        params["text"] = Value::String(t.to_string());
    }

    mgr.client
        .send_command("Input.dispatchKeyEvent", Some(params), Some(&session_id))
        .await?;

    Ok(json!({ "dispatched": event_type }))
}

// ---------------------------------------------------------------------------
// Phase 5 handlers
// ---------------------------------------------------------------------------

async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let tabs = mgr.tab_list();
    Ok(json!({ "tabs": tabs }))
}

async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let url = cmd.get("url").and_then(|v| v.as_str());
    let label = cmd.get("label").and_then(|v| v.as_str());
    state.ref_map.clear();
    state.iframe_sessions.clear();
    state.active_frame_id = None;
    let result = {
        let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
        mgr.tab_new(url, label).await?
    };
    // A new tab is a new CDP session; stealth scripts registered on the prior
    // session don't carry over, so patch the new tab too.
    if let Some(sid) = state
        .browser
        .as_ref()
        .and_then(|m| m.active_session_id().ok())
        .map(|s| s.to_string())
    {
        apply_stealth_to_session(state, &sid).await;
    }
    Ok(result)
}

async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
    let tab_ref_str = cmd
        .get("tabId")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'tabId' parameter (expected `t<N>` or a label)")?;
    let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
    let tab_id = mgr.resolve_tab_ref(&tab_ref)?;
    state.ref_map.clear();
    state.iframe_sessions.clear();
    state.active_frame_id = None;
    let result = mgr.tab_switch_by_id(tab_id).await?;

    if let Some(ref server) = state.stream_server {
        if let Ok(dims) = mgr
            .evaluate(
                "JSON.stringify([window.innerWidth,window.innerHeight])",
                None,
            )
            .await
        {
            if let Some(s) = dims.get("result").and_then(|v| v.as_str()) {
                if let Ok(arr) = serde_json::from_str::<Vec<u32>>(s) {
                    if arr.len() == 2 && arr[0] > 0 && arr[1] > 0 {
                        server.set_viewport(arr[0], arr[1]).await;
                    }
                }
            }
        }
    }

    Ok(result)
}

async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
    let tab_id = match cmd.get("tabId").and_then(|v| v.as_str()) {
        Some(s) => {
            let tab_ref = super::browser::TabRef::parse(s)?;
            Some(mgr.resolve_tab_ref(&tab_ref)?)
        }
        None => None,
    };
    state.ref_map.clear();
    state.iframe_sessions.clear();
    state.active_frame_id = None;
    mgr.tab_close_by_id(tab_id).await
}

async fn handle_viewport(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let width = cmd.get("width").and_then(|v| v.as_i64()).unwrap_or(1280) as i32;
    let height = cmd.get("height").and_then(|v| v.as_i64()).unwrap_or(720) as i32;
    let scale = cmd
        .get("deviceScaleFactor")
        .and_then(|v| v.as_f64())
        .unwrap_or(1.0);
    let mobile = cmd.get("mobile").and_then(|v| v.as_bool()).unwrap_or(false);

    mgr.set_viewport(width, height, scale, mobile).await?;

    state.viewport = Some((width, height, scale, mobile));

    // Update stream server viewport so status messages and screencast use the new dimensions
    if let Some(ref server) = state.stream_server {
        server.set_viewport(width as u32, height as u32).await;
    }

    Ok(json!({ "width": width, "height": height, "deviceScaleFactor": scale, "mobile": mobile }))
}

async fn handle_user_agent(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let ua = cmd
        .get("userAgent")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'userAgent' parameter")?;
    mgr.set_user_agent(ua).await?;
    Ok(json!({ "userAgent": ua }))
}

async fn handle_set_media(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let media = cmd.get("media").and_then(|v| v.as_str());

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

    if let Some(scheme) = cmd.get("colorScheme").and_then(|v| v.as_str()) {
        feat_list.push(("prefers-color-scheme".to_string(), scheme.to_string()));
    }
    if let Some(motion) = cmd.get("reducedMotion").and_then(|v| v.as_str()) {
        feat_list.push(("prefers-reduced-motion".to_string(), motion.to_string()));
    }

    if let Some(obj) = cmd.get("features").and_then(|v| v.as_object()) {
        for (k, v) in obj {
            feat_list.push((k.clone(), v.as_str().unwrap_or("").to_string()));
        }
    }

    let features = if feat_list.is_empty() {
        None
    } else {
        Some(feat_list)
    };

    mgr.set_emulated_media(media, features).await?;
    Ok(json!({ "set": true }))
}

async fn handle_download(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let path_str = cmd
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'path' parameter")?;

    // Resolve to absolute path and canonicalize to prevent path traversal
    let raw_dest = if std::path::Path::new(path_str).is_absolute() {
        PathBuf::from(path_str)
    } else {
        std::env::current_dir()
            .map_err(|e| format!("Failed to get current directory: {}", e))?
            .join(path_str)
    };

    // Extract directory and desired filename
    let download_dir = raw_dest
        .parent()
        .ok_or("Invalid download path: no parent directory")?
        .to_path_buf();

    // Create the directory if it doesn't exist
    std::fs::create_dir_all(&download_dir)
        .map_err(|e| format!("Failed to create download directory: {}", e))?;

    // Canonicalize after mkdir so the path actually exists for resolution
    let download_dir = download_dir
        .canonicalize()
        .map_err(|e| format!("Failed to resolve download directory: {}", e))?;
    let dest = download_dir.join(
        raw_dest
            .file_name()
            .ok_or("Invalid download path: no filename")?,
    );
    let download_dir_str = download_dir
        .to_str()
        .ok_or("Download directory path is not valid UTF-8")?;

    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    // Set download behavior to save to the parent directory
    mgr.set_download_behavior(download_dir_str).await?;

    // Subscribe to CDP events before clicking so we don't miss the download event
    let mut rx = mgr.client.subscribe();

    // Click the element to trigger the download
    interaction::click(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        "left",
        1,
        &state.iframe_sessions,
    )
    .await?;

    // Wait for download to complete
    const DOWNLOAD_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs(30);
    let deadline = tokio::time::Instant::now() + DOWNLOAD_TIMEOUT;
    let mut downloaded_guid: Option<String> = None;

    loop {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            return Err("Timeout waiting for download to complete".to_string());
        }

        match tokio::time::timeout(remaining, rx.recv()).await {
            Ok(Ok(event)) => {
                // Browser-domain download events may arrive without a sessionId
                // or with a different sessionId than the page session, so we
                // accept them regardless. Page-domain events are matched by
                // session to avoid cross-tab confusion.
                let is_page_session = event.session_id.as_deref() == Some(&session_id);
                let is_download_event = |method: &str, browser_method: &str, page_method: &str| {
                    method == browser_method || (method == page_method && is_page_session)
                };

                // Capture the GUID from downloadWillBegin
                if is_download_event(
                    &event.method,
                    "Browser.downloadWillBegin",
                    "Page.downloadWillBegin",
                ) {
                    if let Some(guid) = event.params.get("guid").and_then(|v| v.as_str()) {
                        downloaded_guid = Some(guid.to_string());
                    }
                }
                // Check for download completion or cancellation
                if is_download_event(
                    &event.method,
                    "Browser.downloadProgress",
                    "Page.downloadProgress",
                ) {
                    match event.params.get("state").and_then(|v| v.as_str()) {
                        Some("completed") => break,
                        Some("canceled") => {
                            return Err("Download was canceled".to_string());
                        }
                        _ => {}
                    }
                }
            }
            Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
            Ok(Err(_)) => return Err("Event stream closed".to_string()),
            Err(_) => return Err("Timeout waiting for download to complete".to_string()),
        }
    }

    // With "allowAndName" behavior, Chrome saves the file using the GUID as filename.
    // Rename it to the user-requested filename.
    if let Some(guid) = downloaded_guid {
        let guid_path = download_dir.join(&guid);
        // Chrome may still be flushing the file to disk after signalling
        // completion; wait briefly for it to appear.
        for _ in 0..10 {
            if guid_path.exists() {
                break;
            }
            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        }
        if guid_path.exists() {
            std::fs::rename(&guid_path, &dest)
                .map_err(|e| format!("Failed to rename downloaded file: {}", e))?;
        } else {
            // The file might have been saved under its original name instead
            // of the GUID (e.g. when Chrome falls back to "allow" behavior).
            if !dest.exists() {
                return Err(format!(
                    "Downloaded file not found at expected path (GUID: {})",
                    guid
                ));
            }
        }
    } else {
        // GUID capture failed -- the file may have been saved under its original name
        // by Chrome. Only return success if dest already exists (avoid touching
        // unrelated files in the directory).
        if !dest.exists() {
            return Err(
                "Download completed but could not determine the downloaded file name".to_string(),
            );
        }
    }

    let dest_str = dest.to_string_lossy().to_string();
    Ok(json!({ "path": dest_str }))
}

// ---------------------------------------------------------------------------
// Phase 4 handlers
// ---------------------------------------------------------------------------

async fn handle_trace_start(state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    native_tracing::trace_start(&mgr.client, &session_id, &mut state.tracing_state).await
}

async fn handle_trace_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let path = cmd.get("path").and_then(|v| v.as_str());
    native_tracing::trace_stop(&mgr.client, &session_id, &mut state.tracing_state, path).await
}

async fn handle_profiler_start(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let categories = cmd.get("categories").and_then(|v| v.as_array()).map(|arr| {
        arr.iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect()
    });
    native_tracing::profiler_start(
        &mgr.client,
        &session_id,
        &mut state.tracing_state,
        categories,
    )
    .await
}

async fn handle_profiler_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let path = cmd.get("path").and_then(|v| v.as_str());
    native_tracing::profiler_stop(&mgr.client, &session_id, &mut state.tracing_state, path).await
}

async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let path = cmd
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'path' parameter")?;

    let recording_url = cmd
        .get("url")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty());

    let viewport = state.viewport;

    let (client, new_session_id) = {
        let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
        let old_session_id = mgr.active_session_id()?.to_string();

        // Capture current URL if no URL specified
        let nav_url = if let Some(u) = recording_url {
            u.to_string()
        } else {
            mgr.get_url()
                .await
                .unwrap_or_else(|_| "about:blank".to_string())
        };

        // Capture current cookies
        let cookies_result = mgr
            .client
            .send_command_no_params("Network.getAllCookies", Some(&old_session_id))
            .await
            .ok();

        // Create new browser context
        let ctx_result = mgr
            .client
            .send_command_no_params("Target.createBrowserContext", None)
            .await?;
        let context_id = ctx_result
            .get("browserContextId")
            .and_then(|v| v.as_str())
            .ok_or("Failed to get browserContextId")?
            .to_string();

        // Create page in new context
        let create_result: CreateTargetResult = mgr
            .client
            .send_command_typed(
                "Target.createTarget",
                &json!({ "url": "about:blank", "browserContextId": context_id }),
                None,
            )
            .await?;

        let attach_result: AttachToTargetResult = mgr
            .client
            .send_command_typed(
                "Target.attachToTarget",
                &AttachToTargetParams {
                    target_id: create_result.target_id.clone(),
                    flatten: true,
                },
                None,
            )
            .await?;

        let new_session_id = attach_result.session_id.clone();
        mgr.enable_domains_pub(&new_session_id).await?;

        // Re-apply download behavior to the recording context.
        // Without this, downloads in the recording context are silently dropped
        // because Browser.setDownloadBehavior at launch only applies to the default context.
        if let Some(ref dl_path) = mgr.download_path {
            let _ = mgr
                .client
                .send_command(
                    "Browser.setDownloadBehavior",
                    Some(json!({
                        "behavior": "allow",
                        "downloadPath": dl_path,
                        "browserContextId": context_id,
                        "eventsEnabled": true
                    })),
                    None,
                )
                .await;
        }

        // Re-apply HTTPS error ignore to the recording context.
        // Security.setIgnoreCertificateErrors at launch only applies to the session it was sent on.
        if mgr.ignore_https_errors {
            let _ = mgr
                .client
                .send_command(
                    "Security.setIgnoreCertificateErrors",
                    Some(json!({ "ignore": true })),
                    Some(&new_session_id),
                )
                .await;
        }

        // Transfer cookies to new context
        if let Some(ref cr) = cookies_result {
            if let Some(cookie_arr) = cr.get("cookies").and_then(|v| v.as_array()) {
                if !cookie_arr.is_empty() {
                    let _ = mgr
                        .client
                        .send_command(
                            "Network.setCookies",
                            Some(json!({ "cookies": cookie_arr })),
                            Some(&new_session_id),
                        )
                        .await;
                }
            }
        }

        // Add page and switch to it
        let tab_id = mgr.assign_tab_id();
        mgr.add_page(super::browser::PageInfo {
            tab_id,
            label: None,
            target_id: create_result.target_id,
            session_id: new_session_id.clone(),
            url: nav_url.clone(),
            title: String::new(),
            target_type: "page".to_string(),
        });

        if let Some((w, h, scale, mobile)) = viewport {
            let _ = mgr.set_viewport(w, h, scale, mobile).await;
        }

        // Navigate to URL
        if nav_url != "about:blank" {
            let _ = mgr
                .client
                .send_command(
                    "Page.navigate",
                    Some(json!({ "url": nav_url })),
                    Some(&new_session_id),
                )
                .await;
            tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
        }

        (mgr.client.clone(), new_session_id)
    };

    let result = recording::recording_start(&mut state.recording_state, path)?;
    state.start_recording_task(client, new_session_id).await?;

    if let Some(ref server) = state.stream_server {
        server.set_recording(true, &state.engine).await;
    }

    Ok(result)
}

async fn handle_recording_stop(state: &mut DaemonState) -> Result<Value, String> {
    state.stop_recording_task().await?;
    let result = recording::recording_stop(&mut state.recording_state);

    if let Some(ref server) = state.stream_server {
        server.set_recording(false, &state.engine).await;
    }

    result
}

async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let path = cmd
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'path' parameter")?;

    let _ = state.stop_recording_task().await;
    let result = recording::recording_restart(&mut state.recording_state, path)?;

    if let Some(ref browser) = state.browser {
        let session_id = browser.active_session_id()?.to_string();
        state
            .start_recording_task(browser.client.clone(), session_id)
            .await?;
    }

    Ok(result)
}

async fn handle_pdf(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let params = json!({
        "printBackground": cmd.get("printBackground").and_then(|v| v.as_bool()).unwrap_or(true),
        "landscape": cmd.get("landscape").and_then(|v| v.as_bool()).unwrap_or(false),
        "preferCSSPageSize": cmd.get("preferCSSPageSize").and_then(|v| v.as_bool()).unwrap_or(false),
    });

    let result = mgr
        .client
        .send_command("Page.printToPDF", Some(params), Some(&session_id))
        .await?;

    let data = result
        .get("data")
        .and_then(|v| v.as_str())
        .ok_or("No PDF data returned")?;

    let path = cmd.get("path").and_then(|v| v.as_str());
    let save_path = match path {
        Some(p) => p.to_string(),
        None => {
            let dir = dirs::home_dir()
                .unwrap_or_else(std::env::temp_dir)
                .join(".agent-browser")
                .join("tmp")
                .join("pdfs");
            let _ = std::fs::create_dir_all(&dir);
            let timestamp = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis();
            dir.join(format!("page-{}.pdf", timestamp))
                .to_string_lossy()
                .to_string()
        }
    };

    let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
        .map_err(|e| format!("Failed to decode PDF: {}", e))?;
    std::fs::write(&save_path, &bytes).map_err(|e| format!("Failed to save PDF: {}", e))?;

    Ok(json!({ "path": save_path }))
}

// ---------------------------------------------------------------------------
// Phase 8 handlers
// ---------------------------------------------------------------------------

async fn handle_focus(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::focus(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "focused": selector }))
}

async fn handle_clear(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::clear(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "cleared": selector }))
}

async fn handle_selectall(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::select_all(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "selected": selector }))
}

async fn handle_scrollintoview(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::scroll_into_view(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "scrolled": selector }))
}

async fn handle_dispatch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let event_type = cmd
        .get("event")
        .or_else(|| cmd.get("eventType"))
        .and_then(|v| v.as_str())
        .ok_or("Missing 'event' parameter")?;
    let event_init = cmd.get("eventInit");

    interaction::dispatch_event(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        event_type,
        event_init,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "dispatched": event_type, "selector": selector }))
}

async fn handle_highlight(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    interaction::highlight(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "highlighted": selector }))
}

async fn handle_tap(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let selector = cmd.get("selector").and_then(|v| v.as_str());

    // Route through Appium for iOS/WebDriver using coordinate-based tap
    if let Some(ref appium) = state.appium {
        if state.browser.is_none() {
            let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(200.0);
            let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(200.0);
            appium.tap(x, y).await?;
            return Ok(json!({ "tapped": true, "x": x, "y": y }));
        }
    }

    let sel = selector.ok_or("Missing 'selector' parameter")?;
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    interaction::tap_touch(
        &mgr.client,
        &session_id,
        &state.ref_map,
        sel,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "tapped": sel }))
}

async fn handle_boundingbox(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let bbox = super::element::get_element_bounding_box(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(bbox)
}

async fn handle_innertext(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let text = super::element::get_element_inner_text(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "text": text }))
}

async fn handle_innerhtml(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let html = super::element::get_element_inner_html(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "html": html }))
}

async fn handle_inputvalue(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let value = super::element::get_element_input_value(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "value": value }))
}

async fn handle_setvalue(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let value = cmd
        .get("value")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'value' parameter")?;

    super::element::set_element_value(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        value,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "set": selector, "value": value }))
}

async fn handle_count(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let count = super::element::get_element_count(&mgr.client, &session_id, selector).await?;
    Ok(json!({ "count": count, "selector": selector }))
}

async fn handle_styles(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let properties = cmd.get("properties").and_then(|v| v.as_array()).map(|arr| {
        arr.iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect()
    });

    let styles = super::element::get_element_styles(
        &mgr.client,
        &session_id,
        &state.ref_map,
        selector,
        properties,
        &state.iframe_sessions,
    )
    .await?;
    Ok(json!({ "styles": styles }))
}

async fn handle_bringtofront(state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    mgr.bring_to_front().await?;
    Ok(json!({ "broughtToFront": true }))
}

async fn handle_timezone(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let timezone = cmd
        .get("timezoneId")
        .or_else(|| cmd.get("timezone"))
        .and_then(|v| v.as_str())
        .ok_or("Missing 'timezoneId' parameter")?;
    mgr.set_timezone(timezone).await?;
    Ok(json!({ "timezoneId": timezone }))
}

async fn handle_locale(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let locale = cmd
        .get("locale")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'locale' parameter")?;
    mgr.set_locale(locale).await?;
    Ok(json!({ "locale": locale }))
}

async fn handle_geolocation(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let latitude = cmd
        .get("latitude")
        .and_then(|v| v.as_f64())
        .ok_or("Missing 'latitude' parameter")?;
    let longitude = cmd
        .get("longitude")
        .and_then(|v| v.as_f64())
        .ok_or("Missing 'longitude' parameter")?;
    let accuracy = cmd.get("accuracy").and_then(|v| v.as_f64());

    mgr.set_geolocation(latitude, longitude, accuracy).await?;
    Ok(json!({ "latitude": latitude, "longitude": longitude }))
}

async fn handle_permissions(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let permissions: Vec<String> = cmd
        .get("permissions")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    mgr.grant_permissions(&permissions).await?;
    Ok(json!({ "granted": permissions }))
}

async fn handle_dialog(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let response = cmd.get("response").and_then(|v| v.as_str());

    // dialog status — return pending dialog info
    if response == Some("status") {
        return Ok(match &state.pending_dialog {
            Some(dialog) => {
                let mut obj = json!({
                    "hasDialog": true,
                    "type": dialog.dialog_type,
                    "message": dialog.message,
                });
                if let Some(ref prompt) = dialog.default_prompt {
                    obj["defaultPrompt"] = json!(prompt);
                }
                obj
            }
            None => json!({ "hasDialog": false }),
        });
    }

    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let accept = response
        .map(|r| r == "accept")
        .or_else(|| cmd.get("accept").and_then(|v| v.as_bool()))
        .unwrap_or(true);
    let prompt_text = cmd.get("promptText").and_then(|v| v.as_str());

    mgr.handle_dialog(accept, prompt_text).await?;
    state.pending_dialog = None;
    Ok(json!({ "handled": true, "accepted": accept }))
}

async fn handle_upload(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let files: Vec<String> = cmd
        .get("files")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .or_else(|| {
            cmd.get("file")
                .and_then(|v| v.as_str())
                .map(|s| vec![s.to_string()])
        })
        .unwrap_or_default();

    mgr.upload_files(selector, &files, &state.ref_map, &state.iframe_sessions)
        .await?;
    Ok(json!({ "uploaded": files.len(), "selector": selector }))
}

async fn handle_addscript(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let content = cmd
        .get("content")
        .or_else(|| cmd.get("source"))
        .or_else(|| cmd.get("script"))
        .and_then(|v| v.as_str());
    let url = cmd.get("url").and_then(|v| v.as_str());

    if content.is_none() && url.is_none() {
        return Err("At least one of 'content' or 'url' is required".to_string());
    }

    if let Some(src_url) = url {
        let js = format!(
            r#"new Promise((resolve, reject) => {{
                const s = document.createElement('script');
                s.src = {};
                s.onload = () => resolve(true);
                s.onerror = () => reject(new Error('Failed to load script'));
                document.head.appendChild(s);
            }})"#,
            serde_json::to_string(src_url).unwrap_or_default()
        );
        mgr.evaluate(&js, None).await?;
    } else if let Some(source) = content {
        let js = format!(
            r#"(() => {{
                const s = document.createElement('script');
                s.textContent = {};
                document.head.appendChild(s);
            }})()"#,
            serde_json::to_string(source).unwrap_or_default()
        );
        mgr.evaluate(&js, None).await?;
    }

    Ok(json!({ "added": true }))
}

async fn handle_addinitscript(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let source = cmd
        .get("script")
        .or_else(|| cmd.get("source"))
        .or_else(|| cmd.get("content"))
        .and_then(|v| v.as_str())
        .ok_or("Missing 'script' parameter")?;

    let identifier = mgr.add_script_to_evaluate(source).await?;
    Ok(json!({ "added": true, "identifier": identifier }))
}

async fn handle_removeinitscript(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let identifier = cmd
        .get("identifier")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'identifier' parameter")?;
    mgr.remove_script_to_evaluate(identifier).await?;
    Ok(json!({ "removed": true, "identifier": identifier }))
}

// === React / Web primitives ===

/// Parse a `Runtime.evaluate` result whose expression returned a JSON string.
/// Returns a helpful error if parsing fails.
fn parse_json_string(value: Value, what: &str) -> Result<Value, String> {
    let s = value
        .as_str()
        .ok_or_else(|| format!("{} returned non-string value", what))?;
    serde_json::from_str(s).map_err(|e| format!("{} returned invalid JSON: {}", what, e))
}

async fn handle_react_tree(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let result = mgr.evaluate(react::scripts::TREE_SNAPSHOT, None).await?;
    let nodes_json = parse_json_string(result, "react tree")?;
    let nodes: Vec<react::TreeNode> = serde_json::from_value(nodes_json)
        .map_err(|e| format!("Failed to parse tree nodes: {}", e))?;

    let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
    if return_json {
        let nodes_value: Vec<Value> = nodes
            .iter()
            .map(|n| {
                json!({
                    "id": n.id,
                    "type": n.node_type,
                    "name": n.name,
                    "key": n.key,
                    "parent": n.parent,
                })
            })
            .collect();
        Ok(json!({ "nodes": nodes_value }))
    } else {
        Ok(json!({ "tree": react::format_tree(&nodes) }))
    }
}

async fn handle_react_inspect(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let fiber_id = cmd
        .get("fiberId")
        .and_then(|v| v.as_i64())
        .ok_or("Missing 'fiberId' parameter (numeric React fiber id)")?;

    let script = react::scripts::TREE_INSPECT.replace("{{ID}}", &fiber_id.to_string());
    let result = mgr.evaluate(&script, None).await?;
    let parsed = parse_json_string(result, "react inspect")?;
    Ok(parsed)
}

async fn handle_react_renders_start(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    // Install for future navigations, then evaluate immediately so the
    // current page starts recording without a reload.
    let identifier = mgr
        .add_script_to_evaluate(react::scripts::RENDERS_INIT)
        .await?;
    mgr.evaluate(react::scripts::RENDERS_INIT, None).await?;
    let _ = cmd;
    Ok(json!({
        "recording": true,
        "identifier": identifier,
        "message": "recording renders - interact with the page, then run `react renders stop`"
    }))
}

async fn handle_react_renders_stop(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let result = mgr.evaluate(react::scripts::RENDERS_STOP, None).await?;
    let data_json = parse_json_string(result, "react renders stop")?;
    let data: react::RendersData = serde_json::from_value(data_json.clone())
        .map_err(|e| format!("Failed to parse renders data: {}", e))?;

    let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
    if return_json {
        Ok(data_json)
    } else {
        Ok(json!({ "report": react::format_renders_report(&data) }))
    }
}

async fn handle_react_suspense(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let result = mgr.evaluate(react::scripts::SUSPENSE_WALK, None).await?;
    let boundaries_json = parse_json_string(result, "react suspense")?;
    let boundaries: Vec<react::Boundary> = serde_json::from_value(boundaries_json.clone())
        .map_err(|e| format!("Failed to parse suspense boundaries: {}", e))?;

    let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
    let only_dynamic = cmd
        .get("onlyDynamic")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    if return_json {
        // When only-dynamic is set, filter the JSON payload too so callers
        // get consistent output regardless of format choice.
        if only_dynamic {
            let filtered: Vec<&react::Boundary> = boundaries
                .iter()
                .filter(|b| {
                    b.parent_id != 0
                        && (b.is_suspended
                            || !b.suspended_by.is_empty()
                            || b.unknown_suspenders.is_some())
                })
                .collect();
            Ok(json!({ "boundaries": filtered }))
        } else {
            Ok(json!({ "boundaries": boundaries_json }))
        }
    } else {
        Ok(json!({ "report": react::format_suspense_report(&boundaries, only_dynamic) }))
    }
}

async fn handle_vitals(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    // Install observers BEFORE the navigation/reload that we want to measure.
    // The script is idempotent — a no-op if already installed on the current page.
    {
        let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
        let _ = mgr.evaluate(react::scripts::VITALS_INIT, None).await?;
    }

    // Register as an init script too, so navigations done via `vitals --url`
    // start observing from the first paint.
    {
        let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
        let _ = mgr
            .add_script_to_evaluate(react::scripts::VITALS_INIT)
            .await;
    }

    // Navigate to the target URL (or reload the current page) to trigger a
    // full page load the observers can capture.
    let target = cmd.get("url").and_then(|v| v.as_str()).map(String::from);
    if let Some(url) = target {
        let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
        let _ = mgr.navigate(&url, WaitUntil::Load).await?;
    } else {
        handle_reload(state).await?;
    }

    // Give layout shifts and React effects a chance to settle.
    tokio::time::sleep(std::time::Duration::from_millis(3000)).await;

    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let url = mgr.get_url().await.unwrap_or_default();
    let result = mgr.evaluate(react::scripts::VITALS_READ, None).await?;
    let raw = parse_json_string(result, "vitals")?;

    // The raw payload has { cwv, timing, ttfb }. Merge with URL and process
    // timing into React hydration phases + per-component durations.
    let cwv = raw.get("cwv").cloned().unwrap_or(json!({}));
    let timing = raw
        .get("timing")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let ttfb = raw.get("ttfb").and_then(|v| v.as_f64());
    let lcp = cwv.get("lcp").cloned().unwrap_or(Value::Null);
    let cls_score = cwv.get("cls").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let cls_entries = cwv.get("clsEntries").cloned().unwrap_or(json!([]));
    let fcp = cwv.get("fcp").and_then(|v| v.as_f64());
    let inp = cwv.get("inp").and_then(|v| v.as_f64());

    let round = |n: f64| (n * 100.0).round() / 100.0;

    let mut hydration_phases: Vec<Value> = Vec::new();
    let mut hydration_start = f64::INFINITY;
    let mut hydration_end = 0.0f64;
    let mut hydrated_components: Vec<Value> = Vec::new();
    // React's profiling build emits `console.timeStamp(label, start, end,
    // track, trackGroup, color)` entries whose `track` / `trackGroup`
    // fields are literal strings containing the atom glyph (e.g.
    // "Scheduler ⚛", "Components ⚛"). The comparisons below match those
    // exact strings — don't "clean up" the glyphs.
    for e in &timing {
        let label = e.get("label").and_then(|v| v.as_str()).unwrap_or("");
        let track = e.get("track").and_then(|v| v.as_str()).unwrap_or("");
        let track_group = e.get("trackGroup").and_then(|v| v.as_str()).unwrap_or("");
        let color = e.get("color").and_then(|v| v.as_str()).unwrap_or("");
        let start = e.get("startTime").and_then(|v| v.as_f64()).unwrap_or(0.0);
        let end = e.get("endTime").and_then(|v| v.as_f64()).unwrap_or(0.0);
        if end <= start {
            continue;
        }
        if track_group == "Scheduler ⚛" {
            hydration_phases.push(json!({
                "label": label,
                "startTime": round(start),
                "endTime": round(end),
                "duration": round(end - start),
            }));
            if label == "Hydrated" {
                if start < hydration_start {
                    hydration_start = start;
                }
                if end > hydration_end {
                    hydration_end = end;
                }
            }
        } else if track == "Components ⚛" && color.starts_with("tertiary") {
            hydrated_components.push(json!({
                "name": label,
                "startTime": round(start),
                "endTime": round(end),
                "duration": round(end - start),
            }));
        }
    }
    hydrated_components.sort_by(|a, b| {
        let da = a.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0);
        let db = b.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0);
        db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
    });

    let hydration = if hydration_start.is_finite() && hydration_end > 0.0 {
        json!({
            "startTime": round(hydration_start),
            "endTime": round(hydration_end),
            "duration": round(hydration_end - hydration_start),
        })
    } else {
        Value::Null
    };

    let data_value = json!({
        "url": url,
        "ttfb": ttfb,
        "lcp": lcp,
        "cls": { "score": round(cls_score), "entries": cls_entries },
        "fcp": fcp,
        "inp": inp,
        "hydration": hydration,
        "phases": hydration_phases,
        "hydratedComponents": hydrated_components,
    });

    let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
    if return_json {
        Ok(data_value)
    } else {
        let data: react::VitalsData = serde_json::from_value(data_value.clone())
            .map_err(|e| format!("Failed to parse vitals data: {}", e))?;
        Ok(json!({ "report": react::format_vitals_report(&data) }))
    }
}

async fn handle_pushstate(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let url = cmd
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url' parameter")?;
    let script = react::scripts::PUSHSTATE.replace(
        "{{URL}}",
        &serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()),
    );
    let result = mgr.evaluate(&script, None).await?;
    let after = result.as_str().map(String::from).unwrap_or_default();
    Ok(json!({ "url": after }))
}

async fn handle_addstyle(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let content = cmd
        .get("content")
        .or_else(|| cmd.get("css"))
        .and_then(|v| v.as_str());
    let url = cmd.get("url").and_then(|v| v.as_str());

    if content.is_none() && url.is_none() {
        return Err("At least one of 'content' or 'url' is required".to_string());
    }

    if let Some(href) = url {
        let js = format!(
            r#"new Promise((resolve, reject) => {{
                const link = document.createElement('link');
                link.rel = 'stylesheet';
                link.href = {};
                link.onload = () => resolve(true);
                link.onerror = () => reject(new Error('Failed to load stylesheet'));
                document.head.appendChild(link);
            }})"#,
            serde_json::to_string(href).unwrap_or_default()
        );
        mgr.evaluate(&js, None).await?;
    } else if let Some(css) = content {
        let js = format!(
            r#"(() => {{
                const style = document.createElement('style');
                style.textContent = {};
                document.head.appendChild(style);
            }})()"#,
            serde_json::to_string(css).unwrap_or_default()
        );
        mgr.evaluate(&js, None).await?;
    }

    Ok(json!({ "added": true }))
}

async fn handle_clipboard(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let action = cmd
        .get("subAction")
        .or_else(|| cmd.get("operation"))
        .and_then(|v| v.as_str())
        .unwrap_or("read");

    let session_id = mgr.active_session_id()?.to_string();

    // cfg! is compile-time; assumes the browser runs on the same OS as the CLI binary.
    let modifier: i32 = if cfg!(target_os = "macos") { 4 } else { 2 };

    match action {
        "write" => {
            let text = cmd
                .get("text")
                .or_else(|| cmd.get("value"))
                .and_then(|v| v.as_str())
                .ok_or("Missing 'text' parameter")?;
            let js = format!(
                "navigator.clipboard.writeText({})",
                serde_json::to_string(text).unwrap_or_default()
            );
            mgr.evaluate(&js, None).await?;
            Ok(json!({ "written": text }))
        }
        "copy" => {
            interaction::press_key_with_modifiers(&mgr.client, &session_id, "c", Some(modifier))
                .await?;
            Ok(json!({ "copied": true }))
        }
        "paste" => {
            interaction::press_key_with_modifiers(&mgr.client, &session_id, "v", Some(modifier))
                .await?;
            Ok(json!({ "pasted": true }))
        }
        _ => {
            let result = mgr.evaluate("navigator.clipboard.readText()", None).await?;
            Ok(json!({ "text": result }))
        }
    }
}

async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let delta_x = cmd.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let delta_y = cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);

    // Humanize: at Off this is one instant wheel event (unchanged); at
    // Fast/Human the scroll is split into eased, slightly-jittered segments so
    // it ramps and settles like a real wheel/trackpad flick.
    let level = humanize::active_level();
    let seed = humanize::next_seed();
    for (dx, dy, delay) in humanize::scroll_segments(delta_x, delta_y, level, seed) {
        mgr.client
            .send_command(
                "Input.dispatchMouseEvent",
                Some(json!({
                    "type": "mouseWheel",
                    "x": x,
                    "y": y,
                    "deltaX": dx,
                    "deltaY": dy,
                })),
                Some(&session_id),
            )
            .await?;
        if !delay.is_zero() {
            tokio::time::sleep(delay).await;
        }
    }

    Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y }))
}

async fn handle_device(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let name = cmd
        .get("name")
        .or_else(|| cmd.get("device"))
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name' parameter")?;

    let (width, height, scale, mobile, ua) = match name.to_lowercase().as_str() {
        "iphone 15" | "iphone15" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"),
        "iphone 16" | "iphone16" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
        "iphone 16 pro" | "iphone16pro" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
        "iphone 17" | "iphone17" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1"),
        "ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"),
        "ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"),
        "pixel 9" | "pixel9" => (412, 923, 2.625, true, "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"),
        "galaxy s25" | "galaxys25" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 15; SM-S931B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"),
        // Legacy aliases
        "iphone 12" | "iphone12" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"),
        "iphone 14" | "iphone14" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"),
        "pixel 5" | "pixel5" => (393, 851, 2.75, true, "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"),
        "pixel 7" | "pixel7" => (412, 915, 2.625, true, "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"),
        "galaxy s21" | "galaxys21" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"),
        _ => return Err(format!("Unknown device: {}. Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro, Pixel 9, Galaxy S25", name)),
    };

    mgr.set_viewport(width, height, scale, mobile).await?;
    mgr.set_user_agent(ua).await?;

    state.viewport = Some((width, height, scale, mobile));

    // Update stream server viewport so status messages and screencast use the new dimensions
    if let Some(ref server) = state.stream_server {
        server.set_viewport(width as u32, height as u32).await;
    }

    Ok(json!({
        "device": name,
        "width": width,
        "height": height,
        "deviceScaleFactor": scale,
        "mobile": mobile,
    }))
}

// ---------------------------------------------------------------------------
// Stream handlers
// ---------------------------------------------------------------------------

fn stream_file_path(session_id: &str) -> PathBuf {
    get_socket_dir().join(format!("{}.stream", session_id))
}

fn write_stream_file(session_id: &str, port: u16) -> Result<(), String> {
    let path = stream_file_path(session_id);
    fs::write(&path, port.to_string()).map_err(|e| {
        format!(
            "Failed to write stream metadata '{}': {}",
            path.display(),
            e
        )
    })
}

fn remove_stream_file(session_id: &str) -> Result<(), String> {
    let path = stream_file_path(session_id);
    match fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(format!(
            "Failed to remove stream metadata '{}': {}",
            path.display(),
            err
        )),
    }
}

fn engine_file_path(session_id: &str) -> PathBuf {
    get_socket_dir().join(format!("{}.engine", session_id))
}

fn write_engine_file(session_id: &str, engine: &str) {
    let _ = fs::write(engine_file_path(session_id), engine);
}

fn remove_engine_file(session_id: &str) {
    let _ = fs::remove_file(engine_file_path(session_id));
}

fn provider_file_path(session_id: &str) -> PathBuf {
    get_socket_dir().join(format!("{}.provider", session_id))
}

fn write_provider_file(session_id: &str, provider: &str) {
    let _ = fs::write(provider_file_path(session_id), provider);
}

fn remove_provider_file(session_id: &str) {
    let _ = fs::remove_file(provider_file_path(session_id));
}

fn extensions_file_path(session_id: &str) -> PathBuf {
    get_socket_dir().join(format!("{}.extensions", session_id))
}

fn write_extensions_file(session_id: &str) {
    if let Ok(val) = env::var("AGENT_BROWSER_EXTENSIONS") {
        let trimmed = val.trim();
        if !trimmed.is_empty() {
            let _ = fs::write(extensions_file_path(session_id), trimmed);
            return;
        }
    }
    let _ = fs::remove_file(extensions_file_path(session_id));
}

fn remove_extensions_file(session_id: &str) {
    let _ = fs::remove_file(extensions_file_path(session_id));
}

async fn current_stream_status(state: &DaemonState) -> Value {
    debug_assert_eq!(
        state.stream_server.is_some(),
        state.stream_client.is_some(),
        "stream server and stream client slot should be set together"
    );

    let connected = match state.browser.as_ref() {
        Some(mgr) => mgr.is_connection_alive().await,
        None => false,
    };
    let runtime_screencasting = match state.stream_server.as_ref() {
        Some(server) => server.is_screencasting().await,
        None => false,
    };

    json!({
        "enabled": state.stream_server.is_some(),
        "port": state
            .stream_server
            .as_ref()
            .map(|server| Value::from(server.port()))
            .unwrap_or(Value::Null),
        "connected": connected,
        "screencasting": connected && (state.screencasting || runtime_screencasting),
    })
}

async fn handle_stream_enable(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    if state.stream_server.is_some() {
        return Err("Streaming is already enabled for this session".to_string());
    }

    let requested_port = match cmd.get("port").and_then(|value| value.as_u64()) {
        Some(raw) => u16::try_from(raw)
            .map_err(|_| format!("Invalid stream port '{}': expected 0-65535", raw))?,
        None => 0,
    };

    let (server, client_slot) =
        StreamServer::start_without_client(requested_port, state.session_id.clone(), false).await?;
    let port = server.port();
    if let Err(err) = write_stream_file(&state.session_id, port) {
        server.shutdown().await;
        return Err(err);
    }

    state.stream_client = Some(client_slot);
    state.stream_server = Some(Arc::new(server));
    state.request_tracking = true;
    if state.screencasting {
        if let Some(ref server) = state.stream_server {
            server.set_screencasting(true).await;
        }
    }
    state.update_stream_client().await;

    Ok(current_stream_status(state).await)
}

async fn handle_stream_disable(state: &mut DaemonState) -> Result<Value, String> {
    let Some(server) = state.stream_server.clone() else {
        return Err("Streaming is not enabled for this session".to_string());
    };

    server.shutdown().await;
    state.stream_server = None;
    state.stream_client = None;
    remove_stream_file(&state.session_id)?;
    remove_engine_file(&state.session_id);
    remove_provider_file(&state.session_id);

    Ok(json!({ "disabled": true }))
}

async fn handle_stream_status(state: &DaemonState) -> Result<Value, String> {
    Ok(current_stream_status(state).await)
}

// ---------------------------------------------------------------------------
// Screencast handlers
// ---------------------------------------------------------------------------

async fn handle_screencast_start(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    if state.screencasting {
        return Err("Screencast already active".to_string());
    }

    // Use stored viewport as default for screencast dimensions
    let (default_w, default_h) = if let Some(ref server) = state.stream_server {
        server.viewport().await
    } else {
        (1280, 720)
    };
    let format = cmd.get("format").and_then(|v| v.as_str()).unwrap_or("jpeg");
    let quality = cmd.get("quality").and_then(|v| v.as_i64()).unwrap_or(80) as i32;
    let max_width = cmd
        .get("maxWidth")
        .and_then(|v| v.as_i64())
        .unwrap_or(default_w as i64) as i32;
    let max_height = cmd
        .get("maxHeight")
        .and_then(|v| v.as_i64())
        .unwrap_or(default_h as i64) as i32;

    stream::start_screencast(
        &mgr.client,
        &session_id,
        format,
        quality,
        max_width,
        max_height,
    )
    .await?;
    state.screencasting = true;

    if let Some(ref server) = state.stream_server {
        server.set_screencasting(true).await;
        server
            .broadcast_status(
                true,
                true,
                max_width as u32,
                max_height as u32,
                &state.engine,
            )
            .await;
    }

    Ok(json!({ "started": true }))
}

async fn handle_screencast_stop(state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?;

    if !state.screencasting {
        return Err("No screencast active".to_string());
    }

    stream::stop_screencast(&mgr.client, session_id).await?;
    state.screencasting = false;

    if let Some(ref server) = state.stream_server {
        server.set_screencasting(false).await;
        let (vw, vh) = server.viewport().await;
        server
            .broadcast_status(true, false, vw, vh, &state.engine)
            .await;
    }

    Ok(json!({ "stopped": true }))
}

// ---------------------------------------------------------------------------
// Wait variant handlers
// ---------------------------------------------------------------------------

async fn handle_waitforurl(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let url_pattern = cmd
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url' parameter")?;
    let timeout_ms = state.timeout_ms(cmd);

    wait_for_url(&mgr.client, &session_id, url_pattern, timeout_ms).await?;
    let url = mgr.get_url().await.unwrap_or_default();
    Ok(json!({ "url": url }))
}

async fn handle_waitforloadstate(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let load_state = cmd.get("state").and_then(|v| v.as_str()).unwrap_or("load");
    let timeout_ms = state.timeout_ms(cmd);

    let wait_until = WaitUntil::from_str(load_state);
    let _ = tokio::time::timeout(
        tokio::time::Duration::from_millis(timeout_ms),
        mgr.wait_for_lifecycle_external(wait_until, &session_id),
    )
    .await
    .map_err(|_| format!("Timeout waiting for load state: {}", load_state))?;

    Ok(json!({ "state": load_state }))
}

async fn handle_waitforfunction(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let expression = cmd
        .get("expression")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'expression' parameter")?;
    let timeout_ms = state.timeout_ms(cmd);

    wait_for_function(&mgr.client, &session_id, expression, timeout_ms).await?;

    let result: super::cdp::types::EvaluateResult = mgr
        .client
        .send_command_typed(
            "Runtime.evaluate",
            &super::cdp::types::EvaluateParams {
                expression: format!("({})", expression),
                return_by_value: Some(true),
                await_promise: Some(true),
            },
            Some(&session_id),
        )
        .await?;

    Ok(json!({ "result": result.result.value.unwrap_or(Value::Null) }))
}

// ---------------------------------------------------------------------------
// Frame handlers
// ---------------------------------------------------------------------------

async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let selector = cmd.get("selector").and_then(|v| v.as_str());
    let name = cmd.get("name").and_then(|v| v.as_str());
    let url = cmd.get("url").and_then(|v| v.as_str());

    if selector.is_none() && name.is_none() && url.is_none() {
        return Err("At least one of 'selector', 'name', or 'url' is required".to_string());
    }

    let tree_result = mgr
        .client
        .send_command_no_params("Page.getFrameTree", Some(&session_id))
        .await?;

    fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option<String> {
        let frame = tree.get("frame")?;
        let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or("");
        let frame_url = frame.get("url").and_then(|v| v.as_str()).unwrap_or("");
        let frame_id = frame.get("id").and_then(|v| v.as_str())?;

        if let Some(n) = name {
            if frame_name == n {
                return Some(frame_id.to_string());
            }
        }
        if let Some(u) = url {
            if frame_url.contains(u) {
                return Some(frame_id.to_string());
            }
        }

        if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
            for child in children {
                if let Some(id) = find_frame(child, name, url) {
                    return Some(id);
                }
            }
        }
        None
    }

    let frame_tree = &tree_result["frameTree"];

    // If selector is a ref (@e1), resolve the iframe element from the ref map
    if let Some(sel) = selector {
        if let Some(ref_id) = super::element::parse_ref(sel) {
            let entry = state
                .ref_map
                .get(&ref_id)
                .ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
            let backend_node_id = entry
                .backend_node_id
                .ok_or_else(|| format!("Ref {} has no backend node id", ref_id))?;

            // Use DOM.describeNode to resolve the child frame ID directly.
            // This works reliably for all iframes, including those without
            // name, id, or src attributes.
            let describe: Value = mgr
                .client
                .send_command(
                    "DOM.describeNode",
                    Some(json!({ "backendNodeId": backend_node_id, "depth": 1 })),
                    Some(&session_id),
                )
                .await?;

            // Verify this is an iframe/frame element
            let node_name = describe
                .get("node")
                .and_then(|n| n.get("nodeName"))
                .and_then(|v| v.as_str())
                .unwrap_or("");
            if node_name != "IFRAME" && node_name != "FRAME" {
                return Err("Ref does not point to an iframe element".to_string());
            }

            // Try contentDocument.frameId first (standard for iframes)
            let frame_id = describe
                .get("node")
                .and_then(|n| n.get("contentDocument"))
                .and_then(|cd| cd.get("frameId"))
                .and_then(|v| v.as_str())
                // Fallback: the node itself may carry a frameId
                .or_else(|| {
                    describe
                        .get("node")
                        .and_then(|n| n.get("frameId"))
                        .and_then(|v| v.as_str())
                })
                .ok_or("Could not resolve frame ID for iframe element")?;

            let label = describe
                .get("node")
                .and_then(|n| n.get("attributes"))
                .and_then(|a| a.as_array())
                .and_then(|attrs| {
                    attrs
                        .iter()
                        .enumerate()
                        .find(|(_, v)| v.as_str() == Some("name"))
                        .and_then(|(i, _)| attrs.get(i + 1))
                        .and_then(|v| v.as_str())
                })
                .unwrap_or(&ref_id);

            state.active_frame_id = Some(frame_id.to_string());
            return Ok(json!({ "frame": label }));
        }

        // CSS selector path
        let js = format!(
            r#"(() => {{
                const el = document.querySelector({});
                if (!el) return null;
                if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {{
                    return el.name || el.id || el.src || null;
                }}
                return null;
            }})()"#,
            serde_json::to_string(sel).unwrap_or_default()
        );
        let result = mgr.evaluate(&js, None).await?;
        let frame_name = result.as_str().ok_or("Could not find frame for selector")?;
        if let Some(frame_id) = find_frame(frame_tree, Some(frame_name), None) {
            state.active_frame_id = Some(frame_id);
            return Ok(json!({ "frame": frame_name }));
        }
    }

    if let Some(frame_id) = find_frame(frame_tree, name, url) {
        let label = name.or(url).unwrap_or("frame");
        state.active_frame_id = Some(frame_id);
        return Ok(json!({ "frame": label }));
    }

    Err("Frame not found".to_string())
}

async fn handle_mainframe(state: &mut DaemonState) -> Result<Value, String> {
    state.active_frame_id = None;
    Ok(json!({ "frame": "main" }))
}

// ---------------------------------------------------------------------------
// Semantic locator handlers
// ---------------------------------------------------------------------------

async fn execute_subaction(
    cmd: &Value,
    state: &mut DaemonState,
    selector: &str,
) -> Result<Value, String> {
    let subaction = cmd
        .get("subaction")
        .and_then(|v| v.as_str())
        .unwrap_or("click");
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    match subaction {
        "click" => {
            interaction::click(
                &mgr.client,
                &session_id,
                &state.ref_map,
                selector,
                "left",
                1,
                &state.iframe_sessions,
            )
            .await?;
            Ok(json!({ "clicked": selector }))
        }
        "fill" => {
            let value = cmd
                .get("value")
                .and_then(|v| v.as_str())
                .ok_or("Missing 'value' for fill subaction")?;
            interaction::fill(
                &mgr.client,
                &session_id,
                &state.ref_map,
                selector,
                value,
                &state.iframe_sessions,
            )
            .await?;
            Ok(json!({ "filled": selector }))
        }
        "check" => {
            interaction::check(
                &mgr.client,
                &session_id,
                &state.ref_map,
                selector,
                &state.iframe_sessions,
            )
            .await?;
            Ok(json!({ "checked": selector }))
        }
        "hover" => {
            interaction::hover(
                &mgr.client,
                &session_id,
                &state.ref_map,
                selector,
                &state.iframe_sessions,
            )
            .await?;
            Ok(json!({ "hovered": selector }))
        }
        "text" => {
            let text = super::element::get_element_text(
                &mgr.client,
                &session_id,
                &state.ref_map,
                selector,
                &state.iframe_sessions,
            )
            .await?;
            Ok(json!({ "text": text }))
        }
        _ => Err(format!("Unknown subaction: {}", subaction)),
    }
}

/// CSS selector that matches an ARIA `role` — both an explicit `role="X"`
/// attribute AND the HTML elements that carry that role *implicitly*. The naive
/// `[role="X"], X` form fails for every role whose implicit element has a
/// different tag than the role name (e.g. role `link` ⇒ `<a href>`, not `<link>`;
/// role `heading` ⇒ `<h1>`..`<h6>`), which made `find role link/heading` never
/// match real elements.
fn role_to_query(role: &str) -> String {
    let implicit = match role {
        "link" => "a[href], area[href]",
        "button" => "button, input[type=button], input[type=submit], input[type=reset], summary",
        "heading" => "h1, h2, h3, h4, h5, h6",
        "textbox" => {
            "input[type=text], input[type=search], input[type=email], input[type=url], \
             input[type=tel], input[type=password], input:not([type]), textarea"
        }
        "searchbox" => "input[type=search]",
        "checkbox" => "input[type=checkbox]",
        "radio" => "input[type=radio]",
        "combobox" => "select",
        "listbox" => "select[multiple]",
        "slider" => "input[type=range]",
        "spinbutton" => "input[type=number]",
        "img" => "img",
        "list" => "ul, ol",
        "listitem" => "li",
        "table" => "table",
        "row" => "tr",
        "cell" | "gridcell" => "td",
        "columnheader" | "rowheader" => "th",
        "article" => "article",
        "navigation" => "nav",
        "main" => "main",
        "banner" => "header",
        "contentinfo" => "footer",
        "complementary" => "aside",
        "figure" => "figure",
        "separator" => "hr",
        "progressbar" => "progress",
        "group" => "fieldset",
        _ => "",
    };
    if implicit.is_empty() {
        format!("[role=\"{role}\"], {role}")
    } else {
        format!("[role=\"{role}\"], {implicit}")
    }
}

fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String {
    match name {
        Some(n) => {
            let exact_str = if exact { ", exact: true" } else { "" };
            format!("getByRole('{}', {{ name: '{}'{} }})", role, n, exact_str)
        }
        None => format!("getByRole('{}')", role),
    }
}

async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let role = cmd
        .get("role")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'role' parameter")?;
    let name = cmd.get("name").and_then(|v| v.as_str());
    let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);

    // Accessible-name approximation: aria-label, then title/alt/value, then the
    // element's text. Covers links (text), input buttons (value), images (alt).
    let name_match = name
        .map(|n| {
            let nj = serde_json::to_string(n).unwrap_or_default();
            if exact {
                format!("__an === {nj}")
            } else {
                format!("__an.includes({nj})")
            }
        })
        .unwrap_or_else(|| "true".to_string());

    let js = format!(
        r#"(() => {{
            const els = document.querySelectorAll({selector});
            for (const el of els) {{
                const __an = (el.getAttribute('aria-label') || el.getAttribute('title')
                    || el.getAttribute('alt') || el.value || el.textContent || '').trim();
                if ({name_match}) {{
                    el.setAttribute('data-agent-browser-located', 'true');
                    return true;
                }}
            }}
            return false;
        }})()"#,
        selector = serde_json::to_string(&role_to_query(role)).unwrap_or_default(),
        name_match = name_match,
    );

    let result: super::cdp::types::EvaluateResult = mgr
        .client
        .send_command_typed(
            "Runtime.evaluate",
            &super::cdp::types::EvaluateParams {
                expression: js,
                return_by_value: Some(true),
                await_promise: Some(false),
            },
            Some(&session_id),
        )
        .await?;

    if !result
        .result
        .value
        .as_ref()
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        let desc = build_role_selector(role, name, exact);
        return Err(format!("No element found: {}", desc));
    }

    let selector = "[data-agent-browser-located='true']";
    let result = execute_subaction(cmd, state, selector).await;

    // Clean up the marker attribute
    if let Some(ref browser) = state.browser {
        if browser.active_session_id().is_ok() {
            let _ = browser
                .evaluate(
                    "document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')",
                    None,
                )
                .await;
        }
    }

    result
}

async fn handle_semantic_locator(
    cmd: &Value,
    state: &mut DaemonState,
    strategy: &str,
    param_name: &str,
) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let value = cmd
        .get(param_name)
        .and_then(|v| v.as_str())
        .ok_or(format!("Missing '{}' parameter", param_name))?;
    let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);

    let match_fn = if exact {
        format!(
            "el.textContent.trim() === {}",
            serde_json::to_string(value).unwrap_or_default()
        )
    } else {
        format!(
            "el.textContent.includes({})",
            serde_json::to_string(value).unwrap_or_default()
        )
    };

    let query = match strategy {
        "label" => format!(
            r#"(() => {{
                const label = Array.from(document.querySelectorAll('label')).find(el => {match_fn});
                if (!label) return false;
                const forId = label.getAttribute('for');
                const target = forId ? document.getElementById(forId) : label.querySelector('input,select,textarea');
                if (target) {{ target.setAttribute('data-agent-browser-located', 'true'); return true; }}
                return false;
            }})()"#,
            match_fn = match_fn,
        ),
        "placeholder" => format!(
            r#"(() => {{
                const el = document.querySelector('input[placeholder={val}], textarea[placeholder={val}]');
                if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
                return false;
            }})()"#,
            val = serde_json::to_string(value).unwrap_or_default(),
        ),
        "alttext" => format!(
            r#"(() => {{
                const el = document.querySelector('img[alt={val}], [alt={val}]');
                if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
                return false;
            }})()"#,
            val = serde_json::to_string(value).unwrap_or_default(),
        ),
        "title" => format!(
            r#"(() => {{
                const el = document.querySelector('[title={val}]');
                if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
                return false;
            }})()"#,
            val = serde_json::to_string(value).unwrap_or_default(),
        ),
        "testid" => format!(
            r#"(() => {{
                const el = document.querySelector('[data-testid={val}]');
                if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
                return false;
            }})()"#,
            val = serde_json::to_string(value).unwrap_or_default(),
        ),
        _ => {
            // "text" strategy
            format!(
                r#"(() => {{
                    const all = document.querySelectorAll('*');
                    for (const el of all) {{
                        if (el.children.length === 0 && {match_fn}) {{
                            el.setAttribute('data-agent-browser-located', 'true');
                            return true;
                        }}
                    }}
                    return false;
                }})()"#,
                match_fn = match_fn,
            )
        }
    };

    let result: super::cdp::types::EvaluateResult = mgr
        .client
        .send_command_typed(
            "Runtime.evaluate",
            &super::cdp::types::EvaluateParams {
                expression: query,
                return_by_value: Some(true),
                await_promise: Some(false),
            },
            Some(&session_id),
        )
        .await?;

    if !result
        .result
        .value
        .as_ref()
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        return Err(format!("No element found by {} '{}'", strategy, value));
    }

    let selector = "[data-agent-browser-located='true']";
    let action_result = execute_subaction(cmd, state, selector).await;

    if let Some(ref browser) = state.browser {
        let _ = browser
            .evaluate(
                "document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')",
                None,
            )
            .await;
    }

    action_result
}

async fn handle_getbytext(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    handle_semantic_locator(cmd, state, "text", "text").await
}

async fn handle_getbylabel(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    handle_semantic_locator(cmd, state, "label", "label").await
}

async fn handle_getbyplaceholder(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    handle_semantic_locator(cmd, state, "placeholder", "placeholder").await
}

async fn handle_getbyalttext(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    handle_semantic_locator(cmd, state, "alttext", "text").await
}

async fn handle_getbytitle(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    handle_semantic_locator(cmd, state, "title", "text").await
}

async fn handle_getbytestid(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    handle_semantic_locator(cmd, state, "testid", "testId").await
}

async fn handle_nth(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let index = cmd
        .get("index")
        .and_then(|v| v.as_i64())
        .ok_or("Missing 'index' parameter")?;

    let js = format!(
        r#"(() => {{
            const els = document.querySelectorAll({sel});
            const idx = {idx} < 0 ? els.length + {idx} : {idx};
            if (idx < 0 || idx >= els.length) return false;
            els[idx].setAttribute('data-agent-browser-located', 'true');
            return true;
        }})()"#,
        sel = serde_json::to_string(selector).unwrap_or_default(),
        idx = index,
    );

    let result: super::cdp::types::EvaluateResult = mgr
        .client
        .send_command_typed(
            "Runtime.evaluate",
            &super::cdp::types::EvaluateParams {
                expression: js,
                return_by_value: Some(true),
                await_promise: Some(false),
            },
            Some(&session_id),
        )
        .await?;

    if !result
        .result
        .value
        .as_ref()
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        return Err(format!(
            "No element at index {} for selector '{}'",
            index, selector
        ));
    }

    let located = "[data-agent-browser-located='true']";
    let action_result = execute_subaction(cmd, state, located).await;

    if let Some(ref browser) = state.browser {
        let _ = browser
            .evaluate(
                "document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')",
                None,
            )
            .await;
    }

    action_result
}

async fn handle_find(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;

    let js = format!(
        r#"(() => {{
            const els = document.querySelectorAll({});
            return Array.from(els).map((el, i) => ({{
                index: i,
                tagName: el.tagName.toLowerCase(),
                text: el.textContent?.trim().substring(0, 100) || '',
                visible: el.offsetWidth > 0 && el.offsetHeight > 0,
            }}));
        }})()"#,
        serde_json::to_string(selector).unwrap_or_default()
    );

    let result = mgr.evaluate(&js, None).await?;
    Ok(json!({ "elements": result, "selector": selector }))
}

async fn handle_evalhandle(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let script = cmd
        .get("script")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'script' parameter")?;

    let result: super::cdp::types::EvaluateResult = mgr
        .client
        .send_command_typed(
            "Runtime.evaluate",
            &super::cdp::types::EvaluateParams {
                expression: script.to_string(),
                return_by_value: Some(false),
                await_promise: Some(true),
            },
            Some(&session_id),
        )
        .await?;

    let handle = result.result.object_id.unwrap_or_default();
    Ok(json!({ "handle": handle }))
}

// ---------------------------------------------------------------------------
// Advanced interaction handlers
// ---------------------------------------------------------------------------

async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let source = cmd
        .get("source")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'source' parameter")?;
    let target = cmd
        .get("target")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'target' parameter")?;

    let (sx, sy, _, _, source_session_id) = super::element::resolve_element_center(
        &mgr.client,
        &session_id,
        &state.ref_map,
        source,
        &state.iframe_sessions,
    )
    .await?;
    let (tx, ty, _, _, target_session_id) = super::element::resolve_element_center(
        &mgr.client,
        &session_id,
        &state.ref_map,
        target,
        &state.iframe_sessions,
    )
    .await?;

    // Mouse down at source
    mgr.client
        .send_command(
            "Input.dispatchMouseEvent",
            Some(json!({ "type": "mouseMoved", "x": sx, "y": sy })),
            Some(&source_session_id),
        )
        .await?;
    mgr.client
        .send_command(
            "Input.dispatchMouseEvent",
            Some(json!({ "type": "mousePressed", "x": sx, "y": sy, "button": "left", "buttons": 1, "clickCount": 1 })),
            Some(&source_session_id),
        )
        .await?;

    // Move to the target with the left button held (buttons: 1) so the browser
    // sees a drag. At Off this is the original linear 10-step path; at
    // Fast/Human it follows humanize's curved, decelerating trajectory.
    let level = humanize::active_level();
    let drag_path: Vec<(f64, f64, std::time::Duration)> =
        if matches!(level, humanize::HumanizeLevel::Off) {
            (1..=10)
                .map(|i| {
                    let cx = sx + (tx - sx) * (i as f64) / 10.0;
                    let cy = sy + (ty - sy) * (i as f64) / 10.0;
                    (cx, cy, std::time::Duration::from_millis(10))
                })
                .collect()
        } else {
            humanize::move_path((sx, sy), (tx, ty), level, humanize::next_seed())
                .into_iter()
                .map(|s| (s.x, s.y, s.delay))
                .collect()
        };
    for (cx, cy, delay) in drag_path {
        mgr.client
            .send_command(
                "Input.dispatchMouseEvent",
                Some(json!({ "type": "mouseMoved", "x": cx, "y": cy, "button": "left", "buttons": 1 })),
                Some(&target_session_id),
            )
            .await?;
        if !delay.is_zero() {
            tokio::time::sleep(delay).await;
        }
    }

    // Mouse up at target
    mgr.client
        .send_command(
            "Input.dispatchMouseEvent",
            Some(json!({ "type": "mouseReleased", "x": tx, "y": ty, "button": "left", "buttons": 0, "clickCount": 1 })),
            Some(&target_session_id),
        )
        .await?;

    Ok(json!({ "dragged": true, "source": source, "target": target }))
}

async fn handle_expose(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let name = cmd
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name' parameter")?;

    mgr.client
        .send_command(
            "Runtime.addBinding",
            Some(json!({ "name": name })),
            Some(&session_id),
        )
        .await?;

    Ok(json!({ "exposed": name }))
}

async fn handle_pause(_state: &DaemonState) -> Result<Value, String> {
    Ok(json!({ "paused": true, "note": "Use DevTools to inspect. The daemon remains running." }))
}

async fn handle_multiselect(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let selector = cmd
        .get("selector")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'selector' parameter")?;
    let values: Vec<String> = cmd
        .get("values")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    let values_json = serde_json::to_string(&values).unwrap_or("[]".to_string());
    let js = format!(
        r#"(() => {{
            const select = document.querySelector({sel});
            if (!select) throw new Error('Select element not found');
            const vals = {vals};
            for (const opt of select.options) {{
                opt.selected = vals.includes(opt.value);
            }}
            select.dispatchEvent(new Event('change', {{ bubbles: true }}));
            return Array.from(select.selectedOptions).map(o => o.value);
        }})()"#,
        sel = serde_json::to_string(selector).unwrap_or_default(),
        vals = values_json,
    );

    let result = mgr.evaluate(&js, None).await?;
    Ok(json!({ "selected": result }))
}

async fn handle_responsebody(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let url_pattern = cmd
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url' parameter")?;
    let timeout_ms = state.timeout_ms(cmd);

    let mut rx = mgr.client.subscribe();
    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);

    loop {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            return Err(format!(
                "Timeout waiting for response matching '{}'",
                url_pattern
            ));
        }

        match tokio::time::timeout(remaining, rx.recv()).await {
            Ok(Ok(event)) => {
                if event.method == "Network.responseReceived"
                    && event.session_id.as_deref() == Some(&session_id)
                {
                    if let Some(resp_url) = event
                        .params
                        .get("response")
                        .and_then(|r| r.get("url"))
                        .and_then(|u| u.as_str())
                    {
                        if resp_url.contains(url_pattern) {
                            let request_id = event
                                .params
                                .get("requestId")
                                .and_then(|v| v.as_str())
                                .ok_or("No requestId in response event")?;
                            let status = event
                                .params
                                .get("response")
                                .and_then(|r| r.get("status"))
                                .and_then(|v| v.as_i64())
                                .unwrap_or(0);
                            let headers = event
                                .params
                                .get("response")
                                .and_then(|r| r.get("headers"))
                                .cloned()
                                .unwrap_or(json!({}));

                            let body_result = mgr
                                .client
                                .send_command(
                                    "Network.getResponseBody",
                                    Some(json!({ "requestId": request_id })),
                                    Some(&session_id),
                                )
                                .await?;
                            let body = body_result
                                .get("body")
                                .and_then(|v| v.as_str())
                                .unwrap_or("");

                            return Ok(
                                json!({ "body": body, "status": status, "headers": headers }),
                            );
                        }
                    }
                }
            }
            Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
            Ok(Err(_)) => return Err("Event stream closed".to_string()),
            Err(_) => {
                return Err(format!(
                    "Timeout waiting for response matching '{}'",
                    url_pattern
                ));
            }
        }
    }
}

async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let timeout_ms = state.timeout_ms(cmd);

    let mut rx = mgr.client.subscribe();
    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);

    loop {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            return Err("Timeout waiting for download".to_string());
        }

        match tokio::time::timeout(remaining, rx.recv()).await {
            Ok(Ok(event)) => {
                // Browser-domain events may arrive without a sessionId;
                // Page-domain events are matched by session.
                let is_page_session = event.session_id.as_deref() == Some(&session_id);
                let is_progress = event.method == "Browser.downloadProgress"
                    || (event.method == "Page.downloadProgress" && is_page_session);

                if is_progress
                    && event.params.get("state").and_then(|v| v.as_str()) == Some("completed")
                {
                    let path = cmd
                        .get("path")
                        .and_then(|v| v.as_str())
                        .unwrap_or("download");
                    return Ok(json!({ "path": path }));
                }
            }
            Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
            Ok(Err(_)) => return Err("Event stream closed".to_string()),
            Err(_) => return Err("Timeout waiting for download".to_string()),
        }
    }
}

async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_mut().ok_or("Browser not launched")?;

    // Create a new browser context
    let context_result = mgr
        .client
        .send_command_no_params("Target.createBrowserContext", None)
        .await?;
    let context_id = context_result
        .get("browserContextId")
        .and_then(|v| v.as_str())
        .ok_or("Failed to create browser context")?
        .to_string();

    let create_result: super::cdp::types::CreateTargetResult = mgr
        .client
        .send_command_typed(
            "Target.createTarget",
            &json!({ "url": "about:blank", "browserContextId": context_id }),
            None,
        )
        .await?;

    let attach: super::cdp::types::AttachToTargetResult = mgr
        .client
        .send_command_typed(
            "Target.attachToTarget",
            &super::cdp::types::AttachToTargetParams {
                target_id: create_result.target_id.clone(),
                flatten: true,
            },
            None,
        )
        .await?;

    let tab_id = mgr.assign_tab_id();
    mgr.add_page(super::browser::PageInfo {
        tab_id,
        label: None,
        target_id: create_result.target_id,
        session_id: attach.session_id,
        url: "about:blank".to_string(),
        title: String::new(),
        target_type: "page".to_string(),
    });

    if let Some(viewport) = cmd.get("viewport") {
        let width = viewport
            .get("width")
            .and_then(|v| v.as_i64())
            .unwrap_or(1280) as i32;
        let height = viewport
            .get("height")
            .and_then(|v| v.as_i64())
            .unwrap_or(720) as i32;
        mgr.set_viewport(width, height, 1.0, false).await?;

        // Update stream server viewport
        if let Some(ref server) = state.stream_server {
            server.set_viewport(width as u32, height as u32).await;
        }
    }

    let total = mgr.page_count();
    state.ref_map.clear();

    Ok(json!({
        "tabId": super::browser::format_tab_id(tab_id),
        "total": total,
    }))
}

async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let baseline_path = cmd
        .get("baseline")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'baseline' parameter")?;

    let threshold = cmd.get("threshold").and_then(|v| v.as_f64()).unwrap_or(0.1);

    let options = ScreenshotOptions {
        selector: cmd
            .get("selector")
            .and_then(|v| v.as_str())
            .map(String::from),
        path: None,
        full_page: cmd
            .get("fullPage")
            .and_then(|v| v.as_bool())
            .unwrap_or(false),
        format: "png".to_string(),
        quality: None,
        annotate: false,
        output_dir: None,
    };

    let result = screenshot::take_screenshot(
        &mgr.client,
        &session_id,
        &state.ref_map,
        &options,
        &state.iframe_sessions,
    )
    .await?;

    let current_bytes =
        base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &result.base64)
            .map_err(|e| format!("Failed to decode screenshot: {}", e))?;

    let baseline_bytes =
        std::fs::read(baseline_path).map_err(|e| format!("Failed to read baseline: {}", e))?;

    let result = diff::diff_screenshot(&baseline_bytes, &current_bytes, threshold)?;

    let output_path = cmd.get("output").and_then(|v| v.as_str());
    if let (Some(out_path), Some(ref diff_data)) = (output_path, &result.diff_image) {
        std::fs::write(out_path, diff_data)
            .map_err(|e| format!("Failed to write diff image: {}", e))?;
    }

    Ok(json!({
        "match": result.matched,
        "mismatchPercentage": result.mismatch_percentage,
        "totalPixels": result.total_pixels,
        "differentPixels": result.different_pixels,
        "diffPath": output_path,
        "dimensionMismatch": result.dimension_mismatch,
    }))
}

// ---------------------------------------------------------------------------
// Video and HAR handlers
// ---------------------------------------------------------------------------

async fn handle_video_start(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let path = cmd
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'path' parameter")?;

    if state.recording_state.active {
        return Err("A recording is already in progress".to_string());
    }

    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    recording::recording_start(&mut state.recording_state, path)?;
    state
        .start_recording_task(mgr.client.clone(), session_id)
        .await?;

    Ok(json!({
        "started": true,
        "note": "Video recording started. Use video_stop to save the recording."
    }))
}

async fn handle_video_stop(state: &mut DaemonState) -> Result<Value, String> {
    if !state.recording_state.active {
        return Ok(json!({
            "stopped": false,
            "note": "No video recording was started. Use recording_stop if you used recording_start."
        }));
    }

    state.stop_recording_task().await?;
    recording::recording_stop(&mut state.recording_state)
}

/// Begin capturing network traffic for a later HAR export.
async fn handle_har_start(state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    mgr.client
        .send_command_no_params("Network.enable", Some(&session_id))
        .await?;
    // Also enable Network on cross-origin iframe sessions so their
    // requests are captured in the HAR output.
    for iframe_sid in state.iframe_sessions.values() {
        let _ = mgr
            .client
            .send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
            .await;
    }
    state.har_recording = true;
    state.har_entries.clear();
    Ok(json!({ "started": true }))
}

/// Stop HAR recording and write the captured requests to disk.
async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let path = har_output_path(cmd.get("path").and_then(|v| v.as_str()));

    state.har_recording = false;

    let entries: Vec<Value> = state.har_entries.drain(..).map(har_entry_to_json).collect();
    let request_count = entries.len();
    let browser = har_browser_metadata(state).await;

    let mut log = json!({
        "version": "1.2",
        "creator": {
            "name": "agent-browser",
            "version": env!("CARGO_PKG_VERSION")
        },
        "entries": entries
    });
    if let Some(browser) = browser {
        log["browser"] = browser;
    }
    let har = json!({ "log": log });

    let har_str = serde_json::to_string_pretty(&har)
        .map_err(|e| format!("Failed to serialize HAR: {}", e))?;
    std::fs::write(&path, har_str).map_err(|e| format!("Failed to write HAR: {}", e))?;

    Ok(json!({ "path": path, "requestCount": request_count }))
}

// ---------------------------------------------------------------------------
// HAR serialization helpers
// ---------------------------------------------------------------------------

/// Convert a `HarEntry` (collected from CDP events) into a HAR 1.2 entry object.
fn har_entry_to_json(e: HarEntry) -> Value {
    let started_date_time = har_wall_time_to_rfc3339(e.wall_time);

    let request_cookies = e
        .request_headers
        .iter()
        .find(|(k, _)| k.eq_ignore_ascii_case("cookie"))
        .map(|(_, v)| har_parse_request_cookies(v))
        .unwrap_or_default();

    let query_string = har_parse_query_string(&e.url);

    let req_headers: Vec<Value> = e
        .request_headers
        .iter()
        .map(|(k, v)| json!({ "name": k, "value": v }))
        .collect();

    let resp_cookies: Vec<Value> = e
        .response_headers
        .iter()
        .filter(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
        .map(|(_, v)| {
            // Split on ';' first to discard attributes (Path, HttpOnly, etc.),
            // then split on '=' once to separate name from value.
            let name_value = v.split(';').next().unwrap_or("");
            let (name, value) = name_value.split_once('=').unwrap_or((name_value, ""));
            json!({ "name": name.trim(), "value": value.trim() })
        })
        .collect();

    let resp_headers: Vec<Value> = e
        .response_headers
        .iter()
        .map(|(k, v)| json!({ "name": k, "value": v }))
        .collect();

    let (timings, total_time) =
        har_compute_timings(e.cdp_timing.as_ref(), e.loading_finished_timestamp);

    let mime_type = if e.mime_type.is_empty() {
        "application/octet-stream".to_string()
    } else {
        e.mime_type
    };

    let post_content_type = e
        .request_headers
        .iter()
        .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
        .map(|(_, v)| v.as_str())
        .unwrap_or("text/plain")
        .to_string();

    let mut request = json!({
        "method": e.method,
        "url": e.url,
        "httpVersion": e.http_version,
        "cookies": request_cookies,
        "headers": req_headers,
        "queryString": query_string,
        "headersSize": -1,
        "bodySize": e.request_body_size,
    });
    if let Some(body) = e.post_data {
        request["postData"] = json!({ "mimeType": post_content_type, "text": body });
    }

    json!({
        "startedDateTime": started_date_time,
        "time": total_time,
        "request": request,
        "response": {
            "status": e.status.unwrap_or(0),
            "statusText": e.status_text,
            "httpVersion": e.http_version,
            "cookies": resp_cookies,
            "headers": resp_headers,
            "content": {
                "size": e.response_body_size,
                "mimeType": mime_type,
            },
            "redirectURL": e.redirect_url,
            "headersSize": -1,
            "bodySize": e.response_body_size,
        },
        "cache": {},
        "timings": timings,
        "_resourceType": e.resource_type,
    })
}

/// Convert a CDP headers object (`{ "Name": "value", ... }`) into a flat
/// `Vec<(name, value)>` preserving insertion order.
fn har_extract_headers(headers_val: Option<&Value>) -> Vec<(String, String)> {
    headers_val
        .and_then(|v| v.as_object())
        .map(|obj| {
            obj.iter()
                .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
                .collect()
        })
        .unwrap_or_default()
}

/// Map a CDP `response.protocol` value to an HTTP-version string as required
/// by the HAR spec (e.g. `"h2"` → `"HTTP/2.0"`).
fn har_cdp_protocol_to_http_version(protocol: &str) -> String {
    match protocol.to_ascii_lowercase().as_str() {
        "h2" => "HTTP/2.0".to_string(),
        "h3" => "HTTP/3.0".to_string(),
        "http/1.0" => "HTTP/1.0".to_string(),
        _ => "HTTP/1.1".to_string(),
    }
}

/// Parse query-string parameters from a URL into a HAR `queryString` array.
fn har_parse_query_string(url_str: &str) -> Vec<Value> {
    url::Url::parse(url_str)
        .map(|u| {
            u.query_pairs()
                .map(|(k, v)| json!({ "name": k.as_ref(), "value": v.as_ref() }))
                .collect()
        })
        .unwrap_or_default()
}

/// Parse a `Cookie: name1=val1; name2=val2` header value into HAR cookie objects.
fn har_parse_request_cookies(cookie_header: &str) -> Vec<Value> {
    cookie_header
        .split(';')
        .filter_map(|pair| {
            let pair = pair.trim();
            if pair.is_empty() {
                return None;
            }
            let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
            Some(json!({ "name": name.trim(), "value": value.trim() }))
        })
        .collect()
}

/// Compute HAR `timings` and total `time` (ms) from a CDP `ResourceTiming`
/// object and the optional `Network.loadingFinished` monotonic timestamp.
///
/// CDP timing values are milliseconds relative to `requestTime` (seconds since
/// browser start). A value of `-1` means the phase did not occur.
fn har_compute_timings(
    cdp_timing: Option<&Value>,
    loading_finished_ts: Option<f64>,
) -> (Value, f64) {
    let Some(t) = cdp_timing else {
        return (json!({ "send": 0, "wait": 0, "receive": 0 }), 0.0);
    };

    let get = |key: &str| t.get(key).and_then(|v| v.as_f64()).unwrap_or(-1.0);

    let request_time = get("requestTime");
    let dns_start = get("dnsStart");
    let dns_end = get("dnsEnd");
    let connect_start = get("connectStart");
    let connect_end = get("connectEnd");
    let ssl_start = get("sslStart");
    let ssl_end = get("sslEnd");
    let send_start = get("sendStart");
    let send_end = get("sendEnd");
    let recv_headers_start = get("receiveHeadersStart");
    let recv_headers_end = get("receiveHeadersEnd");

    let dns = if dns_start >= 0.0 && dns_end >= 0.0 {
        dns_end - dns_start
    } else {
        -1.0
    };
    let connect = if connect_start >= 0.0 && connect_end >= 0.0 {
        connect_end - connect_start
    } else {
        -1.0
    };
    let ssl = if ssl_start >= 0.0 && ssl_end >= 0.0 {
        ssl_end - ssl_start
    } else {
        -1.0
    };
    let send = (send_end - send_start).max(0.0);

    // wait: end of sending → first byte of response headers.
    let wait_end = if recv_headers_start >= 0.0 {
        recv_headers_start
    } else {
        recv_headers_end
    };
    let wait = if send_end >= 0.0 && wait_end >= send_end {
        wait_end - send_end
    } else {
        0.0
    };

    // receive: first response byte → loading complete.
    // requestTime (seconds) + recv_headers_end (ms) / 1000 = absolute headers-end timestamp.
    let receive = loading_finished_ts
        .filter(|_| request_time >= 0.0 && recv_headers_end >= 0.0)
        .map(|lf_ts| {
            let recv_start_abs = request_time + recv_headers_end / 1000.0;
            ((lf_ts - recv_start_abs) * 1000.0).max(0.0)
        })
        .unwrap_or(0.0);

    let blocked = if dns_start > 0.0 {
        dns_start
    } else if connect_start > 0.0 {
        connect_start
    } else if send_start > 0.0 {
        send_start
    } else {
        -1.0
    };

    let total: f64 = [
        if blocked > 0.0 { blocked } else { 0.0 },
        if dns >= 0.0 { dns } else { 0.0 },
        if connect >= 0.0 { connect } else { 0.0 },
        send,
        wait,
        receive,
    ]
    .iter()
    .sum();

    let mut timings = json!({ "send": send, "wait": wait, "receive": receive });
    if blocked > 0.0 {
        timings["blocked"] = json!(blocked);
    }
    if dns >= 0.0 {
        timings["dns"] = json!(dns);
    }
    if connect >= 0.0 {
        timings["connect"] = json!(connect);
    }
    if ssl >= 0.0 {
        timings["ssl"] = json!(ssl);
    }

    (timings, total)
}

/// Format a Unix epoch timestamp (seconds, fractional) as RFC 3339 using the
/// `time` crate, e.g. `"2024-03-17T10:30:00.456Z"`.
fn har_wall_time_to_rfc3339(wall_time: f64) -> String {
    if wall_time > 0.0 {
        let nanos = (wall_time * 1_000_000_000.0).round() as i128;
        if let Ok(dt) = OffsetDateTime::from_unix_timestamp_nanos(nanos) {
            if let Ok(s) = dt.format(&Rfc3339) {
                return s;
            }
        }
    }
    OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}

fn har_output_path(explicit_path: Option<&str>) -> String {
    match explicit_path {
        Some(path) => path.to_string(),
        None => {
            let dir = get_har_dir();
            let _ = std::fs::create_dir_all(&dir);
            dir.join(format!("har-{}.har", unix_timestamp_millis()))
                .to_string_lossy()
                .to_string()
        }
    }
}

fn get_har_dir() -> PathBuf {
    if let Some(home) = dirs::home_dir() {
        home.join(".agent-browser").join("tmp").join("har")
    } else {
        std::env::temp_dir().join("agent-browser").join("har")
    }
}

fn unix_timestamp_millis() -> u128 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
}

async fn har_browser_metadata(state: &DaemonState) -> Option<Value> {
    let mgr = state.browser.as_ref()?;
    if !mgr.is_connection_alive().await {
        return None;
    }

    let version = mgr
        .client
        .send_command_no_params("Browser.getVersion", None)
        .await
        .ok()?;
    browser_metadata_from_version(&version)
}

fn browser_metadata_from_version(version: &Value) -> Option<Value> {
    let product = version.get("product").and_then(|v| v.as_str())?;
    let (name, browser_version) = product.split_once('/').unwrap_or((product, ""));
    Some(json!({
        "name": name,
        "version": browser_version,
    }))
}

// ---------------------------------------------------------------------------
// Fetch interception resolver (domain filter + routes + origin headers)
// ---------------------------------------------------------------------------

async fn resolve_fetch_paused(
    client: &CdpClient,
    domain_filter: Option<&DomainFilter>,
    routes: &[RouteEntry],
    origin_headers: &HashMap<String, HashMap<String, String>>,
    paused: &FetchPausedRequest,
) {
    let session_id = &paused.session_id;

    // Domain filter check (takes priority over routes and origin headers)
    if let Some(filter) = domain_filter {
        if let Ok(parsed) = url::Url::parse(&paused.url) {
            let scheme = parsed.scheme();
            if scheme != "http" && scheme != "https" {
                if paused.resource_type.eq_ignore_ascii_case("document") {
                    let _ = client
                        .send_command(
                            "Fetch.failRequest",
                            Some(json!({
                                "requestId": paused.request_id,
                                "errorReason": "BlockedByClient"
                            })),
                            Some(session_id),
                        )
                        .await;
                } else {
                    let _ = client
                        .send_command(
                            "Fetch.continueRequest",
                            Some(json!({ "requestId": paused.request_id })),
                            Some(session_id),
                        )
                        .await;
                }
                return;
            }

            if let Some(hostname) = parsed.host_str() {
                if !filter.is_allowed(hostname) {
                    if paused.resource_type.eq_ignore_ascii_case("document") {
                        let error_body = format!(
                            "<html><body><h1>Blocked</h1><p>Navigation to {} is not allowed by domain filter.</p></body></html>",
                            hostname
                        );
                        let encoded = base64::Engine::encode(
                            &base64::engine::general_purpose::STANDARD,
                            error_body.as_bytes(),
                        );
                        let _ = client
                            .send_command(
                                "Fetch.fulfillRequest",
                                Some(json!({
                                    "requestId": paused.request_id,
                                    "responseCode": 403,
                                    "responseHeaders": [
                                        { "name": "Content-Type", "value": "text/html" },
                                    ],
                                    "body": encoded,
                                })),
                                Some(session_id),
                            )
                            .await;
                    } else {
                        let _ = client
                            .send_command(
                                "Fetch.failRequest",
                                Some(json!({
                                    "requestId": paused.request_id,
                                    "errorReason": "BlockedByClient"
                                })),
                                Some(session_id),
                            )
                            .await;
                    }
                    return;
                }
            }
        }
    }

    // Route matching
    for route in routes {
        let url_matches = if route.url_pattern == "*" {
            true
        } else if route.url_pattern.contains('*') {
            let parts: Vec<&str> = route.url_pattern.split('*').collect();
            if parts.len() == 2 {
                paused.url.starts_with(parts[0]) && paused.url.ends_with(parts[1])
            } else {
                paused.url.contains(&route.url_pattern)
            }
        } else {
            paused.url.contains(&route.url_pattern)
        };

        let resource_type_matches = route.resource_types.is_empty()
            || route
                .resource_types
                .iter()
                .any(|rt| rt.eq_ignore_ascii_case(&paused.resource_type));

        let matches = url_matches && resource_type_matches;

        if matches {
            if route.abort {
                let _ = client
                    .send_command(
                        "Fetch.failRequest",
                        Some(json!({
                            "requestId": paused.request_id,
                            "errorReason": "Failed"
                        })),
                        Some(session_id),
                    )
                    .await;
                return;
            }

            if let Some(ref resp) = route.response {
                let status = resp.status.unwrap_or(200);
                let body_str = resp.body.as_deref().unwrap_or("");
                let encoded = base64::Engine::encode(
                    &base64::engine::general_purpose::STANDARD,
                    body_str.as_bytes(),
                );
                let mut headers = vec![];
                if let Some(ct) = &resp.content_type {
                    headers.push(json!({ "name": "Content-Type", "value": ct }));
                }
                if let Some(h) = &resp.headers {
                    for (k, v) in h {
                        headers.push(json!({ "name": k, "value": v }));
                    }
                }

                let _ = client
                    .send_command(
                        "Fetch.fulfillRequest",
                        Some(json!({
                            "requestId": paused.request_id,
                            "responseCode": status,
                            "responseHeaders": headers,
                            "body": encoded,
                        })),
                        Some(session_id),
                    )
                    .await;
                return;
            }
        }
    }

    // No matching route — continue, injecting origin-scoped headers if applicable.
    let extra = url::Url::parse(&paused.url)
        .ok()
        .map(|u| u.origin().ascii_serialization())
        .and_then(|o| origin_headers.get(&o));

    if let Some(extra_headers) = extra {
        // Merge original request headers with extra headers.
        // Fetch.continueRequest replaces (not merges), so include originals.
        let mut combined: Vec<Value> = Vec::new();
        if let Some(ref orig) = paused.request_headers {
            for (k, v) in orig {
                if !extra_headers.keys().any(|ek| ek.eq_ignore_ascii_case(k)) {
                    if let Some(s) = v.as_str() {
                        combined.push(json!({ "name": k, "value": s }));
                    }
                }
            }
        }
        for (k, v) in extra_headers {
            combined.push(json!({ "name": k, "value": v }));
        }
        let _ = client
            .send_command(
                "Fetch.continueRequest",
                Some(json!({ "requestId": paused.request_id, "headers": combined })),
                Some(session_id),
            )
            .await;
    } else {
        let _ = client
            .send_command(
                "Fetch.continueRequest",
                Some(json!({ "requestId": paused.request_id })),
                Some(session_id),
            )
            .await;
    }
}

// ---------------------------------------------------------------------------
// Route handlers
// ---------------------------------------------------------------------------

/// Build the Fetch.enable patterns list from current routes, domain filter,
/// and origin headers state.  When domain filtering or origin-scoped headers
/// are active a wildcard pattern is included so all requests are intercepted.
async fn build_fetch_patterns(state: &DaemonState) -> Vec<Value> {
    let routes = state.routes.read().await;
    let mut patterns: Vec<Value> = routes
        .iter()
        .map(|r| json!({ "urlPattern": r.url_pattern }))
        .collect();
    let has_domain_filter = state.domain_filter.read().await.is_some();
    let has_origin_headers = !state.origin_headers.read().await.is_empty();
    let has_proxy_creds = state.proxy_credentials.read().await.is_some();
    if (has_domain_filter || has_origin_headers || has_proxy_creds)
        && !patterns.iter().any(|p| p["urlPattern"] == "*")
    {
        patterns.push(json!({ "urlPattern": "*" }));
    }
    patterns
}

/// Build the full Fetch.enable params object, including `handleAuthRequests`
/// when proxy credentials are configured.
async fn build_fetch_enable_params(state: &DaemonState, patterns: Vec<Value>) -> Value {
    let has_proxy_creds = state.proxy_credentials.read().await.is_some();
    if has_proxy_creds {
        json!({ "patterns": patterns, "handleAuthRequests": true })
    } else {
        json!({ "patterns": patterns })
    }
}

async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let url_pattern = cmd
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url' parameter")?
        .to_string();
    let abort = cmd.get("abort").and_then(|v| v.as_bool()).unwrap_or(false);

    let resource_types: Vec<String> = cmd
        .get("resourceType")
        .or_else(|| cmd.get("resourceTypes"))
        .and_then(|v| {
            if let Some(s) = v.as_str() {
                Some(
                    s.split(',')
                        .map(|p| p.trim().to_string())
                        .filter(|p| !p.is_empty())
                        .collect(),
                )
            } else {
                v.as_array().map(|arr| {
                    arr.iter()
                        .filter_map(|x| x.as_str().map(String::from))
                        .filter(|s| !s.is_empty())
                        .collect()
                })
            }
        })
        .unwrap_or_default();

    let response = cmd.get("response").and_then(|v| {
        if v.is_null() {
            return None;
        }
        Some(RouteResponse {
            status: v.get("status").and_then(|s| s.as_u64()).map(|s| s as u16),
            body: v.get("body").and_then(|s| s.as_str()).map(String::from),
            content_type: v
                .get("contentType")
                .and_then(|s| s.as_str())
                .map(String::from),
            headers: v.get("headers").and_then(|h| {
                h.as_object().map(|m| {
                    m.iter()
                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                        .collect()
                })
            }),
        })
    });

    {
        let mut routes = state.routes.write().await;
        routes.push(RouteEntry {
            url_pattern: url_pattern.clone(),
            response,
            abort,
            resource_types,
        });
    }

    let patterns = build_fetch_patterns(state).await;
    let params = build_fetch_enable_params(state, patterns).await;
    mgr.client
        .send_command("Fetch.enable", Some(params), Some(&session_id))
        .await?;

    Ok(json!({ "routed": url_pattern }))
}

async fn handle_unroute(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let url = cmd.get("url").and_then(|v| v.as_str());

    {
        let mut routes = state.routes.write().await;
        match url {
            Some(pattern) => {
                routes.retain(|r| r.url_pattern != pattern);
            }
            None => {
                routes.clear();
            }
        }
    }

    let patterns = build_fetch_patterns(state).await;
    if patterns.is_empty() {
        mgr.client
            .send_command("Fetch.disable", None, Some(&session_id))
            .await?;
    } else {
        let params = build_fetch_enable_params(state, patterns).await;
        mgr.client
            .send_command("Fetch.enable", Some(params), Some(&session_id))
            .await?;
    }

    let label = url.unwrap_or("all");
    Ok(json!({ "unrouted": label }))
}

pub fn matches_status_filter(status: Option<i64>, filter: &str) -> bool {
    let Some(code) = status else { return false };
    let f = filter.to_lowercase();
    if let Ok(exact) = f.parse::<i64>() {
        return code == exact;
    }
    if f.len() == 3 && f.ends_with("xx") {
        if let Ok(prefix) = f[..1].parse::<i64>() {
            return code / 100 == prefix;
        }
    }
    if let Some((lo, hi)) = f.split_once('-') {
        if let (Ok(lo), Ok(hi)) = (lo.parse::<i64>(), hi.parse::<i64>()) {
            return code >= lo && code <= hi;
        }
    }
    false
}

async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
        state.tracked_requests.clear();
        return Ok(json!({ "cleared": true }));
    }

    if !state.request_tracking {
        state.request_tracking = true;
        if let Some(ref mgr) = state.browser {
            if let Ok(session_id) = mgr.active_session_id() {
                let _ = mgr
                    .client
                    .send_command_no_params("Network.enable", Some(session_id))
                    .await;
            }
        }
    }

    let filter = cmd.get("filter").and_then(|v| v.as_str());
    let type_filter = cmd.get("type").and_then(|v| v.as_str());
    let method_filter = cmd.get("method").and_then(|v| v.as_str());
    let status_filter = cmd.get("status").and_then(|v| v.as_str());

    let type_list: Vec<String> = type_filter
        .map(|t| t.split(',').map(|s| s.trim().to_lowercase()).collect())
        .unwrap_or_default();

    let requests: Vec<&TrackedRequest> = state
        .tracked_requests
        .iter()
        .filter(|r| {
            if let Some(f) = filter {
                if !r.url.contains(f) {
                    return false;
                }
            }
            if !type_list.is_empty() && !type_list.contains(&r.resource_type.to_lowercase()) {
                return false;
            }
            if let Some(m) = method_filter {
                if !r.method.eq_ignore_ascii_case(m) {
                    return false;
                }
            }
            if let Some(s) = status_filter {
                if !matches_status_filter(r.status, s) {
                    return false;
                }
            }
            true
        })
        .collect();

    Ok(json!({ "requests": requests }))
}

async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let request_id = cmd
        .get("requestId")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'requestId' parameter")?;

    let entry = state
        .tracked_requests
        .iter()
        .find(|r| r.request_id == request_id)
        .ok_or("Request not found")?;

    let mut result = serde_json::to_value(entry).unwrap_or(json!({}));

    if let Some(ref mgr) = state.browser {
        if let Ok(session_id) = mgr.active_session_id() {
            if let Ok(body_result) = mgr
                .client
                .send_command(
                    "Network.getResponseBody",
                    Some(json!({ "requestId": request_id })),
                    Some(session_id),
                )
                .await
            {
                let base64_encoded = body_result
                    .get("base64Encoded")
                    .and_then(|v| v.as_bool())
                    .unwrap_or(false);
                let body = body_result
                    .get("body")
                    .and_then(|v| v.as_str())
                    .unwrap_or("");
                if base64_encoded {
                    result["responseBody"] = json!(format!("[base64, {} chars]", body.len()));
                } else {
                    result["responseBody"] = json!(body);
                }
            }
        }
    }

    Ok(result)
}

async fn handle_http_credentials(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let username = cmd
        .get("username")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'username' parameter")?;
    let password = cmd
        .get("password")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'password' parameter")?;

    let encoded = base64::Engine::encode(
        &base64::engine::general_purpose::STANDARD,
        format!("{}:{}", username, password),
    );

    let mut headers = HashMap::new();
    headers.insert("Authorization".to_string(), format!("Basic {}", encoded));
    network::set_extra_headers(&mgr.client, &session_id, &headers).await?;

    Ok(json!({ "set": true }))
}

// ---------------------------------------------------------------------------
// Auth handlers
// ---------------------------------------------------------------------------

/// Wait for any selector in `selectors` to appear and return the first match.
///
/// This is used by `auth_login` auto-detection so SPA login forms can render
/// after initial navigation without requiring global network-idle.
async fn wait_for_any_selector(
    client: &super::cdp::client::CdpClient,
    session_id: &str,
    selectors: &[&str],
    timeout_ms: u64,
) -> Result<String, String> {
    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);

    loop {
        for selector in selectors {
            let expression = format!(
                r#"(() => {{
                    const el = document.querySelector({sel});
                    if (!el) return false;

                    const r = el.getBoundingClientRect();
                    const s = window.getComputedStyle(el);
                    const opacity = parseFloat(s.opacity || '1');
                    const isVisible =
                        r.width > 0 &&
                        r.height > 0 &&
                        s.visibility !== 'hidden' &&
                        s.display !== 'none' &&
                        (!Number.isFinite(opacity) || opacity > 0);

                    if (!isVisible) return false;
                    if (el.matches(':disabled')) return false;

                    if (el instanceof HTMLInputElement && el.type === 'hidden') return false;
                    if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.readOnly) return false;

                    return true;
                }})()"#,
                sel = serde_json::to_string(selector).unwrap_or_default()
            );

            let result: super::cdp::types::EvaluateResult = client
                .send_command_typed(
                    "Runtime.evaluate",
                    &super::cdp::types::EvaluateParams {
                        expression,
                        return_by_value: Some(true),
                        await_promise: Some(true),
                    },
                    Some(session_id),
                )
                .await?;

            if result
                .result
                .value
                .as_ref()
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                return Ok((*selector).to_string());
            }
        }

        if tokio::time::Instant::now() >= deadline {
            return Err(format!("Wait timed out after {}ms", timeout_ms));
        }

        tokio::time::sleep(tokio::time::Duration::from_millis(
            AUTH_LOGIN_SELECTOR_POLL_INTERVAL_MS,
        ))
        .await;
    }
}

async fn handle_auth_save(cmd: &Value) -> Result<Value, String> {
    let name = cmd
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name'")?;
    let url = cmd
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'url'")?;
    let username = cmd
        .get("username")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'username'")?;
    let password = cmd
        .get("password")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'password'")?;
    let username_selector = cmd.get("usernameSelector").and_then(|v| v.as_str());
    let password_selector = cmd.get("passwordSelector").and_then(|v| v.as_str());
    let submit_selector = cmd.get("submitSelector").and_then(|v| v.as_str());
    auth::auth_save(
        name,
        url,
        username,
        password,
        username_selector,
        password_selector,
        submit_selector,
    )
}

async fn handle_auth_login(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let name = cmd
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'name'")?;
    let cred = auth::credentials_get_full(name)?;
    if cred.url.is_empty() {
        return Err("Credential has no URL".to_string());
    }
    let url = cred.url;
    let username = cred.username;
    let password = cred.password;

    let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
    mgr.navigate(&url, AUTH_LOGIN_WAIT_UNTIL).await?;

    let session_id = mgr.active_session_id()?.to_string();
    let auth_timeout_ms = mgr.default_timeout_ms();

    let preferred_user_selectors = [
        "input[type=email]",
        "input[name=email]",
        "input[id=email]",
        "input[autocomplete=email]",
        "input[autocomplete=username]",
        "input[name=username]",
        "input[name*=email i]",
        "input[name*=user i]",
        "input[id*=email i]",
        "input[id*=user i]",
        "input[type=text][name*=email i]",
        "input[type=text][name*=user i]",
        "input[type=text][id*=email i]",
        "input[type=text][id*=user i]",
        "input[type=text][autocomplete=email]",
        "input[type=text][autocomplete=username]",
    ];
    let fallback_user_selectors = ["input[type=text]", "input:not([type])"];
    let auto_submit_selectors = [
        "button[type=submit]",
        "input[type=submit]",
        "button:not([type])",
    ];

    let username_sel = cmd
        .get("usernameSelector")
        .and_then(|v| v.as_str())
        .map(String::from)
        .or(cred.username_selector);
    let password_sel = cmd
        .get("passwordSelector")
        .and_then(|v| v.as_str())
        .map(String::from)
        .or(cred.password_selector);
    let submit_sel = cmd
        .get("submitSelector")
        .and_then(|v| v.as_str())
        .map(String::from)
        .or(cred.submit_selector);

    // Find and fill username
    let user_sel = if let Some(s) = username_sel {
        wait_for_selector(&mgr.client, &session_id, &s, "visible", auth_timeout_ms)
            .await
            .map_err(|_| format!("Timed out waiting for username selector '{}'", s))?;
        s
    } else {
        let preferred_window_ms = auth_timeout_ms.min(AUTH_LOGIN_PREFERRED_SELECTOR_WINDOW_MS);
        let fallback_window_ms = auth_timeout_ms.saturating_sub(preferred_window_ms);

        match wait_for_any_selector(
            &mgr.client,
            &session_id,
            &preferred_user_selectors,
            preferred_window_ms,
        )
        .await
        {
            Ok(selector) => selector,
            Err(_) => {
                if fallback_window_ms == 0 {
                    return Err(format!(
                        "Timed out waiting for username field (preferred selectors for {}ms: {})",
                        preferred_window_ms,
                        preferred_user_selectors.join(", ")
                    ));
                }

                wait_for_any_selector(
                    &mgr.client,
                    &session_id,
                    &fallback_user_selectors,
                    fallback_window_ms,
                )
                .await
                .map_err(|_| {
                    format!(
                        "Timed out waiting for username field (preferred selectors for {}ms: {}; fallback selectors for {}ms: {})",
                        preferred_window_ms,
                        preferred_user_selectors.join(", "),
                        fallback_window_ms,
                        fallback_user_selectors.join(", ")
                    )
                })?
            }
        }
    };
    interaction::fill(
        &mgr.client,
        &session_id,
        &state.ref_map,
        &user_sel,
        &username,
        &state.iframe_sessions,
    )
    .await?;

    // Find and fill password
    let pass_sel = password_sel.unwrap_or_else(|| "input[type=password]".to_string());
    wait_for_selector(
        &mgr.client,
        &session_id,
        &pass_sel,
        "visible",
        auth_timeout_ms,
    )
    .await
    .map_err(|_| format!("Timed out waiting for password selector '{}'", pass_sel))?;
    interaction::fill(
        &mgr.client,
        &session_id,
        &state.ref_map,
        &pass_sel,
        &password,
        &state.iframe_sessions,
    )
    .await?;

    // Find and click submit
    let sub_sel = if let Some(s) = submit_sel {
        wait_for_selector(&mgr.client, &session_id, &s, "visible", auth_timeout_ms)
            .await
            .map_err(|_| format!("Timed out waiting for submit selector '{}'", s))?;
        s
    } else {
        wait_for_any_selector(
            &mgr.client,
            &session_id,
            &auto_submit_selectors,
            auth_timeout_ms,
        )
        .await
        .map_err(|_| {
            format!(
                "Timed out waiting for submit button (tried selectors: {})",
                auto_submit_selectors.join(", ")
            )
        })?
    };
    interaction::click(
        &mgr.client,
        &session_id,
        &state.ref_map,
        &sub_sel,
        "left",
        1,
        &state.iframe_sessions,
    )
    .await?;

    // Wait for navigation after submit (with fallback timeout)
    let mut rx = mgr.client.subscribe();
    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
    let mut navigated = false;

    loop {
        let result = tokio::time::timeout_at(deadline, rx.recv()).await;
        match result {
            Ok(Ok(event)) => {
                if event.session_id.as_deref() == Some(&session_id) {
                    match event.method.as_str() {
                        "Page.frameNavigated" | "Page.loadEventFired" => {
                            navigated = true;
                            break;
                        }
                        _ => {}
                    }
                }
            }
            Ok(Err(_)) => break,
            Err(_) => break,
        }
    }

    if !navigated {
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
    }

    Ok(json!({ "loggedIn": true, "name": name }))
}

// ---------------------------------------------------------------------------
// Confirmation handlers (stub)
// ---------------------------------------------------------------------------

async fn handle_confirm(_cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let pending = state
        .pending_confirmation
        .take()
        .ok_or("No pending confirmation")?;

    // Temporarily remove policy and confirm_actions to avoid re-triggering confirmation
    let policy = state.policy.take();
    let confirm_actions = state.confirm_actions.take();
    let result = Box::pin(execute_command(&pending.cmd, state)).await;
    state.policy = policy;
    state.confirm_actions = confirm_actions;

    Ok(json!({ "confirmed": true, "action": pending.action, "result": result }))
}

async fn handle_deny(_cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let pending = state
        .pending_confirmation
        .take()
        .ok_or("No pending confirmation")?;

    Ok(json!({ "denied": true, "action": pending.action }))
}

// ---------------------------------------------------------------------------
// iOS handlers (stub)
// ---------------------------------------------------------------------------

async fn handle_swipe(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    // Route through Appium for iOS/WebDriver
    if let Some(ref appium) = state.appium {
        if state.browser.is_none() {
            let start_x = cmd.get("startX").and_then(|v| v.as_f64()).unwrap_or(200.0);
            let start_y = cmd.get("startY").and_then(|v| v.as_f64()).unwrap_or(400.0);
            let end_x = cmd.get("endX").and_then(|v| v.as_f64()).unwrap_or(200.0);
            let end_y = cmd.get("endY").and_then(|v| v.as_f64()).unwrap_or(100.0);

            if let Some(direction) = cmd.get("direction").and_then(|v| v.as_str()) {
                let distance = cmd
                    .get("distance")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(300.0);
                let (dx, dy) = match direction {
                    "up" => (0.0, -distance),
                    "down" => (0.0, distance),
                    "left" => (-distance, 0.0),
                    "right" => (distance, 0.0),
                    _ => (0.0, -distance),
                };
                let actual_end_x = start_x + dx;
                let actual_end_y = start_y + dy;
                let duration = cmd.get("duration").and_then(|v| v.as_u64()).unwrap_or(800);
                appium
                    .swipe(start_x, start_y, actual_end_x, actual_end_y, duration)
                    .await?;
                return Ok(json!({ "swiped": direction }));
            }

            let duration = cmd.get("duration").and_then(|v| v.as_u64()).unwrap_or(800);
            appium
                .swipe(start_x, start_y, end_x, end_y, duration)
                .await?;
            return Ok(json!({ "swiped": true, "from": [start_x, start_y], "to": [end_x, end_y] }));
        }
    }

    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();

    let start_x = cmd.get("startX").and_then(|v| v.as_f64()).unwrap_or(200.0);
    let start_y = cmd.get("startY").and_then(|v| v.as_f64()).unwrap_or(400.0);
    let end_x = cmd.get("endX").and_then(|v| v.as_f64()).unwrap_or(200.0);
    let end_y = cmd.get("endY").and_then(|v| v.as_f64()).unwrap_or(100.0);

    if let Some(direction) = cmd.get("direction").and_then(|v| v.as_str()) {
        let distance = cmd
            .get("distance")
            .and_then(|v| v.as_f64())
            .unwrap_or(300.0);
        let (dx, dy) = match direction {
            "up" => (0.0, -distance),
            "down" => (0.0, distance),
            "left" => (-distance, 0.0),
            "right" => (distance, 0.0),
            _ => (0.0, -distance),
        };
        let cx = start_x;
        let cy = start_y;

        mgr.client
            .send_command(
                "Input.dispatchTouchEvent",
                Some(json!({ "type": "touchStart", "touchPoints": [{ "x": cx, "y": cy }] })),
                Some(&session_id),
            )
            .await?;

        let steps = 10;
        for i in 1..=steps {
            let x = cx + dx * (i as f64) / (steps as f64);
            let y = cy + dy * (i as f64) / (steps as f64);
            mgr.client
                .send_command(
                    "Input.dispatchTouchEvent",
                    Some(json!({ "type": "touchMove", "touchPoints": [{ "x": x, "y": y }] })),
                    Some(&session_id),
                )
                .await?;
            tokio::time::sleep(tokio::time::Duration::from_millis(16)).await;
        }

        mgr.client
            .send_command(
                "Input.dispatchTouchEvent",
                Some(json!({ "type": "touchEnd", "touchPoints": [] })),
                Some(&session_id),
            )
            .await?;

        return Ok(json!({ "swiped": direction }));
    }

    // Manual coordinates
    mgr.client
        .send_command(
            "Input.dispatchTouchEvent",
            Some(json!({ "type": "touchStart", "touchPoints": [{ "x": start_x, "y": start_y }] })),
            Some(&session_id),
        )
        .await?;

    let steps = 10;
    for i in 1..=steps {
        let x = start_x + (end_x - start_x) * (i as f64) / (steps as f64);
        let y = start_y + (end_y - start_y) * (i as f64) / (steps as f64);
        mgr.client
            .send_command(
                "Input.dispatchTouchEvent",
                Some(json!({ "type": "touchMove", "touchPoints": [{ "x": x, "y": y }] })),
                Some(&session_id),
            )
            .await?;
        tokio::time::sleep(tokio::time::Duration::from_millis(16)).await;
    }

    mgr.client
        .send_command(
            "Input.dispatchTouchEvent",
            Some(json!({ "type": "touchEnd", "touchPoints": [] })),
            Some(&session_id),
        )
        .await?;

    Ok(json!({ "swiped": true, "from": [start_x, start_y], "to": [end_x, end_y] }))
}

async fn handle_device_list() -> Result<Value, String> {
    #[cfg(target_os = "macos")]
    {
        use super::webdriver::ios;
        let devices = ios::list_all_devices()?;
        Ok(ios::to_device_json(&devices))
    }

    #[cfg(not(target_os = "macos"))]
    {
        Err("device_list is only available on macOS with Xcode".to_string())
    }
}

// ---------------------------------------------------------------------------
// Input event handlers
// ---------------------------------------------------------------------------

fn mouse_button_mask(button: &str) -> i32 {
    match button {
        "left" => 1,
        "right" => 2,
        "middle" => 4,
        "back" => 8,
        "forward" => 16,
        _ => 0,
    }
}

fn primary_button_from_mask(buttons: i32) -> &'static str {
    if buttons & 1 != 0 {
        "left"
    } else if buttons & 2 != 0 {
        "right"
    } else if buttons & 4 != 0 {
        "middle"
    } else if buttons & 8 != 0 {
        "back"
    } else if buttons & 16 != 0 {
        "forward"
    } else {
        "none"
    }
}

#[allow(clippy::too_many_arguments)]
fn build_mouse_event_params(
    mouse_state: &mut MouseState,
    event_type: &str,
    x: Option<f64>,
    y: Option<f64>,
    button: Option<&str>,
    buttons: Option<i32>,
    click_count: Option<i32>,
    delta_x: Option<f64>,
    delta_y: Option<f64>,
    modifiers: Option<i32>,
) -> DispatchMouseEventParams {
    let x = x.unwrap_or(mouse_state.x);
    let y = y.unwrap_or(mouse_state.y);
    mouse_state.x = x;
    mouse_state.y = y;

    let mut next_buttons = buttons.unwrap_or(mouse_state.buttons);
    if buttons.is_none() {
        match event_type {
            "mousePressed" => {
                next_buttons |= mouse_button_mask(button.unwrap_or("left"));
            }
            "mouseReleased" => {
                next_buttons &= !mouse_button_mask(button.unwrap_or("left"));
            }
            _ => {}
        }
    }
    mouse_state.buttons = next_buttons;

    DispatchMouseEventParams {
        event_type: event_type.to_string(),
        x,
        y,
        button: Some(
            button
                .unwrap_or(primary_button_from_mask(next_buttons))
                .to_string(),
        ),
        buttons: Some(next_buttons),
        click_count,
        delta_x,
        delta_y,
        modifiers,
    }
}

async fn handle_input_mouse(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let event_type = cmd
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("mouseMoved");
    let params = build_mouse_event_params(
        &mut state.mouse_state,
        event_type,
        cmd.get("x").and_then(|v| v.as_f64()),
        cmd.get("y").and_then(|v| v.as_f64()),
        cmd.get("button").and_then(|v| v.as_str()),
        cmd.get("buttons")
            .and_then(|v| v.as_i64())
            .map(|v| v as i32),
        cmd.get("clickCount")
            .and_then(|v| v.as_i64())
            .map(|v| v as i32),
        cmd.get("deltaX").and_then(|v| v.as_f64()),
        cmd.get("deltaY").and_then(|v| v.as_f64()),
        cmd.get("modifiers")
            .and_then(|v| v.as_i64())
            .map(|v| v as i32),
    );

    mgr.client
        .send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
        .await?;
    Ok(json!({ "dispatched": event_type }))
}

async fn handle_input_keyboard(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let event_type = cmd
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("keyDown");

    let mut params = json!({ "type": event_type });
    for key in &["key", "code", "text"] {
        if let Some(v) = cmd.get(*key) {
            params[*key] = v.clone();
        }
    }

    mgr.client
        .send_command("Input.dispatchKeyEvent", Some(params), Some(&session_id))
        .await?;
    Ok(json!({ "dispatched": event_type }))
}

async fn handle_input_touch(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let event_type = cmd
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("touchStart");

    mgr.client
        .send_command(
            "Input.dispatchTouchEvent",
            Some(json!({
                "type": event_type,
                "touchPoints": cmd.get("touchPoints").unwrap_or(&json!([])),
            })),
            Some(&session_id),
        )
        .await?;
    Ok(json!({ "dispatched": event_type }))
}

async fn handle_keydown(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let key = cmd
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'key' parameter")?;

    mgr.client
        .send_command(
            "Input.dispatchKeyEvent",
            Some(json!({ "type": "keyDown", "key": key })),
            Some(&session_id),
        )
        .await?;
    Ok(json!({ "keydown": key }))
}

async fn handle_keyup(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let key = cmd
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'key' parameter")?;

    mgr.client
        .send_command(
            "Input.dispatchKeyEvent",
            Some(json!({ "type": "keyUp", "key": key })),
            Some(&session_id),
        )
        .await?;
    Ok(json!({ "keyup": key }))
}

async fn handle_inserttext(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let text = cmd
        .get("text")
        .and_then(|v| v.as_str())
        .ok_or("Missing 'text' parameter")?;

    mgr.client
        .send_command(
            "Input.insertText",
            Some(json!({ "text": text })),
            Some(&session_id),
        )
        .await?;
    Ok(json!({ "inserted": true }))
}

async fn handle_mousemove(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
    let params = build_mouse_event_params(
        &mut state.mouse_state,
        "mouseMoved",
        Some(x),
        Some(y),
        None,
        None,
        None,
        None,
        None,
        None,
    );

    mgr.client
        .send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
        .await?;
    Ok(json!({ "moved": true }))
}

async fn handle_mousedown(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
    let params = build_mouse_event_params(
        &mut state.mouse_state,
        "mousePressed",
        None,
        None,
        Some(button),
        None,
        Some(1),
        None,
        None,
        None,
    );

    mgr.client
        .send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
        .await?;
    Ok(json!({ "pressed": true }))
}

async fn handle_mouseup(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
    let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
    let session_id = mgr.active_session_id()?.to_string();
    let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
    let params = build_mouse_event_params(
        &mut state.mouse_state,
        "mouseReleased",
        None,
        None,
        Some(button),
        None,
        Some(1),
        None,
        None,
        None,
    );

    mgr.client
        .send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
        .await?;
    Ok(json!({ "released": true }))
}

// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------

fn success_response(id: &str, data: Value) -> Value {
    json!({
        "id": id,
        "success": true,
        "data": data,
    })
}

fn error_response(id: &str, error: &str) -> Value {
    json!({
        "id": id,
        "success": false,
        "error": error,
    })
}

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

    #[test]
    fn test_url_glob_to_regex() {
        assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
        assert_eq!(url_glob_to_regex("**iana**"), "^.*iana.*$");
        assert_eq!(
            url_glob_to_regex("https://x.com/**"),
            "^https://x\\.com/.*$"
        );
        // single * stays within a path segment
        assert_eq!(url_glob_to_regex("/a/*/c"), "^/a/[^/]*/c$");
        assert_eq!(url_glob_to_regex("/p?ge"), "^/p.ge$");
    }

    #[test]
    fn test_url_glob_regex_matches() {
        let re = regex_lite::Regex::new(&url_glob_to_regex("**/help/**")).unwrap();
        assert!(re.is_match("https://www.iana.org/help/example-domains"));
        assert!(!re.is_match("https://www.iana.org/about"));
        let re2 = regex_lite::Regex::new(&url_glob_to_regex("https://www.iana.org/**")).unwrap();
        assert!(re2.is_match("https://www.iana.org/help/example-domains"));
    }

    #[test]
    fn test_role_to_query_implicit_elements() {
        // links are <a href>, not <link>; headings are h1..h6
        assert_eq!(
            role_to_query("link"),
            "[role=\"link\"], a[href], area[href]"
        );
        assert_eq!(
            role_to_query("heading"),
            "[role=\"heading\"], h1, h2, h3, h4, h5, h6"
        );
        assert!(role_to_query("button").contains("button"));
        // unknown/custom roles fall back to the attribute + literal tag
        assert_eq!(role_to_query("tablist"), "[role=\"tablist\"], tablist");
    }

    fn unique_socket_dir(label: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("system clock should be after unix epoch")
            .as_nanos();
        std::env::temp_dir().join(format!(
            "agent-browser-{label}-{}-{nanos}",
            std::process::id()
        ))
    }

    #[tokio::test]
    async fn test_stream_enable_disable_and_status_without_browser() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
        let socket_dir = unique_socket_dir("stream-runtime");
        fs::create_dir_all(&socket_dir).expect("socket dir should be created");
        guard.set(
            "AGENT_BROWSER_SOCKET_DIR",
            socket_dir.to_str().expect("socket dir should be utf-8"),
        );
        guard.set("AGENT_BROWSER_SESSION", "stream-runtime-session");

        let mut state = DaemonState::new();

        let disabled_status = handle_stream_status(&state)
            .await
            .expect("status should work before enable");
        assert_eq!(disabled_status["enabled"], false);
        assert_eq!(disabled_status["port"], Value::Null);
        assert_eq!(disabled_status["connected"], false);
        assert_eq!(disabled_status["screencasting"], false);

        let enabled_status = handle_stream_enable(&json!({ "port": 0 }), &mut state)
            .await
            .expect("stream enable should succeed");
        let port = enabled_status["port"]
            .as_u64()
            .expect("runtime stream should report a bound port");
        assert!(port > 0, "runtime stream should bind a non-zero port");
        assert_eq!(enabled_status["enabled"], true);
        assert_eq!(enabled_status["connected"], false);
        assert_eq!(enabled_status["screencasting"], false);

        let stream_path = socket_dir.join("stream-runtime-session.stream");
        let port_file =
            fs::read_to_string(&stream_path).expect("stream metadata file should exist");
        assert_eq!(port_file.trim(), port.to_string());

        let duplicate_err = handle_stream_enable(&json!({}), &mut state)
            .await
            .expect_err("duplicate enable should fail");
        assert!(duplicate_err.contains("already enabled"));

        let status = handle_stream_status(&state)
            .await
            .expect("status should work after enable");
        assert_eq!(status["enabled"], true);
        assert_eq!(status["port"], port);

        let disabled = handle_stream_disable(&mut state)
            .await
            .expect("stream disable should succeed");
        assert_eq!(disabled["disabled"], true);
        assert!(
            !stream_path.exists(),
            "disabling runtime stream should remove the metadata file"
        );
        assert!(state.stream_server.is_none());
        assert!(state.stream_client.is_none());

        let final_status = handle_stream_status(&state)
            .await
            .expect("status should work after disable");
        assert_eq!(final_status["enabled"], false);
        assert_eq!(final_status["port"], Value::Null);

        let disable_err = handle_stream_disable(&mut state)
            .await
            .expect_err("duplicate disable should fail");
        assert!(disable_err.contains("not enabled"));

        let _ = fs::remove_dir_all(&socket_dir);
    }

    #[tokio::test]
    async fn test_stream_disable_preserves_existing_screencast_state() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
        let socket_dir = unique_socket_dir("stream-preserve-screencast");
        fs::create_dir_all(&socket_dir).expect("socket dir should be created");
        guard.set(
            "AGENT_BROWSER_SOCKET_DIR",
            socket_dir.to_str().expect("socket dir should be utf-8"),
        );
        guard.set(
            "AGENT_BROWSER_SESSION",
            "stream-preserve-screencast-session",
        );

        let mut state = DaemonState::new();
        handle_stream_enable(&json!({ "port": 0 }), &mut state)
            .await
            .expect("stream enable should succeed");
        state.screencasting = true;

        let disabled = handle_stream_disable(&mut state)
            .await
            .expect("stream disable should succeed");
        assert_eq!(disabled["disabled"], true);
        assert!(
            state.screencasting,
            "stream disable should not clear an independently managed screencast state"
        );

        let _ = fs::remove_dir_all(&socket_dir);
    }

    #[tokio::test]
    async fn test_stream_disable_clears_state_when_stream_file_removal_fails() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
        let socket_dir = unique_socket_dir("stream-disable-cleanup");
        fs::create_dir_all(&socket_dir).expect("socket dir should be created");
        guard.set(
            "AGENT_BROWSER_SOCKET_DIR",
            socket_dir.to_str().expect("socket dir should be utf-8"),
        );
        guard.set("AGENT_BROWSER_SESSION", "stream-disable-cleanup-session");

        let mut state = DaemonState::new();
        handle_stream_enable(&json!({ "port": 0 }), &mut state)
            .await
            .expect("stream enable should succeed");

        let stream_path = socket_dir.join("stream-disable-cleanup-session.stream");
        fs::remove_file(&stream_path).expect("stream metadata file should exist");
        fs::create_dir(&stream_path).expect("directory should force remove_stream_file failure");

        let err = handle_stream_disable(&mut state)
            .await
            .expect_err("stream disable should surface file removal failure");
        assert!(err.contains("Failed to remove stream metadata"));
        assert!(
            state.stream_server.is_none(),
            "stream disable should clear stream_server even when metadata cleanup fails"
        );
        assert!(
            state.stream_client.is_none(),
            "stream disable should clear stream_client even when metadata cleanup fails"
        );

        let _ = fs::remove_dir_all(&socket_dir);
    }

    #[tokio::test]
    async fn test_stream_enable_port_conflict_returns_error() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
        let socket_dir = unique_socket_dir("stream-port-conflict");
        fs::create_dir_all(&socket_dir).expect("socket dir should be created");
        guard.set(
            "AGENT_BROWSER_SOCKET_DIR",
            socket_dir.to_str().expect("socket dir should be utf-8"),
        );
        guard.set("AGENT_BROWSER_SESSION", "stream-port-conflict-session");

        let listener = std::net::TcpListener::bind("127.0.0.1:0")
            .expect("test should reserve an ephemeral port");
        let port = listener
            .local_addr()
            .expect("listener should have local addr")
            .port();

        let mut state = DaemonState::new();
        let err = handle_stream_enable(&json!({ "port": port }), &mut state)
            .await
            .expect_err("conflicting port should fail");
        assert!(err.contains("Failed to bind stream server"));
        assert!(state.stream_server.is_none());
        assert!(state.stream_client.is_none());
        assert!(
            !socket_dir
                .join("stream-port-conflict-session.stream")
                .exists(),
            "failed enable should not leave stale metadata behind"
        );

        drop(listener);
        let _ = fs::remove_dir_all(&socket_dir);
    }

    #[test]
    fn test_success_response_structure() {
        let resp = success_response("cmd-1", json!({"url": "https://example.com"}));
        assert_eq!(resp["id"], "cmd-1");
        assert_eq!(resp["success"], true);
        assert!(resp["data"].is_object());
        assert_eq!(resp["data"]["url"], "https://example.com");
    }

    #[test]
    fn test_error_response_structure() {
        let resp = error_response("cmd-2", "Something went wrong");
        assert_eq!(resp["id"], "cmd-2");
        assert_eq!(resp["success"], false);
        assert_eq!(resp["error"], "Something went wrong");
    }

    #[tokio::test]
    async fn test_daemon_state_new() {
        let guard = EnvGuard::new(&[
            "AGENT_BROWSER_ALLOWED_DOMAINS",
            "AGENT_BROWSER_SESSION_NAME",
            "AGENT_BROWSER_SESSION",
        ]);
        guard.remove("AGENT_BROWSER_ALLOWED_DOMAINS");
        guard.remove("AGENT_BROWSER_SESSION_NAME");
        guard.remove("AGENT_BROWSER_SESSION");

        let state = DaemonState::new();
        assert!(state.browser.is_none());
        assert!(state.domain_filter.read().await.is_none());
        assert_eq!(state.session_id, "default");
        assert!(!state.tracing_state.active);
        assert!(!state.recording_state.active);
        assert_eq!(state.mouse_state.x, 0.0);
        assert_eq!(state.mouse_state.y, 0.0);
        assert_eq!(state.mouse_state.buttons, 0);
    }

    #[test]
    fn test_mouse_event_params_preserve_position_and_buttons() {
        let mut mouse_state = MouseState::default();

        let move_params = build_mouse_event_params(
            &mut mouse_state,
            "mouseMoved",
            Some(120.0),
            Some(240.0),
            None,
            None,
            None,
            None,
            None,
            None,
        );
        assert_eq!(move_params.x, 120.0);
        assert_eq!(move_params.y, 240.0);
        assert_eq!(move_params.buttons, Some(0));

        let down_params = build_mouse_event_params(
            &mut mouse_state,
            "mousePressed",
            None,
            None,
            Some("left"),
            None,
            Some(1),
            None,
            None,
            None,
        );
        assert_eq!(down_params.x, 120.0);
        assert_eq!(down_params.y, 240.0);
        assert_eq!(down_params.button.as_deref(), Some("left"));
        assert_eq!(down_params.buttons, Some(1));
        assert_eq!(mouse_state.buttons, 1);

        let drag_move_params = build_mouse_event_params(
            &mut mouse_state,
            "mouseMoved",
            Some(150.0),
            Some(260.0),
            None,
            None,
            None,
            None,
            None,
            None,
        );
        assert_eq!(drag_move_params.buttons, Some(1));
        assert_eq!(drag_move_params.button.as_deref(), Some("left"));
        assert_eq!(mouse_state.x, 150.0);
        assert_eq!(mouse_state.y, 260.0);

        let up_params = build_mouse_event_params(
            &mut mouse_state,
            "mouseReleased",
            None,
            None,
            Some("left"),
            None,
            Some(1),
            None,
            None,
            None,
        );
        assert_eq!(up_params.x, 150.0);
        assert_eq!(up_params.y, 260.0);
        assert_eq!(up_params.buttons, Some(0));
        assert_eq!(mouse_state.buttons, 0);
    }

    #[test]
    fn test_reset_input_state_clears_mouse_state() {
        let mut state = DaemonState::new();
        state.mouse_state.x = 12.0;
        state.mouse_state.y = 34.0;
        state.mouse_state.buttons = 1;

        state.reset_input_state();

        assert_eq!(state.mouse_state.x, 0.0);
        assert_eq!(state.mouse_state.y, 0.0);
        assert_eq!(state.mouse_state.buttons, 0);
    }

    #[test]
    fn test_launch_options_from_env_defaults() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
        guard.remove("AGENT_BROWSER_HEADED");
        guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
        let opts = launch_options_from_env();
        assert!(opts.headless);
        assert!(opts.args.is_empty());
        assert!(!opts.allow_file_access);
        assert!(opts.hide_scrollbars);
    }

    #[test]
    fn test_launch_options_from_env_headed_flag() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
        guard.set("AGENT_BROWSER_HEADED", "1");
        guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
        let opts = launch_options_from_env();
        assert!(
            !opts.headless,
            "AGENT_BROWSER_HEADED=1 should set headless=false"
        );
    }

    #[test]
    fn test_launch_options_from_env_hide_scrollbars_false() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
        guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
        let opts = launch_options_from_env();
        assert!(!opts.hide_scrollbars);
    }

    #[test]
    fn test_launch_cmd_hide_scrollbars_missing_uses_env_default() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
        guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");

        assert!(!hide_scrollbars_from_launch_cmd(&json!({
            "action": "launch"
        })));
    }

    #[test]
    fn test_launch_cmd_hide_scrollbars_explicit_overrides_env_default() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
        guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");

        assert!(hide_scrollbars_from_launch_cmd(&json!({
            "action": "launch",
            "hideScrollbars": true
        })));
    }

    #[test]
    fn test_har_entry_to_json_enriches_request_and_response() {
        // wall_time: 2026-03-15T12:00:00Z = 1_773_576_000
        let entry = HarEntry {
            request_id: "req-1".to_string(),
            wall_time: 1773576000.0,
            method: "POST".to_string(),
            url: "https://example.com/api?foo=bar&baz=qux".to_string(),
            request_headers: vec![
                ("Accept".to_string(), "application/json".to_string()),
                ("Content-Type".to_string(), "application/json".to_string()),
                ("Cookie".to_string(), "session=abc; theme=dark".to_string()),
            ],
            post_data: Some(r#"{"x":1}"#.to_string()),
            request_body_size: 7,
            resource_type: "XHR".to_string(),
            status: Some(201),
            status_text: "Created".to_string(),
            http_version: "HTTP/2.0".to_string(),
            response_headers: vec![
                ("content-type".to_string(), "application/json".to_string()),
                (
                    "location".to_string(),
                    "https://example.com/api/1".to_string(),
                ),
                (
                    "set-cookie".to_string(),
                    "token=xyz; Path=/; HttpOnly".to_string(),
                ),
            ],
            mime_type: "application/json".to_string(),
            redirect_url: "https://example.com/api/1".to_string(),
            response_body_size: 42,
            cdp_timing: None,
            loading_finished_timestamp: None,
        };

        let har = har_entry_to_json(entry);
        assert_eq!(har["startedDateTime"], "2026-03-15T12:00:00Z");
        assert_eq!(har["request"]["method"], "POST");
        assert_eq!(har["request"]["httpVersion"], "HTTP/2.0");
        assert_eq!(har["request"]["queryString"][0]["name"], "foo");
        assert_eq!(har["request"]["queryString"][0]["value"], "bar");
        assert_eq!(har["request"]["bodySize"], 7);
        assert_eq!(har["request"]["postData"]["mimeType"], "application/json");
        assert_eq!(har["request"]["postData"]["text"], r#"{"x":1}"#);
        assert_eq!(har["request"]["cookies"][0]["name"], "session");
        assert_eq!(har["request"]["cookies"][0]["value"], "abc");
        assert_eq!(har["request"]["cookies"][1]["name"], "theme");
        assert_eq!(har["request"]["cookies"][1]["value"], "dark");
        assert_eq!(har["response"]["status"], 201);
        assert_eq!(har["response"]["statusText"], "Created");
        assert_eq!(har["response"]["content"]["mimeType"], "application/json");
        assert_eq!(har["response"]["content"]["size"], 42);
        assert_eq!(har["response"]["redirectURL"], "https://example.com/api/1");
        assert_eq!(har["response"]["cookies"][0]["name"], "token");
        assert_eq!(har["response"]["cookies"][0]["value"], "xyz");
        assert_eq!(har["_resourceType"], "XHR");
    }

    #[test]
    fn test_har_wall_time_to_rfc3339_epoch() {
        // Known timestamp: 2026-03-15T12:00:00Z = 1_773_576_000
        let result = har_wall_time_to_rfc3339(1773576000.0);
        assert!(result.starts_with("2026-03-15T12:00:00"));
    }

    #[test]
    fn test_har_wall_time_to_rfc3339_fractional_seconds() {
        let result = har_wall_time_to_rfc3339(1773576000.456);
        assert!(result.contains(".456") || result.contains("456"));
    }

    #[test]
    fn test_har_cdp_protocol_to_http_version() {
        assert_eq!(har_cdp_protocol_to_http_version("h2"), "HTTP/2.0");
        assert_eq!(har_cdp_protocol_to_http_version("h3"), "HTTP/3.0");
        assert_eq!(har_cdp_protocol_to_http_version("http/1.0"), "HTTP/1.0");
        assert_eq!(har_cdp_protocol_to_http_version("http/1.1"), "HTTP/1.1");
        assert_eq!(har_cdp_protocol_to_http_version("unknown"), "HTTP/1.1");
    }

    #[test]
    fn test_har_parse_request_cookies() {
        let cookies = har_parse_request_cookies("session=abc; theme=dark; empty=");
        assert_eq!(cookies.len(), 3);
        assert_eq!(cookies[0]["name"], "session");
        assert_eq!(cookies[0]["value"], "abc");
        assert_eq!(cookies[1]["name"], "theme");
        assert_eq!(cookies[1]["value"], "dark");
        assert_eq!(cookies[2]["name"], "empty");
        assert_eq!(cookies[2]["value"], "");
    }

    #[test]
    fn test_har_set_cookie_strips_attributes_before_equal_split() {
        let entry = HarEntry {
            request_id: "r".to_string(),
            wall_time: 1773576000.0,
            method: "GET".to_string(),
            url: "https://example.com/".to_string(),
            request_headers: vec![],
            post_data: None,
            request_body_size: 0,
            resource_type: "Document".to_string(),
            status: Some(200),
            status_text: "OK".to_string(),
            http_version: "HTTP/1.1".to_string(),
            response_headers: vec![(
                "set-cookie".to_string(),
                "token=abc; Path=/; HttpOnly".to_string(),
            )],
            mime_type: "text/html".to_string(),
            redirect_url: String::new(),
            response_body_size: 0,
            cdp_timing: None,
            loading_finished_timestamp: None,
        };
        let har = har_entry_to_json(entry);
        assert_eq!(har["response"]["cookies"][0]["name"], "token");
        assert_eq!(har["response"]["cookies"][0]["value"], "abc");
    }

    #[test]
    fn test_har_compute_timings_no_cdp_timing() {
        let (timings, total) = har_compute_timings(None, None);
        assert_eq!(timings["send"], 0);
        assert_eq!(timings["wait"], 0);
        assert_eq!(timings["receive"], 0);
        assert_eq!(total, 0.0);
    }

    #[test]
    fn test_har_compute_timings_with_cdp_timing() {
        let cdp = json!({
            "requestTime": 1000.0,
            "dnsStart": 0.0, "dnsEnd": 5.0,
            "connectStart": 5.0, "connectEnd": 15.0,
            "sslStart": 8.0, "sslEnd": 15.0,
            "sendStart": 15.0, "sendEnd": 16.0,
            "receiveHeadersStart": 16.0, "receiveHeadersEnd": 50.0,
        });
        let (timings, total) = har_compute_timings(Some(&cdp), Some(1000.1));
        assert_eq!(timings["dns"], 5.0);
        assert_eq!(timings["connect"], 10.0);
        assert_eq!(timings["ssl"], 7.0);
        assert_eq!(timings["send"], 1.0);
        assert!(total > 0.0);
    }

    #[tokio::test]
    async fn test_handle_har_stop_without_path_uses_default_location() {
        let mut state = DaemonState::new();
        state.har_recording = true;
        state.har_entries.push(HarEntry {
            request_id: "req-2".to_string(),
            wall_time: 1773576000.0,
            method: "GET".to_string(),
            url: "https://example.com/".to_string(),
            request_headers: vec![("Accept".to_string(), "text/html".to_string())],
            post_data: None,
            request_body_size: 0,
            resource_type: "Document".to_string(),
            status: Some(200),
            status_text: "OK".to_string(),
            http_version: "HTTP/2.0".to_string(),
            response_headers: vec![("content-type".to_string(), "text/html".to_string())],
            mime_type: "text/html".to_string(),
            redirect_url: String::new(),
            response_body_size: 128,
            cdp_timing: None,
            loading_finished_timestamp: None,
        });

        let result = handle_har_stop(&json!({ "action": "har_stop" }), &mut state)
            .await
            .unwrap();

        let path = result["path"].as_str().unwrap();
        assert!(path.ends_with(".har"));
        assert!(std::path::Path::new(path).starts_with(get_har_dir()));
        assert_eq!(result["requestCount"], 1);
        assert!(!state.har_recording);
        assert!(state.har_entries.is_empty());

        let har: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
        assert_eq!(har["log"]["version"], "1.2");
        assert_eq!(har["log"]["creator"]["name"], "agent-browser");
        assert!(har["log"].get("browser").is_none());
        assert_eq!(har["log"]["entries"][0]["response"]["content"]["size"], 128);

        let _ = fs::remove_file(path);
    }

    #[tokio::test]
    async fn test_execute_har_stop_skips_browser_auto_launch() {
        let path = std::env::temp_dir().join(format!(
            "agent-browser-har-stop-{}.har",
            unix_timestamp_millis()
        ));
        let mut state = DaemonState::new();
        state.har_entries.push(HarEntry {
            request_id: "req-3".to_string(),
            wall_time: 1773576000.0,
            method: "GET".to_string(),
            url: "https://example.com/".to_string(),
            request_headers: vec![],
            post_data: None,
            request_body_size: 0,
            resource_type: "Document".to_string(),
            status: Some(200),
            status_text: "OK".to_string(),
            http_version: "HTTP/1.1".to_string(),
            response_headers: vec![],
            mime_type: "text/html".to_string(),
            redirect_url: String::new(),
            response_body_size: 64,
            cdp_timing: None,
            loading_finished_timestamp: None,
        });

        let result = execute_command(
            &json!({
                "action": "har_stop",
                "id": "har-stop-1",
                "path": path.to_string_lossy().to_string()
            }),
            &mut state,
        )
        .await;

        assert_eq!(result["success"], true);
        assert_eq!(result["data"]["requestCount"], 1);
        let _ = fs::remove_file(path);
    }

    #[test]
    fn test_browser_metadata_from_version_parses_product() {
        let metadata = browser_metadata_from_version(&json!({
            "product": "HeadlessChrome/123.0.6312.0"
        }))
        .unwrap();

        assert_eq!(metadata["name"], "HeadlessChrome");
        assert_eq!(metadata["version"], "123.0.6312.0");
    }

    #[test]
    fn test_default_timeout_ms_from_env() {
        // When AGENT_BROWSER_DEFAULT_TIMEOUT is set, DaemonState should use it
        env::set_var("AGENT_BROWSER_DEFAULT_TIMEOUT", "3000");
        let state = DaemonState::new();
        assert_eq!(state.default_timeout_ms, 3000);
        env::remove_var("AGENT_BROWSER_DEFAULT_TIMEOUT");
    }

    #[test]
    fn test_default_timeout_ms_fallback() {
        // When AGENT_BROWSER_DEFAULT_TIMEOUT is unset, DaemonState uses 30000
        env::remove_var("AGENT_BROWSER_DEFAULT_TIMEOUT");
        let state = DaemonState::new();
        assert_eq!(state.default_timeout_ms, 30_000);
    }

    #[tokio::test]
    async fn test_execute_unknown_command() {
        let mut state = DaemonState::new();
        let cmd = json!({ "action": "unknown_action_xyz", "id": "test-1" });
        let result = execute_command(&cmd, &mut state).await;
        assert_eq!(result["success"], false);
        let error_msg = result["error"].as_str().unwrap();
        assert!(
            error_msg.contains("Not yet implemented") || error_msg.contains("Auto-launch failed"),
            "Unexpected error: {}",
            error_msg
        );
    }

    #[tokio::test]
    async fn test_execute_empty_action() {
        let mut state = DaemonState::new();
        let cmd = json!({ "id": "test-2" });
        let result = execute_command(&cmd, &mut state).await;
        // Empty action triggers auto-launch which will fail without a browser
        assert_eq!(result["success"], false);
    }

    #[tokio::test]
    async fn test_execute_close_without_browser() {
        let mut state = DaemonState::new();
        let cmd = json!({ "action": "close", "id": "test-3" });
        let result = execute_command(&cmd, &mut state).await;
        assert_eq!(result["success"], true);
        assert_eq!(result["data"]["closed"], true);
    }

    #[tokio::test]
    async fn test_navigate_without_browser() {
        let mut state = DaemonState::new();
        {
            let mut df = state.domain_filter.write().await;
            *df = Some(DomainFilter::new("example.com"));
        }
        let cmd = json!({
            "action": "navigate",
            "url": "https://blocked.com",
            "id": "test-4"
        });
        let result = execute_command(&cmd, &mut state).await;
        // Will fail because auto-launch fails, but the domain filter won't block since
        // auto-launch happens first
        assert_eq!(result["success"], false);
    }

    #[tokio::test]
    #[allow(clippy::await_holding_lock)]
    async fn test_credentials_roundtrip_via_actions() {
        let _lock = crate::native::auth::AUTH_TEST_MUTEX.lock().unwrap();
        let key_var = "AGENT_BROWSER_ENCRYPTION_KEY";
        let original = std::env::var(key_var).ok();
        // SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
        unsafe { std::env::set_var(key_var, "a".repeat(64)) };

        let mut state = DaemonState::new();

        let set_cmd = json!({
            "action": "credentials_set",
            "name": "test-cred-action",
            "username": "user",
            "password": "pass",
            "id": "c1"
        });
        let result = execute_command(&set_cmd, &mut state).await;
        assert_eq!(result["success"], true);

        let get_cmd = json!({
            "action": "credentials_get",
            "name": "test-cred-action",
            "id": "c2"
        });
        let result = execute_command(&get_cmd, &mut state).await;
        assert_eq!(result["success"], true);
        assert_eq!(result["data"]["username"], "user");

        let list_cmd = json!({ "action": "credentials_list", "id": "c3" });
        let result = execute_command(&list_cmd, &mut state).await;
        assert_eq!(result["success"], true);

        let del_cmd = json!({
            "action": "credentials_delete",
            "name": "test-cred-action",
            "id": "c4"
        });
        let result = execute_command(&del_cmd, &mut state).await;
        assert_eq!(result["success"], true);

        // SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
        match original {
            Some(val) => unsafe { std::env::set_var(key_var, val) },
            None => unsafe { std::env::remove_var(key_var) },
        }
    }

    #[tokio::test]
    async fn test_state_list_via_actions() {
        let mut state = DaemonState::new();
        let cmd = json!({ "action": "state_list", "id": "s1" });
        let result = execute_command(&cmd, &mut state).await;
        assert_eq!(result["success"], true);
        assert!(result["data"]["files"].is_array());
    }

    #[tokio::test]
    async fn test_build_fetch_patterns_empty_state() {
        let state = DaemonState::new();
        let patterns = build_fetch_patterns(&state).await;
        assert!(
            patterns.is_empty(),
            "No routes/filters/headers → no patterns"
        );
    }

    #[tokio::test]
    async fn test_build_fetch_patterns_with_routes() {
        let state = DaemonState::new();
        {
            let mut routes = state.routes.write().await;
            routes.push(super::RouteEntry {
                url_pattern: "https://example.com/*".to_string(),
                response: None,
                abort: true,
                resource_types: Vec::new(),
            });
        }
        let patterns = build_fetch_patterns(&state).await;
        assert_eq!(patterns.len(), 1);
        assert_eq!(patterns[0]["urlPattern"], "https://example.com/*");
    }

    #[tokio::test]
    async fn test_build_fetch_patterns_adds_wildcard_for_domain_filter() {
        let state = DaemonState::new();
        {
            let mut df = state.domain_filter.write().await;
            *df = Some(super::super::network::DomainFilter::new("example.com"));
        }
        let patterns = build_fetch_patterns(&state).await;
        assert_eq!(patterns.len(), 1);
        assert_eq!(patterns[0]["urlPattern"], "*");
    }

    #[tokio::test]
    async fn test_build_fetch_patterns_adds_wildcard_for_origin_headers() {
        let state = DaemonState::new();
        {
            let mut oh = state.origin_headers.write().await;
            let mut headers = HashMap::new();
            headers.insert("Authorization".to_string(), "Bearer xxx".to_string());
            oh.insert("http://example.com".to_string(), headers);
        }
        let patterns = build_fetch_patterns(&state).await;
        assert_eq!(patterns.len(), 1);
        assert_eq!(patterns[0]["urlPattern"], "*");
    }

    #[tokio::test]
    async fn test_build_fetch_patterns_no_duplicate_wildcard() {
        let state = DaemonState::new();
        {
            let mut routes = state.routes.write().await;
            routes.push(super::RouteEntry {
                url_pattern: "*".to_string(),
                response: None,
                abort: false,
                resource_types: Vec::new(),
            });
        }
        {
            let mut df = state.domain_filter.write().await;
            *df = Some(super::super::network::DomainFilter::new("example.com"));
        }
        let patterns = build_fetch_patterns(&state).await;
        assert_eq!(
            patterns.len(),
            1,
            "Should not add a second wildcard when routes already contain one"
        );
    }

    #[test]
    fn test_auth_login_waits_for_load_event() {
        use super::super::browser::WaitUntil;
        assert_eq!(
            super::AUTH_LOGIN_WAIT_UNTIL,
            WaitUntil::Load,
            "auth_login should navigate with Load and then wait for form \
             selectors explicitly"
        );
    }

    #[test]
    fn test_parse_key_chord_plain_key() {
        let (key, mods) = parse_key_chord("a");
        assert_eq!(key, "a");
        assert_eq!(mods, None);
    }

    #[test]
    fn test_parse_key_chord_enter() {
        let (key, mods) = parse_key_chord("Enter");
        assert_eq!(key, "Enter");
        assert_eq!(mods, None);
    }

    #[test]
    fn test_parse_key_chord_control_a() {
        let (key, mods) = parse_key_chord("Control+a");
        assert_eq!(key, "a");
        assert_eq!(mods, Some(2));
    }

    #[test]
    fn test_parse_key_chord_ctrl_alias() {
        let (key, mods) = parse_key_chord("Ctrl+c");
        assert_eq!(key, "c");
        assert_eq!(mods, Some(2));
    }

    #[test]
    fn test_parse_key_chord_shift_enter() {
        let (key, mods) = parse_key_chord("Shift+Enter");
        assert_eq!(key, "Enter");
        assert_eq!(mods, Some(8));
    }

    #[test]
    fn test_parse_key_chord_control_shift_a() {
        let (key, mods) = parse_key_chord("Control+Shift+a");
        assert_eq!(key, "a");
        assert_eq!(mods, Some(2 | 8));
    }

    #[test]
    fn test_parse_key_chord_meta_a() {
        let (key, mods) = parse_key_chord("Meta+a");
        assert_eq!(key, "a");
        assert_eq!(mods, Some(4));
    }

    #[test]
    fn test_parse_key_chord_alt_tab() {
        let (key, mods) = parse_key_chord("Alt+Tab");
        assert_eq!(key, "Tab");
        assert_eq!(mods, Some(1));
    }

    #[test]
    fn test_parse_key_chord_plus_key() {
        // A bare "+" should not be confused with a separator
        let (key, mods) = parse_key_chord("+");
        assert_eq!(key, "+");
        assert_eq!(mods, None);
    }

    #[tokio::test]
    async fn test_auto_dialog_enabled_by_default() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
        std::env::remove_var("AGENT_BROWSER_NO_AUTO_DIALOG");
        let state = DaemonState::new();
        assert!(state.auto_dialog, "auto_dialog should be true by default");
        drop(guard);
    }

    #[tokio::test]
    async fn test_auto_dialog_disabled_by_env() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
        guard.set("AGENT_BROWSER_NO_AUTO_DIALOG", "1");
        let state = DaemonState::new();
        assert!(
            !state.auto_dialog,
            "auto_dialog should be false when AGENT_BROWSER_NO_AUTO_DIALOG=1"
        );
        drop(guard);
    }

    #[tokio::test]
    async fn test_auto_dialog_disabled_by_env_true() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
        guard.set("AGENT_BROWSER_NO_AUTO_DIALOG", "true");
        let state = DaemonState::new();
        assert!(
            !state.auto_dialog,
            "auto_dialog should be false when AGENT_BROWSER_NO_AUTO_DIALOG=true"
        );
        drop(guard);
    }

    #[tokio::test]
    async fn test_auto_dialog_not_disabled_by_random_value() {
        let guard = EnvGuard::new(&["AGENT_BROWSER_NO_AUTO_DIALOG"]);
        guard.set("AGENT_BROWSER_NO_AUTO_DIALOG", "no");
        let state = DaemonState::new();
        assert!(
            state.auto_dialog,
            "auto_dialog should remain true for non-truthy env values"
        );
        drop(guard);
    }

    #[test]
    fn test_pending_dialog_not_set_for_auto_handled_alert() {
        // Simulate what handle_browser_event does: when auto_dialog is true,
        // alert/beforeunload should NOT populate pending_dialog.
        let auto_dialog = true;
        for dialog_type in &["alert", "beforeunload"] {
            let auto_handled = auto_dialog && matches!(*dialog_type, "beforeunload" | "alert");
            assert!(
                auto_handled,
                "{dialog_type} should be auto-handled when auto_dialog is true"
            );
        }
    }

    #[test]
    fn test_pending_dialog_set_for_confirm_prompt() {
        // confirm and prompt should NOT be auto-handled even when auto_dialog is true.
        let auto_dialog = true;
        for dialog_type in &["confirm", "prompt"] {
            let auto_handled = auto_dialog && matches!(*dialog_type, "beforeunload" | "alert");
            assert!(!auto_handled, "{dialog_type} should NOT be auto-handled");
        }
    }
}
