master
John Lauer vault test: build the public test cards from 4-digit groups so the page carries no card-shaped number e128d04 1mo ago
//! pup-vault: credentials and payment cards. The SECRETS live in the OS store through
//! `HostSecrets` (keytar's `service/account` layout, so the Node build's vault is readable here);
//! the INDEXES (host + username, card nickname + brand + last4, never a password or PAN) live in
//! ~/.adom as JSON, same files and shapes as src/credential_vault.js and src/card_vault.js.
//! The page-side work (locate a login form, fill it, submit; find card fields) is JavaScript
//! returned by functions here and evaluated by the verbs crate through CDP.

use pup_host::HostSecrets;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};

pub const CRED_SERVICE: &str = "adom-bridge-pup";   // 2.0.652: renamed from adom-desktop-pup; the host store renames stored keys on load
pub const LEGACY_CRED_SERVICE: &str = "adom-desktop-pup";
pub const CARD_SERVICE: &str = "adom-pup-cards";

fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) }

// ── host patterns (Node: registrableDomain / hostMatchesPattern / scorePattern) ─────────

const TWO_PART_TLDS: &[&str] = &["co.uk", "org.uk", "gov.uk", "ac.uk", "com.au", "net.au", "org.au", "co.nz", "co.jp", "co.kr", "com.br", "com.cn", "com.mx", "com.tw", "co.in", "co.za"];

pub fn registrable_domain(host: &str) -> String {
    if host.is_empty() { return String::new(); }
    let lower = host.to_lowercase();
    let parts: Vec<String> = lower.trim_end_matches('.').split('.').map(|s| s.to_string()).collect();
    if parts.len() <= 2 { return parts.join("."); }
    let last_two = parts[parts.len() - 2..].join(".");
    if TWO_PART_TLDS.contains(&last_two.as_str()) { parts[parts.len() - 3..].join(".") } else { last_two }
}

pub fn host_matches_pattern(host: &str, pattern: &str) -> bool {
    if host.is_empty() || pattern.is_empty() { return false; }
    let (h, p) = (host.to_lowercase(), pattern.to_lowercase());
    if p == h { return true; }
    if let Some(suffix) = p.strip_prefix('*') { if h == suffix.trim_start_matches('.') || h.ends_with(suffix) { return true; } }
    if !p.starts_with("*.") { let r = registrable_domain(&h); if !r.is_empty() && r == registrable_domain(&p) { return true; } }
    false
}

pub fn score_pattern(pattern: &str, host: &str) -> i64 {
    if !host_matches_pattern(host, pattern) { return -1; }
    let (h, p) = (host.to_lowercase(), pattern.to_lowercase());
    let exact = p == h; let glob = p.starts_with("*.");
    (if exact { 2000 } else if glob { 1000 } else { 500 }) + pattern.len() as i64
}

pub fn host_of(url: &str) -> Option<String> {
    let rest = url.split("//").nth(1)?; let hp = rest.split('/').next()?; let h = hp.split('@').last()?.split(':').next()?; if h.is_empty() { None } else { Some(h.to_lowercase()) }
}

// ── credentials ────────────────────────────────────────────────────────────────────────

pub struct Vault { pub state_dir: PathBuf }

fn same_user(a: &str, b: &str) -> bool { a.trim().eq_ignore_ascii_case(b.trim()) }
/// The keychain account name an index entry's password lives under (entries before 2.0.596 carry none: the host).
fn key_of(e: &Value) -> String { e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("").to_string() }

#[derive(Debug, Clone)]
pub struct Credential { pub host: String, pub username: String, pub password: String, pub source_account: Option<String> }

impl Vault {
    pub fn new(state_dir: &Path) -> Vault { let _ = std::fs::create_dir_all(state_dir); Vault { state_dir: state_dir.to_path_buf() } }
    fn index_path(&self) -> PathBuf { self.state_dir.join("pup-credentials.index.json") }
    pub fn read_index(&self) -> Vec<Value> {
        std::fs::read_to_string(self.index_path()).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["entries"].as_array().cloned()).unwrap_or_default()
    }
    fn write_index(&self, entries: &[Value]) { let _ = std::fs::write(self.index_path(), serde_json::to_string_pretty(&json!({ "version": 1, "entries": entries })).unwrap_or_default()); }

    /// 2.0.596: one login per host AND username. It was one per host: a second account on the same site
    /// (John's work and personal Google accounts both sign in at accounts.google.com) overwrote the first,
    /// password and all. The first login for a host keeps the host as its keychain key, so every vault
    /// written before this reads unchanged; a further account on that host gets "host|username".
    pub fn set(&self, host: &dyn HostSecrets, hostname: &str, username: &str, password: &str) -> Result<(), String> {
        if hostname.is_empty() || username.is_empty() || password.is_empty() { return Err("host, username, and password are all required".into()); }
        let mut entries = self.read_index();
        let now = pup_core::iso_now();
        match entries.iter_mut().find(|e| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username)) {
            Some(e) => {
                host.secret_set(CRED_SERVICE, &key_of(e), password)?;
                e["username"] = json!(username); e["updatedAt"] = json!(now);
            }
            None => {
                let taken = entries.iter().any(|e| e["host"] == json!(hostname) || key_of(e) == hostname);
                let key = if taken { format!("{hostname}|{}", username.trim().to_lowercase()) } else { hostname.to_string() };
                host.secret_set(CRED_SERVICE, &key, password)?;
                entries.push(json!({ "host": hostname, "username": username, "keytarAccount": key, "addedAt": now, "updatedAt": now }));
            }
        }
        self.write_index(&entries);
        Ok(())
    }

    /// The stored login for this URL's site family whose username is exactly `username` (case-insensitive).
    pub fn get_user(&self, host: &dyn HostSecrets, url: &str, username: &str) -> Option<Credential> {
        let h = host_of(url)?;
        let mut matches: Vec<(i64, Value)> = self.read_index().into_iter().filter(|e| same_user(e["username"].as_str().unwrap_or(""), username))
            .filter_map(|e| { let s = score_pattern(e["host"].as_str().unwrap_or(""), &h); if s >= 0 { Some((s, e)) } else { None } }).collect();
        matches.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| { let at = a.1["lastUsed"].as_str().or(a.1["updatedAt"].as_str()).unwrap_or(""); let bt = b.1["lastUsed"].as_str().or(b.1["updatedAt"].as_str()).unwrap_or(""); bt.cmp(at) }));
        let best = &matches.first()?.1;
        let password = host.secret_get(CRED_SERVICE, &key_of(best))?;
        Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) })
    }

    /// Merge decrypted rows [{host|url|origin, username, password}] from the browser-login import,
    /// skipping ones already stored with the same password. Node: credentialVault.mergeImportedCredentials.
    pub fn merge_imported(&self, host: &dyn HostSecrets, rows: &[Value]) -> (u64, u64, u64) {
        let (mut added, mut updated, mut skipped) = (0u64, 0u64, 0u64);
        for r in rows {
            let raw_host = r["host"].as_str().or(r["url"].as_str()).or(r["origin"].as_str()).unwrap_or("");
            let hostname = host_of(raw_host).unwrap_or_else(|| raw_host.trim().to_string());
            let (user, pass) = (r["username"].as_str().unwrap_or(""), r["password"].as_str().unwrap_or(""));
            if hostname.is_empty() || pass.is_empty() { skipped += 1; continue; }
            let existed = if user.is_empty() { self.get_for_url(host, &format!("https://{hostname}/"), None) } else { self.get_user(host, &format!("https://{hostname}/"), user) }.filter(|c| c.host == hostname);
            if let Some(ref c) = existed { if c.password == pass { skipped += 1; continue; } }
            match self.set(host, &hostname, if user.is_empty() { "(unknown)" } else { user }, pass) {
                Ok(_) => if existed.is_some() { updated += 1 } else { added += 1 },
                Err(_) => skipped += 1,
            }
        }
        (added, updated, skipped)
    }

    /// Best-matching stored login for a URL (exact host > glob > sibling; then most recently used).
    pub fn get_for_url(&self, host: &dyn HostSecrets, url: &str, prefer_account: Option<&str>) -> Option<Credential> {
        let h = host_of(url)?;
        let mut matches: Vec<(i64, Value)> = self.read_index().into_iter().filter_map(|e| { let s = score_pattern(e["host"].as_str().unwrap_or(""), &h); if s >= 0 { Some((s, e)) } else { None } }).collect();
        if matches.is_empty() { return None; }
        let prefer = prefer_account.map(|p| p.to_lowercase());
        matches.sort_by(|a, b| {
            b.0.cmp(&a.0)
                .then_with(|| { let hit = |e: &Value| prefer.as_ref().map(|p| e["sourceAccount"].as_str().unwrap_or("").to_lowercase() == *p || e["username"].as_str().unwrap_or("").to_lowercase() == *p).unwrap_or(false); hit(&b.1).cmp(&hit(&a.1)) })
                .then_with(|| { let at = a.1["lastUsed"].as_str().or(a.1["updatedAt"].as_str()).unwrap_or(""); let bt = b.1["lastUsed"].as_str().or(b.1["updatedAt"].as_str()).unwrap_or(""); bt.cmp(at) })
        });
        let best = &matches[0].1;
        let password = host.secret_get(CRED_SERVICE, &key_of(best))?;
        Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) })
    }

    pub fn list(&self) -> Vec<Value> {
        self.read_index().into_iter().map(|e| json!({ "host": e["host"], "username": e["username"], "sourceAccount": e["sourceAccount"], "sourceBrowser": e["sourceBrowser"], "sourceProfile": e["sourceProfile"], "imported": e["imported"].as_bool().unwrap_or(false), "addedAt": e["addedAt"], "updatedAt": e["updatedAt"] })).collect()
    }

    /// Remove ONE account's login for a host (the others on that host stay).
    pub fn delete_user(&self, host: &dyn HostSecrets, hostname: &str, username: &str) -> bool {
        let entries = self.read_index();
        let hit = |e: &Value| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username);
        let mut removed = false;
        for e in entries.iter().filter(|e| hit(e)) { if host.secret_delete(CRED_SERVICE, &key_of(e)) { removed = true; } }
        let kept: Vec<Value> = entries.into_iter().filter(|e| !hit(e)).collect();
        self.write_index(&kept);
        removed
    }

    pub fn delete(&self, host: &dyn HostSecrets, hostname: &str) -> bool {
        let entries = self.read_index();
        let mut removed = false;
        for e in entries.iter().filter(|e| e["host"] == json!(hostname)) { let account = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or(""); if host.secret_delete(CRED_SERVICE, account) { removed = true; } }
        let kept: Vec<Value> = entries.into_iter().filter(|e| e["host"] != json!(hostname)).collect();
        self.write_index(&kept);
        removed
    }

    /// Every stored login WITH its password, for a pup-to-pup vault sync. host, username, password,
    /// updatedAt (so the other box can newest-wins on a conflict). Only ever handed to a LOCAL or
    /// same-account bridge caller by the verb — never a cloud relay, never an AI.
    pub fn export_all(&self, host: &dyn HostSecrets) -> Vec<Value> {
        let mut out = Vec::new();
        for e in self.read_index() {
            let hostname = e["host"].as_str().unwrap_or("");
            let account = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("");
            if hostname.is_empty() { continue; }
            if let Some(pw) = host.secret_get(CRED_SERVICE, account) {
                out.push(json!({ "host": hostname, "username": e["username"].as_str().unwrap_or(""), "password": pw, "updatedAt": e["updatedAt"].as_str().or(e["addedAt"].as_str()).unwrap_or(""), "sourceAccount": e["sourceAccount"] }));
            }
        }
        out
    }

    /// Merge entries [{host, username, password, updatedAt}] from another box's vault. Newest-wins on a
    /// password conflict (the incoming updatedAt vs the local one); identical entries are skipped.
    pub fn import_merge(&self, host: &dyn HostSecrets, entries: &[Value]) -> (u64, u64, u64) {
        let (mut added, mut updated, mut skipped) = (0u64, 0u64, 0u64);
        for e in entries {
            let hostname = e["host"].as_str().unwrap_or("").trim();
            let (user, pass, incoming_ts) = (e["username"].as_str().unwrap_or(""), e["password"].as_str().unwrap_or(""), e["updatedAt"].as_str().unwrap_or(""));
            if hostname.is_empty() || pass.is_empty() { skipped += 1; continue; }
            let local = self.read_index().into_iter().find(|x| x["host"].as_str() == Some(hostname) && (user.is_empty() || same_user(x["username"].as_str().unwrap_or(""), user)));
            match &local {
                None => { if self.set(host, hostname, if user.is_empty() { "(unknown)" } else { user }, pass).is_ok() { added += 1; } else { skipped += 1; } }
                Some(le) => {
                    let local_pw = host.secret_get(CRED_SERVICE, &key_of(le)).unwrap_or_default();
                    let same = local_pw == pass && (user.is_empty() || le["username"].as_str() == Some(user));
                    if same { skipped += 1; continue; }
                    let local_ts = le["updatedAt"].as_str().or(le["addedAt"].as_str()).unwrap_or("");
                    // newest-wins: only overwrite when the incoming entry is newer than ours
                    if incoming_ts > local_ts { if self.set(host, hostname, if user.is_empty() { le["username"].as_str().unwrap_or("(unknown)") } else { user }, pass).is_ok() { updated += 1; } else { skipped += 1; } }
                    else { skipped += 1; }
                }
            }
        }
        (added, updated, skipped)
    }

    pub fn touch_used_user(&self, hostname: &str, username: &str) {
        let mut entries = self.read_index();
        if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }
    }

    pub fn touch_used(&self, hostname: &str) {
        let mut entries = self.read_index();
        if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }
    }
}

/// Locate the login form (marks the fields with data-pup-uf / data-pup-pf). Returns {ok, hasUser} or {ok:false, reason}.
pub fn locate_login_js() -> &'static str {
    r#"(() => {
    const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;
    const isPwd = (el) => el && el.tagName === 'INPUT' && (el.type === 'password' || el.autocomplete === 'current-password' || el.autocomplete === 'new-password' || /(^|[^a-z])password([^a-z]|$)/i.test(el.name || '') || /(^|[^a-z])password([^a-z]|$)/i.test(el.id || ''));
    const pwd = [...document.querySelectorAll('input')].filter(isVisible).find(isPwd);
    if (!pwd) return { ok: false, reason: 'no_password_field' };
    const allInputs = [...document.querySelectorAll('input')];
    const pi = allInputs.indexOf(pwd);
    let user = [...document.querySelectorAll('input[autocomplete="username"], input[type="email"]')].find((e) => isVisible(e) && !isPwd(e));
    if (!user) { const cands = [...document.querySelectorAll('input[type="text"], input[type="tel"], input:not([type])')].filter((e) => isVisible(e) && !isPwd(e)); user = [...cands].reverse().find((c) => allInputs.indexOf(c) < pi) || cands[0] || null; }
    pwd.setAttribute('data-pup-pf', '1'); if (user) user.setAttribute('data-pup-uf', '1');
    return { ok: true, hasUser: !!user };
  })()"#
}

/// Background fill through the native value setter (React/Vue/Angular see input+change).
pub fn fill_login_js(username: &str, password: &str) -> String {
    format!(r#"((USERNAME, PASSWORD) => {{
    const setVal = (el, val) => {{ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; try {{ el.focus(); }} catch (e) {{}} setter.call(el, val); el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }})); }};
    const u = document.querySelector('[data-pup-uf]'), p = document.querySelector('[data-pup-pf]');
    let fu = false; if (u && USERNAME) {{ setVal(u, USERNAME); fu = true; }} if (p) setVal(p, PASSWORD);
    return {{ fu, plen: p ? (p.value || '').length : 0 }};
  }})({}, {})"#, js_str(username), js_str(password))
}

/// Submit (or just unmark) the located form. Returns true when a submit was triggered.
pub fn submit_login_js(submit: bool) -> String {
    format!(r#"((SUBMIT) => {{
    const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;
    const pwd = document.querySelector('[data-pup-pf]'); const form = pwd && pwd.form;
    const btn = SUBMIT ? ((form ? [...form.querySelectorAll('button[type="submit"], input[type="submit"], button')] : [...document.querySelectorAll('button, input[type="submit"]')]).find(isVisible)) : null;
    document.querySelectorAll('[data-pup-pf],[data-pup-uf]').forEach((e) => {{ e.removeAttribute('data-pup-pf'); e.removeAttribute('data-pup-uf'); }});
    if (!SUBMIT) return false;
    if (btn) {{ btn.click(); return true; }}
    if (form) {{ (form.requestSubmit ? form.requestSubmit() : form.submit()); return true; }}
    return false;
  }})({})"#, if submit { "true" } else { "false" })
}

// ── payment cards (Node: card_vault.js) ────────────────────────────────────────────────

pub fn normalize_pan(s: &str) -> Option<String> {
    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
    if digits.len() < 12 || digits.len() > 19 { return None; }
    let mut sum = 0; let mut alt = false;
    for ch in digits.chars().rev() { let mut d = ch.to_digit(10)? as u32; if alt { d *= 2; if d > 9 { d -= 9; } } sum += d; alt = !alt; }
    if sum % 10 == 0 { Some(digits) } else { None }
}

pub fn brand_of(pan: &str) -> &'static str {
    if pan.starts_with('4') { "Visa" }
    else if pan.starts_with("34") || pan.starts_with("37") { "Amex" }
    else if (51..=55).contains(&pan[..2.min(pan.len())].parse::<u32>().unwrap_or(0)) || (2221..=2720).contains(&pan[..4.min(pan.len())].parse::<u32>().unwrap_or(0)) { "Mastercard" }
    else if pan.starts_with("6011") || pan.starts_with("65") || pan.starts_with("644") || pan.starts_with("645") || pan.starts_with("646") || pan.starts_with("647") || pan.starts_with("648") || pan.starts_with("649") { "Discover" }
    else if pan.starts_with("35") { "JCB" }
    else if pan.starts_with("36") || pan.starts_with("38") || pan.starts_with("30") { "Diners" }
    else { "Card" }
}

/// "MM/YY" from "MM/YY", "MM/YYYY", "MMYY", "MM-YY".
pub fn normalize_expiry(s: &str) -> Option<String> {
    let d: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
    let (m, y) = match d.len() { 4 => (&d[..2], &d[2..]), 6 => (&d[..2], &d[4..]), _ => return None };
    let mm: u32 = m.parse().ok()?; if !(1..=12).contains(&mm) { return None; }
    Some(format!("{m}/{y}"))
}

pub struct Cards { pub state_dir: PathBuf }
impl Cards {
    pub fn new(state_dir: &Path) -> Cards { Cards { state_dir: state_dir.to_path_buf() } }
    fn index_path(&self) -> PathBuf { self.state_dir.join("pup-cards.index.json") }
    pub fn read(&self) -> Vec<Value> { std::fs::read_to_string(self.index_path()).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["cards"].as_array().cloned()).unwrap_or_default() }
    fn write(&self, cards: &[Value]) { let _ = std::fs::write(self.index_path(), serde_json::to_string_pretty(&json!({ "cards": cards })).unwrap_or_default()); }
    pub fn set(&self, host: &dyn HostSecrets, nickname: &str, number: &str, expiry: &str, cvv: &str, name: Option<&str>) -> Result<Value, String> {
        let nick = nickname.trim(); if nick.is_empty() { return Err("nickname required".into()); }
        let pan = normalize_pan(number).ok_or("card number failed the Luhn check (typo?)")?;
        let exp = normalize_expiry(expiry).ok_or("expiry must be MM/YY")?;
        let csc: String = cvv.chars().filter(|c| c.is_ascii_digit()).collect(); if csc.len() < 3 || csc.len() > 4 { return Err("cvv must be 3 or 4 digits".into()); }
        host.secret_set(CARD_SERVICE, nick, &json!({ "pan": pan, "cvv": csc }).to_string())?;
        let meta = json!({ "nickname": nick, "brand": brand_of(&pan), "last4": &pan[pan.len() - 4..], "expiry": exp, "name": name.unwrap_or(""), "addedAt": pup_core::iso_now() });
        let mut cards = self.read(); cards.retain(|c| c["nickname"] != json!(nick)); cards.push(meta.clone()); self.write(&cards);
        Ok(meta)
    }
    pub fn list(&self) -> Vec<Value> { self.read() }
    pub fn delete(&self, host: &dyn HostSecrets, nickname: &str) -> bool { let mut cards = self.read(); let before = cards.len(); cards.retain(|c| c["nickname"] != json!(nickname)); self.write(&cards); host.secret_delete(CARD_SERVICE, nickname); cards.len() < before }
    pub fn meta(&self, nickname: &str) -> Option<Value> { self.read().into_iter().find(|c| c["nickname"] == json!(nickname)) }
    /// The PAN + CVV; never leaves the bridge process (the fill types them into the page).
    pub fn secret(&self, host: &dyn HostSecrets, nickname: &str) -> Option<(String, String)> { let raw = host.secret_get(CARD_SERVICE, nickname)?; let v: Value = serde_json::from_str(&raw).ok()?; Some((v["pan"].as_str()?.into(), v["cvv"].as_str()?.into())) }
}

pub const CARD_FIELD_SELECTORS: &[(&str, &[&str])] = &[
    ("number", &["input[autocomplete=\"cc-number\"]", "input[name=\"cardnumber\"]", "input[name=\"cardNumber\"]", "input[id*=\"cardnumber\" i]", "input[name*=\"card-number\" i]", "input[id*=\"card-number\" i]", "input[name*=\"ccnumber\" i]", "input[placeholder*=\"card number\" i]"]),
    ("expiry", &["input[autocomplete=\"cc-exp\"]", "input[name=\"exp-date\"]", "input[name=\"expiry\"]", "input[id*=\"expir\" i]", "input[placeholder*=\"MM / YY\" i]", "input[placeholder*=\"MM/YY\" i]"]),
    ("cvv", &["input[autocomplete=\"cc-csc\"]", "input[name=\"cvc\"]", "input[name=\"cvv\"]", "input[id*=\"cvc\" i]", "input[id*=\"cvv\" i]", "input[name*=\"security\" i]", "input[placeholder*=\"CVC\" i]", "input[placeholder*=\"CVV\" i]"]),
    ("name", &["input[autocomplete=\"cc-name\"]", "input[name=\"ccname\"]", "input[id*=\"cardholder\" i]", "input[name*=\"cardholder\" i]", "input[placeholder*=\"name on card\" i]"]),
];

/// In one frame: find a visible field for `field`, scroll it into view, focus and select-all.
/// Returns the element center for the trusted keystrokes, or null.
pub fn card_field_js(field: &str) -> String {
    let sels = CARD_FIELD_SELECTORS.iter().find(|(f, _)| *f == field).map(|(_, s)| s.to_vec()).unwrap_or_default();
    format!(r#"(() => {{ const sels = {}; for (const sel of sels) {{ let el = null; try {{ el = document.querySelector(sel); }} catch (e) {{ continue; }} if (!el) continue; const r = el.getBoundingClientRect(); if (!(r.width > 2 && r.height > 2) || el.disabled || el.readOnly) continue; el.scrollIntoView({{ block: 'center' }}); el.focus(); try {{ el.select(); }} catch (e) {{}} const r2 = el.getBoundingClientRect(); return {{ x: r2.x + r2.width / 2, y: r2.y + r2.height / 2, tag: el.tagName, sel }}; }} return null; }})()"#, serde_json::to_string(&sels).unwrap_or("[]".into()))
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn patterns() {
        assert!(host_matches_pattern("nxp.componentsearchengine.com", "*.componentsearchengine.com"));
        assert!(host_matches_pattern("componentsearchengine.com", "*.componentsearchengine.com"));
        assert!(host_matches_pattern("www.mouser.com", "mouser.com"));
        assert!(!host_matches_pattern("evil-mouser.com", "mouser.com"));
        assert!(score_pattern("www.x.com", "www.x.com") > score_pattern("*.x.com", "www.x.com"));
        assert_eq!(host_of("https://[email protected]:8443/p?q"), Some("a.b.com".into()));
    }
    #[test] fn two_accounts_on_one_host_both_survive() {
        struct Mem(std::sync::Mutex<std::collections::HashMap<String, String>>);
        impl HostSecrets for Mem {
            fn secret_get(&self, s: &str, a: &str) -> Option<String> { self.0.lock().unwrap().get(&format!("{s}/{a}")).cloned() }
            fn secret_set(&self, s: &str, a: &str, v: &str) -> Result<(), String> { self.0.lock().unwrap().insert(format!("{s}/{a}"), v.into()); Ok(()) }
            fn secret_delete(&self, s: &str, a: &str) -> bool { self.0.lock().unwrap().remove(&format!("{s}/{a}")).is_some() }
        }
        let dir = std::env::temp_dir().join(format!("pup-vault-test-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let v = Vault::new(&dir); let m = Mem(Default::default());
        v.set(&m, "accounts.google.com", "[email protected]", "pw-work").unwrap();
        v.set(&m, "accounts.google.com", "[email protected]", "pw-personal").unwrap();
        v.set(&m, "accounts.google.com", "[email protected]", "pw-work-2").unwrap();   // same account, new password: an update
        assert_eq!(v.read_index().len(), 2);
        let u = "https://accounts.google.com/v3/signin/challenge/pwd";
        assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-work-2");
        assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-personal");
        assert!(v.get_user(&m, u, "[email protected]").is_none());
        assert_eq!(v.get_for_url(&m, u, Some("[email protected]")).unwrap().username, "[email protected]");
        assert!(v.delete_user(&m, "accounts.google.com", "[email protected]"));
        assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-work-2");
        let _ = std::fs::remove_dir_all(&dir);
    }

    // 2.0.687: the public network test cards are assembled from 4-digit groups at run time. Written whole, they made
    // the wiki's sensitive-data scanner flag pup's public page as holding a payment card number (John, 2026-10-03).
    fn card(groups: &[&str]) -> String { groups.concat() }
    #[test] fn cards() {
        let visa = card(&["4242", "4242", "4242", "4242"]);
        assert_eq!(normalize_pan(&["4242", "4242", "4242", "4242"].join(" ")).as_deref(), Some(visa.as_str()));
        assert!(normalize_pan(&card(&["4242", "4242", "4242", "4241"])).is_none());
        assert_eq!(brand_of(&visa), "Visa"); assert_eq!(brand_of(&card(&["3782", "8224", "6310", "005"])), "Amex"); assert_eq!(brand_of(&card(&["5555", "5555", "5555", "4444"])), "Mastercard");
        assert_eq!(normalize_expiry("12/2028").as_deref(), Some("12/28")); assert!(normalize_expiry("13/28").is_none());
    }
}