//! Franz-style embedded webview accounts.
//!
//! Hosts third-party web apps (WhatsApp Web, Slack, …) as a child Tauri
//! `Webview` positioned inside the main React window at a rect chosen by the
//! UI. A small per-provider "recipe" JS file is injected via
//! `initialization_script` to scrape the DOM and pipe state back to Rust as
//! `webview_recipe_event` invocations. Rust forwards each event up to the
//! React UI as a `webview:event` Tauri event; React is responsible for
//! persisting interesting payloads to memory via the existing core RPC.
//!
//! Architecture:
//!   React → invoke('webview_account_open',  …)  → spawn child Webview
//!   React → invoke('webview_account_bounds', …) → reposition / resize
//!   recipe → invoke('webview_recipe_event',  …) → emit('webview:event', …)
//!
//! Per-account session isolation: each account gets its own
//! `data_directory` under `{app_local_data_dir}/webview_accounts/{id}` so
//! cookies and storage don't bleed between accounts (best-effort on
//! WKWebView — see Tauri docs on `data_store_identifier` for the macOS path).

use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Mutex;
#[cfg(target_os = "linux")]
use std::sync::{mpsc::sync_channel, OnceLock};
use std::time::{Duration, Instant};

use chrono::{TimeZone, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::{
    webview::NewWindowResponse, AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Runtime,
    Url, WebviewBuilder, WebviewUrl,
};
#[cfg(windows)]
use tauri_plugin_notification::NotificationExt;
// `ImplBrowser` exposes `Browser::identifier()` — bring the trait into scope
// so the `with_webview` callback can read the CEF browser id.
use cef::ImplBrowser;

use crate::cdp;

const RUNTIME_JS: &str = include_str!("runtime.js");
const LINKEDIN_RECIPE_JS: &str = include_str!("../../recipes/linkedin/recipe.js");
const GOOGLE_MEET_RECIPE_JS: &str = include_str!("../../recipes/google-meet/recipe.js");

/// Registered providers and their service URLs. Add a new arm here plus a
/// recipe.js file under `recipes/<id>/` to support another provider.
fn provider_url(provider: &str) -> Option<&'static str> {
    match provider {
        "whatsapp" => Some("https://web.whatsapp.com/"),
        "telegram" => Some("https://web.telegram.org/k/"),
        "linkedin" => Some("https://www.linkedin.com/messaging/"),
        "slack" => Some("https://app.slack.com/client/"),
        "discord" => Some("https://discord.com/channels/@me"),
        "google-meet" => Some("https://meet.google.com/"),
        "zoom" => Some("https://zoom.us/"),
        "browserscan" => Some("https://www.browserscan.net/bot-detection"),
        _ => None,
    }
}

/// Returns the injected recipe.js for providers that still rely on the
/// JS-bridge ingest path. Migrated providers (whatsapp, telegram, slack,
/// discord, browserscan) return `None` — their scraping runs natively via
/// CDP in the per-provider scanner modules.
fn provider_recipe_js(provider: &str) -> Option<&'static str> {
    match provider {
        "linkedin" => Some(LINKEDIN_RECIPE_JS),
        "google-meet" => Some(GOOGLE_MEET_RECIPE_JS),
        _ => None,
    }
}

/// Whether this provider is supported at all. Derived from
/// `provider_url` so there's one canonical list — new providers added
/// to the `provider_url` match automatically become "supported" here.
fn provider_is_supported(provider: &str) -> bool {
    provider_url(provider).is_some()
}

/// Host suffixes the embedded webview is allowed to navigate within. Any
/// navigation to a host outside this set is cancelled and opened in the
/// user's default browser instead. Meet includes Google's auth and
/// static asset hosts so the OAuth redirect loop works; Discord includes
/// its CDN subdomains for the same reason.
fn provider_allowed_hosts(provider: &str) -> &'static [&'static str] {
    match provider {
        "whatsapp" => &["whatsapp.com", "whatsapp.net", "wa.me"],
        "telegram" => &["telegram.org", "t.me"],
        "linkedin" => &[
            "linkedin.com",
            "licdn.com",
            "accounts.google.com",
            "accounts.googleusercontent.com",
            "ssl.gstatic.com",
            "fonts.gstatic.com",
            "lh3.googleusercontent.com",
            "oauth2.googleapis.com",
            "www.googleapis.com",
        ],
        "slack" => &[
            "slack.com",
            "slack-edge.com",
            "slackb.com",
            "accounts.google.com",
            "accounts.googleusercontent.com",
            "ssl.gstatic.com",
            "fonts.gstatic.com",
            "lh3.googleusercontent.com",
            "oauth2.googleapis.com",
            "www.googleapis.com",
        ],
        "discord" => &[
            "discord.com",
            "discord.gg",
            "discordapp.com",
            "discordapp.net",
        ],
        "google-meet" => &[
            "google.com",
            "googleusercontent.com",
            "gstatic.com",
            "googleapis.com",
        ],
        "zoom" => &[
            "zoom.us",
            "zoom.com",
            "zoomgov.com",
            "zdassets.com",
            "accounts.google.com",
            "accounts.googleusercontent.com",
            "ssl.gstatic.com",
            "fonts.gstatic.com",
            "lh3.googleusercontent.com",
            "oauth2.googleapis.com",
            "www.googleapis.com",
        ],
        "browserscan" => &["browserscan.net"],
        _ => &[],
    }
}

/// Rewrite a provider-specific native-app deep link (e.g. Zoom's
/// `zoomus://zoom.us/join?...`) into a web-client URL so the meeting stays
/// inside the embedded webview instead of failing with
/// ERR_UNKNOWN_URL_SCHEME (CEF has no handler for these schemes).
///
/// Returns `Some(rewritten)` when the provider claims the scheme and a
/// valid web-client URL can be built; `None` otherwise (caller should
/// leave the navigation alone).
fn rewrite_provider_deep_link(provider: &str, url: &Url) -> Option<Url> {
    if provider != "zoom" {
        return None;
    }
    if !matches!(url.scheme(), "zoomus" | "zoommtg") {
        return None;
    }
    // Pull the meeting id out of the query string. Zoom uses `confno` on
    // both `action=join` (joining) and `action=start` (hosting) flows.
    let confno = url
        .query_pairs()
        .find(|(k, _)| k == "confno")
        .map(|(_, v)| v.into_owned());
    let pwd = url
        .query_pairs()
        .find(|(k, _)| k == "pwd" || k == "tk")
        .map(|(_, v)| v.into_owned());
    // Build the rewritten URL via `Url` so `confno` and `pwd` are
    // percent-encoded — inbound Zoom tokens can contain reserved chars
    // (`&`, `#`, `%`, `+`, …) that would corrupt a hand-rolled
    // `format!(…)` string and silently break the join/host flow.
    match confno {
        Some(id) if !id.is_empty() => {
            // Base without trailing slash; `path_segments_mut().push(id)`
            // appends `/id` cleanly. A trailing `/` on the base would yield
            // `/wc/join//id` (empty segment preserved by the Url spec).
            let mut rewritten = Url::parse("https://app.zoom.us/wc/join").ok()?;
            rewritten.path_segments_mut().ok()?.push(&id);
            if let Some(p) = pwd.filter(|p| !p.is_empty()) {
                rewritten.query_pairs_mut().append_pair("pwd", &p);
            }
            Some(rewritten)
        }
        _ => Url::parse("https://app.zoom.us/wc/home").ok(),
    }
}

/// `true` if `url` is considered in-app for `provider`. Non-HTTP(S)
/// schemes (`about:blank`, `data:`, `blob:`) have no host and are always
/// allowed so the webview's own internal navigations keep working.
/// Unknown providers are also permissive — better to accidentally keep a
/// link in-app than to leak it to the system browser.
fn url_is_internal(provider: &str, url: &Url) -> bool {
    let Some(host) = url.host_str() else {
        return true;
    };
    // Google services route the post-2FA `SetSID` cookie-setting hop
    // through `accounts.youtube.com` and ccTLD `accounts.google.<rest>`
    // hosts that aren't covered by the suffix-based allowlist. Without
    // this, the auth chain breaks mid-flight and leaks to the system
    // browser (#1053 sign-in leak surfaced in dev:app log line:
    // "external navigation https://accounts.youtube.com/accounts/SetSID?...
    // → system browser"). Whitelist the full Google SSO host family for
    // any provider that uses Google identity.
    if (provider == "gmail" || provider_supports_google_sso(provider)) && is_google_sso_host(host) {
        return true;
    }
    let allowed = provider_allowed_hosts(provider);
    if allowed.is_empty() {
        return true;
    }
    allowed
        .iter()
        .any(|suffix| host == *suffix || host.ends_with(&format!(".{}", suffix)))
}

/// `true` if the provider needs `window.open(url)` to return a live
/// window-handle (i.e. the calling site reads the return value and aborts
/// on falsey). Slack Huddles go through `openManagedChildWindow` which
/// calls `window.open("about:blank", …)` and then programmatically
/// navigates the returned popup to the huddle UI. Denying the popup
/// makes the huddle call fail silently with a `beacon/error`. For these
/// cases we allow the default popup so CEF spawns an in-app child window
/// and returns a real handle to the caller.
///
/// Match is intentionally narrow — only the popup URLs the provider
/// actually needs in-app pass. Cmd/Ctrl-click and `target="_blank"`
/// on ordinary links (which carry a concrete URL) still route out to
/// the user's default browser.
fn popup_should_stay_in_app(provider: &str, url: &Url) -> bool {
    match provider {
        "slack" => {
            // Slack's huddle flow opens `about:blank` first, then navigates
            // the popup to the huddle URL — at popup-creation time there is
            // no host yet. Also accept same-origin slack.com hosts so direct
            // `window.open("https://app.slack.com/...")` calls stay in-app.
            if url.scheme() == "about" {
                return true;
            }
            match url.host_str() {
                Some(host) => host == "app.slack.com" || host.ends_with(".slack.com"),
                None => false,
            }
        }
        "zoom" => {
            // Zoom's "Join from browser" / WebClient launch can go through a
            // `window.open("https://app.zoom.us/wc/...")` popup instead of an
            // in-page navigation. Keep those (and any deep-link-rewritten
            // popup targeting the same path) inside the embedded webview so
            // the meeting doesn't pop out to the system browser.
            match url.host_str() {
                Some(host) => {
                    (host == "app.zoom.us" || host == "zoom.us") && url.path().starts_with("/wc/")
                }
                None => false,
            }
        }
        "linkedin" => {
            // LinkedIn's "Sign in with Google" button is rendered as a Google
            // Identity Services (GSI) iframe loaded from
            // `accounts.google.com/gsi/button`. When the user clicks it, GSI
            // calls `window.open("https://accounts.google.com/gsi/select?...",
            // "gsig", "width=500,height=600,...")` to show the account chooser.
            //
            // This popup MUST stay as a real in-app child window — NOT routed
            // to the system browser (blank screen) and NOT a parent navigation
            // (the parent page would be replaced, so the postMessage credential
            // callback from the popup can never reach LinkedIn's JS handler).
            //
            // After the user selects an account the popup postMessages the
            // signed credential back to the opener; LinkedIn's GSI callback
            // receives it and completes sign-in (#1021).
            match url.host_str() {
                Some(host) => {
                    is_google_sso_host(host) && url.path().to_ascii_lowercase().contains("gsi")
                }
                None => false,
            }
        }
        _ => false,
    }
}

/// `true` if `scheme` is a known provider native-desktop-app deep-link
/// scheme. We suppress these instead of routing them to the system
/// browser because macOS hands them to the native provider app
/// (e.g. `slack://magic-login/<token>` signs the native Slack app into
/// the workspace, breaking embedded-webview isolation: the workspace's
/// session ends up inside the native client even though the user only
/// signed in via OpenHuman's embedded webview).
///
/// The HTTPS fallback in each provider's web flow handles sign-in
/// without the deep link, so suppression is safe — the page just
/// continues on the next link in the sequence.
///
/// Caller contract: only suppress when [`rewrite_provider_deep_link`]
/// has already returned `None` for the URL. Schemes we DO know how to
/// rewrite into a web-client URL (e.g. `zoomus://`) must take the
/// rewrite path first; those flows expect to stay in-app, not be
/// silently dropped.
fn is_provider_native_deep_link_scheme(scheme: &str) -> bool {
    matches!(
        scheme,
        "slack" | "discord" | "tg" | "msteams" | "zoomus" | "zoommtg"
    )
}

/// `true` if this provider lets users sign in with their Google
/// account from inside the embedded webview.
///
/// Slack workspaces commonly enable "Sign in with Google" SSO, so the
/// Google OAuth popup flow (`window.open("https://accounts.google.com/...")`)
/// must stay in the per-account CEF session — exactly the same way it
/// has to for Google Meet. Routing it to the system browser leaks the
/// auth cookie into the wrong jar and breaks sign-in (#1036).
///
/// Keep this list narrow: only providers that actually need to issue
/// `accounts.google.com` popups should be listed. Other providers
/// continue to fall through to the default popup-handling path.
fn provider_supports_google_sso(provider: &str) -> bool {
    matches!(provider, "google-meet" | "slack" | "zoom" | "linkedin")
}

/// `true` if a popup request should be denied AND the parent webview
/// should be navigated to the popup URL instead.
///
/// Used for Google's "Sign in" / "Use another account" flow on embedded
/// providers that support Google SSO: clicking the link issues
/// `window.open("https://accounts.google.com/...")`. We can't route
/// that to the system browser (the auth cookie would land in the
/// wrong jar) and we don't want to let CEF spawn an unmanaged child
/// window (it has no host rect, so it renders blank/black). The safe
/// option is to deny the popup and replace the parent's URL so the
/// in-app webview finishes the auth flow inside the embedded session.
fn popup_should_navigate_parent(provider: &str, url: &Url) -> Option<Url> {
    if !provider_supports_google_sso(provider) {
        return None;
    }
    if url.scheme() == "about" {
        return None;
    }
    if is_google_auth_popup(url) {
        return Some(url.clone());
    }
    // Gmeet: "Start an instant meeting" / "New meeting" / clicking
    // a meeting code link calls `window.open(meet.google.com/<roomid>)`
    // to launch the room. Default popup handling would route the
    // URL to the user's system browser, leaking the Meet session
    // out of OpenHuman entirely. Deny the popup and navigate the
    // embedded parent into the room URL instead — matches the
    // user's expectation that the meeting stays in-app.
    if provider == "google-meet" {
        if let Some(host) = url.host_str() {
            if host == "meet.google.com" {
                return Some(url.clone());
            }
        }
    }
    None
}

/// `true` if `host` is a Google SSO / account-handoff host that may
/// participate in the OAuth flow for any Google service (Meet, Gmail,
/// Drive, etc.). Google rotates the post-2FA `SetSID` cookie-setting hop
/// across `accounts.google.<cctld>` and `accounts.youtube.com` (sic — the
/// YouTube subdomain is part of the Google identity infra), so a literal
/// `accounts.google.com` match misses real auth popups and leaks them to
/// the system browser.
///
/// Match family:
/// - `accounts.google.com`
/// - `accounts.google.<cctld>` — e.g. `accounts.google.co.in`, `accounts.google.co.uk`,
///   `accounts.google.de`
/// - `accounts.googleusercontent.com`
/// - `accounts.youtube.com` (post-2FA `SetSID` hop)
/// - `myaccount.google.com`
fn is_google_sso_host(host: &str) -> bool {
    let host = host.to_ascii_lowercase();
    if host == "accounts.google.com"
        || host == "accounts.googleusercontent.com"
        || host == "accounts.youtube.com"
        || host == "myaccount.google.com"
    {
        return true;
    }
    // ccTLD variants: `accounts.google.<cctld>`. We must reject phishing
    // shapes like `accounts.google.com.evil` and `accounts.google.co.attacker`
    // — the dots-only check we used previously accepted both because
    // `com.evil` and `co.attacker` each have one dot. Anchor the suffix
    // against a real ccTLD shape: either a single 2-letter cc tld
    // (`accounts.google.de`, `accounts.google.fr`) OR a 2-label form
    // `<sld>.<cc>` where sld ∈ {co, com, net, org} (`accounts.google.co.in`,
    // `accounts.google.com.au`).
    if let Some(rest) = host.strip_prefix("accounts.google.") {
        let labels: Vec<&str> = rest.split('.').collect();
        let is_cc = |s: &str| s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic());
        return match labels.as_slice() {
            [tld] => is_cc(tld),
            [sld, tld] => matches!(*sld, "co" | "com" | "net" | "org") && is_cc(tld),
            _ => false,
        };
    }
    false
}

fn is_google_auth_popup(url: &Url) -> bool {
    let Some(host) = url.host_str() else {
        return false;
    };
    if !is_google_sso_host(host) {
        return false;
    }

    let path = url.path().to_ascii_lowercase();
    if path.contains("signin")
        || path.contains("servicelogin")
        || path.contains("accountchooser")
        || path.contains("chooseaccount")
        || path.contains("setsid")
        || path.contains("oauth2")
    {
        return true;
    }

    url.query_pairs().any(|(key, value)| {
        let k = key.to_ascii_lowercase();
        let v = value.to_ascii_lowercase();
        matches!(k.as_str(), "flowname" | "service" | "continue")
            && (v.contains("signin")
                || v.contains("servicelogin")
                || v.contains("accountchooser")
                || v.contains("chooseaccount")
                || v.contains("meet.google.com")
                || v.contains("mail.google.com")
                || v.contains("linkedin.com"))
    })
}

/// `true` if a gmeet navigation lands on Google's Workspace marketing page
/// for Meet — the host bounce that fires when an unauthenticated webview hits
/// `meet.google.com`.
///
/// The `on_navigation` rewrite is scoped to this exact path family so we
/// don't hijack legitimate `workspace.google.com` pages a user might reach
/// from inside Meet (admin console links, Workspace Status, support pages,
/// etc.). Matches `workspace.google.com` (and any subdomain) AND a path
/// starting with `/products/meet` — empirically the only path Google's
/// edge bounces unauthenticated Meet GETs to (`/products/meet/` or
/// `/products/meet/<sub>`).
fn is_gmeet_marketing_redirect(host: &str, path: &str) -> bool {
    let host = host.to_ascii_lowercase();
    let host_matches = host == "workspace.google.com" || host.ends_with(".workspace.google.com");
    if !host_matches {
        return false;
    }
    let p = path.to_ascii_lowercase();
    p == "/products/meet" || p == "/products/meet/" || p.starts_with("/products/meet/")
}

fn redact_navigation_url(url: &Url) -> String {
    let mut safe = url.clone();
    safe.set_query(None);
    safe.set_fragment(None);
    safe.to_string()
}

fn redact_native_deep_link_url(url: &Url) -> String {
    format!("{}://<redacted>", url.scheme())
}
/// Unwrap provider-side "link safety" redirects so the system browser
/// lands on the real destination.
///
/// These wrappers (LinkedIn's `/safety/go/?url=…`, etc.) require the
/// user to be logged into the provider in the destination browser. In
/// our setup the session lives inside the embedded CEF webview's cookie
/// jar, not the user's default browser — opening the wrapper URL there
/// shows a broken safety page instead of completing the redirect.
/// Extract the `url` query param and return the resolved destination.
fn unwrap_provider_redirect(url: &Url) -> Option<Url> {
    let host = url.host_str()?;
    let path = url.path();
    let matches_linkedin = (host == "www.linkedin.com" || host == "linkedin.com")
        && (path == "/safety/go/" || path == "/safety/go" || path == "/redir/redirect");
    if !matches_linkedin {
        return None;
    }
    let (_, raw) = url.query_pairs().find(|(k, _)| k == "url")?;
    Url::parse(&raw).ok()
}

/// Fire-and-forget handoff to the OS default URL handler. Any error is
/// logged but not propagated — we've already cancelled the in-app
/// navigation so there's nowhere to surface a failure to.
///
/// On macOS we shell out to `/usr/bin/open` directly rather than via
/// `tauri_plugin_opener::open_url`: the plugin returned Ok but no browser
/// actually launched in the CEF runtime (suspected sandbox/launch-service
/// interaction with the `open` crate's detached spawn). The direct
/// Command call is equivalent to what a user would type in Terminal and
/// works reliably.
fn open_in_system_browser(url: &str) {
    #[cfg(target_os = "macos")]
    {
        match std::process::Command::new("/usr/bin/open").arg(url).spawn() {
            Ok(_) => log::info!("[webview-accounts] opened externally (macos open): {}", url),
            Err(e) => log::warn!(
                "[webview-accounts] /usr/bin/open {} failed: {} — falling back to opener plugin",
                url,
                e
            ),
        }
    }
    #[cfg(not(target_os = "macos"))]
    {
        match tauri_plugin_opener::open_url(url, None::<&str>) {
            Ok(()) => log::info!("[webview-accounts] opened externally: {}", url),
            Err(e) => log::warn!("[webview-accounts] open_url({}) failed: {}", url, e),
        }
    }
}

fn payload_string(payload: &serde_json::Value, key: &str) -> Option<String> {
    payload
        .get(key)
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
}

fn payload_bool(payload: &serde_json::Value, key: &str) -> Option<bool> {
    payload.get(key).and_then(|v| v.as_bool())
}

fn payload_i64(payload: &serde_json::Value, key: &str) -> Option<i64> {
    payload.get(key).and_then(|v| v.as_i64())
}

fn first_message_field(payload: &serde_json::Value, key: &str) -> Option<String> {
    payload
        .get("messages")
        .and_then(|v| v.as_array())
        .and_then(|messages| messages.first())
        .and_then(|message| message.get(key))
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
}

fn event_timestamp_rfc3339(ts_ms: Option<i64>) -> String {
    ts_ms
        .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
        .unwrap_or_else(Utc::now)
        .to_rfc3339()
}

fn normalize_provider_surfaces_event(args: &RecipeEventArgs) -> Option<serde_json::Value> {
    if args.kind != "ingest" {
        return None;
    }

    let entity_id = payload_string(&args.payload, "entity_id")
        .or_else(|| payload_string(&args.payload, "threadId"))
        .or_else(|| payload_string(&args.payload, "chatId"))
        .or_else(|| payload_string(&args.payload, "snapshotKey"))
        .unwrap_or_else(|| {
            format!(
                "{}:{}:{}",
                args.provider,
                args.account_id,
                args.ts.unwrap_or_else(|| Utc::now().timestamp_millis())
            )
        });

    let thread_id = payload_string(&args.payload, "threadId")
        .or_else(|| payload_string(&args.payload, "chatId"))
        .or_else(|| payload_string(&args.payload, "conversationId"));
    let title = payload_string(&args.payload, "title")
        .or_else(|| payload_string(&args.payload, "chatName"))
        .or_else(|| payload_string(&args.payload, "channelName"));
    let snippet = payload_string(&args.payload, "snippet")
        .or_else(|| first_message_field(&args.payload, "body"));
    let sender_name = payload_string(&args.payload, "senderName")
        .or_else(|| first_message_field(&args.payload, "from"));
    let sender_handle = payload_string(&args.payload, "senderHandle");
    let deep_link = payload_string(&args.payload, "deepLink");
    let unread = payload_i64(&args.payload, "unread").unwrap_or(0);
    let requires_attention = payload_bool(&args.payload, "requires_attention")
        .unwrap_or(unread > 0 || sender_name.is_some() || snippet.is_some());

    Some(json!({
        "provider": args.provider,
        "account_id": args.account_id,
        "event_kind": args.kind,
        "entity_id": entity_id,
        "thread_id": thread_id,
        "title": title,
        "snippet": snippet,
        "sender_name": sender_name,
        "sender_handle": sender_handle,
        "timestamp": event_timestamp_rfc3339(args.ts),
        "deep_link": deep_link,
        "requires_attention": requires_attention,
        "raw_payload": args.payload,
    }))
}

async fn post_provider_surfaces_event(args: &RecipeEventArgs) -> Result<(), String> {
    let Some(params) = normalize_provider_surfaces_event(args) else {
        return Ok(());
    };

    let body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "openhuman.provider_surfaces_ingest_event",
        "params": params,
    });

    let url = std::env::var("OPENHUMAN_CORE_RPC_URL")
        .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string());
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .map_err(|e| format!("http client: {e}"))?;
    let resp = client
        .post(&url)
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("POST {url}: {e}"))?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().await.unwrap_or_default();
        return Err(format!("{status}: {body}"));
    }
    let v: serde_json::Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
    if let Some(err) = v.get("error") {
        return Err(format!("rpc error: {err}"));
    }
    Ok(())
}

/// Human-readable label used as the title prefix on native notifications
/// so users can tell which provider fired the ping. Matches the labels
/// in the frontend `PROVIDERS` registry.
pub fn provider_display_name(provider: &str) -> &'static str {
    match provider {
        "whatsapp" => "WhatsApp",
        "telegram" => "Telegram",
        "linkedin" => "LinkedIn",
        "slack" => "Slack",
        "discord" => "Discord",
        "google-meet" => "Google Meet",
        "zoom" => "Zoom",
        "browserscan" => "BrowserScan",
        _ => "OpenHuman",
    }
}

#[derive(Default)]
pub struct WebviewAccountsState {
    /// account_id -> webview label (we use `acct_<id>` as the label).
    inner: Mutex<HashMap<String, String>>,
    /// account_id -> provider id. Kept so late reveal/close paths can log
    /// provider-scoped diagnostics without trusting frontend echo fields.
    account_providers: Mutex<HashMap<String, String>>,
    /// account_id -> CEF `Browser::identifier()`. Populated asynchronously
    /// inside the `with_webview` callback once the renderer hands us the
    /// browser handle, and consumed at close/purge time so we can call
    /// `tauri_runtime_cef::notification::unregister` without leaking
    /// per-browser handler entries across account churn.
    browser_ids: Mutex<HashMap<String, i32>>,
    /// account_id -> CDP session task. One long-lived task per account
    /// keeps the UA override resident (see `cdp::session`); aborted on
    /// close/purge so reopen cycles don't stack multiple live loops.
    cdp_sessions: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
    /// account_id -> 15s `webview-account:load{state:"timeout"}` watchdog.
    /// Aborted in close/purge so a watchdog spawned for a now-closed
    /// account can't fire a stale timeout against a freshly-reused id.
    load_watchdogs: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
    /// account_id of webviews that have already emitted their first
    /// `webview-account:load{state:"finished"}` event. Used to dedup
    /// triple-signal fires (native on_page_load, CDP `Page.loadEventFired`,
    /// 15 s watchdog) so the frontend only reveals once per cold open.
    loaded_accounts: Mutex<HashSet<String>>,
    /// Last bounds requested by the frontend for a given account, captured at
    /// `webview_account_open` time so the off-screen-spawned webview can be
    /// revealed at the right rect without the frontend having to round-trip
    /// them again.
    requested_bounds: Mutex<HashMap<String, Bounds>>,
    /// account_id -> `Instant` captured at the moment the cold spawn returns
    /// from `add_child`. Consumed by `webview_account_reveal` to compute
    /// `elapsed_ms` (spawn -> frontend reveal call) for the diagnostic log
    /// instrumented for the Slack first-load investigation (#1036). Cleared
    /// alongside `loaded_accounts` on close/purge so a subsequent reopen
    /// starts fresh.
    spawn_started_at: Mutex<HashMap<String, Instant>>,
    /// Runtime notification-bypass controls used by the settings UI.
    notification_bypass: Mutex<NotificationBypassPrefs>,
    /// Per-label rewrite counter for the gmeet `workspace.google.com`
    /// marketing intercept. Google's edge SSR-redirects unauthenticated
    /// `meet.google.com` GETs back to `workspace.google.com/products/meet/`,
    /// so an unguarded rewrite (see `:1534-1559`) ping-pongs forever and
    /// the page never lands. We track `(last_attempt, attempts_in_window)`
    /// per webview label and bail to the Google sign-in flow after a
    /// threshold so the user breaks out of the loop.
    gmeet_marketing_rewrites: Mutex<HashMap<String, (Instant, u32)>>,
    /// Per-label "awaiting post-auth handoff" flag. Set when the gmeet
    /// rewrite-loop bail navigates to `accounts.google.com/ServiceLogin`,
    /// consumed (single-shot) by the `myaccount.google.com` intercept so
    /// only the immediate post-auth bounce gets rewritten back to Meet —
    /// legitimate user-initiated `myaccount.google.com` navigations (e.g.
    /// "Manage your Google Account" from the avatar menu) are passed
    /// through unchanged.
    gmeet_awaiting_handoff: Mutex<HashSet<String>>,
    /// account_ids spawned via `webview_account_prewarm` that have not yet
    /// been opened by the user. Issue #1233 — emit_load_finished suppresses
    /// `webview-account:load` events for these so the React UI never sees
    /// load/timeout signals for an account it didn't ask to open. The flag
    /// is cleared on the first user-initiated `webview_account_open`
    /// (warm-reopen branch) and on close/purge so subsequent reopens flow
    /// through the normal cold-load lifecycle.
    prewarm_accounts: Mutex<HashSet<String>>,
}

/// Threshold and window for the gmeet workspace-marketing rewrite loop
/// breaker. After `GMEET_REWRITE_MAX_ATTEMPTS` rewrites within
/// `GMEET_REWRITE_WINDOW`, we bail to the Google sign-in URL instead of
/// rewriting again.
pub(crate) const GMEET_REWRITE_MAX_ATTEMPTS: u32 = 3;
pub(crate) const GMEET_REWRITE_WINDOW: Duration = Duration::from_secs(5);

/// Result of consulting the gmeet marketing-redirect counter.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum GmeetRewriteAction {
    /// Allow the rewrite — fewer than `GMEET_REWRITE_MAX_ATTEMPTS` attempts in
    /// the current window.
    Rewrite,
    /// Loop detected — caller should navigate to the Google sign-in URL
    /// instead of rewriting back to `meet.google.com`.
    Bail,
}

impl WebviewAccountsState {
    /// Drop the gmeet marketing-rewrite counter for `label`. Called after
    /// the post-auth handoff so a future workspace-marketing bounce (which
    /// shouldn't occur post-auth, but could on session expiry) gets a fresh
    /// counter window instead of inheriting half-saturated state from the
    /// pre-auth loop.
    pub(crate) fn clear_gmeet_marketing_rewrite(&self, label: &str) {
        if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() {
            g.remove(label);
        }
    }

    /// Mark `label` as awaiting the post-auth `myaccount.google.com` →
    /// `meet.google.com` handoff. Set by the rewrite-loop bail right
    /// before navigating to `accounts.google.com/ServiceLogin?continue=`,
    /// so the next `myaccount.google.com` commit on this label is treated
    /// as the auth chain's terminal hop (the `?utm_source=sign_in_no_continue`
    /// dump-page) and gets force-redirected to Meet.
    pub(crate) fn mark_awaiting_gmeet_handoff(&self, label: &str) {
        if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() {
            g.insert(label.to_string());
        }
    }

    /// Single-shot consume of the post-auth handoff flag for `label`.
    /// Returns `true` exactly once after `mark_awaiting_gmeet_handoff`,
    /// then resets so subsequent `myaccount.google.com` navigations
    /// (e.g. user-initiated profile/settings visits) pass through as
    /// normal and aren't hijacked back to Meet.
    pub(crate) fn take_awaiting_gmeet_handoff(&self, label: &str) -> bool {
        match self.gmeet_awaiting_handoff.lock() {
            Ok(mut g) => g.remove(label),
            Err(_) => false,
        }
    }

    /// Increment the per-label gmeet marketing-rewrite counter for `now` and
    /// decide whether to rewrite or bail. Resets the counter when the last
    /// attempt was outside `GMEET_REWRITE_WINDOW` so a future genuine
    /// `workspace.google.com` navigation (e.g. user clicks a link inside Meet
    /// after sign-in) gets intercepted normally.
    pub(crate) fn track_gmeet_marketing_rewrite(
        &self,
        label: &str,
        now: Instant,
    ) -> GmeetRewriteAction {
        let mut map = match self.gmeet_marketing_rewrites.lock() {
            Ok(g) => g,
            Err(poisoned) => poisoned.into_inner(),
        };
        let entry = map.entry(label.to_string()).or_insert((now, 0));
        if now.duration_since(entry.0) > GMEET_REWRITE_WINDOW {
            *entry = (now, 0);
        }
        entry.1 += 1;
        entry.0 = now;
        if entry.1 > GMEET_REWRITE_MAX_ATTEMPTS {
            GmeetRewriteAction::Bail
        } else {
            GmeetRewriteAction::Rewrite
        }
    }

    /// Drain every per-account resource owned by this state and abort the
    /// associated background tasks. Returns the `(account_id, label)`
    /// pairs of webviews that still need closing — the caller does the
    /// actual `wv.close()` because that needs an `AppHandle`. Splitting
    /// it out keeps the rest of the teardown unit-testable without
    /// constructing a Tauri runtime.
    ///
    /// Aborts CDP session tasks and load watchdogs, unregisters CEF
    /// notification handlers, and clears the loaded-accounts /
    /// requested-bounds bookkeeping. All collections are drained — a
    /// repeat call returns an empty `Vec` and is a safe no-op.
    fn drain_for_shutdown(&self) -> Vec<(String, String)> {
        let cdp_tasks: Vec<_> = self
            .cdp_sessions
            .lock()
            .ok()
            .map(|mut g| g.drain().collect())
            .unwrap_or_default();
        for (acct, task) in cdp_tasks {
            task.abort();
            log::debug!("[webview-accounts] shutdown abort cdp account={}", acct);
        }
        let watchdogs: Vec<_> = self
            .load_watchdogs
            .lock()
            .ok()
            .map(|mut g| g.drain().collect())
            .unwrap_or_default();
        for (acct, task) in watchdogs {
            task.abort();
            log::debug!(
                "[webview-accounts] shutdown abort watchdog account={}",
                acct
            );
        }
        let browser_ids: Vec<_> = self
            .browser_ids
            .lock()
            .ok()
            .map(|mut g| g.drain().collect())
            .unwrap_or_default();
        for (acct, browser_id) in browser_ids {
            tauri_runtime_cef::notification::unregister(browser_id);
            log::debug!(
                "[notify-cef] shutdown unregistered handler account={} browser_id={}",
                acct,
                browser_id
            );
        }
        if let Ok(mut g) = self.loaded_accounts.lock() {
            g.clear();
        }
        if let Ok(mut g) = self.requested_bounds.lock() {
            g.clear();
        }
        if let Ok(mut g) = self.spawn_started_at.lock() {
            g.clear();
        }
        if let Ok(mut g) = self.account_providers.lock() {
            g.clear();
        }
        // Per-label gmeet rewrite counter must clear too — `label_for()`
        // reuses the same label on reopen, so a stale saturated entry
        // would jump a fresh open straight to the bail URL.
        if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() {
            g.clear();
        }
        // Drop the post-auth handoff flag too — a stale flag would
        // hijack the first user-initiated `myaccount.google.com` visit
        // after a relaunch back to Meet.
        if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() {
            g.clear();
        }
        // Issue #1233 — clear prewarm flags so a relaunch can't suppress
        // load events for accounts that were prewarmed in the previous
        // session.
        if let Ok(mut g) = self.prewarm_accounts.lock() {
            g.clear();
        }
        self.inner
            .lock()
            .ok()
            .map(|mut g| g.drain().collect())
            .unwrap_or_default()
    }

    /// Tear down every per-account resource owned by this state — used by
    /// the app's `RunEvent::ExitRequested` path so nothing outlives the
    /// tokio runtime / `AppHandle` (issue #920).
    ///
    /// On top of [`drain_for_shutdown`], this closes every `acct_*` child
    /// webview so CEF browsers tear down before `cef::shutdown()` runs,
    /// and tells the per-account scanner registries to forget the
    /// account so a future open of the same id starts from a clean slate.
    /// All collections are drained — repeat calls are cheap no-ops.
    pub fn shutdown_all<R: Runtime>(&self, app: &AppHandle<R>) -> Vec<String> {
        teardown_all_account_scanners(app);
        let labels = self.drain_for_shutdown();
        let mut closed_labels = Vec::with_capacity(labels.len());
        for (acct, label) in labels {
            teardown_account_scanners(app, &acct);
            if let Some(wv) = app.get_webview(&label) {
                // Track the label as soon as the webview exists so a failed
                // `close()` still participates in the post-close drain poll
                // (issue #1120 / CodeRabbit).
                closed_labels.push(label.clone());
                if let Err(e) = wv.close() {
                    log::warn!(
                        "[webview-accounts] shutdown close({label}) failed account={acct}: {e}"
                    );
                }
            } else {
                log::debug!(
                    "[webview-accounts] shutdown label already gone account={} label={}",
                    acct,
                    label
                );
            }
        }
        log::info!(
            "[webview-accounts] shutdown_all complete closed_labels={:?}",
            closed_labels
        );
        closed_labels
    }
}

/// Abort every provider scanner task tracked by the per-provider
/// registries. Used by full-app shutdown before the per-account state is
/// drained so CDP loops stop even if an account label was already removed
/// from `WebviewAccountsState`.
fn teardown_all_account_scanners<R: Runtime>(app: &AppHandle<R>) {
    let mut total = 0usize;
    if let Some(registry) =
        app.try_state::<std::sync::Arc<crate::whatsapp_scanner::ScannerRegistry>>()
    {
        total += registry.inner().forget_all();
    }
    if let Some(registry) = app.try_state::<std::sync::Arc<crate::slack_scanner::ScannerRegistry>>()
    {
        total += registry.inner().forget_all();
    }
    if let Some(registry) =
        app.try_state::<std::sync::Arc<crate::discord_scanner::ScannerRegistry>>()
    {
        total += registry.inner().forget_all();
    }
    if let Some(registry) =
        app.try_state::<std::sync::Arc<crate::telegram_scanner::ScannerRegistry>>()
    {
        total += registry.inner().forget_all();
    }
    if total > 0 {
        log::info!(
            "[webview-accounts] aborted {} provider scanner task(s) for shutdown",
            total
        );
    }
}

/// Tell the per-account scanner registries (whatsapp / slack / discord /
/// telegram) to forget `account_id`. Shared by `webview_account_close`,
/// `webview_account_purge`, and `WebviewAccountsState::shutdown_all` so
/// every exit path goes through the same teardown.
fn teardown_account_scanners<R: Runtime>(app: &AppHandle<R>, account_id: &str) {
    if let Some(registry) =
        app.try_state::<std::sync::Arc<crate::whatsapp_scanner::ScannerRegistry>>()
    {
        registry.inner().forget(account_id);
    }
    if let Some(registry) = app.try_state::<std::sync::Arc<crate::slack_scanner::ScannerRegistry>>()
    {
        registry.inner().forget(account_id);
    }
    if let Some(registry) =
        app.try_state::<std::sync::Arc<crate::discord_scanner::ScannerRegistry>>()
    {
        registry.inner().forget(account_id);
    }
    if let Some(registry) =
        app.try_state::<std::sync::Arc<crate::telegram_scanner::ScannerRegistry>>()
    {
        registry.inner().forget(account_id);
    }
}

#[derive(Debug, Clone)]
struct NotificationBypassPrefs {
    global_dnd: bool,
    muted_accounts: HashSet<String>,
    bypass_when_focused: bool,
    focused_account: Option<String>,
}

impl Default for NotificationBypassPrefs {
    fn default() -> Self {
        Self {
            global_dnd: false,
            muted_accounts: HashSet::new(),
            // Match the existing UI copy: focused account may suppress toast.
            bypass_when_focused: true,
            focused_account: None,
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct NotificationBypassPrefsPayload {
    pub global_dnd: bool,
    pub muted_accounts: Vec<String>,
    pub bypass_when_focused: bool,
}

impl From<&NotificationBypassPrefs> for NotificationBypassPrefsPayload {
    fn from(value: &NotificationBypassPrefs) -> Self {
        let mut muted_accounts = value.muted_accounts.iter().cloned().collect::<Vec<_>>();
        muted_accounts.sort();
        Self {
            global_dnd: value.global_dnd,
            muted_accounts,
            bypass_when_focused: value.bypass_when_focused,
        }
    }
}

/// Title prefix applied to every OS toast fired from an embedded webview.
/// Matches `openhuman_core::webview_notifications::OPENHUMAN_TITLE_PREFIX`
/// — kept inline here so the shell crate doesn't take a build-time dep on
/// the core library. Disambiguates from natively-installed apps (Slack,
/// Discord, Telegram desktop) firing the same message twice.
const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: ";

fn slack_scanner_enabled() -> bool {
    std::env::var("OPENHUMAN_DISABLE_SLACK_SCANNER")
        .map(|v| {
            let v = v.trim().to_ascii_lowercase();
            !(v == "1" || v == "true" || v == "yes" || v == "on")
        })
        .unwrap_or(true)
}

/// Serialised fire-event payload shipped to the frontend over the
/// `webview-notification:fired` Tauri event. Carries `account_id` +
/// `provider` so the React side can route a subsequent click back to
/// the originating webview via Redux.
#[derive(Debug, Clone, Serialize)]
struct WebviewNotificationFired {
    account_id: String,
    provider: String,
    title: String,
    body: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    tag: Option<String>,
}

/// Linux: one worker thread + bounded queue so a burst of toasts does not
/// spawn unbounded `std::thread` handles (each would block in `wait_for_action`).
#[cfg(target_os = "linux")]
const LINUX_NOTIFY_QUEUE_CAP: usize = 16;

#[cfg(target_os = "linux")]
static LINUX_NOTIFY_TX: OnceLock<std::sync::mpsc::SyncSender<Box<dyn FnOnce() + Send>>> =
    OnceLock::new();

#[cfg(target_os = "linux")]
fn enqueue_linux_notification(job: Box<dyn FnOnce() + Send>) {
    let tx = LINUX_NOTIFY_TX.get_or_init(|| {
        let (tx, rx) = sync_channel::<Box<dyn FnOnce() + Send>>(LINUX_NOTIFY_QUEUE_CAP);
        std::thread::Builder::new()
            .name("openhuman-linux-notify".to_string())
            .spawn(move || {
                while let Ok(j) = rx.recv() {
                    j();
                }
            })
            .expect("spawn openhuman-linux-notify");
        tx
    });
    if let Err(e) = tx.try_send(job) {
        log::warn!(
            "[notify-cef] linux notification queue full (cap={}), dropping toast: {}",
            LINUX_NOTIFY_QUEUE_CAP,
            e
        );
    }
}

/// Translate a `tauri-runtime-cef` notification payload into a native OS
/// toast via `tauri-plugin-notification`, and mirror the fire to the
/// React frontend so it can drive click-to-focus routing.
///
/// Gated on the runtime `NotificationSettings` flag (OFF by default) so
/// v1 ships the plumbing without surprising users with a toast storm the
/// first time they open a busy Slack tab.
fn forward_native_notification<R: Runtime>(
    app: &AppHandle<R>,
    account_id: &str,
    provider: &str,
    payload: &tauri_runtime_cef::notification::NotificationPayload,
) {
    if let Some(state) = app.try_state::<WebviewAccountsState>() {
        let prefs = state.notification_bypass.lock().unwrap().clone();
        if prefs.global_dnd {
            log::debug!(
                "[notify-bypass][{}] suppressed global_dnd provider={}",
                account_id,
                provider
            );
            return;
        }
        if prefs.muted_accounts.contains(account_id) {
            log::debug!(
                "[notify-bypass][{}] suppressed muted_account provider={}",
                account_id,
                provider
            );
            return;
        }
        if prefs.bypass_when_focused && prefs.focused_account.as_deref() == Some(account_id) {
            log::debug!(
                "[notify-bypass][{}] suppressed focused_account provider={}",
                account_id,
                provider
            );
            return;
        }
    }

    // Feature flag — bail early when the user hasn't opted in.
    if let Some(settings) =
        app.try_state::<crate::notification_settings::NotificationSettingsState>()
    {
        if !settings.enabled() {
            log::debug!(
                "[notify-cef][{}] suppressed (feature flag off) provider={}",
                account_id,
                provider
            );
            return;
        }
    }

    let provider_label = provider_display_name(provider);
    let raw_title = payload.title.as_str().trim();
    let notify_title = if raw_title.is_empty() {
        format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}")
    } else {
        format!("{OPENHUMAN_TITLE_PREFIX}{provider_label} — {raw_title}")
    };
    let body = payload.body.as_deref().unwrap_or("");
    log::info!(
        "[notify-cef][{}] source={:?} tag={:?} silent={} title_chars={} body_chars={}",
        account_id,
        payload.source,
        payload.tag,
        payload.silent,
        raw_title.chars().count(),
        body.chars().count()
    );
    log::debug!("[notify-cef][{}] raw_title={:?}", account_id, raw_title);

    // Mirror to the frontend BEFORE firing the OS toast so the Redux
    // store has the routing context ready by the time the user clicks.
    let fired = WebviewNotificationFired {
        account_id: account_id.to_string(),
        provider: provider.to_string(),
        title: notify_title.clone(),
        body: body.to_string(),
        tag: payload.tag.clone(),
    };
    if let Err(err) = app.emit("webview-notification:fired", &fired) {
        log::warn!(
            "[notify-cef][{}] emit webview-notification:fired failed: {}",
            account_id,
            err
        );
    }

    // Respect the Web Notification `silent` flag — the mirror event above
    // still updates the in-app notification center, but the OS toast is
    // suppressed so the user is not audibly/visually interrupted for
    // notifications the page explicitly marked as silent.
    if payload.silent {
        log::debug!(
            "[notify-cef][{}] silent=true, suppressing OS toast",
            account_id
        );
        return;
    }

    // Fire the OS toast and wire a click callback that emits `notification:click`
    // so the frontend can bring the originating account into focus.
    //
    // macOS: mac-notification-sys blocks in wait_for_click mode — run on a
    //        blocking thread so the async executor is not stalled.
    // Linux: notify_rust's wait_for_action hooks D-Bus action delivery.
    // Windows: no click callback available; fall back to fire-and-forget.
    let acct_for_click = account_id.to_string();
    let prov_for_click = provider.to_string();
    let app_for_click = app.clone();

    #[cfg(target_os = "macos")]
    {
        use std::sync::atomic::{AtomicUsize, Ordering};
        // Each `wait_for_click` thread blocks at ~100% CPU until the user
        // clicks or the toast auto-dismisses. Under notification bursts this
        // can pin many cores; cap concurrent click-wait threads and fall back
        // to fire-and-forget (no click callback) once the budget is reached.
        const MAX_CLICK_WAIT_THREADS: usize = 8;
        static IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);

        let title_c = notify_title.clone();
        let body_c = body.to_string();
        let app_id = app.config().identifier.clone();
        let prev = IN_FLIGHT.fetch_add(1, Ordering::AcqRel);
        if prev >= MAX_CLICK_WAIT_THREADS {
            IN_FLIGHT.fetch_sub(1, Ordering::AcqRel);
            log::debug!(
                "[notify-cef][{}] click-wait budget exhausted ({}), firing without click callback",
                account_id,
                prev
            );
            std::thread::spawn(move || {
                let _ = mac_notification_sys::set_application(if tauri::is_dev() {
                    "com.apple.Terminal"
                } else {
                    &app_id
                });
                use mac_notification_sys::Notification as MacNotif;
                let mut n = MacNotif::new();
                n.title(&title_c).message(&body_c);
                let _ = n.send();
            });
            return;
        }

        std::thread::spawn(move || {
            struct Guard;
            impl Drop for Guard {
                fn drop(&mut self) {
                    IN_FLIGHT.fetch_sub(1, Ordering::AcqRel);
                }
            }
            let _guard = Guard;

            let _ = mac_notification_sys::set_application(if tauri::is_dev() {
                "com.apple.Terminal"
            } else {
                &app_id
            });
            use mac_notification_sys::{Notification as MacNotif, NotificationResponse};
            let t = title_c;
            let b = body_c;
            let mut n = MacNotif::new();
            n.title(&t).message(&b).wait_for_click(true);
            match n.send() {
                Ok(NotificationResponse::Click) | Ok(NotificationResponse::ActionButton(_)) => {
                    log::info!(
                        "[notify-click][{}] clicked provider={}",
                        acct_for_click,
                        prov_for_click
                    );
                    if let Err(e) = app_for_click.emit(
                        "notification:click",
                        serde_json::json!({
                            "account_id": acct_for_click,
                            "provider": prov_for_click,
                        }),
                    ) {
                        log::warn!(
                            "[notify-click][{}] emit notification:click failed: {}",
                            acct_for_click,
                            e
                        );
                    }
                }
                Ok(other) => {
                    log::info!("[notify-click][{}] response={:?}", acct_for_click, other);
                }
                Err(e) => {
                    log::warn!("[notify-click][{}] send error: {}", acct_for_click, e);
                }
            }
        });
    }

    #[cfg(target_os = "linux")]
    {
        let title_c = notify_title.clone();
        let body_c = body.to_string();
        enqueue_linux_notification(Box::new(move || {
            let t = title_c;
            let b = body_c;
            let mut n = notify_rust::Notification::new();
            n.summary(&t).body(&b);
            match n.show() {
                Ok(handle) => {
                    handle.wait_for_action(|action| {
                        // "__closed" is the synthetic dismiss action; skip it.
                        if action != "__closed" && !action.is_empty() {
                            log::info!(
                                "[notify-click][{}] action={} provider={}",
                                acct_for_click,
                                action,
                                prov_for_click
                            );
                            if let Err(e) = app_for_click.emit(
                                "notification:click",
                                serde_json::json!({
                                    "account_id": acct_for_click,
                                    "provider": prov_for_click,
                                }),
                            ) {
                                log::warn!(
                                    "[notify-click][{}] emit notification:click failed: {}",
                                    acct_for_click,
                                    e
                                );
                            }
                        }
                    });
                }
                Err(e) => {
                    log::warn!("[notify-click][{}] show failed: {}", acct_for_click, e);
                }
            }
        }));
    }

    #[cfg(windows)]
    {
        let mut builder = app.notification().builder().title(&notify_title);
        if !body.is_empty() {
            builder = builder.body(body);
        }
        if let Err(e) = builder.show() {
            log::warn!(
                "[notify-cef][{}] notification show failed: {}",
                account_id,
                e
            );
        }
    }
}

pub(crate) fn forward_synthetic_notification<R: Runtime>(
    app: &AppHandle<R>,
    account_id: &str,
    provider: &str,
    title: impl Into<String>,
    body: impl Into<String>,
) {
    let payload = tauri_runtime_cef::notification::NotificationPayload {
        source: tauri_runtime_cef::notification::NotificationSource::Window,
        title: title.into(),
        body: Some(body.into()),
        icon: None,
        tag: None,
        silent: false,
        origin: format!("synthetic://{}", provider),
    };
    forward_native_notification(app, account_id, provider, &payload);
}

#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Bounds {
    pub x: f64,
    pub y: f64,
    pub width: f64,
    pub height: f64,
}

#[derive(Debug, Deserialize)]
pub struct OpenArgs {
    pub account_id: String,
    pub provider: String,
    /// Optional URL override (debug tooling) — falls back to `provider_url`.
    pub url: Option<String>,
    pub bounds: Option<Bounds>,
    /// Issue #1233 — when true, spawn the webview off-screen and route the
    /// load through the prewarm-suppression path. The full handler/scanner/
    /// notification setup is identical to a normal cold open; only the
    /// initial position and the load-event emit are different. Defaults
    /// to false so the field is forwards-compatible with frontends that
    /// don't pass it.
    #[serde(default)]
    pub prewarm: bool,
}

/// Issue #1233 — args for the background `webview_account_prewarm` command.
/// No bounds — prewarm always spawns at a fixed off-screen 1×1 rect; the
/// user-initiated open later supplies the visible rect via the warm-reopen
/// branch in `webview_account_open`.
#[derive(Debug, Deserialize)]
pub struct PrewarmArgs {
    pub account_id: String,
    pub provider: String,
    /// Optional URL override (debug tooling) — falls back to `provider_url`.
    #[serde(default)]
    pub url: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct BoundsArgs {
    pub account_id: String,
    pub bounds: Bounds,
}

#[derive(Debug, Deserialize)]
pub struct RevealArgs {
    pub account_id: String,
    pub bounds: Bounds,
    #[serde(default)]
    pub trigger: Option<String>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RevealTrigger {
    Load,
    Watchdog,
}

impl RevealTrigger {
    fn as_str(self) -> &'static str {
        match self {
            Self::Load => "load",
            Self::Watchdog => "watchdog",
        }
    }

    fn from_ipc(raw: Option<&str>) -> Self {
        match raw {
            Some("load") | None => Self::Load,
            Some("watchdog") => Self::Watchdog,
            Some(other) => {
                log::warn!(
                    "[webview-accounts] unknown reveal trigger {:?}; defaulting to load",
                    other
                );
                Self::Load
            }
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct AccountIdArgs {
    pub account_id: String,
}

#[derive(Debug, Deserialize)]
pub struct RecipeEventArgs {
    pub account_id: String,
    pub provider: String,
    pub kind: String,
    pub payload: serde_json::Value,
    pub ts: Option<i64>,
}

#[derive(Debug, Clone, Serialize)]
pub struct WebviewEvent {
    pub account_id: String,
    pub provider: String,
    pub kind: String,
    pub payload: serde_json::Value,
    pub ts: Option<i64>,
}

/// Strip query string and fragment from a URL before emitting to the log.
/// Provider URLs occasionally embed auth material (Telegram WebApp data,
/// OAuth callback codes, sometimes session tokens) and we don't want those
/// to land in the long-lived shell log file. Returns the original input on
/// parse failure so we still surface *something* useful for debugging.
pub(crate) fn redact_url_for_log(raw: &str) -> String {
    match Url::parse(raw) {
        Ok(mut u) => {
            u.set_query(None);
            u.set_fragment(None);
            u.to_string()
        }
        Err(_) => {
            // Fallback: drop everything from the first '?' or '#'.
            raw.split(['?', '#']).next().unwrap_or(raw).to_string()
        }
    }
}

/// Grow the first-cold-open webview back to its full requested bounds and
/// notify the frontend once the page is actually loaded. Called from three
/// signals (native `WebviewBuilder::on_page_load`, CDP `Page.loadEventFired`,
/// and the 15 s watchdog).
///
/// Timeout is a non-terminal state: we emit `webview-account:load{state:
/// "timeout"}` so the frontend can show retry/help UI, but we deliberately do
/// NOT reveal or mark the account as loaded yet. If a later `finished` signal
/// arrives, that call still reveals and emits `state:"finished"`.
///
/// Resetting the terminal loaded marker happens in `webview_account_close` /
/// `webview_account_purge` so a reopen fires again.
///
/// Doing the `set_size` server-side (instead of waiting for the frontend to
/// invoke `webview_account_reveal`) avoids an extra IPC round-trip and the
/// brief blank frame that would otherwise sit between the load event and
/// the frontend's reveal call.
pub(crate) fn emit_load_finished<R: Runtime>(
    app: &AppHandle<R>,
    account_id: &str,
    state: &str,
    url: &str,
    trigger: RevealTrigger,
) {
    let Some(app_state) = app.try_state::<WebviewAccountsState>() else {
        // No state => emit anyway so the frontend doesn't hang; best-effort.
        log::warn!(
            "[webview-accounts][{}] WebviewAccountsState missing — emitting without reveal",
            account_id
        );
        let _ = app.emit(
            "webview-account:load",
            serde_json::json!({
                "account_id": account_id,
                "state": state,
                "trigger": trigger.as_str(),
                "url": url,
            }),
        );
        return;
    };

    // Issue #1233 — accounts in prewarm mode have no React UI listening
    // for their load events; the user hasn't clicked the rail icon yet.
    // Suppress emit + reveal so the prewarm cycle finishes silently. The
    // page is still painted in the off-screen 1×1 webview so the eventual
    // user click hits the warm-reopen branch and emits `state:"reused"`.
    if app_state
        .prewarm_accounts
        .lock()
        .unwrap()
        .contains(account_id)
    {
        log::info!(
            "[webview-accounts][{}] prewarm load suppressed state={} url={}",
            account_id,
            state,
            redact_url_for_log(url)
        );
        // Mark the account as loaded so any later signals from the same
        // cold-load (native on_page_load + CDP Page.loadEventFired both
        // arriving) don't double-fire if the prewarm flag flips off in
        // between.
        if state != "timeout" {
            app_state
                .loaded_accounts
                .lock()
                .unwrap()
                .insert(account_id.to_string());
        }
        return;
    }

    if state == "timeout" {
        // If we've already observed a terminal load, ignore late watchdogs.
        let already_loaded = app_state
            .loaded_accounts
            .lock()
            .unwrap()
            .contains(account_id);
        if already_loaded {
            log::debug!(
                "[webview-accounts][{}] timeout deduped after terminal load url={}",
                account_id,
                url
            );
            return;
        }

        log::info!(
            "[webview-accounts][{}] load timeout event trigger={} url={}",
            account_id,
            trigger.as_str(),
            redact_url_for_log(url)
        );
        if let Err(err) = app.emit(
            "webview-account:load",
            serde_json::json!({
                "account_id": account_id,
                "state": state,
                "trigger": trigger.as_str(),
                "url": url,
            }),
        ) {
            log::warn!(
                "[webview-accounts][{}] emit webview-account:load(timeout) failed: {}",
                account_id,
                err
            );
        }
        return;
    }

    let is_first_terminal = app_state
        .loaded_accounts
        .lock()
        .unwrap()
        .insert(account_id.to_string());
    if !is_first_terminal {
        log::debug!(
            "[webview-accounts][{}] load event deduped state={} url={}",
            account_id,
            state,
            url
        );
        return;
    }

    // Restore the webview to its full requested size. The spawn path created
    // it at 1×1 so the React loading spinner wasn't covered; now that the page
    // is painted we can grow it into the placeholder rect.
    let label = app_state.inner.lock().unwrap().get(account_id).cloned();
    let bounds = app_state
        .requested_bounds
        .lock()
        .unwrap()
        .get(account_id)
        .copied();
    match (label, bounds) {
        (Some(label), Some(b)) => {
            if let Some(wv) = app.get_webview(&label) {
                if let Err(e) = wv.set_size(LogicalSize::new(b.width, b.height)) {
                    log::warn!(
                        "[webview-accounts][{}] reveal set_size failed: {}",
                        account_id,
                        e
                    );
                }
                if let Err(e) = wv.set_position(LogicalPosition::new(b.x, b.y)) {
                    log::warn!(
                        "[webview-accounts][{}] reveal set_position failed: {}",
                        account_id,
                        e
                    );
                }
                let _ = wv.show();
                log::info!(
                    "[webview-accounts][{}] revealed label={} bounds={:?} state={}",
                    account_id,
                    label,
                    b,
                    state
                );
            } else {
                log::warn!(
                    "[webview-accounts][{}] reveal: webview {} missing",
                    account_id,
                    label
                );
            }
        }
        _ => {
            log::info!(
                "[webview-accounts][{}] reveal skipped (account closed before load) state={}",
                account_id,
                state
            );
        }
    }

    // Redact the URL in the log: providers like Telegram (`#tgWebAppData=…`)
    // and OAuth callbacks embed auth material in the query/fragment. The full
    // URL still flows to the frontend listener over the Tauri event so any
    // consumer that needs it has access; we just don't persist it to the
    // shell's log file.
    log::info!(
        "[webview-accounts][{}] load event state={} trigger={} url={}",
        account_id,
        state,
        trigger.as_str(),
        redact_url_for_log(url)
    );
    if let Err(err) = app.emit(
        "webview-account:load",
        serde_json::json!({
            "account_id": account_id,
            "state": state,
            "trigger": trigger.as_str(),
            "url": url,
        }),
    ) {
        log::warn!(
            "[webview-accounts][{}] emit webview-account:load failed: {}",
            account_id,
            err
        );
    }
}

/// Reject any `account_id` that isn't strictly `[A-Za-z0-9_-]+`. The ID comes
/// from IPC (React shell, but also from injected recipe code running inside
/// third-party origins via `webview_recipe_event`), so treat it as untrusted.
/// Enforcing this early prevents `../` sequences from escaping the per-account
/// data directory in `data_directory_for` (which feeds `create_dir_all` and
/// `remove_dir_all`).
fn sanitize_account_id(account_id: &str) -> Result<&str, String> {
    if account_id.is_empty()
        || !account_id
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err(format!("invalid account_id: {account_id:?}"));
    }
    Ok(account_id)
}

fn label_for(account_id: &str) -> String {
    // Webview labels must be alphanumeric + `-` / `_`. Callers that reached
    // here without first going through `sanitize_account_id` still get a
    // defensively-scrubbed label so invalid characters never reach the
    // tauri webview-label parser.
    let safe: String = account_id
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();
    format!("acct_{}", safe)
}

fn data_directory_for<R: Runtime>(app: &AppHandle<R>, account_id: &str) -> Result<PathBuf, String> {
    // Guard against path traversal — `account_id` is joined into a filesystem
    // path that is later passed to `create_dir_all` / `remove_dir_all`.
    let account_id = sanitize_account_id(account_id)?;
    let base = app
        .path()
        .app_local_data_dir()
        .map_err(|e| format!("app_local_data_dir: {e}"))?;
    Ok(base.join("webview_accounts").join(account_id))
}

/// Produce the `initialization_script` payload for this webview.
///
/// Empty for the 5 migrated providers (whatsapp, telegram, slack, discord,
/// browserscan) — they load with ZERO injected JS; their scraping runs via
/// CDP, and the per-account CDP session opener (`cdp::session`) injects the
/// notification-permission shim via `Page.addScriptToEvaluateOnNewDocument`
/// before the real provider URL loads. The 2 deferred providers
/// (linkedin, google-meet) still get the JS recipe bridge.
fn build_init_script(account_id: &str, provider: &str) -> String {
    let Some(recipe_js) = provider_recipe_js(provider) else {
        return String::new();
    };
    let ctx = serde_json::json!({
        "accountId": account_id,
        "provider": provider,
    });
    format!(
        "window.__OPENHUMAN_RECIPE_CTX__ = {ctx};\n\n{runtime}\n\n{recipe}\n",
        ctx = ctx,
        runtime = RUNTIME_JS,
        recipe = recipe_js
    )
}

/// Spawn (or focus) the embedded webview for an account.
#[tauri::command]
pub async fn webview_account_open<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: OpenArgs,
) -> Result<String, String> {
    let label = label_for(&args.account_id);
    log::info!(
        "[webview-accounts] open account_id={} provider={} label={}",
        args.account_id,
        args.provider,
        label
    );

    // Reject unknown providers early. `provider_url` already errors when
    // no URL override is supplied; the `provider_is_supported` check
    // additionally gates custom-URL overrides so an arbitrary provider
    // string can't ride in via the debug `url` field.
    if !provider_is_supported(&args.provider) {
        return Err(format!("unknown provider: {}", args.provider));
    }
    let real_url_str = args
        .url
        .as_deref()
        .or_else(|| provider_url(&args.provider))
        .ok_or_else(|| format!("no url for provider: {}", args.provider))?
        .to_string();
    // Validate the real URL up front — otherwise a malformed debug
    // `args.url` would only fail later inside the async CDP session
    // loop, which is much harder to surface to the caller. The parsed
    // Url also feeds `scanner_url_prefix` so scanners match on the
    // actual origin the user navigated to (honoring debug overrides).
    let real_url: Url = real_url_str
        .parse()
        .map_err(|e| format!("invalid provider url {real_url_str}: {e}"))?;
    // Scanner target-match uses `url.starts_with(prefix)`, so the
    // prefix needs to be the ORIGIN (scheme + host), not the full URL
    // — same-host intra-app navigations must keep matching after the
    // initial load.
    let scanner_url_prefix = format!("{}/", real_url.origin().ascii_serialization());
    let skip_cdp_for_debug = args.provider == "slack" && !slack_scanner_enabled();
    // We normally open the webview at a tiny placeholder URL so the CDP
    // session opener can attach and inject the notification-permission
    // shim (see `cdp/session.rs`) BEFORE the real provider URL loads;
    // without it Slack surfaces in-app "enable notifications"
    // banners. For Slack debug sessions we allow opting out via
    // `OPENHUMAN_DISABLE_SLACK_SCANNER=1`, which also skips the long-lived
    // CDP session so external DevTools can attach cleanly.
    let initial_url_str = if skip_cdp_for_debug {
        real_url_str.clone()
    } else {
        cdp::placeholder_url(&args.account_id)
    };
    let initial_url: Url = initial_url_str
        .parse()
        .map_err(|e| format!("invalid initial url {initial_url_str}: {e}"))?;

    // If a webview for this account already exists, just reposition / show.
    {
        let map = state.inner.lock().unwrap();
        if let Some(existing_label) = map.get(&args.account_id).cloned() {
            drop(map);
            if let Some(existing) = app.get_webview(&existing_label) {
                // Issue #1233 — when this is a prewarm call landing on an
                // already-prewarmed account, do nothing: the webview is
                // already off-screen, the CDP session is already attached,
                // and the prewarm flag should stay set so the eventual
                // user-initiated open can promote it. Just return the label.
                if args.prewarm {
                    log::debug!(
                        "[webview-accounts] prewarm idempotent skip: account={} already warm label={}",
                        args.account_id,
                        existing_label
                    );
                    return Ok(existing_label);
                }
                // Issue #1233 — a prewarmed webview is reaching its first
                // user-initiated open. Clear the prewarm flag BEFORE we
                // resize/reveal so any in-flight CDP load event still
                // racing toward `emit_load_finished` flows through the
                // normal path instead of being silently suppressed.
                let was_prewarmed = state
                    .prewarm_accounts
                    .lock()
                    .unwrap()
                    .remove(&args.account_id);
                if was_prewarmed {
                    log::info!(
                        "[webview-accounts] prewarm hit account={} label={} — promoting to live",
                        args.account_id,
                        existing_label
                    );
                }
                if let Some(b) = args.bounds {
                    let _ = existing.set_position(LogicalPosition::new(b.x, b.y));
                    let _ = existing.set_size(LogicalSize::new(b.width, b.height));
                    state
                        .requested_bounds
                        .lock()
                        .unwrap()
                        .insert(args.account_id.clone(), b);
                }
                let _ = existing.show();
                log::info!(
                    "[webview-accounts] reused existing label={} for account={}",
                    existing_label,
                    args.account_id
                );
                // Warm re-open: the page is already painted, so skip the
                // loading overlay cycle and tell the frontend to go straight
                // to `open`. We bypass `emit_load_finished` because the
                // `loaded_accounts` dedup set would swallow the emit after
                // the first cold open of this account.
                let reuse_url = existing.url().map(|u| u.to_string()).unwrap_or_default();
                if let Err(err) = app.emit(
                    "webview-account:load",
                    serde_json::json!({
                        "account_id": args.account_id,
                        "state": "reused",
                        "trigger": RevealTrigger::Load.as_str(),
                        "url": reuse_url,
                    }),
                ) {
                    log::warn!(
                        "[webview-accounts][{}] emit reused event failed: {}",
                        args.account_id,
                        err
                    );
                }
                return Ok(existing_label);
            }
            // Stale entry — fall through and rebuild
            log::warn!(
                "[webview-accounts] stale label {} found for account {}, rebuilding",
                existing_label,
                args.account_id
            );
        }
    }

    // Grab the raw Window (not WebviewWindow) so `add_child` works even
    // after we've attached sibling webviews — `get_webview_window` checks
    // `is_webview_window()` which flips to false once a window has more
    // than one webview.
    let parent_window = app
        .get_window("main")
        .ok_or_else(|| "main window not found".to_string())?;

    let data_dir = data_directory_for(&app, &args.account_id)?;
    if let Err(err) = std::fs::create_dir_all(&data_dir) {
        log::warn!(
            "[webview-accounts] failed to create data dir {}: {}",
            data_dir.display(),
            err
        );
    }

    let init_script = build_init_script(&args.account_id, &args.provider);

    let mut builder = WebviewBuilder::new(label.clone(), WebviewUrl::External(initial_url))
        .data_directory(data_dir);
    if !init_script.is_empty() {
        builder = builder.initialization_script(&init_script);
    }

    // Keep link clicks that leave the provider's host set in the OS
    // browser, not the embedded webview. Same-host navigations (including
    // OAuth hops to accounts.google.com etc., which we pre-declare per
    // provider) stay in-app. Provider-specific native-app deep links
    // (`zoomus://`, `zoommtg://`, …) are rewritten to the web-client URL
    // and re-navigated in-app so meetings don't bounce out.
    let nav_provider = args.provider.clone();
    let nav_app = app.clone();
    let nav_label = label.clone();
    let nav_account_id = args.account_id.clone();
    builder = builder.on_navigation(move |url| {
        // Notify the frontend on every committed navigation. The
        // `webview-account:load` event is dedup'd per cold open, so it
        // can't be used to spot post-login redirects (e.g. Google
        // Meet's accounts.google.com → meet.google.com hop). Frontends
        // that
        // care about live URL transitions — onboarding's auto-detect
        // for "user finished signing in", for instance — listen here.
        if let Err(err) = nav_app.emit(
            "webview-account:navigate",
            serde_json::json!({
                "account_id": nav_account_id,
                "provider": nav_provider,
                "url": redact_navigation_url(url),
            }),
        ) {
            log::debug!(
                "[webview-accounts] emit webview-account:navigate failed: {}",
                err
            );
        }
        // Google Meet: when Google's edge SSR-redirects the post-account-
        // picker URL to `workspace.google.com/products/meet/...` (the
        // marketing landing page), `workspace.google.com` matches the
        // bare `google.com` suffix in `provider_allowed_hosts` so
        // `url_is_internal` would commit the navigation and the user
        // would land on the Workspace marketing page instead of Meet.
        // Catch this here and replace the parent URL with the canonical
        // Meet entry point so the embedded view stays on the app.
        //
        // BUT: unauthenticated users get bounced right back to
        // `workspace.google.com/products/meet/` by Google's edge, so an
        // unguarded rewrite ping-pongs forever (`navigate` → Google
        // redirect → `on_navigation` → `navigate` → …). We track per-label
        // attempts in `WebviewAccountsState::gmeet_marketing_rewrites` and
        // bail to a Google sign-in URL after a small threshold so the user
        // can break out of the loop. See #1213 (downstream watchdog
        // symptom) and `track_gmeet_marketing_rewrite` for the policy.
        if nav_provider == "google-meet" {
            if let Some(host) = url.host_str() {
                // Post-auth handoff: when the bail's `ServiceLogin?continue=`
                // chain completes, Google sometimes drops the continue param
                // (URL ends in `?utm_source=sign_in_no_continue`) and dumps
                // the user on `myaccount.google.com` instead of Meet. The
                // session cookie is now valid, so a direct navigation to
                // `meet.google.com` will paint Meet without bouncing back
                // through the workspace marketing redirect (which was the
                // unauthenticated branch). Force the hop here so the user
                // doesn't have to click through the apps grid manually.
                //
                // Gated on the per-label `gmeet_awaiting_handoff` flag —
                // set by the Bail branch right before navigating to
                // `ServiceLogin?continue=` — so legitimate user-initiated
                // visits to `myaccount.google.com` (e.g. "Manage your
                // Google Account" from the avatar menu) pass through and
                // remain reachable in-app.
                if host == "myaccount.google.com" {
                    let consumed = nav_app
                        .try_state::<WebviewAccountsState>()
                        .map(|s| s.take_awaiting_gmeet_handoff(&nav_label))
                        .unwrap_or(false);
                    if !consumed {
                        log::debug!(
                            "[webview-accounts] gmeet myaccount.google.com nav (label={}) not in handoff window; passing through",
                            nav_label
                        );
                    } else {
                        log::info!(
                            "[webview-accounts] gmeet post-auth handoff detected on myaccount.google.com; navigating parent to https://meet.google.com/"
                        );
                        let app = nav_app.clone();
                        let label = nav_label.clone();
                        // Reset the marketing-rewrite counter so the next
                        // workspace bounce (if any) gets a fresh window —
                        // the user is now authenticated and shouldn't
                        // loop again.
                        if let Some(s) = nav_app.try_state::<WebviewAccountsState>() {
                            s.clear_gmeet_marketing_rewrite(&nav_label);
                        }
                        tauri::async_runtime::spawn(async move {
                            if let Some(wv) = app.get_webview(&label) {
                                if let Ok(target) = Url::parse("https://meet.google.com/") {
                                    if let Err(e) = wv.navigate(target) {
                                        log::warn!(
                                            "[webview-accounts] gmeet post-auth navigate failed label={} err={}",
                                            label,
                                            e
                                        );
                                    }
                                }
                            }
                        });
                        return false;
                    }
                }
                if is_gmeet_marketing_redirect(host, url.path()) {
                    let action = nav_app
                        .try_state::<WebviewAccountsState>()
                        .map(|s| s.track_gmeet_marketing_rewrite(&nav_label, Instant::now()))
                        .unwrap_or(GmeetRewriteAction::Rewrite);
                    let app = nav_app.clone();
                    let label = nav_label.clone();
                    let target_url = match action {
                        GmeetRewriteAction::Rewrite => {
                            log::info!(
                                "[webview-accounts] gmeet workspace marketing redirect intercepted ({}); rewriting parent to https://meet.google.com/",
                                url
                            );
                            "https://meet.google.com/"
                        }
                        GmeetRewriteAction::Bail => {
                            log::warn!(
                                "[webview-accounts] gmeet workspace rewrite loop detected on label={} (>{} attempts in {}s); falling through to Google sign-in",
                                nav_label,
                                GMEET_REWRITE_MAX_ATTEMPTS,
                                GMEET_REWRITE_WINDOW.as_secs()
                            );
                            // Arm the post-auth handoff flag so the next
                            // `myaccount.google.com` commit on this label
                            // (the `?utm_source=sign_in_no_continue` dump
                            // page Google sometimes drops users on after
                            // the ServiceLogin chain) gets force-redirected
                            // back to Meet. Without this gate, ANY
                            // `myaccount.google.com` visit was hijacked,
                            // breaking legitimate "Manage your Google
                            // Account" flows.
                            if let Some(s) = nav_app.try_state::<WebviewAccountsState>() {
                                s.mark_awaiting_gmeet_handoff(&nav_label);
                            }
                            // `service=meet` is rejected by Google (`400
                            // malformed`); the continue param must be
                            // URL-encoded since `&` would split it. Drop
                            // `service=` and encode `continue=` so the
                            // sign-in landing actually loads.
                            "https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fmeet.google.com%2F"
                        }
                    };
                    tauri::async_runtime::spawn(async move {
                        if let Some(wv) = app.get_webview(&label) {
                            if let Ok(target) = Url::parse(target_url) {
                                if let Err(e) = wv.navigate(target) {
                                    log::warn!(
                                        "[webview-accounts] gmeet workspace rewrite navigate failed label={} target={} err={}",
                                        label,
                                        target_url,
                                        e
                                    );
                                }
                            }
                        }
                    });
                    return false;
                }
            }
        }
        if let Some(rewritten) = rewrite_provider_deep_link(&nav_provider, url) {
            log::info!(
                "[webview-accounts] deep-link rewrite {} → {} (provider={})",
                url,
                rewritten,
                nav_provider
            );
            let app = nav_app.clone();
            let label = nav_label.clone();
            tauri::async_runtime::spawn(async move {
                if let Some(wv) = app.get_webview(&label) {
                    if let Err(e) = wv.navigate(rewritten) {
                        log::warn!(
                            "[webview-accounts] post-rewrite navigate failed label={} err={}",
                            label,
                            e
                        );
                    }
                }
            });
            return false;
        }
        if url_is_internal(&nav_provider, url) {
            true
        } else {
            // Suppress provider native-desktop-app deep-link schemes that
            // we don't know how to rewrite. macOS would otherwise hand
            // these to the native provider app — `slack://magic-login/…`
            // signs the native Slack app into the workspace, breaking
            // embedded-webview isolation (#1074). The web flow's HTTPS
            // fallback handles sign-in without the deep link.
            if is_provider_native_deep_link_scheme(url.scheme()) {
                log::warn!(
                    "[webview-accounts] suppressing native-app deep-link scheme={} url={} (would breach workspace isolation)",
                    url.scheme(),
                    redact_native_deep_link_url(url)
                );
                return false;
            }
            let target = unwrap_provider_redirect(url)
                .map(|u| u.to_string())
                .unwrap_or_else(|| url.to_string());
            if target != url.as_str() {
                log::info!(
                    "[webview-accounts] external navigation {} → (unwrapped) {} → system browser",
                    url,
                    target
                );
            } else {
                log::info!(
                    "[webview-accounts] external navigation {} → system browser",
                    url
                );
            }
            open_in_system_browser(&target);
            false
        }
    });

    // Cmd/Ctrl-click and `target="_blank"` / `window.open(...)` trigger a
    // new-window request. Default policy: deny and hand the URL to the
    // system browser — matches user intent of "open in new tab outside
    // the app".
    //
    // Exception: some providers (Slack Huddles) spawn popups via
    // `window.open()` and abort the flow if the return value is falsey.
    // For those URLs we allow CEF's default popup handling so an in-app
    // child window opens and the caller gets a real window handle.
    let popup_provider = args.provider.clone();
    let popup_app = app.clone();
    let popup_label = label.clone();
    builder = builder.on_new_window(move |url, _features| {
        if let Some(rewritten) = rewrite_provider_deep_link(&popup_provider, &url) {
            log::info!(
                "[webview-accounts] new-window deep-link rewrite {} → {} (provider={})",
                url,
                rewritten,
                popup_provider
            );
            let app = popup_app.clone();
            let label = popup_label.clone();
            tauri::async_runtime::spawn(async move {
                if let Some(wv) = app.get_webview(&label) {
                    if let Err(e) = wv.navigate(rewritten) {
                        log::warn!(
                            "[webview-accounts] post-rewrite navigate (popup) failed label={} err={}",
                            label,
                            e
                        );
                    }
                }
            });
            return NewWindowResponse::Deny;
        }
        if let Some(target) = popup_should_navigate_parent(&popup_provider, &url) {
            log::info!(
                "[webview-accounts] new-window {} → navigate parent (provider={})",
                redact_navigation_url(&url),
                popup_provider
            );
            let app = popup_app.clone();
            let label = popup_label.clone();
            tauri::async_runtime::spawn(async move {
                if let Some(wv) = app.get_webview(&label) {
                    if let Err(e) = wv.navigate(target) {
                        log::warn!(
                            "[webview-accounts] popup→parent navigate failed label={} err={}",
                            label,
                            e
                        );
                    }
                }
            });
            return NewWindowResponse::Deny;
        }
        if popup_should_stay_in_app(&popup_provider, &url) {
            log::info!(
                "[webview-accounts] new-window request {} → in-app popup (provider={})",
                url,
                popup_provider
            );
            NewWindowResponse::Allow
        } else {
            // Suppress provider native-desktop-app deep-link schemes that
            // we don't know how to rewrite (matches the on_navigation
            // fallback). Without this, a `slack://...` popup would land
            // in the native Slack app via macOS's URL handler and
            // breach embedded-webview workspace isolation (#1074).
            if is_provider_native_deep_link_scheme(url.scheme()) {
                log::warn!(
                    "[webview-accounts] suppressing native-app deep-link scheme={} url={} (would breach workspace isolation)",
                    url.scheme(),
                    redact_native_deep_link_url(&url)
                );
                return NewWindowResponse::Deny;
            }
            let target = unwrap_provider_redirect(&url)
                .map(|u| u.to_string())
                .unwrap_or_else(|| url.to_string());
            if target != url.as_str() {
                log::info!(
                    "[webview-accounts] new-window request {} → (unwrapped) {} → system browser",
                    url,
                    target
                );
            } else {
                log::info!(
                    "[webview-accounts] new-window request {} → system browser",
                    url
                );
            }
            open_in_system_browser(&target);
            NewWindowResponse::Deny
        }
    });

    // Enable devtools on child webviews in debug builds only so recipe
    // diagnostics and IndexedDB state can be inspected. Access on macOS is via
    //   Safari → Develop → <App name> → <webview label>
    // (the parent Tauri window's right-click "Inspect" does not propagate
    // into child webviews on WKWebView). In release builds we leave CDP off
    // so third-party-site webviews are not remotely inspectable.
    if cfg!(debug_assertions) {
        builder = builder.devtools(true);
    }

    // Wire the native page-load signal and forward only *usable* load
    // completions to `emit_load_finished`:
    //   - skip placeholder `about:blank#openhuman-acct-*` commits (otherwise
    //     we reveal a blank viewport before real content arrives),
    //   - treat Chromium network error pages (`chrome-error://…`) as timeout
    //     signals so frontend shows retry/help UI instead of the dino page.
    //
    // Real provider commits still emit `finished`. Dedup against CDP
    // `Page.loadEventFired` + watchdog happens in `emit_load_finished`.
    let page_load_app = app.clone();
    let page_load_account_id = args.account_id.clone();
    let page_load_placeholder_fragment = format!("#{}", cdp::placeholder_marker(&args.account_id));
    let page_load_real_url = real_url_str.clone();
    builder = builder.on_page_load(move |_webview, payload| {
        if !matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) {
            return;
        }
        let url = payload.url();
        if url.scheme() == "data" {
            return;
        }
        if !skip_cdp_for_debug && url.as_str().ends_with(&page_load_placeholder_fragment) {
            log::debug!(
                "[webview-accounts][{}] skipping placeholder native-finished url={}",
                page_load_account_id,
                redact_url_for_log(url.as_str())
            );
            return;
        }
        if url.scheme() == "chrome-error" {
            emit_load_finished(
                &page_load_app,
                &page_load_account_id,
                "timeout",
                &page_load_real_url,
                RevealTrigger::Load,
            );
            return;
        }
        emit_load_finished(
            &page_load_app,
            &page_load_account_id,
            "finished",
            url.as_str(),
            RevealTrigger::Load,
        );
    });

    let bounds = args.bounds.unwrap_or(Bounds {
        x: 0.0,
        y: 0.0,
        width: 800.0,
        height: 600.0,
    });

    // Park the webview off-screen during its first page load so the React
    // placeholder's loading spinner is not covered by the native CEF subview.
    // `webview_account_reveal` (invoked from the frontend after the load event
    // arrives, or by the 15 s watchdog) moves it back to `bounds` + shows it.
    //
    // Warm-open reuse (when a webview already exists for this account) earlier
    // in this function returns before we get here, so existing webviews keep
    // their current position — we only off-screen the first cold spawn.
    // Spawn strategy: keep the webview at the caller's requested position
    // but shrink the initial size to 1×1 under CEF so the native subview
    // doesn't paint over the React loading spinner. `webview_account_reveal`
    // grows it back to `bounds.width × bounds.height` once the page-loaded
    // signal arrives.
    //
    // Why not move off-screen: moving the NSView after a cold CEF spawn on
    // macOS sometimes leaves the page painted but not repainted at the new
    // origin, leaving the user looking at a blank viewport until they
    // reload. Keeping the position stable and only toggling size sidesteps
    // that repaint edge case while still keeping the webview visually
    // hidden (1 px under the overlay) during load.
    //
    // Issue #1233 — when `args.prewarm == true`, the frontend has not asked
    // for a visible rect (the user hasn't clicked the rail icon yet). Spawn
    // the webview at a fixed off-screen position with size 1×1 so it never
    // paints anywhere on screen until the eventual user-initiated open
    // promotes it via the warm-reopen branch above.
    let (initial_position, initial_size) = if args.prewarm {
        (
            LogicalPosition::new(PREWARM_OFFSCREEN_X, PREWARM_OFFSCREEN_Y),
            LogicalSize::new(1.0, 1.0),
        )
    } else if skip_cdp_for_debug {
        (
            LogicalPosition::new(bounds.x, bounds.y),
            LogicalSize::new(bounds.width, bounds.height),
        )
    } else {
        (
            LogicalPosition::new(bounds.x, bounds.y),
            LogicalSize::new(1.0, 1.0),
        )
    };

    // Issue #1233 — only remember `requested_bounds` for non-prewarm opens.
    // Prewarm doesn't have a visible rect to restore to; the user-initiated
    // open later supplies the bounds via the warm-reopen branch.
    if !args.prewarm {
        state
            .requested_bounds
            .lock()
            .unwrap()
            .insert(args.account_id.clone(), bounds);
    }
    // Issue #1233 — mark the account as prewarmed BEFORE add_child so the
    // load-event suppression in `emit_load_finished` is in place by the time
    // the CDP session or native on_page_load fires.
    if args.prewarm {
        state
            .prewarm_accounts
            .lock()
            .unwrap()
            .insert(args.account_id.clone());
    }
    // Defensive reset: if a prior close/purge was raced by a stale emit we
    // could still have the account marked as "already loaded". Clear here so
    // the fresh spawn is allowed to fire the first event again.
    state
        .loaded_accounts
        .lock()
        .unwrap()
        .remove(&args.account_id);

    let webview = parent_window
        .add_child(builder, initial_position, initial_size)
        .map_err(|e| format!("add_child failed: {e}"))?;

    // Capture the cold-spawn timestamp so the reveal-time log can compute
    // spawn -> frontend reveal latency for the Slack first-load investigation.
    state
        .spawn_started_at
        .lock()
        .unwrap()
        .insert(args.account_id.clone(), Instant::now());
    state
        .account_providers
        .lock()
        .unwrap()
        .insert(args.account_id.clone(), args.provider.clone());

    log::info!(
        "[webview-accounts] spawned label={} requested_bounds={:?} initial_size={:?}",
        webview.label(),
        bounds,
        initial_size
    );

    state
        .inner
        .lock()
        .unwrap()
        .insert(args.account_id.clone(), label.clone());

    // Spawn the per-account CDP session opener: holds an attached session
    // for the lifetime of the webview so `Emulation.setUserAgentOverride`
    // (which reverts on detach) keeps applying, and drives the initial
    // Page.navigate from our placeholder URL to the real provider URL.
    // Also installs the `#openhuman-account-{id}` fragment the scanners
    // match on for multi-account disambiguation.
    // Spawn the per-account CDP session opener, replacing any prior
    // handle for this account (the old one would still be trying to
    // attach to a target that's been torn down).
    {
        if skip_cdp_for_debug {
            log::info!(
                "[webview-accounts] skipping CDP session via OPENHUMAN_DISABLE_SLACK_SCANNER for account={}",
                args.account_id
            );
        } else {
            let cdp::SpawnedSession { session, watchdog } =
                cdp::spawn_session(app.clone(), args.account_id.clone(), real_url_str.clone());
            if let Some(old) = state
                .cdp_sessions
                .lock()
                .unwrap()
                .insert(args.account_id.clone(), session)
            {
                old.abort();
            }
            if let Some(old) = state
                .load_watchdogs
                .lock()
                .unwrap()
                .insert(args.account_id.clone(), watchdog)
            {
                old.abort();
            }
        }
    }

    // For providers we know how to scrape via CDP, kick off the IndexedDB
    // scanner. CDP requires the CEF runtime's remote-debugging port.
    {
        // Prefix is derived from the validated real URL's origin above
        // so debug `args.url` overrides (alt hosts, localhost mirrors)
        // resolve correctly — previously we always used the static
        // `provider_url(...)` default even when the webview had
        // navigated elsewhere.
        if args.provider == "whatsapp" {
            let registry = app
                .try_state::<std::sync::Arc<crate::whatsapp_scanner::ScannerRegistry>>()
                .map(|s| s.inner().clone());
            if let Some(registry) = registry {
                registry.ensure_scanner(
                    app.clone(),
                    args.account_id.clone(),
                    scanner_url_prefix.clone(),
                );
            } else {
                log::warn!("[webview-accounts] CDP ScannerRegistry not in app state");
            }
        } else if args.provider == "slack" {
            if slack_scanner_enabled() {
                let registry = app
                    .try_state::<std::sync::Arc<crate::slack_scanner::ScannerRegistry>>()
                    .map(|s| s.inner().clone());
                if let Some(registry) = registry {
                    registry.ensure_scanner(
                        app.clone(),
                        args.account_id.clone(),
                        scanner_url_prefix.clone(),
                    );
                } else {
                    log::warn!("[webview-accounts] slack ScannerRegistry not in app state");
                }
            } else {
                log::info!(
                    "[webview-accounts] slack scanner disabled via OPENHUMAN_DISABLE_SLACK_SCANNER for account={}",
                    args.account_id
                );
            }
        } else if args.provider == "telegram" {
            let registry = app
                .try_state::<std::sync::Arc<crate::telegram_scanner::ScannerRegistry>>()
                .map(|s| s.inner().clone());
            if let Some(registry) = registry {
                registry.ensure_scanner(
                    app.clone(),
                    args.account_id.clone(),
                    scanner_url_prefix.clone(),
                );
            } else {
                log::warn!("[webview-accounts] telegram ScannerRegistry not in app state");
            }
        } else if args.provider == "discord" {
            // Discord MITM uses CDP `Network.*` to capture HTTP API calls
            // and gateway WebSocket frames — see `discord_scanner/mod.rs`.
            let registry = app
                .try_state::<std::sync::Arc<crate::discord_scanner::ScannerRegistry>>()
                .map(|s| s.inner().clone());
            if let Some(registry) = registry {
                registry.ensure_scanner(
                    app.clone(),
                    args.account_id.clone(),
                    scanner_url_prefix.clone(),
                );
            } else {
                log::warn!("[webview-accounts] discord ScannerRegistry not in app state");
            }
        }

        // Browser Notification interception, native CEF path. The renderer
        // subprocess (cef-helper) has already replaced `window.Notification`
        // and `ServiceWorkerRegistration.prototype.showNotification` with
        // V8 native bindings that send a `"openhuman.notify"` ProcessMessage
        // to the browser process. `tauri-runtime-cef::notification::register`
        // installs a per-browser callback that the runtime invokes when that
        // IPC arrives. We need the CEF browser id to key the registration —
        // hence the `with_webview` downcast hop. The callback is dispatched
        // from a CEF thread, so keep work inside it short / non-blocking.
        let app_for_register = app.clone();
        let acct_for_register = args.account_id.clone();
        let provider_for_register = args.provider.clone();
        if let Err(err) = webview.with_webview(move |raw| {
            let Some(browser) = raw.downcast_ref::<cef::Browser>() else {
                log::warn!(
                    "[notify-cef] with_webview returned non-cef::Browser handle for account={} — skipping notification registration",
                    acct_for_register
                );
                return;
            };
            let browser_id = browser.identifier();
            if let Some(state) = app_for_register.try_state::<WebviewAccountsState>() {
                state
                    .browser_ids
                    .lock()
                    .unwrap()
                    .insert(acct_for_register.clone(), browser_id);
            }
            let acct_in_handler = acct_for_register.clone();
            let provider_in_handler = provider_for_register.clone();
            let app_in_handler = app_for_register.clone();
            tauri_runtime_cef::notification::register(browser_id, move |payload| {
                forward_native_notification(
                    &app_in_handler,
                    &acct_in_handler,
                    &provider_in_handler,
                    &payload,
                );
            });
            log::info!(
                "[notify-cef] registered handler account={} provider={} browser_id={}",
                acct_for_register,
                provider_for_register,
                browser_id
            );
        }) {
            log::warn!(
                "[notify-cef] with_webview dispatch failed for account={}: {}",
                args.account_id,
                err
            );
        }
    }

    Ok(label)
}

/// Off-screen position used for the prewarmed webview. Same magnitude as
/// the [`super::lib::CEF_PREWARM_LABEL`] warmup placeholder so the native
/// view is well outside any plausible monitor layout. Issue #1233.
pub(crate) const PREWARM_OFFSCREEN_X: f64 = -20_000.0;
pub(crate) const PREWARM_OFFSCREEN_Y: f64 = -20_000.0;

/// Issue #1233 — spawn a hidden 1×1 webview for `account_id` so its CEF
/// profile and provider page are warm before the user clicks the rail icon.
/// On the user's first click, the existing `webview_account_open` warm-reopen
/// branch reuses the prewarmed webview and emits `state:"reused"` so the React
/// loading overlay never has to wait for a cold load.
///
/// Implemented as a thin delegate to `webview_account_open` with
/// `prewarm: true`. Sharing the cold-open code path means the prewarmed
/// webview gets the full handler suite (`on_navigation`, `on_new_window`,
/// `on_page_load`), the per-provider scanner bootstrap, and the CEF
/// notification registration — none of which can be retroactively wired
/// when the warm-reopen branch later returns early.
///
/// Idempotent — calling for an already-warm account is a no-op. Best-effort —
/// the frontend can safely fire-and-forget; on failure the worst case is a
/// normal cold open later.
#[tauri::command]
pub async fn webview_account_prewarm<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: PrewarmArgs,
) -> Result<(), String> {
    log::info!(
        "[webview-accounts] prewarm account_id={} provider={}",
        args.account_id,
        args.provider
    );
    let open_args = OpenArgs {
        account_id: args.account_id,
        provider: args.provider,
        url: args.url,
        bounds: None,
        prewarm: true,
    };
    webview_account_open(app, state, open_args)
        .await
        .map(|_| ())
}

#[tauri::command]
pub async fn webview_account_close<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: AccountIdArgs,
) -> Result<(), String> {
    let label_opt = state.inner.lock().unwrap().remove(&args.account_id);
    let Some(label) = label_opt else {
        log::debug!(
            "[webview-accounts] close: no webview for account {}",
            args.account_id
        );
        return Ok(());
    };
    if let Some(wv) = app.get_webview(&label) {
        if let Err(e) = wv.close() {
            log::warn!("[webview-accounts] close({label}) failed: {e}");
        }
    }
    teardown_account_scanners(&app, &args.account_id);
    if let Some(browser_id) = state.browser_ids.lock().unwrap().remove(&args.account_id) {
        tauri_runtime_cef::notification::unregister(browser_id);
        log::debug!(
            "[notify-cef] unregistered handler account={} browser_id={}",
            args.account_id,
            browser_id
        );
    }
    if let Some(task) = state.cdp_sessions.lock().unwrap().remove(&args.account_id) {
        task.abort();
        log::debug!(
            "[cdp-session] aborted session task for account={}",
            args.account_id
        );
    }
    if let Some(task) = state
        .load_watchdogs
        .lock()
        .unwrap()
        .remove(&args.account_id)
    {
        task.abort();
        log::debug!(
            "[webview-accounts] aborted load watchdog for account={}",
            args.account_id
        );
    }
    // Reset load-overlay bookkeeping so the next open of this account starts
    // with a fresh "not yet loaded" state.
    state
        .loaded_accounts
        .lock()
        .unwrap()
        .remove(&args.account_id);
    state
        .requested_bounds
        .lock()
        .unwrap()
        .remove(&args.account_id);
    state
        .spawn_started_at
        .lock()
        .unwrap()
        .remove(&args.account_id);
    state
        .account_providers
        .lock()
        .unwrap()
        .remove(&args.account_id);
    // Issue #1233 — drop the prewarm flag too so a future prewarm dispatch
    // for the same id can re-attempt cleanly.
    state
        .prewarm_accounts
        .lock()
        .unwrap()
        .remove(&args.account_id);
    // Drop any gmeet workspace-rewrite counter for this label — labels are
    // reused on reopen, so a stale entry from a closed-mid-loop session
    // would saturate the next fresh open's window.
    state.clear_gmeet_marketing_rewrite(&label);
    log::info!("[webview-accounts] closed label={}", label);
    Ok(())
}

/// Close the webview AND wipe its on-disk `data_directory` so cookies,
/// storage and cached credentials are forgotten. Use this for the
/// user-initiated "logout" action — `webview_account_close` keeps the
/// data dir intact so the next open restores the session.
#[tauri::command]
pub async fn webview_account_purge<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: AccountIdArgs,
) -> Result<(), String> {
    // Close first so the native webview releases its file handles before we
    // try to delete the data directory.
    let label_opt = state.inner.lock().unwrap().remove(&args.account_id);
    if let Some(label) = label_opt.as_ref() {
        if let Some(wv) = app.get_webview(label) {
            if let Err(e) = wv.close() {
                log::warn!("[webview-accounts] purge close({label}) failed: {e}");
            }
        }
    }

    teardown_account_scanners(&app, &args.account_id);
    if let Some(browser_id) = state.browser_ids.lock().unwrap().remove(&args.account_id) {
        tauri_runtime_cef::notification::unregister(browser_id);
        log::debug!(
            "[notify-cef] purge unregistered handler account={} browser_id={}",
            args.account_id,
            browser_id
        );
    }
    if let Some(task) = state.cdp_sessions.lock().unwrap().remove(&args.account_id) {
        task.abort();
        log::debug!(
            "[cdp-session] purge aborted session task for account={}",
            args.account_id
        );
    }
    if let Some(task) = state
        .load_watchdogs
        .lock()
        .unwrap()
        .remove(&args.account_id)
    {
        task.abort();
        log::debug!(
            "[webview-accounts] purge aborted load watchdog for account={}",
            args.account_id
        );
    }
    state
        .loaded_accounts
        .lock()
        .unwrap()
        .remove(&args.account_id);
    state
        .requested_bounds
        .lock()
        .unwrap()
        .remove(&args.account_id);
    state
        .spawn_started_at
        .lock()
        .unwrap()
        .remove(&args.account_id);
    state
        .account_providers
        .lock()
        .unwrap()
        .remove(&args.account_id);
    // Issue #1233 — drop the prewarm flag too on purge.
    state
        .prewarm_accounts
        .lock()
        .unwrap()
        .remove(&args.account_id);
    if let Some(label) = label_opt.as_ref() {
        state.clear_gmeet_marketing_rewrite(label);
        // Drop any pending handoff flag for this label so a stale entry
        // can't hijack the next genuine `myaccount.google.com` visit on
        // a webview that re-uses the same label.
        state.take_awaiting_gmeet_handoff(label);
    }

    let data_dir = data_directory_for(&app, &args.account_id)?;
    purge_data_dir_with_retry(&data_dir)
        .await
        .map_err(|e| format!("purge data dir {}: {e}", data_dir.display()))?;

    log::info!(
        "[webview-accounts] purged account={} label={:?}",
        args.account_id,
        label_opt
    );
    Ok(())
}

/// CEF / WKWebView holds file handles briefly after `wv.close()` returns,
/// so a single `remove_dir_all` racing the close call routinely fails on
/// macOS and leaves the per-account cookie jar on disk. Re-adding the same
/// account after a logout then lands the user already signed in (#1076).
///
/// Retry the deletion a handful of times with exponential backoff so the
/// subprocess has a chance to drop its handles. Logs every attempt so a
/// stuck handle is diagnosable from the audit log.
async fn purge_data_dir_with_retry(data_dir: &std::path::Path) -> std::io::Result<()> {
    if !data_dir.exists() {
        return Ok(());
    }
    const MAX_ATTEMPTS: u32 = 5;
    const INITIAL_BACKOFF_MS: u64 = 100;
    let mut backoff = INITIAL_BACKOFF_MS;
    for attempt in 1..=MAX_ATTEMPTS {
        match std::fs::remove_dir_all(data_dir) {
            Ok(()) => {
                log::info!(
                    "[webview-accounts] purged data dir {} (attempt {}/{})",
                    data_dir.display(),
                    attempt,
                    MAX_ATTEMPTS
                );
                return Ok(());
            }
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                log::info!(
                    "[webview-accounts] purge data dir {} already removed before attempt {}/{}",
                    data_dir.display(),
                    attempt,
                    MAX_ATTEMPTS
                );
                return Ok(());
            }
            Err(err) if attempt < MAX_ATTEMPTS => {
                log::debug!(
                    "[webview-accounts] purge remove_dir_all {} attempt {}/{} failed: {} — retrying in {}ms",
                    data_dir.display(),
                    attempt,
                    MAX_ATTEMPTS,
                    err,
                    backoff
                );
                tokio::time::sleep(std::time::Duration::from_millis(backoff)).await;
                backoff *= 2;
            }
            Err(err) => {
                log::warn!(
                    "[webview-accounts] purge remove_dir_all {} failed after {} attempts: {} — cookies may persist; cross-launch fallback handled by schedule_cef_profile_purge",
                    data_dir.display(),
                    MAX_ATTEMPTS,
                    err
                );
                return Err(err);
            }
        }
    }
    Ok(())
}

#[tauri::command]
pub async fn webview_account_bounds<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: BoundsArgs,
) -> Result<(), String> {
    let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
    let Some(label) = label_opt else {
        return Err(format!("no webview for account {}", args.account_id));
    };
    let wv = app
        .get_webview(&label)
        .ok_or_else(|| format!("webview {label} missing"))?;
    wv.set_position(LogicalPosition::new(args.bounds.x, args.bounds.y))
        .map_err(|e| format!("set_position: {e}"))?;
    wv.set_size(LogicalSize::new(args.bounds.width, args.bounds.height))
        .map_err(|e| format!("set_size: {e}"))?;
    log::trace!(
        "[webview-accounts] bounds label={} -> {:?}",
        label,
        args.bounds
    );
    // Keep the in-state bounds synced so `webview_account_reveal` has the
    // latest rect even if the frontend's own cache is cleared between the
    // `webview_account_open` call and the `webview-account:load` signal.
    state
        .requested_bounds
        .lock()
        .unwrap()
        .insert(args.account_id.clone(), args.bounds);
    Ok(())
}

/// Move an off-screen-spawned webview back to the frontend's desired rect and
/// show it. Invoked by the frontend when it receives the `webview-account:load`
/// event so the loading spinner is uncovered only after the page has painted.
///
/// Called as the final step of the first-open flow:
///   1. `webview_account_open` — CEF subview spawned off-screen
///   2. native `on_page_load` OR CDP `Page.loadEventFired` OR 15 s watchdog
///   3. frontend listener → `webview_account_reveal`
#[tauri::command]
pub async fn webview_account_reveal<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: RevealArgs,
) -> Result<(), String> {
    let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
    let Some(label) = label_opt else {
        // Reveal race: the webview was closed before the load event arrived.
        // Return Ok so the frontend doesn't surface an error.
        log::debug!(
            "[webview-accounts] reveal: no webview for account {}",
            args.account_id
        );
        return Ok(());
    };
    let wv = app
        .get_webview(&label)
        .ok_or_else(|| format!("webview {label} missing"))?;
    wv.set_position(LogicalPosition::new(args.bounds.x, args.bounds.y))
        .map_err(|e| format!("set_position: {e}"))?;
    wv.set_size(LogicalSize::new(args.bounds.width, args.bounds.height))
        .map_err(|e| format!("set_size: {e}"))?;
    wv.show().map_err(|e| format!("show: {e}"))?;
    state
        .requested_bounds
        .lock()
        .unwrap()
        .insert(args.account_id.clone(), args.bounds);
    let provider = state
        .account_providers
        .lock()
        .unwrap()
        .get(&args.account_id)
        .cloned()
        .unwrap_or_else(|| "unknown".to_string());
    let elapsed_ms = state
        .spawn_started_at
        .lock()
        .unwrap()
        .remove(&args.account_id)
        .map(|started| started.elapsed().as_millis())
        .map(|ms| ms.to_string())
        .unwrap_or_else(|| "unknown".to_string());
    let trigger = RevealTrigger::from_ipc(args.trigger.as_deref()).as_str();
    log::info!(
        "[webview-accounts][{}][{}] reveal trigger={} elapsed_ms={} bounds={:?}",
        provider,
        args.account_id,
        trigger,
        elapsed_ms,
        args.bounds
    );
    Ok(())
}

#[tauri::command]
pub async fn webview_account_hide<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: AccountIdArgs,
) -> Result<(), String> {
    let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
    let Some(label) = label_opt else {
        return Ok(());
    };
    if let Some(wv) = app.get_webview(&label) {
        let _ = wv.hide();
        log::debug!("[webview-accounts] hide label={}", label);
    }
    Ok(())
}

#[tauri::command]
pub async fn webview_account_show<R: Runtime>(
    app: AppHandle<R>,
    state: tauri::State<'_, WebviewAccountsState>,
    args: AccountIdArgs,
) -> Result<(), String> {
    let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
    let Some(label) = label_opt else {
        return Ok(());
    };
    if let Some(wv) = app.get_webview(&label) {
        let _ = wv.show();
        log::debug!("[webview-accounts] show label={}", label);
    }
    Ok(())
}

/// Web-shape notification permission state used by frontend parity code.
/// Effectively granted because interception is handled in-app via CEF.
#[tauri::command]
pub fn webview_notification_permission_state() -> String {
    "granted".to_string()
}

/// Request notification permission and return web-shape state.
#[tauri::command]
pub fn webview_notification_permission_request() -> String {
    webview_notification_permission_state()
}

/// Enable/disable global DND for embedded webview OS toasts.
#[tauri::command]
pub fn webview_notification_set_dnd(
    state: tauri::State<'_, WebviewAccountsState>,
    enabled: bool,
) -> Result<(), String> {
    let mut prefs = state.notification_bypass.lock().unwrap();
    prefs.global_dnd = enabled;
    log::debug!("[notify-bypass] set global_dnd={enabled}");
    Ok(())
}

/// Mute/unmute a specific embedded account for OS toasts.
#[tauri::command]
pub fn webview_notification_mute_account(
    state: tauri::State<'_, WebviewAccountsState>,
    account_id: String,
    muted: bool,
) -> Result<(), String> {
    let account_id = sanitize_account_id(&account_id)?.to_string();
    let mut prefs = state.notification_bypass.lock().unwrap();
    if muted {
        prefs.muted_accounts.insert(account_id.clone());
    } else {
        prefs.muted_accounts.remove(&account_id);
    }
    log::debug!(
        "[notify-bypass] set muted account_id={} muted={}",
        account_id,
        muted
    );
    Ok(())
}

/// Return current bypass preferences for the settings UI.
#[tauri::command]
pub fn webview_notification_get_bypass_prefs(
    state: tauri::State<'_, WebviewAccountsState>,
) -> NotificationBypassPrefsPayload {
    let prefs = state.notification_bypass.lock().unwrap();
    NotificationBypassPrefsPayload::from(&*prefs)
}

/// Track which account is currently focused in the shell UI.
#[tauri::command]
pub fn webview_set_focused_account(
    state: tauri::State<'_, WebviewAccountsState>,
    account_id: Option<String>,
) -> Result<(), String> {
    let mut prefs = state.notification_bypass.lock().unwrap();
    prefs.focused_account = match account_id {
        Some(id) => Some(sanitize_account_id(&id)?.to_string()),
        None => None,
    };
    log::debug!(
        "[notify-bypass] set focused_account={}",
        prefs.focused_account.as_deref().unwrap_or("<none>")
    );
    Ok(())
}

/// Called from the injected runtime each time the recipe emits an event.
/// We forward to React via a Tauri event so the UI can render and persist.
#[tauri::command]
pub async fn webview_recipe_event<R: Runtime>(
    app: AppHandle<R>,
    webview: tauri::Webview<R>,
    args: RecipeEventArgs,
) -> Result<(), String> {
    // The event can only be trusted if the invoking webview is the
    // `acct_<account_id>` webview for the account in the payload. A
    // compromised renderer or a sibling child webview must not be able to
    // forge events for another account.
    let caller_label = webview.label().to_string();
    let expected_label = label_for(&args.account_id);
    if caller_label != expected_label {
        log::warn!(
            "[webview-accounts] recipe_event rejected: caller_label={} expected={} account={}",
            caller_label,
            expected_label,
            args.account_id
        );
        return Err("webview label does not match account_id".to_string());
    }
    log::debug!(
        "[webview-accounts] recipe_event account={} provider={} kind={}",
        args.account_id,
        args.provider,
        args.kind
    );
    if args.provider == "google-meet" {
        match args.kind.as_str() {
            "meet_call_started" => {
                let code = args
                    .payload
                    .get("code")
                    .and_then(|v| v.as_str())
                    .unwrap_or("?");
                log::info!("[gmeet][{}] call_started code={}", args.account_id, code);
            }
            "meet_captions" => {
                let code = args
                    .payload
                    .get("code")
                    .and_then(|v| v.as_str())
                    .unwrap_or("?");
                let n = args
                    .payload
                    .get("captions")
                    .and_then(|v| v.as_array())
                    .map(|a| a.len())
                    .unwrap_or(0);
                log::info!(
                    "[gmeet][{}] captions code={} rows={}",
                    args.account_id,
                    code,
                    n
                );
            }
            "meet_call_ended" => {
                let code = args
                    .payload
                    .get("code")
                    .and_then(|v| v.as_str())
                    .unwrap_or("?");
                let reason = args
                    .payload
                    .get("reason")
                    .and_then(|v| v.as_str())
                    .unwrap_or("unknown");
                log::info!(
                    "[gmeet][{}] call_ended code={} reason={}",
                    args.account_id,
                    code,
                    reason
                );
            }
            _ => {}
        }
    }
    if args.kind == "ingest" {
        if let Some(messages) = args.payload.get("messages").and_then(|v| v.as_array()) {
            log::info!(
                "[webview-accounts] ingest from acct_{}: {} messages",
                args.account_id,
                messages.len()
            );
        }
    } else if args.kind == "ws_message" {
        let direction = args
            .payload
            .get("direction")
            .and_then(|v| v.as_str())
            .unwrap_or("?");
        let size = args
            .payload
            .get("size")
            .and_then(|v| v.as_i64())
            .unwrap_or(0);
        log::trace!(
            "[webview-accounts][{}] ws {} {} bytes",
            args.account_id,
            direction,
            size
        );
    } else if args.kind == "log" {
        let level = args
            .payload
            .get("level")
            .and_then(|v| v.as_str())
            .unwrap_or("info");
        let msg = args
            .payload
            .get("msg")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        match level {
            "warn" => log::warn!("[webview-accounts][{}] {}", args.account_id, msg),
            "error" => log::error!("[webview-accounts][{}] {}", args.account_id, msg),
            _ => log::info!("[webview-accounts][{}] {}", args.account_id, msg),
        }
    }

    if let Err(err) = post_provider_surfaces_event(&args).await {
        log::warn!(
            "[webview-accounts] provider_surfaces ingest failed account={} provider={} kind={}: {}",
            args.account_id,
            args.provider,
            args.kind,
            err
        );
    }

    let event = WebviewEvent {
        account_id: args.account_id,
        provider: args.provider,
        kind: args.kind,
        payload: args.payload,
        ts: args.ts,
    };
    app.emit("webview:event", &event)
        .map_err(|e| format!("emit failed: {e}"))?;
    Ok(())
}

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

    fn url(s: &str) -> Url {
        Url::parse(s).expect("valid url")
    }

    #[test]
    fn reveal_trigger_from_ipc_warns_and_defaults_unknown_to_load() {
        assert_eq!(RevealTrigger::from_ipc(None), RevealTrigger::Load);
        assert_eq!(RevealTrigger::from_ipc(Some("load")), RevealTrigger::Load);
        assert_eq!(
            RevealTrigger::from_ipc(Some("watchdog")),
            RevealTrigger::Watchdog
        );
        assert_eq!(
            RevealTrigger::from_ipc(Some("watchdog-typo")),
            RevealTrigger::Load
        );
    }

    // ── shutdown teardown ──────────────────────────────────

    /// Smoke-test [`WebviewAccountsState::drain_for_shutdown`] in isolation
    /// from the Tauri runtime. Populates the state with representative
    /// per-account resources (CDP / watchdog `JoinHandle`s, a CEF browser
    /// id, an `acct_*` label, plus the small bookkeeping sets) and asserts
    /// that one call drains every collection and aborts the long-running
    /// tasks, that the returned label list is what `shutdown_all` will
    /// `wv.close()` against, and that a second call is a safe no-op.
    ///
    /// `shutdown_all` itself takes an `AppHandle` and is exercised end-to-
    /// end at runtime; the inner `drain_for_shutdown` covers the part of
    /// the teardown that doesn't need a Tauri runtime to verify.
    #[tokio::test]
    async fn drain_for_shutdown_clears_state_and_repeat_is_noop() {
        use std::time::Duration;

        let state = WebviewAccountsState::default();

        let cdp_task = tokio::spawn(async {
            tokio::time::sleep(Duration::from_secs(60)).await;
        });
        let cdp_abort = cdp_task.abort_handle();
        let watchdog_task = tokio::spawn(async {
            tokio::time::sleep(Duration::from_secs(60)).await;
        });
        let watchdog_abort = watchdog_task.abort_handle();

        state
            .cdp_sessions
            .lock()
            .unwrap()
            .insert("acct-1".into(), cdp_task);
        state
            .load_watchdogs
            .lock()
            .unwrap()
            .insert("acct-1".into(), watchdog_task);
        state
            .browser_ids
            .lock()
            .unwrap()
            .insert("acct-1".into(), 42);
        state
            .inner
            .lock()
            .unwrap()
            .insert("acct-1".into(), "acct_1".into());
        state
            .account_providers
            .lock()
            .unwrap()
            .insert("acct-1".into(), "slack".into());
        state
            .loaded_accounts
            .lock()
            .unwrap()
            .insert("acct-1".into());
        state.requested_bounds.lock().unwrap().insert(
            "acct-1".into(),
            Bounds {
                x: 0.0,
                y: 0.0,
                width: 800.0,
                height: 600.0,
            },
        );
        state
            .spawn_started_at
            .lock()
            .unwrap()
            .insert("acct-1".into(), Instant::now());
        // Saturate the gmeet rewrite counter so we can assert it gets
        // cleared by drain (otherwise the next reopen would inherit a
        // stale entry — `label_for()` reuses the same label).
        for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS {
            let _ = state.track_gmeet_marketing_rewrite("acct_1", Instant::now());
        }
        assert!(!state.gmeet_marketing_rewrites.lock().unwrap().is_empty());

        let labels = state.drain_for_shutdown();
        tokio::task::yield_now().await;

        assert_eq!(
            labels,
            vec![("acct-1".to_string(), "acct_1".to_string())],
            "shutdown_all should close the acct_* webview returned here"
        );
        assert!(cdp_abort.is_finished(), "CDP session task was aborted");
        assert!(
            watchdog_abort.is_finished(),
            "load watchdog task was aborted"
        );
        assert!(state.cdp_sessions.lock().unwrap().is_empty());
        assert!(state.load_watchdogs.lock().unwrap().is_empty());
        assert!(state.browser_ids.lock().unwrap().is_empty());
        assert!(state.inner.lock().unwrap().is_empty());
        assert!(state.account_providers.lock().unwrap().is_empty());
        assert!(state.loaded_accounts.lock().unwrap().is_empty());
        assert!(state.requested_bounds.lock().unwrap().is_empty());
        assert!(state.spawn_started_at.lock().unwrap().is_empty());
        assert!(
            state.gmeet_marketing_rewrites.lock().unwrap().is_empty(),
            "gmeet rewrite counter must clear on drain so reopens don't inherit stale entries"
        );

        // Second call must be a safe no-op: nothing left to drain.
        let labels2 = state.drain_for_shutdown();
        assert!(labels2.is_empty());
        assert!(state.cdp_sessions.lock().unwrap().is_empty());
        assert!(state.inner.lock().unwrap().is_empty());
        assert!(state.account_providers.lock().unwrap().is_empty());
    }

    // ── provider registry match arms ──────────────────────────────────

    #[test]
    fn zoom_registered_in_provider_url() {
        assert_eq!(provider_url("zoom"), Some("https://zoom.us/"));
    }

    #[test]
    fn zoom_has_no_recipe_js_injection() {
        // Per the CLAUDE.md "no new JS injection" rule for CEF child
        // webviews, Zoom must rely solely on Rust `on_navigation` +
        // `on_new_window` (plus CDP from scanner modules, if any) — no
        // `recipe.js` should be registered.
        assert!(provider_recipe_js("zoom").is_none());
    }

    #[test]
    fn zoom_allowed_hosts_covers_core_domains() {
        let hosts = provider_allowed_hosts("zoom");
        assert!(hosts.contains(&"zoom.us"), "zoom.us in allowlist");
        assert!(hosts.contains(&"zoomgov.com"), "zoomgov.com in allowlist");
        assert!(hosts.contains(&"zdassets.com"), "zdassets.com in allowlist");
    }

    #[test]
    fn zoom_allowed_hosts_covers_google_oauth() {
        // Zoom's "Sign in with Google" reroutes the popup into the
        // embedded webview (see popup_should_navigate_parent). The
        // resulting accounts.google.com / oauth2.googleapis.com /
        // www.googleapis.com hops MUST be classified internal so the
        // auth chain doesn't escape to the system browser mid-flight
        // and trigger Zoom error 300 (#1294).
        assert!(url_is_internal(
            "zoom",
            &url("https://accounts.google.com/v3/signin/identifier"),
        ));
        assert!(url_is_internal(
            "zoom",
            &url("https://oauth2.googleapis.com/token"),
        ));
        assert!(url_is_internal(
            "zoom",
            &url("https://www.googleapis.com/oauth2/v3/userinfo"),
        ));
    }

    #[test]
    fn zoom_supports_google_sso() {
        // Zoom's web client offers "Sign in with Google" via a popup
        // window.open("https://accounts.google.com/..."). The popup
        // gate at popup_should_navigate_parent gates on this helper —
        // without zoom listed the popup falls through to the system
        // browser and breaks the auth callback (#1294).
        assert!(provider_supports_google_sso("zoom"));
    }

    #[test]
    fn zoom_popup_navigates_parent_for_google_sso() {
        // Mirror of slack_google_signin_popup_navigates_parent —
        // clicking "Sign in with Google" inside Zoom MUST replace the
        // parent webview's URL instead of escaping to the system
        // browser, so the Google session cookie lands in the per-account
        // CEF profile (#1294).
        assert_eq!(
            popup_should_navigate_parent(
                "zoom",
                &url("https://accounts.google.com/v3/signin/identifier"),
            )
            .map(|u| u.to_string()),
            Some("https://accounts.google.com/v3/signin/identifier".to_string())
        );
    }

    // ── LinkedIn Google SSO (issue #1021) ──────────────────────────────

    #[test]
    fn linkedin_supports_google_sso() {
        // LinkedIn's "Sign in with Google" button must be handled in-app;
        // without linkedin in provider_supports_google_sso the popup falls
        // through to the system browser, which opens blank (#1021).
        assert!(provider_supports_google_sso("linkedin"));
    }

    #[test]
    fn linkedin_allowed_hosts_cover_google_oauth() {
        // Google auth chain hops through oauth2.googleapis.com and
        // www.googleapis.com which are not Google SSO hosts and must be
        // present in the explicit allowlist so mid-flight redirects don't
        // leak to the system browser.
        let hosts = provider_allowed_hosts("linkedin");
        for host in [
            "accounts.google.com",
            "accounts.googleusercontent.com",
            "ssl.gstatic.com",
            "fonts.gstatic.com",
            "lh3.googleusercontent.com",
            "oauth2.googleapis.com",
            "www.googleapis.com",
        ] {
            assert!(hosts.contains(&host), "{host} in LinkedIn allowlist");
        }
    }

    #[test]
    fn linkedin_google_signin_popup_navigates_parent() {
        // Clicking "Sign in with Google" on LinkedIn's login page issues a
        // window.open to accounts.google.com/signin/... — must navigate the
        // parent in-app instead of opening the system browser (#1021).
        assert_eq!(
            popup_should_navigate_parent(
                "linkedin",
                &url("https://accounts.google.com/v3/signin/identifier"),
            )
            .map(|u| u.to_string()),
            Some("https://accounts.google.com/v3/signin/identifier".to_string())
        );
    }

    #[test]
    fn linkedin_google_oauth2_popup_navigates_parent() {
        // LinkedIn may issue window.open to the initial OAuth2 auth
        // endpoint (/o/oauth2/v2/auth) which doesn't contain "signin"
        // in the path — must still be caught and routed in-app (#1021).
        assert!(popup_should_navigate_parent(
            "linkedin",
            &url("https://accounts.google.com/o/oauth2/v2/auth?client_id=x&redirect_uri=https://www.linkedin.com/..."),
        )
        .is_some());
    }

    #[test]
    fn linkedin_google_sso_navigation_is_internal() {
        // Direct (non-popup) navigation to accounts.google.com during the
        // LinkedIn Google SSO flow must be classified internal so it stays
        // in the embedded webview.
        assert!(url_is_internal(
            "linkedin",
            &url("https://accounts.google.com/v3/signin/identifier"),
        ));
        assert!(url_is_internal(
            "linkedin",
            &url("https://accounts.youtube.com/accounts/SetSID?..."),
        ));
    }

    #[test]
    fn linkedin_own_domain_still_internal() {
        assert!(url_is_internal(
            "linkedin",
            &url("https://www.linkedin.com/messaging/"),
        ));
        assert!(url_is_internal(
            "linkedin",
            &url("https://media.licdn.com/dms/image/foo.jpg"),
        ));
    }

    #[test]
    fn linkedin_unrelated_popup_still_goes_to_system_browser() {
        // Non-Google external links from LinkedIn must still route out.
        assert!(
            popup_should_navigate_parent("linkedin", &url("https://example.com/blog"),).is_none()
        );
        assert!(!popup_should_stay_in_app(
            "linkedin",
            &url("https://example.com/blog"),
        ));
    }

    #[test]
    fn linkedin_gsi_popup_stays_in_app() {
        // LinkedIn's "Sign in with Google" uses the Google Identity Services
        // (GSI) library. The GSI button iframe (accounts.google.com/gsi/button)
        // calls window.open("accounts.google.com/gsi/select?...") to show the
        // account chooser. This popup must be an in-app child window — NOT sent
        // to the system browser (blank screen) and NOT a parent navigation (the
        // postMessage credential callback would have no opener to reach) (#1021).
        assert!(popup_should_stay_in_app(
            "linkedin",
            &url(
                "https://accounts.google.com/gsi/select?client_id=990339570472-k6nq&ux_mode=popup"
            ),
        ));
        assert!(popup_should_stay_in_app(
            "linkedin",
            &url("https://accounts.google.com/gsi/issue?client_id=x"),
        ));
    }

    #[test]
    fn linkedin_gsi_popup_does_not_navigate_parent() {
        // The GSI account-chooser popup must NOT navigate the parent — it needs
        // to remain a child popup for postMessage to work.
        assert!(popup_should_navigate_parent(
            "linkedin",
            &url("https://accounts.google.com/gsi/select?client_id=x"),
        )
        .is_none());
    }

    #[test]
    fn slack_allowed_hosts_include_google_oauth() {
        let hosts = provider_allowed_hosts("slack");
        for host in [
            "accounts.google.com",
            "accounts.googleusercontent.com",
            "ssl.gstatic.com",
            "fonts.gstatic.com",
            "lh3.googleusercontent.com",
            "oauth2.googleapis.com",
            "www.googleapis.com",
        ] {
            assert!(hosts.contains(&host), "{host} in Slack allowlist");
        }
    }

    #[test]
    fn slack_allowed_hosts_still_internal_for_slack_origins() {
        assert!(url_is_internal(
            "slack",
            &url("https://app.slack.com/client/T123/C456"),
        ));
        assert!(url_is_internal(
            "slack",
            &url("https://a.slack-edge.com/bv1/app.js"),
        ));
        assert!(url_is_internal(
            "slack",
            &url("https://wss-primary.slack.com/?ticket=redacted"),
        ));
    }

    #[test]
    fn slack_allowed_hosts_do_not_bare_allow_google() {
        let hosts = provider_allowed_hosts("slack");
        assert!(
            !hosts.contains(&"google.com"),
            "bare google.com not allowed"
        );
        assert!(!hosts.contains(&"googleusercontent.com"));
        assert!(!hosts.contains(&"gstatic.com"));
        assert!(!hosts.contains(&"googleapis.com"));

        assert!(url_is_internal(
            "slack",
            &url("https://accounts.google.com/v3/signin/identifier"),
        ));
        assert!(!url_is_internal("slack", &url("https://google.com/")));
        assert!(!url_is_internal("slack", &url("https://mail.google.com/")));
        assert!(!url_is_internal("slack", &url("https://apis.google.com/")));
    }

    #[test]
    fn zoom_is_supported() {
        assert!(provider_is_supported("zoom"));
    }

    // ── url_is_internal: subdomain + exact match ──────────────────────

    #[test]
    fn zoom_web_client_subdomain_is_internal() {
        assert!(url_is_internal(
            "zoom",
            &url("https://app.zoom.us/wc/join/123")
        ));
    }

    #[test]
    fn zoom_apex_domain_is_internal() {
        assert!(url_is_internal("zoom", &url("https://zoom.us/signin")));
    }

    #[test]
    fn zoom_external_host_is_not_internal() {
        assert!(!url_is_internal(
            "zoom",
            &url("https://unrelated.example.com/")
        ));
    }

    // ── rewrite_provider_deep_link: Zoom flows ────────────────────────

    #[test]
    fn rewrite_join_flow_with_confno() {
        let rewritten = rewrite_provider_deep_link(
            "zoom",
            &url("zoomus://zoom.us/join?action=join&confno=9819254358"),
        )
        .expect("rewrite should succeed");
        assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358");
    }

    #[test]
    fn rewrite_start_flow_with_confno() {
        let rewritten = rewrite_provider_deep_link(
            "zoom",
            &url("zoomus://zoom.us/start?action=start&confno=86449940711"),
        )
        .expect("rewrite should succeed");
        assert_eq!(
            rewritten.as_str(),
            "https://app.zoom.us/wc/join/86449940711"
        );
    }

    #[test]
    fn rewrite_preserves_pwd_query_param() {
        let rewritten = rewrite_provider_deep_link(
            "zoom",
            &url("zoomus://zoom.us/join?action=join&confno=111&pwd=secret"),
        )
        .expect("rewrite should succeed");
        assert_eq!(
            rewritten.as_str(),
            "https://app.zoom.us/wc/join/111?pwd=secret"
        );
    }

    #[test]
    fn rewrite_falls_back_to_tk_when_pwd_absent() {
        let rewritten = rewrite_provider_deep_link(
            "zoom",
            &url("zoommtg://zoom.us/join?confno=222&tk=tokenvalue"),
        )
        .expect("rewrite should succeed");
        assert_eq!(
            rewritten.as_str(),
            "https://app.zoom.us/wc/join/222?pwd=tokenvalue"
        );
    }

    #[test]
    fn rewrite_accepts_zoommtg_scheme() {
        let rewritten = rewrite_provider_deep_link(
            "zoom",
            &url("zoommtg://zoom.us/join?action=join&confno=333"),
        )
        .expect("rewrite should succeed");
        assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/333");
    }

    #[test]
    fn rewrite_without_confno_falls_back_to_home() {
        let rewritten =
            rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/home?action=home"))
                .expect("rewrite should succeed");
        assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home");
    }

    #[test]
    fn rewrite_with_empty_confno_falls_back_to_home() {
        let rewritten =
            rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/join?action=join&confno="))
                .expect("rewrite should succeed");
        assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home");
    }

    #[test]
    fn rewrite_rejects_non_zoom_provider() {
        assert!(rewrite_provider_deep_link(
            "slack",
            &url("zoomus://zoom.us/join?action=join&confno=444")
        )
        .is_none());
        assert!(rewrite_provider_deep_link(
            "google-meet",
            &url("zoomus://zoom.us/join?action=join&confno=555")
        )
        .is_none());
    }

    #[test]
    fn rewrite_rejects_http_zoom_url() {
        // Ordinary https zoom.us navigations must pass through untouched so
        // the existing `url_is_internal` flow decides.
        assert!(rewrite_provider_deep_link("zoom", &url("https://zoom.us/j/9819254358")).is_none());
    }

    #[test]
    fn rewrite_rejects_unknown_scheme() {
        assert!(rewrite_provider_deep_link(
            "zoom",
            &url("msteams://teams.microsoft.com/l/meetup-join/666")
        )
        .is_none());
    }

    // ── is_provider_native_deep_link_scheme: native-app suppression ───
    //
    // These guard the workspace-isolation contract from #1074: provider
    // native-desktop-app deep-link schemes must NEVER reach the system
    // browser, because macOS hands them off to the native provider app
    // which then signs the user into the workspace using session tokens
    // intended only for the embedded webview (see slack://magic-login
    // smoking gun in the #1074 trace).

    #[test]
    fn deep_link_scheme_matches_known_provider_native_apps() {
        // Slack desktop ("slack://T01.../magic-login/<token>")
        assert!(is_provider_native_deep_link_scheme("slack"));
        // Discord desktop
        assert!(is_provider_native_deep_link_scheme("discord"));
        // Telegram desktop ("tg://join?invite=…")
        assert!(is_provider_native_deep_link_scheme("tg"));
        // Microsoft Teams
        assert!(is_provider_native_deep_link_scheme("msteams"));
        // Zoom client (both variants registered by the installer)
        assert!(is_provider_native_deep_link_scheme("zoomus"));
        assert!(is_provider_native_deep_link_scheme("zoommtg"));
    }

    #[test]
    fn deep_link_scheme_rejects_legitimate_external_schemes() {
        // HTTP(S) — the bread-and-butter external link.
        assert!(!is_provider_native_deep_link_scheme("https"));
        assert!(!is_provider_native_deep_link_scheme("http"));
        // Mail clients are legit external — must NOT be suppressed.
        assert!(!is_provider_native_deep_link_scheme("mailto"));
        // Telephone / sms are legit external too.
        assert!(!is_provider_native_deep_link_scheme("tel"));
        assert!(!is_provider_native_deep_link_scheme("sms"));
        // about: / data: / blob: handled elsewhere; never deep-link.
        assert!(!is_provider_native_deep_link_scheme("about"));
        assert!(!is_provider_native_deep_link_scheme("data"));
        assert!(!is_provider_native_deep_link_scheme("blob"));
        // Empty / unrelated string.
        assert!(!is_provider_native_deep_link_scheme(""));
        assert!(!is_provider_native_deep_link_scheme("file"));
    }

    #[test]
    fn deep_link_scheme_matches_real_world_slack_magic_login_url() {
        // Real slack://-flavoured magic-login URL recorded in the
        // #1074 CDP trace. The handler must catch it before
        // open_in_system_browser is reached.
        let parsed = url("slack://T01CWHNCJ9Z/magic-login/11035712490054-abc");
        assert!(is_provider_native_deep_link_scheme(parsed.scheme()));
    }

    #[test]
    fn deep_link_scheme_does_not_match_https_app_slack_com() {
        // The web-flow URL stays untouched — only the slack:// scheme is
        // suppressed; ordinary HTTPS slack navigations route normally.
        let parsed = url("https://app.slack.com/client/T01CWHNCJ9Z");
        assert!(!is_provider_native_deep_link_scheme(parsed.scheme()));
    }

    /// Locks the contract that zoomus:// stays on the rewrite path
    /// (handled by `rewrite_provider_deep_link` for the "zoom" provider)
    /// rather than being silently suppressed.
    ///
    /// The wiring in on_navigation / on_new_window calls
    /// `rewrite_provider_deep_link` BEFORE the suppress check, so a
    /// rewriteable scheme is rewritten and never reaches the suppress
    /// branch. This test pins both halves of that contract: the rewrite
    /// still succeeds for zoom, AND the scheme is recognised as a
    /// native-app deep-link (so if a future provider config dropped the
    /// rewrite, suppression would be the safe fallback rather than
    /// leaking to the system browser).
    #[test]
    fn zoomus_join_still_rewrites_and_is_recognized_as_native_scheme() {
        let zoom_url = url("zoomus://zoom.us/join?action=join&confno=9819254358");
        assert!(is_provider_native_deep_link_scheme(zoom_url.scheme()));
        let rewritten = rewrite_provider_deep_link("zoom", &zoom_url)
            .expect("zoom rewrite should still succeed before suppress branch");
        assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358");
    }

    #[test]
    fn rewrite_percent_encodes_reserved_chars_in_pwd() {
        // Zoom tokens commonly contain `&` / `=` / `%` / `#` / `+` which
        // would corrupt a hand-rolled format!() URL. The `Url`-based
        // builder must percent-encode them.
        let rewritten = rewrite_provider_deep_link(
            "zoom",
            &url("zoomus://zoom.us/join?action=join&confno=777&pwd=a%26b%3Dc"),
        )
        .expect("rewrite should succeed");
        // `url::Url` round-trips the encoded `%26` (`&`) and `%3D` (`=`)
        // back into the rewritten query.
        assert!(
            rewritten.as_str().contains("pwd=a%26b%3Dc"),
            "expected encoded pwd, got {}",
            rewritten.as_str()
        );
    }

    #[test]
    fn rewrite_percent_encodes_confno_segment() {
        // Defensive — path segments never should carry reserved chars but
        // the helper must not corrupt them if they do.
        let rewritten = rewrite_provider_deep_link(
            "zoom",
            &url("zoomus://zoom.us/join?action=join&confno=abc%2Fdef"),
        )
        .expect("rewrite should succeed");
        // `/` inside the id must be percent-encoded, not merged into the path.
        assert!(
            rewritten.path().ends_with("/abc%2Fdef"),
            "expected encoded path segment, got {}",
            rewritten.path()
        );
    }

    // ── popup_should_stay_in_app: Zoom WebClient popups ───────────────

    #[test]
    fn zoom_webclient_popup_stays_in_app() {
        assert!(popup_should_stay_in_app(
            "zoom",
            &url("https://app.zoom.us/wc/join/999")
        ));
    }

    #[test]
    fn zoom_apex_webclient_popup_stays_in_app() {
        assert!(popup_should_stay_in_app(
            "zoom",
            &url("https://zoom.us/wc/join/999")
        ));
    }

    #[test]
    fn zoom_non_wc_popup_does_not_stay_in_app() {
        // Marketing / blog / download-link popups should hand off to the
        // system browser, not grow an in-app child window.
        assert!(!popup_should_stay_in_app(
            "zoom",
            &url("https://zoom.us/about")
        ));
    }

    #[test]
    fn zoom_popup_to_foreign_host_does_not_stay_in_app() {
        assert!(!popup_should_stay_in_app(
            "zoom",
            &url("https://example.com/wc/join/888")
        ));
    }

    // ── popup_should_navigate_parent: Google-auth popups ──────────────

    #[test]
    fn unsupported_provider_popup_does_not_navigate_parent() {
        // Only providers that explicitly support Google SSO opt into
        // the popup-takeover path. Every other provider (and any unknown
        // string) must fall through to the default popup handling.
        assert!(popup_should_navigate_parent(
            "discord",
            &url("https://accounts.google.com/signin/v2/identifier"),
        )
        .is_none());
        assert!(popup_should_navigate_parent(
            "whatsapp",
            &url("https://accounts.google.com/signin/v2/identifier"),
        )
        .is_none());
        assert!(popup_should_navigate_parent(
            "unknown-provider",
            &url("https://accounts.google.com/signin/v2/identifier"),
        )
        .is_none());
    }

    #[test]
    fn google_meet_accounts_popup_navigates_parent() {
        assert!(popup_should_navigate_parent(
            "google-meet",
            &url("https://accounts.google.com/signin/v2/identifier"),
        )
        .is_some());
    }

    #[test]
    fn slack_google_signin_popup_navigates_parent() {
        assert_eq!(
            popup_should_navigate_parent(
                "slack",
                &url("https://accounts.google.com/v3/signin/identifier"),
            )
            .map(|u| u.to_string()),
            Some("https://accounts.google.com/v3/signin/identifier".to_string())
        );
    }

    #[test]
    fn slack_about_blank_popup_does_not_navigate_parent() {
        assert!(popup_should_navigate_parent("slack", &url("about:blank")).is_none());
    }

    #[test]
    fn slack_same_origin_popup_does_not_navigate_parent() {
        assert!(popup_should_navigate_parent(
            "slack",
            &url("https://app.slack.com/client/T123/C456"),
        )
        .is_none());
    }

    #[test]
    fn slack_unrelated_popup_does_not_navigate_parent() {
        assert!(popup_should_navigate_parent("slack", &url("https://example.com/blog"),).is_none());
    }

    #[test]
    fn slack_meet_google_com_popup_does_not_navigate_parent() {
        assert!(popup_should_navigate_parent(
            "slack",
            &url("https://meet.google.com/abc-defg-hij"),
        )
        .is_none());
    }

    #[test]
    fn gmeet_room_popup_navigates_parent() {
        // "Start an instant meeting" / "New meeting" calls
        // window.open(meet.google.com/<roomid>) to launch a room.
        // Without intervention this would route to system Chrome and
        // leak the meeting out of OpenHuman.
        assert_eq!(
            popup_should_navigate_parent(
                "google-meet",
                &url("https://meet.google.com/abc-defg-hij"),
            )
            .map(|u| u.to_string()),
            Some("https://meet.google.com/abc-defg-hij".to_string())
        );
    }

    #[test]
    fn gmeet_landing_popup_navigates_parent() {
        // Bare meet.google.com (no room code) should also be kept
        // in-app — matches the "back to Meet home" UX after hangup.
        assert!(
            popup_should_navigate_parent("google-meet", &url("https://meet.google.com/"),)
                .is_some()
        );
    }

    #[test]
    fn gmeet_workspace_popup_does_not_navigate_parent() {
        // workspace.google.com is the marketing page; if it ever
        // arrives via window.open() we let the default external-route
        // logic handle it (covered in the on_navigation rewrite path
        // separately).
        assert!(popup_should_navigate_parent(
            "google-meet",
            &url("https://workspace.google.com/products/meet/"),
        )
        .is_none());
    }

    #[test]
    fn gmeet_unrelated_popup_does_not_navigate_parent() {
        // External link in the post-call review screen, for instance.
        // Should NOT navigate the parent — should fall through to the
        // system-browser path.
        assert!(
            popup_should_navigate_parent("google-meet", &url("https://example.com/blog"),)
                .is_none()
        );
    }

    // ── provider_supports_google_sso ───────────────────────────────────

    #[test]
    fn provider_supports_google_sso_matrix() {
        assert!(provider_supports_google_sso("google-meet"));
        assert!(provider_supports_google_sso("slack"));
        assert!(provider_supports_google_sso("zoom"));
        assert!(provider_supports_google_sso("linkedin"));
        assert!(!provider_supports_google_sso("whatsapp"));
        assert!(!provider_supports_google_sso("telegram"));
        assert!(!provider_supports_google_sso("discord"));
        assert!(!provider_supports_google_sso("browserscan"));
        assert!(!provider_supports_google_sso(""));
        assert!(!provider_supports_google_sso("unknown-provider"));
    }

    #[test]
    fn google_meet_service_login_popup_navigates_parent() {
        assert_eq!(
            popup_should_navigate_parent(
                "google-meet",
                &url("https://accounts.google.com/ServiceLogin?continue=https://meet.google.com"),
            )
            .map(|u| u.to_string()),
            Some(
                "https://accounts.google.com/ServiceLogin?continue=https://meet.google.com"
                    .to_string()
            )
        );
    }

    #[test]
    fn redact_navigation_url_strips_query_and_fragment() {
        let redacted = redact_navigation_url(&url(
            "https://accounts.google.com/o/oauth2/v2/auth?code=secret#frag",
        ));
        assert_eq!(redacted, "https://accounts.google.com/o/oauth2/v2/auth");
    }

    // ── purge_data_dir_with_retry ──────────────────────────────────

    #[tokio::test]
    async fn purge_data_dir_with_retry_noop_when_missing() {
        let dir = std::env::temp_dir().join(format!("openhuman-purge-noop-{}", std::process::id()));
        // Sanity: dir must NOT exist
        let _ = std::fs::remove_dir_all(&dir);
        assert!(!dir.exists());

        // Should return without error or panic.
        purge_data_dir_with_retry(&dir)
            .await
            .expect("missing dir should be treated as success");

        assert!(!dir.exists());
    }

    #[tokio::test]
    async fn purge_data_dir_with_retry_removes_existing_dir() {
        let dir = std::env::temp_dir().join(format!(
            "openhuman-purge-existing-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        std::fs::create_dir_all(dir.join("nested/dir")).expect("create test dir");
        std::fs::write(dir.join("cookies.json"), b"{\"sid\":\"abc\"}")
            .expect("write test cookie file");
        std::fs::write(dir.join("nested/dir/local.storage"), b"key=value")
            .expect("write nested file");
        assert!(dir.exists());

        purge_data_dir_with_retry(&dir)
            .await
            .expect("existing dir should be removed");

        assert!(!dir.exists(), "data dir should be removed");
    }

    // ── track_gmeet_marketing_rewrite ──────────────────────────────

    #[test]
    fn gmeet_rewrite_allowed_under_threshold() {
        let state = WebviewAccountsState::default();
        let label = "acct_test";
        let now = Instant::now();
        for i in 1..=GMEET_REWRITE_MAX_ATTEMPTS {
            assert_eq!(
                state.track_gmeet_marketing_rewrite(label, now),
                GmeetRewriteAction::Rewrite,
                "attempt {} should still rewrite",
                i
            );
        }
    }

    #[test]
    fn gmeet_rewrite_bails_after_threshold() {
        let state = WebviewAccountsState::default();
        let label = "acct_test";
        let now = Instant::now();
        for _ in 0..GMEET_REWRITE_MAX_ATTEMPTS {
            let _ = state.track_gmeet_marketing_rewrite(label, now);
        }
        // Next call exceeds the threshold within the window — must bail.
        assert_eq!(
            state.track_gmeet_marketing_rewrite(label, now),
            GmeetRewriteAction::Bail
        );
    }

    #[test]
    fn gmeet_rewrite_resets_after_window() {
        let state = WebviewAccountsState::default();
        let label = "acct_test";
        let start = Instant::now();
        // Saturate the counter at start.
        for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS {
            let _ = state.track_gmeet_marketing_rewrite(label, start);
        }
        // After the window expires, a fresh attempt must rewrite again.
        let later = start + GMEET_REWRITE_WINDOW + Duration::from_secs(1);
        assert_eq!(
            state.track_gmeet_marketing_rewrite(label, later),
            GmeetRewriteAction::Rewrite
        );
    }

    // ── is_google_sso_host ────────────────────────────────────────

    #[test]
    fn google_sso_host_matches_canonical_accounts() {
        assert!(is_google_sso_host("accounts.google.com"));
        assert!(is_google_sso_host("accounts.googleusercontent.com"));
        assert!(is_google_sso_host("accounts.youtube.com"));
        assert!(is_google_sso_host("myaccount.google.com"));
    }

    #[test]
    fn google_sso_host_matches_cctld_variants() {
        assert!(is_google_sso_host("accounts.google.co.in"));
        assert!(is_google_sso_host("accounts.google.co.uk"));
        assert!(is_google_sso_host("accounts.google.de"));
        assert!(is_google_sso_host("accounts.google.fr"));
        assert!(is_google_sso_host("accounts.google.com.au"));
    }

    #[test]
    fn google_sso_host_rejects_phishing_alikes() {
        // Spoofed hosts that hijack the full domain by prefixing `accounts.google.`.
        assert!(!is_google_sso_host("accounts.google.com.evil.tld"));
        assert!(!is_google_sso_host("accounts.google."));
        assert!(!is_google_sso_host("accounts.google.com.evil.example.com"));
        // Two-label suffix where the second label is NOT a real cctld
        // (the dots-only predicate accepted these — CR caught it).
        assert!(!is_google_sso_host("accounts.google.com.evil"));
        assert!(!is_google_sso_host("accounts.google.co.attacker"));
        assert!(!is_google_sso_host("accounts.google.com.attackerlong"));
        // Single label that's not a real cctld (3+ chars).
        assert!(!is_google_sso_host("accounts.google.evil"));
        assert!(!is_google_sso_host("accounts.google.attackerlong"));
        // Unknown sld in the 2-label shape — only co/com/net/org allowed.
        assert!(!is_google_sso_host("accounts.google.xyz.uk"));
        // Unrelated google sub-services that aren't sso surfaces.
        assert!(!is_google_sso_host("mail.google.com"));
        assert!(!is_google_sso_host("meet.google.com"));
        assert!(!is_google_sso_host("workspace.google.com"));
        assert!(!is_google_sso_host("evil.com"));
    }

    #[test]
    fn google_sso_host_case_insensitive() {
        assert!(is_google_sso_host("ACCOUNTS.GOOGLE.COM"));
        assert!(is_google_sso_host("Accounts.Google.Co.Uk"));
    }

    // ── url_is_internal: gmeet SSO coverage ───────────────────────

    #[test]
    fn url_is_internal_allows_youtube_setsid_for_gmeet() {
        assert!(url_is_internal(
            "google-meet",
            &url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://meet.google.com/"),
        ));
    }

    #[test]
    fn url_is_internal_allows_youtube_setsid_for_slack_google_sso() {
        assert!(url_is_internal(
            "slack",
            &url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://app.slack.com/"),
        ));
    }

    #[test]
    fn url_is_internal_allows_cctld_accounts_google_for_gmail() {
        assert!(url_is_internal(
            "gmail",
            &url("https://accounts.google.co.in/signin/v2/identifier"),
        ));
    }

    #[test]
    fn url_is_internal_blocks_unrelated_youtube_for_gmeet() {
        // Plain youtube.com (e.g. video play) MUST stay external for
        // gmeet — the SSO bypass only covers `accounts.youtube.com`.
        assert!(!url_is_internal(
            "google-meet",
            &url("https://www.youtube.com/watch?v=abc"),
        ));
    }

    #[test]
    fn gmeet_rewrite_per_label_independent() {
        let state = WebviewAccountsState::default();
        let now = Instant::now();
        // Saturate label A — bails next time.
        for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS {
            let _ = state.track_gmeet_marketing_rewrite("acct_a", now);
        }
        // Label B must still be allowed independently.
        assert_eq!(
            state.track_gmeet_marketing_rewrite("acct_b", now),
            GmeetRewriteAction::Rewrite
        );
    }

    // ── is_gmeet_marketing_redirect ────────────────────────────────

    #[test]
    fn gmeet_marketing_match_canonical_paths() {
        assert!(is_gmeet_marketing_redirect(
            "workspace.google.com",
            "/products/meet/"
        ));
        assert!(is_gmeet_marketing_redirect(
            "workspace.google.com",
            "/products/meet"
        ));
        assert!(is_gmeet_marketing_redirect(
            "workspace.google.com",
            "/products/meet/learn-more"
        ));
        assert!(is_gmeet_marketing_redirect(
            "WORKSPACE.GOOGLE.COM",
            "/PRODUCTS/MEET/"
        ));
    }

    #[test]
    fn gmeet_marketing_match_subdomain_workspace() {
        assert!(is_gmeet_marketing_redirect(
            "support.workspace.google.com",
            "/products/meet/faq"
        ));
    }

    #[test]
    fn gmeet_marketing_rejects_other_workspace_paths() {
        // Legitimate Workspace pages a user might reach from Meet must NOT
        // be hijacked — admin console, Workspace Status, support, etc.
        assert!(!is_gmeet_marketing_redirect("workspace.google.com", "/"));
        assert!(!is_gmeet_marketing_redirect(
            "workspace.google.com",
            "/products/calendar/"
        ));
        assert!(!is_gmeet_marketing_redirect(
            "admin.workspace.google.com",
            "/ac/users"
        ));
        assert!(!is_gmeet_marketing_redirect(
            "workspace.google.com",
            "/status"
        ));
        assert!(!is_gmeet_marketing_redirect(
            "workspace.google.com",
            "/products/meeter"
        ));
    }

    #[test]
    fn gmeet_marketing_rejects_non_workspace_hosts() {
        assert!(!is_gmeet_marketing_redirect(
            "meet.google.com",
            "/products/meet/"
        ));
        assert!(!is_gmeet_marketing_redirect("evil.com", "/products/meet/"));
        // Phishing alike: workspace-google.com is NOT workspace.google.com
        assert!(!is_gmeet_marketing_redirect(
            "workspace-google.com",
            "/products/meet/"
        ));
    }

    #[test]
    fn gmeet_clear_marketing_rewrite_drops_counter() {
        let state = WebviewAccountsState::default();
        let now = Instant::now();
        for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS {
            let _ = state.track_gmeet_marketing_rewrite("acct_test", now);
        }
        // Counter saturated — next call would bail.
        assert_eq!(
            state.track_gmeet_marketing_rewrite("acct_test", now),
            GmeetRewriteAction::Bail
        );
        // Clear it — next call within the window starts fresh.
        state.clear_gmeet_marketing_rewrite("acct_test");
        assert_eq!(
            state.track_gmeet_marketing_rewrite("acct_test", now),
            GmeetRewriteAction::Rewrite
        );
    }

    #[test]
    fn gmeet_handoff_flag_default_is_unset() {
        let state = WebviewAccountsState::default();
        assert!(!state.take_awaiting_gmeet_handoff("acct_test"));
    }

    #[test]
    fn gmeet_handoff_flag_marks_then_consumes_single_shot() {
        let state = WebviewAccountsState::default();
        state.mark_awaiting_gmeet_handoff("acct_test");
        // First take returns true.
        assert!(state.take_awaiting_gmeet_handoff("acct_test"));
        // Second take returns false — single-shot semantics so a later
        // user-initiated `myaccount.google.com` visit isn't hijacked.
        assert!(!state.take_awaiting_gmeet_handoff("acct_test"));
    }

    #[test]
    fn gmeet_handoff_flag_is_per_label() {
        let state = WebviewAccountsState::default();
        state.mark_awaiting_gmeet_handoff("acct_a");
        // `acct_b` was never marked — must not consume a flag set on `acct_a`.
        assert!(!state.take_awaiting_gmeet_handoff("acct_b"));
        // `acct_a`'s flag is still pending.
        assert!(state.take_awaiting_gmeet_handoff("acct_a"));
    }

    #[test]
    fn gmeet_handoff_flag_cleared_by_drain_for_shutdown() {
        let state = WebviewAccountsState::default();
        state.mark_awaiting_gmeet_handoff("acct_test");
        let _ = state.drain_for_shutdown();
        // Stale flag would hijack the first user-initiated
        // `myaccount.google.com` visit after relaunch.
        assert!(!state.take_awaiting_gmeet_handoff("acct_test"));
    }

    // ── prewarm bookkeeping (issue #1233) ──────────────────

    /// Default state must include an empty `prewarm_accounts` set so
    /// fresh boots never spuriously suppress load events.
    #[test]
    fn prewarm_accounts_default_is_empty() {
        let state = WebviewAccountsState::default();
        assert!(state.prewarm_accounts.lock().unwrap().is_empty());
    }

    /// Inserting an id into `prewarm_accounts` and then removing it should
    /// leave the set empty — covers the warm-reopen path where the user's
    /// first click promotes the prewarmed webview to live.
    #[test]
    fn prewarm_accounts_insert_then_remove_clears() {
        let state = WebviewAccountsState::default();
        state
            .prewarm_accounts
            .lock()
            .unwrap()
            .insert("acct-1".to_string());
        assert!(state.prewarm_accounts.lock().unwrap().contains("acct-1"));
        state.prewarm_accounts.lock().unwrap().remove("acct-1");
        assert!(!state.prewarm_accounts.lock().unwrap().contains("acct-1"));
    }

    /// `drain_for_shutdown` must not leak prewarm flags either — otherwise
    /// a relaunch could spuriously suppress the very first cold open.
    #[test]
    fn prewarm_flag_cleared_by_drain_for_shutdown() {
        let state = WebviewAccountsState::default();
        state
            .prewarm_accounts
            .lock()
            .unwrap()
            .insert("acct-warm".to_string());
        let _ = state.drain_for_shutdown();
        assert!(state.prewarm_accounts.lock().unwrap().is_empty());
    }
}
