123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
//! Browser transport for adom-chip-fetcher.
//!
//! adom-chip-fetcher drives the user's desktop browser to source CAD. There are two
//! transports, and we DEFAULT to the first when it's available:
//!
//!   1. **native-browser** (preferred) — the `adom-browser-extension` bridge
//!      (`nbrowser_*` verbs). Drives the user's REAL signed-in Chrome/Edge, so
//!      their DigiKey/Mouser/SnapEDA logins, distributor pricing, and captcha
//!      human-trust all work. Several sites hard-block Puppeteer (DigiKey is the
//!      clearest) but serve the native session normally.
//!   2. **pup** (fallback) — Puppeteer (`browser_*` verbs), a cold profile where
//!      we have to recreate auth and can hit bot-walls.
//!
//! ISOLATION RULE (John, 2026-06-23): when using the native browser we open ONE
//! new window for the session and manage every site as a TAB inside it, so we
//! never touch the user's other Chrome/Edge windows. That falls out of the
//! session-keyed pattern: `open_window(session)` makes the dedicated window the
//! first time; every later `open_tab(session, …)` adds a tab to *that* window.

use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::process::Command;
use std::sync::OnceLock;

/// Resolved transport: which verb family to use and which desktop to target.
struct Transport {
    /// Verb prefix: "nbrowser_" (native) or "browser_" (pup).
    prefix: &'static str,
    /// `--target <name>` to disambiguate when 2+ desktops are connected. `None`
    /// lets adom-desktop auto-route (fine for a single desktop).
    target: Option<String>,
    /// True when we're on the user's real signed-in browser.
    native: bool,
}

static TRANSPORT: OnceLock<Transport> = OnceLock::new();

fn transport() -> &'static Transport {
    TRANSPORT.get_or_init(detect_transport)
}

/// True when sourcing runs in the user's real signed-in browser.
pub fn is_native() -> bool {
    transport().native
}

/// Human label for the active surface (for dashboards / logs).
pub fn surface_label() -> String {
    let t = transport();
    if t.native {
        format!("native browser{}", t.target.as_ref().map(|x| format!(" ({x})")).unwrap_or_default())
    } else {
        "pup (cold profile)".to_string()
    }
}

fn detect_transport() -> Transport {
    // Explicit override: CHIP_FETCHER_BROWSER=native|pup
    match std::env::var("CHIP_FETCHER_BROWSER").ok().as_deref() {
        Some("pup") => return Transport { prefix: "browser_", target: None, native: false },
        Some("native") => {
            let target = find_native().unwrap_or(None);
            return Transport { prefix: "nbrowser_", target, native: true };
        }
        _ => {}
    }
    // Auto: prefer the native browser whenever its bridge is live + drivable.
    match find_native() {
        Some(target) => {
            eprintln!(
                "[adom-chip-fetcher] sourcing in an ISOLATED window of your signed-in browser \
                 (native-browser bridge{}). Real logins, your distributor pricing, no bot-walls.",
                target.as_ref().map(|t| format!(", {t}")).unwrap_or_default()
            );
            Transport { prefix: "nbrowser_", target, native: true }
        }
        None => {
            eprintln!(
                "[adom-chip-fetcher] native-browser bridge not found — falling back to pup (cold profile). \
                 Install the Adom browser extension for your REAL logins + to skip bot-walls that \
                 block headless Chrome (DigiKey blocks it outright): \
                 https://wiki.adom.inc/adom/adom-browser-extension"
            );
            Transport { prefix: "browser_", target: None, native: false }
        }
    }
}

/// Probe for a live, drivable `native-browser` bridge.
/// Returns `Some(target)` when found (`target=None` ⇒ no explicit `--target`
/// needed), or `None` when absent. The multi-desktop case is handled by reading
/// the `connected[]` list that `bridge_list` returns on `ambiguous_target`, then
/// probing each desktop by name.
fn find_native() -> Option<Option<String>> {
    let (present, connected) = bridge_probe(None);
    if present {
        return Some(None);
    }
    for name in connected {
        if bridge_probe(Some(&name)).0 {
            return Some(Some(name));
        }
    }
    None
}

/// `(native_browser_running, connected_desktop_names)`. The names are non-empty
/// only when the call came back `ambiguous_target` (2+ desktops). `bridge_list`
/// — unlike `ping` — includes `connected[]` in that error, so we read it here.
fn bridge_probe(target: Option<&str>) -> (bool, Vec<String>) {
    let mut cmd = Command::new("adom-desktop");
    if let Some(t) = target {
        cmd.arg("--target").arg(t);
    }
    cmd.arg("bridge_list");
    let v: Value = match cmd.output() {
        Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or(Value::Null),
        _ => return (false, vec![]),
    };
    let present = v
        .get("bridges")
        .and_then(|b| b.as_array())
        .map(|arr| {
            arr.iter().any(|br| {
                br.get("name").and_then(|n| n.as_str()) == Some("native-browser")
                    && br.get("paused").and_then(|p| p.as_bool()) != Some(true)
                    && (br.get("status").and_then(|s| s.as_str()) == Some("running")
                        || br.get("instanceCount").and_then(|c| c.as_u64()).unwrap_or(0) >= 1)
            })
        })
        .unwrap_or(false);
    let connected = connected_from(&v);
    (present, connected)
}

/// Pull the `connected[]` desktop-name list out of an `ambiguous_target` response.
fn connected_from(v: &Value) -> Vec<String> {
    v.get("connected")
        .and_then(|c| c.as_array())
        .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())
        .unwrap_or_default()
}

// ── public browser ops (transport-agnostic) ─────────────────────────────────

/// Open the session's ISOLATED window at `url` (first call) — every later
/// `open_tab` on the same session lands in this same window. On the native
/// browser we drive the active signed-in profile, so the cold-profile `profile`
/// arg is ignored there.
pub fn open_window(session_id: &str, profile: &str, url: &str) -> Result<Value> {
    let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
    let mut args = json!({ "sessionId": session_id, "url": url });
    if !transport().native {
        args["profile"] = json!(profile);
    }
    call_browser("open_window", &args)
}

/// Add a tab to the session's existing isolated window.
pub fn open_tab(session_id: &str, url: &str) -> Result<Value> {
    let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
    call_browser("open_tab", &json!({ "sessionId": session_id, "url": url }))
}

/// True when the session already has a live window (≥1 tab). Used to decide
/// whether to add a tab vs. spawn the window.
pub fn session_live(session_id: &str) -> bool {
    let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {
        Ok(v) => v,
        Err(_) => return false,
    };
    // native: {output:"{tabs:[…]}"}, pup: {tabs:[…]}
    let tabs = v.get("tabs").cloned().or_else(|| {
        v.get("output")
            .and_then(|o| o.as_str())
            .and_then(|s| serde_json::from_str::<Value>(s).ok())
            .and_then(|p| p.get("tabs").cloned())
    });
    tabs.and_then(|t| t.as_array().map(|a| !a.is_empty())).unwrap_or(false)
}

/// True when any tab in the session's window has a URL containing `needle`.
pub fn session_has_url(session_id: &str, needle: &str) -> bool {
    let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {
        Ok(v) => v,
        Err(_) => return false,
    };
    let tabs = v.get("tabs").cloned().or_else(|| {
        v.get("output")
            .and_then(|o| o.as_str())
            .and_then(|s| serde_json::from_str::<Value>(s).ok())
            .and_then(|p| p.get("tabs").cloned())
    });
    tabs.and_then(|t| t.as_array().cloned())
        .map(|arr| {
            arr.iter().any(|tab| {
                tab.get("url").and_then(|u| u.as_str()).map(|u| u.contains(needle)).unwrap_or(false)
            })
        })
        .unwrap_or(false)
}

/// Open `url` in the session's ONE isolated window — as a TAB when the window
/// already exists (e.g. the dashboard is tab 1), else create the window. This
/// is the rule for native browsing: every source lands as a tab beside the
/// dashboard, never in a separate window and never touching the user's others.
pub fn open_site(session_id: &str, profile: &str, url: &str) -> Result<Value> {
    // Native work window: dashboard is ALWAYS tab 1; vendor pages are tabs beside it.
    if is_native() && session_id == WORK_SESSION {
        ensure_work_window()?;
        return open_tab(session_id, url);
    }
    if session_live(session_id) {
        open_tab(session_id, url)
    } else {
        open_window(session_id, profile, url)
    }
}

pub fn eval(session_id: &str, expr: &str) -> Result<Value> {
    let v = call_browser("eval", &json!({ "sessionId": session_id, "expr": expr }))?;
    // native returns {output:{result}}, pup returns {result}; unwrap either.
    if let Some(out) = v.get("output").and_then(|o| o.as_str()) {
        if let Ok(parsed) = serde_json::from_str::<Value>(out) {
            return Ok(parsed.get("result").cloned().unwrap_or(parsed));
        }
    }
    Ok(v.get("result").cloned().unwrap_or(v))
}

/// Close the session's isolated window (cleanup after a run). Best-effort.
pub fn close_window(session_id: &str) -> Result<Value> {
    call_browser("close_window", &json!({ "sessionId": session_id }))
}

// ── credential awareness: adom-chip-fetcher reads the browser's saved-login store ──
// across ALL profiles (Chrome + Edge), maps hosts to the sourcing ladder, and
// auto-switches to the profile that's signed in to the vendor it's about to
// scrape — so the whole flow self-drives without asking the user anything.

#[derive(Clone)]
pub struct Cred {
    pub browser: String,
    pub profile: String,
    pub host: String,
    pub username: String,
}

static CREDS: OnceLock<Vec<Cred>> = OnceLock::new();

/// Every saved login the browser knows about (origins + usernames; never
/// passwords). Cached for the run.
pub fn credentials() -> &'static Vec<Cred> {
    CREDS.get_or_init(|| query_credentials().unwrap_or_default())
}

fn query_credentials() -> Result<Vec<Cred>> {
    let v = call_browser("credentials", &json!({}))?;
    let out = v.get("output").and_then(|o| o.as_str())
        .ok_or_else(|| anyhow::anyhow!("nbrowser_credentials: no output"))?;
    let parsed: Value = serde_json::from_str(out)?;
    let arr = parsed.as_array().cloned()
        .or_else(|| parsed.get("credentials").and_then(|c| c.as_array()).cloned())
        .unwrap_or_default();
    Ok(arr.iter().map(|r| Cred {
        browser: r.get("browser").and_then(|x| x.as_str()).unwrap_or("").to_string(),
        profile: r.get("profile").and_then(|x| x.as_str()).unwrap_or("").to_string(),
        host: r.get("host").and_then(|x| x.as_str()).unwrap_or("").to_lowercase(),
        username: r.get("username").and_then(|x| x.as_str()).unwrap_or("").to_string(),
    }).collect())
}

/// Ladder-vendor host fragments → friendly name.
pub const VENDOR_HOSTS: &[(&str, &str)] = &[
    ("ultralibrarian", "UltraLibrarian"),
    ("snapeda", "SnapEDA"), ("snapmagic", "SnapEDA"),
    ("componentsearchengine", "SamacSys"), ("samacsys", "SamacSys"),
    ("mouser", "Mouser"), ("digikey", "DigiKey"),
    ("st.com", "ST"), ("ti.com", "TI"), ("nordicsemi", "Nordic"),
    ("nxp.com", "NXP"), ("microchip", "Microchip"), ("arrow.com", "Arrow"),
];

/// Hosts whose downloads are authenticated by adom-chip-fetcher's OWN credential
/// vault (an `Authorization: Basic` header on the XHR), NOT by the browser's
/// saved cookie session. For these we must NOT switch the active browser to
/// wherever a saved login happens to live — the vault works in any browser, so
/// we stay on the user's preferred browser (Chrome). This is why John saw Edge:
/// his CSE login is saved in Edge, so the auto-pick dragged the whole run there
/// even though CSE never needed that browser login.
pub const VAULT_AUTH_HOSTS: &[&str] = &["componentsearchengine", "samacsys"];

/// The user's preferred browser to drive (John: "drive this in my Chrome").
/// Profile keys are "<browser>:<email>"; we bias profile selection toward this.
pub const PREFERRED_BROWSER: &str = "chrome";

/// If `url`'s host matches a ladder vendor, return the profile (email) that's
/// signed in to it — preferring the profile with the broadest vendor coverage,
/// so multi-vendor runs stay on one rich profile when possible.
pub fn profile_for_url(url: &str) -> Option<String> {
    let lower = url.to_lowercase();
    // Vault-authed source → never switch browser; stay on the preferred one.
    if VAULT_AUTH_HOSTS.iter().any(|h| lower.contains(*h)) {
        return None;
    }
    let frag = VENDOR_HOSTS.iter().map(|(h, _)| *h).find(|h| lower.contains(*h))?;
    let creds = credentials();
    // count each profile's total ladder-vendor coverage (for tie-breaking)
    use std::collections::HashMap;
    let mut coverage: HashMap<&str, usize> = HashMap::new();
    for c in creds {
        if VENDOR_HOSTS.iter().any(|(h, _)| c.host.contains(*h)) {
            *coverage.entry(c.profile.as_str()).or_default() += 1;
        }
    }
    creds.iter()
        .filter(|c| c.host.contains(frag))
        .max_by_key(|c| coverage.get(c.profile.as_str()).copied().unwrap_or(0))
        .map(|c| c.profile.clone())
}

/// Switch the native browser's active profile for the next nav/fetch.
pub fn use_profile(email: &str) -> Result<Value> {
    call_browser("use_profile", &json!({ "profile": email }))
}

// ── one-window scrape: a dedicated scrape TAB inside the "adom-chip-fetcher" window ──
// In native mode every vendor page is driven in ONE tab of the single
// adom-chip-fetcher window (tab 1 = dashboard, never touched). This keeps scraping in
// the same isolated window instead of spawning a second "adom-chip-fetcher-scrape" one.

const WORK_SESSION: &str = "adom-chip-fetcher";

fn scrape_tab_file() -> std::path::PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());
    std::path::PathBuf::from(home).join(".config/adom-chip-fetcher/scrape-tab")
}

fn extract_tab_id(v: &Value) -> Option<String> {
    if let Some(out) = v.get("output").and_then(|o| o.as_str()) {
        if let Ok(p) = serde_json::from_str::<Value>(out) {
            if let Some(id) = p.get("tabId").and_then(|x| x.as_str()) {
                return Some(id.to_string());
            }
        }
    }
    v.get("tabId").and_then(|x| x.as_str()).map(String::from)
}

fn work_tab_ids() -> Vec<String> {
    let v = match call_browser("list_tabs", &json!({ "sessionId": WORK_SESSION })) {
        Ok(v) => v,
        Err(_) => return vec![],
    };
    let tabs = v.get("tabs").cloned().or_else(|| {
        v.get("output").and_then(|o| o.as_str())
            .and_then(|s| serde_json::from_str::<Value>(s).ok())
            .and_then(|p| p.get("tabs").cloned())
    });
    tabs.and_then(|t| t.as_array().cloned())
        .map(|arr| arr.iter().filter_map(|t| t.get("tabId").and_then(|x| x.as_str().map(String::from))).collect())
        .unwrap_or_default()
}

fn dashboard_url() -> String {
    if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
        format!("https://{host}/proxy/8786/")
    } else if let Ok(tmpl) = std::env::var("VSCODE_PROXY_URI") {
        tmpl.replace("{{port}}", "8786")
    } else {
        "http://127.0.0.1:8786/".to_string()
    }
}

/// John's rule: NEVER open the work window without the adom-chip-fetcher dashboard as
/// the FIRST tab. Every path that adds a vendor/scrape tab calls this first, so
/// the window is always created with the dashboard as tab 1.
pub fn ensure_work_window() -> Result<()> {
    if !is_native() {
        return Ok(());
    }
    if session_has_url(WORK_SESSION, "/proxy/8786/") || session_has_url(WORK_SESSION, ":8786/") {
        return Ok(());
    }
    // Drive the user's PREFERRED browser (Chrome). Switch the bridge's active
    // profile to a Chrome profile before opening, so the work window — and every
    // scrape tab in it — lives in Chrome, not whichever browser happens to hold
    // the most vendor logins.
    prefer_chrome_profile();
    // No dashboard tab present → open the window WITH the dashboard as tab 1.
    open_window(WORK_SESSION, WORK_SESSION, &dashboard_url())?;
    Ok(())
}

/// Switch the bridge's active profile to a Chrome profile if one exists, so the
/// work window opens in Chrome (John's stated preference). No-op if the bridge
/// reports no Chrome profile.
pub fn prefer_chrome_profile() {
    let v = match call_browser("profiles", &json!({})) {
        Ok(v) => v,
        Err(_) => return,
    };
    let out = v.get("output").and_then(|o| o.as_str())
        .and_then(|s| serde_json::from_str::<Value>(s).ok())
        .unwrap_or(v);
    let active = out.get("active").and_then(|a| a.as_str()).unwrap_or("");
    if active.starts_with(PREFERRED_BROWSER) {
        return; // already on Chrome
    }
    if let Some(profs) = out.get("profiles").and_then(|p| p.as_array()) {
        if let Some(chrome) = profs.iter().find(|p| {
            p.get("browser").and_then(|b| b.as_str()) == Some(PREFERRED_BROWSER)
                && p.get("live").and_then(|l| l.as_bool()).unwrap_or(true)
        }) {
            if let Some(key) = chrome.get("profile").and_then(|p| p.as_str()) {
                let _ = use_profile(key);
                eprintln!("[adom-chip-fetcher] driving your Chrome profile '{key}'");
            }
        }
    }
}

/// The dedicated scrape tab in the work window — reuse the stored one if it's
/// still alive, else open a fresh tab (beside the dashboard) and remember it.
pub fn ensure_scrape_tab() -> Result<String> {
    ensure_work_window()?; // dashboard is always tab 1 before any scrape tab
    let alive = work_tab_ids();
    if let Ok(stored) = std::fs::read_to_string(scrape_tab_file()) {
        let id = stored.trim().to_string();
        if !id.is_empty() && alive.contains(&id) {
            return Ok(id);
        }
    }
    let v = call_browser("open_tab", &json!({ "sessionId": WORK_SESSION, "url": "about:blank" }))?;
    let id = extract_tab_id(&v).ok_or_else(|| anyhow::anyhow!("no scrape tabId in {v}"))?;
    if let Some(dir) = scrape_tab_file().parent() {
        let _ = std::fs::create_dir_all(dir);
    }
    let _ = std::fs::write(scrape_tab_file(), &id);
    Ok(id)
}

/// Navigate the dedicated scrape tab (native) or the pup scrape window (fallback).
pub fn scrape_navigate(url: &str) -> Result<Value> {
    let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
    if is_native() {
        // Self-drive: if this is a known vendor, switch to the profile that's
        // signed in to it before loading the page — no questions asked.
        if let Some(prof) = profile_for_url(&url) {
            if use_profile(&prof).is_ok() {
                eprintln!("[adom-chip-fetcher] using your signed-in profile '{prof}' for this vendor");
            }
        }
        let tab = ensure_scrape_tab()?;
        call_browser("navigate", &json!({ "sessionId": WORK_SESSION, "tabId": tab, "url": url }))
    } else {
        // pup: a separate scrape window is the documented two-window pattern.
        let r = call_browser("navigate", &json!({ "sessionId": "adom-chip-fetcher-scrape", "url": url }));
        if r.is_err() {
            return call_browser("open_window", &json!({ "sessionId": "adom-chip-fetcher-scrape", "profile": "adom-chip-fetcher", "url": url }));
        }
        r
    }
}

pub fn scrape_eval(expr: &str) -> Result<Value> {
    let args = if is_native() {
        let tab = ensure_scrape_tab()?;
        json!({ "sessionId": WORK_SESSION, "tabId": tab, "expr": expr })
    } else {
        json!({ "sessionId": "adom-chip-fetcher-scrape", "expr": expr })
    };
    let v = call_browser("eval", &args)?;
    if let Some(out) = v.get("output").and_then(|o| o.as_str()) {
        if let Ok(parsed) = serde_json::from_str::<Value>(out) {
            return Ok(parsed.get("result").cloned().unwrap_or(parsed));
        }
    }
    Ok(v.get("result").cloned().unwrap_or(v))
}

/// Fetch a URL's BYTES through the user's signed-in browser (real cookies, so
/// login-gated CAD works too) and write them to `dest`. No download, no native
/// viewer, no extra window — the bytes come back base64 in one call. Returns
/// (bytes_written, content_type).
pub fn fetch_url_to_file(url: &str, dest: &std::path::Path) -> Result<(usize, String)> {
    let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());
    let v = call_browser("fetch_url", &json!({ "sessionId": WORK_SESSION, "url": url }))?;
    let out = v.get("output").and_then(|o| o.as_str())
        .ok_or_else(|| anyhow::anyhow!("fetch_url: no output ({v})"))?;
    let o: Value = serde_json::from_str(out)
        .map_err(|e| anyhow::anyhow!("fetch_url: bad json ({e}): {out:.120}"))?;
    if !o.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {
        anyhow::bail!("fetch_url failed (HTTP {})", o.get("status").and_then(|s| s.as_i64()).unwrap_or(0));
    }
    let body = o.get("body").and_then(|b| b.as_str()).ok_or_else(|| anyhow::anyhow!("fetch_url: no body"))?;
    let ctype = o.get("contentType").and_then(|c| c.as_str()).unwrap_or("").to_string();
    let bytes = b64_decode(body);
    if let Some(dir) = dest.parent() {
        std::fs::create_dir_all(dir).ok();
    }
    std::fs::write(dest, &bytes)?;
    Ok((bytes.len(), ctype))
}

/// Base64-encode (for the CSE basic-auth Authorization header).
fn b64_encode(data: &[u8]) -> String {
    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
    for ch in data.chunks(3) {
        let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];
        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
        out.push(T[(n >> 18 & 63) as usize] as char);
        out.push(T[(n >> 12 & 63) as usize] as char);
        out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
        out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
    }
    out
}

/// Fetch a Component Search Engine CAD zip for `mpn` via XHR + basic-auth.
/// XHR (unlike fetch()) accepts an Authorization header AND doesn't trigger a
/// browser download — so this bypasses BOTH the fetch() credential ban and the
/// browser's multiple-download block. The scrape tab is navigated to CSE first
/// (same-origin). Chains entry → partID → zipForm → ga/model.php. Returns the
/// raw zip bytes (`.step` + `.kicad_sym` + `.kicad_mod`).
pub fn cse_fetch_zip(mpn: &str, mfr: &str, user: &str, pass: &str) -> Result<Vec<u8>> {
    // Navigate CLEAN (no inline-credential wrapping) — auth is the XHR header,
    // and an inline-cred URL breaks the page + leaks the password into the URL bar.
    let tab = ensure_scrape_tab()?;
    call_browser("navigate", &json!({
        "sessionId": WORK_SESSION, "tabId": tab,
        "url": "https://mouser.componentsearchengine.com/"
    }))?;
    std::thread::sleep(std::time::Duration::from_secs(4));
    let auth = b64_encode(format!("{user}:{pass}").as_bytes());
    let mpn_js = serde_json::to_string(mpn).unwrap();
    let mfr_js = serde_json::to_string(mfr).unwrap();
    let expr = format!(
        "(function(){{return new Promise(function(res){{var A='Basic {auth}';var MPN={mpn_js};var MFR={mfr_js};\
         function xhr(u,ty,cb){{var x=new XMLHttpRequest();x.open('GET',u,true);x.setRequestHeader('Authorization',A);\
         if(ty)x.responseType=ty;x.onload=function(){{cb(x)}};x.onerror=function(){{res(JSON.stringify({{err:'xhr'}}))}};x.send()}}\
         var base='https://mouser.componentsearchengine.com/';\
         xhr(base+'entry_u_newDesign.php?mna='+encodeURIComponent(MFR)+'&mpn='+encodeURIComponent(MPN)+'&pna=mouser&vrq=multi&fmt=zip&o3=0&lang=en-GB',null,function(a){{\
         var m=(a.responseText.match(/partID=(\\d+)/)||[]);var pid=m[1];if(!pid)return res(JSON.stringify({{err:'no-partID'}}));\
         xhr(base+'preview_newDesign.php?o3=0&partID='+pid+'&ev=0&fmt=zip&pna=Mouser',null,function(b){{\
         var f=new DOMParser().parseFromString(b.responseText,'text/html').getElementById('zipForm');if(!f)return res(JSON.stringify({{err:'no-zipForm'}}));\
         var q=[].slice.call(f.querySelectorAll('input,select')).map(function(i){{return encodeURIComponent(i.name)+'='+encodeURIComponent(i.value)}}).filter(function(s){{return s.length>1}}).join('&');\
         xhr(base+'ga/model.php?'+q,'arraybuffer',function(c){{var u=new Uint8Array(c.response);var s='';\
         for(var i=0;i<u.length;i+=8192)s+=String.fromCharCode.apply(null,u.subarray(i,i+8192));\
         res(JSON.stringify({{head:[u[0],u[1],u[2],u[3]],len:u.length,b64:btoa(s)}}))}})}})}})}})}})()",
        auth = auth, mpn_js = mpn_js, mfr_js = mfr_js
    );
    // Cold CSE calls flake (the entry XHR fires before the session cookie
    // fully settles → spurious "no-partID"). Retry the whole chain a few
    // times, re-warming the page each round, before giving up.
    let mut last_err = String::from("no attempt");
    for attempt in 0..4 {
        if attempt > 0 {
            // re-warm: the homepage navigate primes the session cookie
            let _ = call_browser("navigate", &json!({
                "sessionId": WORK_SESSION, "tabId": tab,
                "url": "https://mouser.componentsearchengine.com/"
            }));
            std::thread::sleep(std::time::Duration::from_secs(3));
        }
        let v = scrape_eval(&expr)?;
        let s = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());
        let r: Value = serde_json::from_str(&s).unwrap_or(v);
        let head: Vec<u64> = r.get("head").and_then(|h| h.as_array())
            .map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();
        if head == [80, 75, 3, 4] {
            let b64 = r.get("b64").and_then(|b| b.as_str())
                .ok_or_else(|| anyhow::anyhow!("CSE: no zip body"))?;
            return Ok(b64_decode(b64));
        }
        last_err = format!("head {head:?}, err {:?}", r.get("err"));
    }
    anyhow::bail!("CSE returned non-zip after 4 attempts ({last_err})")
}

/// Minimal base64 decoder (the binary has no base64 crate; it has b64 encode in
/// main.rs). Skips whitespace/newlines, stops at padding.
fn b64_decode(s: &str) -> Vec<u8> {
    let mut t = [255u8; 256];
    for (i, c) in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".iter().enumerate() {
        t[*c as usize] = i as u8;
    }
    let mut out = Vec::with_capacity(s.len() / 4 * 3);
    let (mut buf, mut bits) = (0u32, 0u32);
    for &c in s.as_bytes() {
        if c == b'=' { break; }
        let v = t[c as usize];
        if v == 255 { continue; }
        buf = (buf << 6) | v as u32;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push((buf >> bits) as u8);
        }
    }
    out
}

pub fn scrape_screenshot() -> Result<Value> {
    if is_native() {
        let tab = ensure_scrape_tab()?;
        call_browser("screenshot", &json!({ "sessionId": WORK_SESSION, "tabId": tab }))
    } else {
        call_browser("screenshot", &json!({ "sessionId": "adom-chip-fetcher-scrape" }))
    }
}

/// Pull a file from the user's desktop into a local container directory.
/// Desktop-level verb (not browser-scoped) — same on both transports.
pub fn pull_file(remote_path: &str, save_to: &str) -> Result<std::path::PathBuf> {
    let v = call_desktop("pull_file", &json!({ "filePaths": [remote_path], "saveTo": save_to }))?;
    let local = v["files"][0]["path"]
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("pull_file returned no path: {v}"))?;
    Ok(std::path::PathBuf::from(local))
}

/// Block until a file matching `glob` lands in `path` on the user's desktop.
pub fn desktop_watch_files(path: &str, glob: &str, timeout_ms: u64) -> Result<Value> {
    call_desktop(
        "desktop_watch_files",
        &json!({ "path": path, "glob": glob, "timeoutMs": timeout_ms }),
    )
}

// ── call plumbing ────────────────────────────────────────────────────────────

/// Call a browser verb — prefixes with the active transport's verb family and
/// injects `--target` when one is resolved.
fn call_browser(suffix: &str, args: &Value) -> Result<Value> {
    let t = transport();
    let verb = format!("{}{}", t.prefix, suffix);
    call_with_recovery(&verb, args, t.target.as_deref(), true)
}

/// Call a desktop-level verb (no transport prefix).
fn call_desktop(action: &str, args: &Value) -> Result<Value> {
    call_with_recovery(action, args, transport().target.as_deref(), true)
}

/// Run `adom-desktop [--target T] <action> <args>` and parse the JSON result.
///
/// Recovers from two transient conditions:
///   - `session_not_found` / `session_disconnected`: rescan + retry once.
///   - `ambiguous_target` (2+ desktops, no target passed): discover the native
///     target (or first connected) and retry once with `--target`.
fn call_with_recovery(
    action: &str,
    args: &Value,
    target: Option<&str>,
    allow_recovery: bool,
) -> Result<Value> {
    let mut cmd = Command::new("adom-desktop");
    if let Some(t) = target {
        cmd.arg("--target").arg(t);
    }
    cmd.arg(action).arg(args.to_string());
    let out = cmd
        .output()
        .with_context(|| format!("running adom-desktop {action}"))?;
    if !out.status.success() {
        anyhow::bail!(
            "adom-desktop {action} failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    }
    let v: Value = serde_json::from_slice(&out.stdout)
        .with_context(|| format!("parsing adom-desktop {action} stdout"))?;

    if !allow_recovery {
        return Ok(v);
    }

    // Recovery 1: ambiguous target — pick a concrete desktop and retry.
    if target.is_none() && v.get("error").and_then(|e| e.as_str()) == Some("ambiguous_target") {
        let pick = find_native()
            .and_then(|t| t)
            .or_else(|| connected_from(&v).into_iter().next());
        if let Some(name) = pick {
            println!("WARN: adom-desktop {action} ambiguous_target; retrying on --target {name}");
            return call_with_recovery(action, args, Some(&name), false);
        }
    }

    // Recovery 2: session lost — rescan, then retry once if it came back.
    if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {
        if code == "session_not_found" || code == "session_disconnected" {
            let sess = args.get("sessionId").and_then(|s| s.as_str()).unwrap_or("");
            println!("WARN: adom-desktop {action} returned {code} for session {sess:?}; running rescan");
            let prefix = transport().prefix;
            let mut rescan = Command::new("adom-desktop");
            if let Some(t) = target {
                rescan.arg("--target").arg(t);
            }
            let rescan = rescan.args([&format!("{prefix}rescan"), "{}"]).output();
            let live = rescan.ok().and_then(|o| {
                if !o.status.success() {
                    return None;
                }
                let r: Value = serde_json::from_slice(&o.stdout).ok()?;
                r.get("liveSessions").and_then(|l| l.as_array()).map(|arr| {
                    arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()
                })
            });
            let live = live.unwrap_or_default();
            if !sess.is_empty() && live.iter().any(|s| s == sess) {
                println!("OK: rescan recovered session {sess:?}; retrying {action}");
                return call_with_recovery(action, args, target, false);
            }
            println!("WARN: rescan did not recover {sess:?} (live: {live:?})");
        }
    }
    Ok(v)
}