//! Headless Chromium backend via chromiumoxide.
//!
//! Implements `BrowserBackend` using Chrome DevTools Protocol (CDP).
//! Requires a Chromium/Chrome binary on the system.

use async_trait::async_trait;
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, FocusParams, GetBoxModelParams};
use chromiumoxide::cdp::browser_protocol::input::{
    DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
    MouseButton,
};
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::Page;
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use tokio::time::timeout;

use crate::backend::{BrowserBackend, BrowserError};
use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};

/// Cached mapping from ax_N node IDs to their CDP backend DOM node IDs.
/// Populated during `get_accessibility_tree()`, consumed by `click_element()`
/// and `focus_element()`.
type AxNodeCache = HashMap<String, BackendNodeId>;

/// Headless Chromium browser backend.
pub struct ChromiumBackend {
    page: Arc<RwLock<Option<Page>>>,
    _browser: Arc<RwLock<Option<Browser>>>,
    viewport_width: u32,
    viewport_height: u32,
    /// Cached URL, updated on navigate() and get_accessibility_tree().
    /// Uses std::sync::RwLock so get_current_url() can be synchronous.
    cached_url: std::sync::RwLock<String>,
    /// Cached mapping from ax_N IDs to BackendNodeId, populated by
    /// get_accessibility_tree(). Used by click_element/focus_element/type_into_element
    /// to resolve ax_N to real DOM coordinates.
    ax_node_cache: std::sync::RwLock<AxNodeCache>,
    /// Per-instance Chromium profile directory. Held so the
    /// directory survives until ChromiumBackend is dropped, then
    /// auto-cleans (TempDir runs `remove_dir_all` in its Drop).
    /// `None` when the caller passed an explicit profile dir via
    /// `CAR_BROWSER_PROFILE_DIR` — caller owns lifecycle in that case.
    /// See #148: under Playwright workers running in parallel, the
    /// chromiumoxide default profile dir caused SingletonLock
    /// contention; per-instance dirs eliminate the collision.
    _profile_dir: Option<tempfile::TempDir>,
    /// PID of the Chrome subprocess. Captured at launch so `Drop`
    /// can synchronously SIGKILL it without needing the tokio
    /// runtime. chromiumoxide relies on tokio's `kill_on_drop`,
    /// which only fires if the runtime is still alive when the
    /// `Browser` drops — on process panic/abort the Chrome
    /// subprocess is reparented to PID 1 and leaks. We bypass that
    /// by remembering the PID ourselves.
    chrome_pid: Option<u32>,
    /// Handle to the spawned tokio task that drains chromiumoxide's
    /// CDP event stream. Aborted in `shutdown()` and `Drop` so the
    /// task doesn't outlive the backend (it holds the CDP channel,
    /// which keeps the chromiumoxide `Browser` from quiescing).
    handler_task: StdMutex<Option<JoinHandle<()>>>,
}

/// True iff `url` is on the same (scheme, host, port) as `origin`.
///
/// Used to enforce origin-locality for `set_local_storage`: localStorage is
/// origin-scoped in the browser, so setting items from the wrong page would
/// either fail silently or mutate the wrong origin's state. We require the
/// caller to navigate to the origin first.
fn current_origin_matches(url: &str, origin: &str) -> bool {
    // Empty or `about:blank` is allowed — treat as a pre-page state where
    // localStorage operations will be attached to the first real
    // navigation. Chromium's about:blank has no persistent storage so the
    // `evaluate` call will simply set localStorage on the next real page.
    if url.is_empty() || url.starts_with("about:") {
        return true;
    }
    let extract = |s: &str| {
        let (scheme, rest) = s.split_once("://")?;
        let host_part = rest.split('/').next().unwrap_or("");
        Some(format!("{}://{}", scheme, host_part))
    };
    match (extract(url), extract(origin)) {
        (Some(a), Some(b)) => a == b,
        _ => false,
    }
}

/// Convert our `Modifier` enum to CDP modifier bitmask.
/// CDP defines: Alt=1, Ctrl=2, Meta/Command=4, Shift=8.
fn modifiers_to_cdp_flags(modifiers: &[Modifier]) -> i64 {
    let mut flags: i64 = 0;
    for m in modifiers {
        flags |= match m {
            Modifier::Alt => 1,
            Modifier::Control => 2,
            Modifier::Meta => 4,
            Modifier::Shift => 8,
        };
    }
    flags
}

/// Options for launching a `ChromiumBackend`.
///
/// `headless = false` shows a real visible Chromium window — intended for
/// interactive flows like first-time authentication (LinkedIn, OAuth, SSO)
/// where a human needs to complete sign-in / 2FA / captcha before the rest
/// of the script runs headless against the persisted cookies.
#[derive(Debug, Clone)]
pub struct LaunchOptions {
    pub width: u32,
    pub height: u32,
    pub headless: bool,
    /// Extra command-line flags appended to Chromium's argv at
    /// launch. Use cases include the Google Meet bot (#112) which
    /// needs `--use-fake-ui-for-media-stream`,
    /// `--autoplay-policy=no-user-gesture-required`, and the
    /// container-friendly trio (`--no-sandbox`,
    /// `--disable-dev-shm-usage`, `--disable-setuid-sandbox`).
    /// Flags are passed verbatim — callers responsible for the
    /// correctness of what they append.
    pub extra_args: Vec<String>,
}

impl Default for LaunchOptions {
    fn default() -> Self {
        Self {
            width: 1280,
            height: 720,
            headless: true,
            extra_args: Vec::new(),
        }
    }
}

impl ChromiumBackend {
    /// Launch a new headless Chromium instance.
    pub async fn launch() -> Result<Self, BrowserError> {
        Self::launch_with_viewport(1280, 720).await
    }

    /// Launch with specific viewport dimensions (headless).
    pub async fn launch_with_viewport(width: u32, height: u32) -> Result<Self, BrowserError> {
        Self::launch_with_options(LaunchOptions {
            width,
            height,
            headless: true,
            extra_args: Vec::new(),
        })
        .await
    }

    /// Launch with full options, including headless/headed toggle.
    ///
    /// `headless = true` uses Chromium's new headless mode (`--headless=new`);
    /// `headless = false` shows a visible window (for interactive auth etc.).
    pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
        let mut builder = BrowserConfig::builder().window_size(opts.width, opts.height);
        builder = if opts.headless {
            builder.new_headless_mode()
        } else {
            builder.with_head()
        };
        if !opts.extra_args.is_empty() {
            // chromiumoxide's BrowserConfig::builder().args takes
            // an `IntoIterator<Item = impl Into<String>>` and
            // appends each verbatim to Chromium's argv.
            builder = builder.args(opts.extra_args.iter().map(String::as_str));
        }

        // Per-instance Chromium profile directory.
        //
        // chromiumoxide defaults to a fixed path under $TMPDIR;
        // when the same default is reused by N parallel processes
        // (e.g. Playwright workers each constructing a CarRuntime),
        // they contend over Chromium's `SingletonLock` and the
        // losers either silently get a wrong-port DevTools handshake
        // or hang on navigate (#148).
        //
        // Each ChromiumBackend now gets its own tempdir, scoped to
        // its lifetime. Callers who *want* persistence (cookies +
        // localStorage between runs) can set CAR_BROWSER_PROFILE_DIR
        // and accept they're back on the hook for parallel-launch
        // coordination.
        let (profile_dir, profile_handle) = match std::env::var("CAR_BROWSER_PROFILE_DIR") {
            Ok(path) if !path.is_empty() => (std::path::PathBuf::from(path), None),
            _ => {
                let td = tempfile::Builder::new()
                    .prefix("car-browser-profile-")
                    .tempdir()
                    .map_err(|e| {
                        BrowserError::NotAvailable(format!("create per-instance profile dir: {e}"))
                    })?;
                (td.path().to_path_buf(), Some(td))
            }
        };
        builder = builder.user_data_dir(&profile_dir);

        let config = builder
            .build()
            .map_err(|e| BrowserError::NotAvailable(format!("Config error: {}", e)))?;

        let (mut browser, mut handler) = Browser::launch(config)
            .await
            .map_err(|e| BrowserError::NotAvailable(format!("Failed to launch Chrome: {}", e)))?;

        // Capture the Chrome subprocess PID up front. `get_mut_child`
        // returns the underlying tokio Child; its `id()` is `Some`
        // until the process is reaped. We keep this so `Drop` can
        // SIGKILL synchronously without awaiting (see field docs).
        let chrome_pid = browser.get_mut_child().and_then(|c| c.inner.id());

        // Spawn the CDP event handler. We hold the JoinHandle so
        // shutdown()/Drop can abort it — otherwise it lives for the
        // process lifetime and keeps the CDP channel open, which
        // prevents chromiumoxide from cleanly tearing down the
        // Browser when callers expect `_browser.take()` to suffice.
        let handler_task =
            tokio::spawn(async move { while let Some(_event) = handler.next().await {} });

        let page = browser
            .new_page("about:blank")
            .await
            .map_err(|e| BrowserError::NotAvailable(format!("Failed to create page: {}", e)))?;

        Ok(Self {
            page: Arc::new(RwLock::new(Some(page))),
            _browser: Arc::new(RwLock::new(Some(browser))),
            viewport_width: opts.width,
            viewport_height: opts.height,
            cached_url: std::sync::RwLock::new("about:blank".to_string()),
            ax_node_cache: std::sync::RwLock::new(HashMap::new()),
            _profile_dir: profile_handle,
            chrome_pid,
            handler_task: StdMutex::new(Some(handler_task)),
        })
    }

    /// OS process ID of the spawned Chrome subprocess, if known.
    ///
    /// Returns `None` when chromiumoxide did not spawn the process
    /// itself (e.g. attached to an existing browser, which we do
    /// not currently do but the API allows). Stable across the
    /// lifetime of this backend — Chrome is not respawned on its
    /// own — so callers can use it to assert cleanup in tests.
    pub fn chrome_pid(&self) -> Option<u32> {
        self.chrome_pid
    }

    async fn get_page(&self) -> Result<Page, BrowserError> {
        self.page
            .read()
            .await
            .clone()
            .ok_or(BrowserError::NotAvailable("Page closed".into()))
    }

    /// Update the cached URL by querying the page asynchronously.
    async fn refresh_cached_url(&self) {
        if let Ok(page) = self.get_page().await {
            if let Ok(Some(url)) = page.url().await {
                if let Ok(mut cached) = self.cached_url.write() {
                    *cached = url;
                }
            }
        }
    }

    /// Look up the BackendNodeId for an ax_N node ID from the cache.
    fn resolve_backend_node_id(&self, node_id: &str) -> Result<BackendNodeId, BrowserError> {
        let cache = self.ax_node_cache.read().map_err(|e| {
            BrowserError::PlatformInternal(format!("Failed to read ax_node_cache: {}", e))
        })?;
        cache.get(node_id).copied().ok_or_else(|| {
            BrowserError::ElementNotFound(format!(
                "No cached BackendNodeId for '{}'. Call get_accessibility_tree() first.",
                node_id
            ))
        })
    }

    /// Get the bounding box center for a BackendNodeId via CDP DOM.getBoxModel.
    async fn get_element_center(
        &self,
        backend_node_id: BackendNodeId,
    ) -> Result<(f64, f64), BrowserError> {
        let page = self.get_page().await?;
        let params = GetBoxModelParams::builder()
            .backend_node_id(backend_node_id)
            .build();
        let result = page
            .execute(params)
            .await
            .map_err(|e| BrowserError::ElementNotFound(format!("DOM.getBoxModel failed: {}", e)))?;

        // The content quad is 8 floats: [x1,y1, x2,y2, x3,y3, x4,y4]
        let quad = result.result.model.content.inner();
        if quad.len() < 8 {
            return Err(BrowserError::PlatformInternal(
                "Content quad has fewer than 8 values".into(),
            ));
        }
        // Compute center from the four corners
        let cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4.0;
        let cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4.0;
        Ok((cx, cy))
    }

    /// Focus a DOM element by BackendNodeId via CDP DOM.focus.
    async fn focus_by_backend_node_id(
        &self,
        backend_node_id: BackendNodeId,
    ) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        let params = FocusParams::builder()
            .backend_node_id(backend_node_id)
            .build();
        page.execute(params)
            .await
            .map_err(|e| BrowserError::InputFailed(format!("DOM.focus failed: {}", e)))?;
        Ok(())
    }
}

#[async_trait]
impl BrowserBackend for ChromiumBackend {
    async fn capture_screenshot(&self) -> Result<Vec<u8>, BrowserError> {
        let page = self.get_page().await?;
        page.screenshot(
            chromiumoxide::page::ScreenshotParams::builder()
                .format(CaptureScreenshotFormat::Png)
                .build(),
        )
        .await
        .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))
    }

    async fn get_accessibility_tree(&self) -> Result<Vec<A11yNode>, BrowserError> {
        let page = self.get_page().await?;
        let result = page
            .execute(GetFullAxTreeParams::default())
            .await
            .map_err(|e| BrowserError::AccessibilityFailed(e.to_string()))?;

        // Update cached URL while we have the page
        self.refresh_cached_url().await;

        let mut new_cache = AxNodeCache::new();

        let mut nodes: Vec<A11yNode> = Vec::new();
        for (i, n) in result.result.nodes.iter().enumerate() {
            if n.ignored {
                continue;
            }

            let ax_id = format!("ax_{}", i);

            // Cache the BackendNodeId for later use by click_element/focus_element
            if let Some(backend_id) = n.backend_dom_node_id {
                new_cache.insert(ax_id.clone(), backend_id);
            }

            let role = n
                .role
                .as_ref()
                .and_then(|r| r.value.as_ref())
                .and_then(|v| v.as_str())
                .unwrap_or("unknown")
                .to_string();

            let name = n
                .name
                .as_ref()
                .and_then(|v| v.value.as_ref())
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .map(|s| s.to_string());

            let value = n
                .value
                .as_ref()
                .and_then(|v| v.value.as_ref())
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .map(|s| s.to_string());

            let children: Vec<String> = n
                .child_ids
                .as_ref()
                .map(|ids| ids.iter().map(|id| format!("ax_{}", id.as_ref())).collect())
                .unwrap_or_default();

            // Resolve real bounds via DOM.getBoxModel if we have a backend node ID.
            // Fall back to zero-sized bounds for nodes without a DOM backing (e.g. root).
            let bounds = if let Some(backend_id) = n.backend_dom_node_id {
                let bm_params = GetBoxModelParams::builder()
                    .backend_node_id(backend_id)
                    .build();
                if let Ok(bm_result) = page.execute(bm_params).await {
                    let quad = bm_result.result.model.content.inner();
                    if quad.len() >= 8 {
                        let x = quad[0];
                        let y = quad[1];
                        let width = quad[2] - quad[0];
                        let height = quad[5] - quad[1];
                        Bounds::new(x, y, width.max(0.0), height.max(0.0))
                    } else {
                        Bounds::new(0.0, 0.0, 0.0, 0.0)
                    }
                } else {
                    Bounds::new(0.0, 0.0, 0.0, 0.0)
                }
            } else {
                Bounds::new(0.0, 0.0, 0.0, 0.0)
            };

            nodes.push(A11yNode {
                node_id: ax_id,
                role,
                name,
                value,
                bounds,
                children,
                focusable: true,
                focused: false,
                disabled: false,
            });
        }

        // Update the shared cache
        if let Ok(mut cache) = self.ax_node_cache.write() {
            *cache = new_cache;
        }

        Ok(nodes)
    }

    fn get_viewport(&self) -> Result<Viewport, BrowserError> {
        Ok(Viewport {
            width: self.viewport_width,
            height: self.viewport_height,
            device_pixel_ratio: 1.0,
        })
    }

    fn get_current_url(&self) -> Result<String, BrowserError> {
        self.cached_url
            .read()
            .map(|url| url.clone())
            .map_err(|e| BrowserError::PlatformInternal(format!("URL cache lock poisoned: {}", e)))
    }

    async fn get_page_title(&self) -> Result<String, BrowserError> {
        let page = self.get_page().await?;
        page.evaluate("document.title")
            .await
            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
            .into_value::<String>()
            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))
    }

    async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        page.goto(url)
            .await
            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
        page.wait_for_navigation()
            .await
            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;

        // Update cached URL after navigation
        if let Ok(mut cached) = self.cached_url.write() {
            *cached = url.to_string();
        }
        // Also refresh from the page in case of redirects
        self.refresh_cached_url().await;

        Ok(())
    }

    async fn inject_click(&self, x: f64, y: f64) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        page.execute(
            DispatchMouseEventParams::builder()
                .r#type(DispatchMouseEventType::MousePressed)
                .x(x)
                .y(y)
                .button(MouseButton::Left)
                .click_count(1)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;

        page.execute(
            DispatchMouseEventParams::builder()
                .r#type(DispatchMouseEventType::MouseReleased)
                .x(x)
                .y(y)
                .button(MouseButton::Left)
                .click_count(1)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;

        Ok(())
    }

    async fn inject_text(&self, text: &str) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        for ch in text.chars() {
            page.execute(
                DispatchKeyEventParams::builder()
                    .r#type(DispatchKeyEventType::Char)
                    .text(ch.to_string())
                    .build()
                    .unwrap(),
            )
            .await
            .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
        }
        Ok(())
    }

    async fn inject_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        let cdp_modifiers = modifiers_to_cdp_flags(modifiers);

        page.execute(
            DispatchKeyEventParams::builder()
                .r#type(DispatchKeyEventType::KeyDown)
                .key(key.to_string())
                .modifiers(cdp_modifiers)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;

        page.execute(
            DispatchKeyEventParams::builder()
                .r#type(DispatchKeyEventType::KeyUp)
                .key(key.to_string())
                .modifiers(cdp_modifiers)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;

        Ok(())
    }

    async fn inject_scroll(&self, delta_y: i32) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        page.execute(
            DispatchMouseEventParams::builder()
                .r#type(DispatchMouseEventType::MouseWheel)
                .x(self.viewport_width as f64 / 2.0)
                .y(self.viewport_height as f64 / 2.0)
                .delta_x(0.0)
                .delta_y(delta_y as f64)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
        Ok(())
    }

    async fn click_element(&self, node_id: &str) -> Result<(), BrowserError> {
        let backend_node_id = self.resolve_backend_node_id(node_id)?;
        let (cx, cy) = self.get_element_center(backend_node_id).await?;
        self.inject_click(cx, cy).await
    }

    async fn type_into_element(&self, node_id: &str, text: &str) -> Result<(), BrowserError> {
        let backend_node_id = self.resolve_backend_node_id(node_id)?;
        self.focus_by_backend_node_id(backend_node_id).await?;
        self.inject_text(text).await
    }

    async fn focus_element(&self, node_id: &str) -> Result<(), BrowserError> {
        let backend_node_id = self.resolve_backend_node_id(node_id)?;
        self.focus_by_backend_node_id(backend_node_id).await
    }

    async fn is_page_loaded(&self) -> Result<bool, BrowserError> {
        let page = self.get_page().await?;
        let state = page
            .evaluate("document.readyState")
            .await
            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
            .into_value::<String>()
            .unwrap_or_default();
        Ok(state == "complete")
    }

    async fn wait_until(
        &self,
        condition: &WaitCondition,
        timeout_ms: u64,
    ) -> Result<bool, BrowserError> {
        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);

        // Snapshot the URL at entry for UrlChanged comparisons.
        // Errors here turn into an empty baseline rather than
        // failing the wait — a missing baseline matches anything.
        let entry_url = self.get_current_url().unwrap_or_default();

        loop {
            let met = match condition {
                WaitCondition::PageLoaded => self.is_page_loaded().await?,
                WaitCondition::UrlChanged => {
                    let now = self.get_current_url().unwrap_or_default();
                    !now.is_empty() && now != entry_url
                }
                WaitCondition::A11yContainsText { text } => {
                    let needle = text.to_lowercase();
                    let nodes = self.get_accessibility_tree().await?;
                    nodes.iter().any(|n| {
                        n.name
                            .as_ref()
                            .map(|name| name.to_lowercase().contains(&needle))
                            .unwrap_or(false)
                    })
                }
                WaitCondition::ElementWithName {
                    name_contains,
                    role,
                } => {
                    self.element_exists_a11y(name_contains, role.as_deref())
                        .await?
                }
            };
            if met {
                return Ok(true);
            }
            if tokio::time::Instant::now() >= deadline {
                return Ok(false);
            }
            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        }
    }

    async fn element_exists_a11y(
        &self,
        name_contains: &str,
        role: Option<&str>,
    ) -> Result<bool, BrowserError> {
        let nodes = self.get_accessibility_tree().await?;
        Ok(nodes.iter().any(|n| {
            let name_match = n
                .name
                .as_ref()
                .map(|name| name.to_lowercase().contains(&name_contains.to_lowercase()))
                .unwrap_or(false);
            if !name_match {
                return false;
            }
            match role {
                Some(r) => n.role.to_lowercase() == r.to_lowercase(),
                None => true,
            }
        }))
    }

    async fn set_cookies(
        &self,
        cookies: &[crate::models::CookieParam],
    ) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        for cookie in cookies {
            let mut cdp_cookie = chromiumoxide::cdp::browser_protocol::network::CookieParam::new(
                &cookie.name,
                &cookie.value,
            );
            cdp_cookie.domain = Some(cookie.domain.clone());
            cdp_cookie.path = Some(cookie.path.clone());
            if cookie.secure {
                cdp_cookie.secure = Some(true);
            }
            if cookie.http_only {
                cdp_cookie.http_only = Some(true);
            }
            page.set_cookie(cdp_cookie)
                .await
                .map_err(|e| BrowserError::PlatformInternal(format!("set_cookie failed: {}", e)))?;
        }
        Ok(())
    }

    async fn set_local_storage(
        &self,
        origin: &str,
        items: &[(String, String)],
    ) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        // localStorage is origin-scoped — require the page already be at
        // the target origin so we don't silently navigate behind the
        // caller's back. Callers replay via `set_local_storage` after a
        // `navigate` to the matching origin (or before first `navigate`
        // if the caller wants the script to navigate elsewhere).
        let current = self.get_current_url().unwrap_or_default();
        if !current_origin_matches(&current, origin) {
            return Err(BrowserError::PlatformInternal(format!(
                "set_local_storage: page must be at origin '{}' first (currently '{}'). \
                 Add a `navigate` op before set_local_storage, or call set_local_storage \
                 before any navigate (pre-page state).",
                origin, current
            )));
        }

        // Don't swallow JSON encoding errors — `serde_json::to_string` on a
        // &str can fail in theory; treat that as a platform bug, not a
        // silent empty string.
        for (key, value) in items {
            let k = serde_json::to_string(key)
                .map_err(|e| BrowserError::PlatformInternal(format!("encode key: {}", e)))?;
            let v = serde_json::to_string(value)
                .map_err(|e| BrowserError::PlatformInternal(format!("encode value: {}", e)))?;
            let js = format!("localStorage.setItem({}, {})", k, v);
            page.evaluate(js).await.map_err(|e| {
                BrowserError::PlatformInternal(format!("localStorage.setItem failed: {}", e))
            })?;
        }
        Ok(())
    }

    async fn set_extra_headers(&self, headers: &[(String, String)]) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        // Enable network domain first
        page.execute(chromiumoxide::cdp::browser_protocol::network::EnableParams::default())
            .await
            .map_err(|e| BrowserError::PlatformInternal(format!("network enable failed: {}", e)))?;

        let header_obj: serde_json::Value = headers
            .iter()
            .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
            .collect::<serde_json::Map<String, serde_json::Value>>()
            .into();
        let params = chromiumoxide::cdp::browser_protocol::network::SetExtraHttpHeadersParams::new(
            chromiumoxide::cdp::browser_protocol::network::Headers::new(header_obj),
        );
        page.execute(params).await.map_err(|e| {
            BrowserError::PlatformInternal(format!("set_extra_headers failed: {}", e))
        })?;
        Ok(())
    }

    async fn shutdown(&self) -> Result<(), BrowserError> {
        // Order matters:
        //   1. Close the active page (frees CDP resources).
        //   2. Abort the CDP event handler task so the channel
        //      isn't held open while we try to close the Browser.
        //   3. Send Browser.close via CDP, then wait on the Child.
        //
        // Each step is bounded by a short timeout so a hung or
        // crashed Chrome can't wedge shutdown. If `close`/`wait`
        // time out the explicit kill in `Drop` (or the next call
        // path) still terminates the process by PID.
        if let Some(page) = self.page.write().await.take() {
            let _ = timeout(Duration::from_secs(2), page.close()).await;
        }
        if let Some(h) = self.handler_task.lock().ok().and_then(|mut g| g.take()) {
            h.abort();
        }
        if let Some(mut browser) = self._browser.write().await.take() {
            // Best-effort graceful close, then reap. If `close` is
            // unresponsive (Chrome already dead, CDP channel torn
            // down, etc.) fall back to `kill` so we never leave a
            // running subprocess behind.
            let close_ok = timeout(Duration::from_secs(2), browser.close())
                .await
                .map(|r| r.is_ok())
                .unwrap_or(false);
            if !close_ok {
                let _ = timeout(Duration::from_secs(2), browser.kill()).await;
            }
            let _ = timeout(Duration::from_secs(2), browser.wait()).await;
        }
        Ok(())
    }
}

impl Drop for ChromiumBackend {
    /// Synchronous backstop for the `shutdown()` happy path.
    ///
    /// `shutdown()` is async, so callers who let a `ChromiumBackend`
    /// drop on a panic, an early return, or process exit never get a
    /// chance to run it. chromiumoxide's own `Browser::Drop` relies
    /// on tokio's `kill_on_drop`, which only fires while the tokio
    /// runtime is alive — which it usually isn't during teardown.
    ///
    /// macOS does not deliver a parent-death signal, so any Chrome
    /// subprocess still alive at this moment would be reparented to
    /// launchd (PPID=1) and leak forever. We avoid that by SIGKILL'ing
    /// the captured PID directly.
    fn drop(&mut self) {
        // Abort the CDP event-pump task. `abort()` is non-blocking;
        // the task is detached after this and won't be observable.
        if let Some(h) = self.handler_task.get_mut().ok().and_then(|g| g.take()) {
            h.abort();
        }
        // SIGKILL the Chrome subprocess if we still have its PID.
        // `kill(pid, 0)` is a liveness probe — if it errors with
        // ESRCH the process is already gone and we skip the signal.
        #[cfg(unix)]
        if let Some(pid) = self.chrome_pid {
            // SAFETY: `kill(2)` is a syscall with no aliasing or
            // memory-safety concerns. We only read errno via the
            // return value.
            unsafe {
                if libc::kill(pid as libc::pid_t, 0) == 0 {
                    libc::kill(pid as libc::pid_t, libc::SIGKILL);
                }
            }
        }
        // On non-Unix targets we rely on tokio's `kill_on_drop`,
        // which on Windows uses TerminateProcess synchronously
        // from the Child's Drop. The orphan pattern that motivated
        // this fix is macOS-specific.
    }
}
