main
Drew Owens 0.9.26 source 11bf910 9d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167
//! UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13).
//!
//! Drew, 2026-10-02: *"can we please figure out how to sign me in? … you can't
//! launch the thing without me manually typing in my password every time."*
//!
//! MEASURED: the sign-in is a native window, class
//! `DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault
//! Systèmes", owned by SWXDesktopLauncher.exe — WebView2 content, so its fields
//! are reachable through UI Automation, which ab drives (`desktop_ui_tree`,
//! `desktop_ui_set`, `desktop_ui_click`). It appears when the 3DEXPERIENCE
//! platform session has EXPIRED: not on ~15 launches on 09-30/10-01, then on
//! 10-02 after licence checks failed overnight.
//!
//! NOBODY HAS SEEN THAT WINDOW'S ACCESSIBLE TREE YET. So:
//!   * every time a launch reaches the sign-in, its INVENTORY is captured —
//!     control types, names, automation ids, IsPassword, checkbox states, NEVER
//!     a field's value — into the launch state and the reply;
//!   * nothing here assumes field names: the password field is the one edit
//!     whose IsPassword is true, the user field the one other edit, the submit
//!     the one button named like a sign-in; anything else is
//!     `sign_in_window_unrecognized`, with the inventory.
//!
//! THE SECRET (Drew's standing rules): it lives ONLY in the pup vault on his
//! laptop. This bridge runs on that laptop and asks ab for it LOCALLY
//! (`pup_credential_reveal`), types it through UIA, and zeroes its own copy.
//! It never appears in a reply, a log line, the ledger, an error text or a
//! command line: every value that leaves this module is scrubbed of it.
//! A rejected password is NEVER retried (lockout risk).

use serde_json::{json, Value};
use std::time::Duration;

const AB_TIMEOUT: Duration = Duration::from_secs(60);
pub const IMPORT_COMMAND: &str = "pup_import_browser_logins {\"hostFilter\":\"3ds.com\"}";

/// The vault entry asked for. `SWX_SIGN_IN_HOST` overrides.
pub fn vault_host() -> String {
    std::env::var("SWX_SIGN_IN_HOST").ok().filter(|h| !h.trim().is_empty()).unwrap_or_else(|| "3ds.com".into())
}

fn verify_secs() -> u64 {
    std::env::var("SWX_SIGN_IN_VERIFY_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(30)
}

// ── the secret ──────────────────────────────────────────────────────────────

/// The credential, held only as long as the fill takes, and zeroed on drop.
/// Best effort: the HTTP buffers that carried it are dropped, not wiped.
struct Secret {
    user: Option<Vec<u8>>,
    pass: Vec<u8>,
}

impl Secret {
    fn needles(&self) -> Vec<String> {
        let mut v = vec![String::from_utf8_lossy(&self.pass).to_string()];
        // The username is not a secret, but it is personal: kept out too.
        if let Some(u) = &self.user {
            v.push(String::from_utf8_lossy(u).to_string());
        }
        v.into_iter().filter(|s| !s.is_empty()).collect()
    }
}

fn wipe(b: &mut [u8]) {
    for x in b.iter_mut() {
        // SAFETY: a plain write to memory we own; volatile so it is not elided.
        unsafe { std::ptr::write_volatile(x, 0) };
    }
}

impl Drop for Secret {
    fn drop(&mut self) {
        wipe(&mut self.pass);
        if let Some(u) = self.user.as_mut() {
            wipe(u);
        }
    }
}

/// SCRUB: every string in `v` with every occurrence of every needle replaced,
/// and every key that could carry a credential removed.
pub fn scrub(v: &Value, needles: &[String]) -> Value {
    match v {
        Value::String(s) => {
            let mut s = s.clone();
            for n in needles {
                if !n.is_empty() {
                    s = s.replace(n.as_str(), "[redacted]");
                }
            }
            Value::String(s)
        }
        Value::Array(a) => Value::Array(a.iter().map(|x| scrub(x, needles)).collect()),
        Value::Object(m) => Value::Object(
            m.iter()
                .filter(|(k, _)| {
                    let k = k.to_ascii_lowercase();
                    !matches!(k.as_str(), "password" | "secret" | "value" | "text" | "credential" | "username")
                })
                .map(|(k, x)| (k.clone(), scrub(x, needles)))
                .collect(),
        ),
        other => other.clone(),
    }
}

// ── ab replies ──────────────────────────────────────────────────────────────

/// ab answers either with the data inline or as `output` holding a JSON string
/// (the shape the rest of this bridge reads); `data` is unwrapped if present.
fn unwrap_ab(v: &Value) -> Value {
    let mut cur = v.clone();
    if let Some(s) = cur.get("output").and_then(|o| o.as_str()) {
        if let Ok(p) = serde_json::from_str::<Value>(s) {
            cur = p;
        }
    }
    if let Some(d) = cur.get("data").cloned() {
        if !d.is_null() {
            cur = d;
        }
    }
    cur
}

/// MEASURED 2026-10-12: pup verbs (`pup_credentials`, `pup_credential_reveal`)
/// answer `{"ok":true,"status":"ok","error":"", …}` — an EMPTY `error` string on
/// success. Treating any non-null `error` as a failure made the vault look
/// unavailable with an empty message (0.9.23). An error is a non-empty string,
/// or `success:false` / `ok:false`.
fn ab_ok(v: &Value) -> bool {
    let flagged_false = |k: &str| v.get(k).and_then(|s| s.as_bool()) == Some(false);
    let error_text = v.get("error").and_then(|e| e.as_str()).map(|t| !t.trim().is_empty()).unwrap_or(false);
    let error_object = v.get("error").map(|e| !e.is_null() && !e.is_string()).unwrap_or(false);
    !flagged_false("success") && !flagged_false("ok") && !error_text && !error_object
}

fn ab_error(v: &Value) -> String {
    match v.get("error") {
        Some(Value::String(t)) if !t.trim().is_empty() => t.clone(),
        Some(Value::Null) | None | Some(Value::String(_)) => "ab reported a failure".to_string(),
        Some(other) => other.to_string(),
    }
}

// ── the inventory ───────────────────────────────────────────────────────────

/// One accessible element — its SHAPE, never its value.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Element {
    pub control_type: String,
    pub name: String,
    pub automation_id: String,
    pub is_password: Option<bool>,
    pub toggle_state: Option<String>,
    pub enabled: Option<bool>,
    /// ab's `invokable` / `settable` (MEASURED 2026-10-02 on ab 2.1.163).
    pub invokable: Option<bool>,
    pub settable: Option<bool>,
    /// Screen rectangle (x, y, w, h), when ab reports one — for a real click.
    pub rect: Option<[i64; 4]>,
    /// Screen centre — ab's own `rect.centerX/centerY` when it gives them
    /// (MEASURED 2026-10-02: click there, in screen px).
    pub center: Option<(i64, i64)>,
    /// LOCATED BY `desktop_find_control`, not read from the tree (0.9.23): the
    /// SWXDesktopUpdate-hosted login's tree holds only the frame (measured
    /// 2026-10-12, 14 nodes), so its controls are found one by one by name /
    /// automation id. Such an element is targeted by its automation id.
    pub by_find: bool,
    /// Does the field already hold text? (A non-empty `value` in ab's node —
    /// the value itself is NEVER copied.) MEASURED 2026-10-12: on page 2 the
    /// user-name edit is prefilled and READ-ONLY.
    pub has_value: Option<bool>,
}

impl Element {
    /// OFF-SCREEN (MEASURED 2026-10-02: the "Remember me" checkbox's native
    /// input sits at centerX -14493; the visible control is its Text label).
    pub fn offscreen(&self) -> bool {
        self.center.map(|(x, y)| x < -1000 || y < -1000).unwrap_or(false)
    }
    /// Off-screen, or a zero-size rectangle (a hidden input a find can still
    /// match). Never a page's field.
    pub fn hidden(&self) -> bool {
        self.offscreen() || self.rect.map(|[_, _, w, h]| w <= 0 || h <= 0).unwrap_or(false)
    }
}

impl Element {
    pub fn to_json(&self) -> Value {
        let mut v = json!({
            "controlType": self.control_type,
            "name": self.name,
            "automationId": self.automation_id,
            "isPassword": self.is_password,
            "toggleState": self.toggle_state,
            "enabled": self.enabled,
            "invokable": self.invokable,
            "settable": self.settable,
        });
        if self.by_find {
            v["locatedBy"] = json!("desktop_find_control");
        }
        if let Some(h) = self.has_value {
            v["hasValue"] = json!(h);
        }
        v
    }
    fn is(&self, t: &str) -> bool {
        self.control_type == t
    }
}

fn norm_type(t: &str) -> String {
    let t = t.to_ascii_lowercase();
    let t = t.trim_start_matches("controltype.").trim_end_matches("control").trim();
    match t {
        "edit" | "textbox" | "text box" | "input" | "textfield" => "edit".into(),
        "button" | "pushbutton" | "push button" => "button".into(),
        "checkbox" | "check box" => "checkbox".into(),
        other => other.to_string(),
    }
}

fn str_of(m: &serde_json::Map<String, Value>, keys: &[&str]) -> String {
    keys.iter().find_map(|k| m.get(*k).and_then(|v| v.as_str())).unwrap_or("").to_string()
}

/// One element from one ab node (a tree node or a find_control match) — its
/// SHAPE, never its value.
fn element_of(m: &serde_json::Map<String, Value>) -> Option<Element> {
    let ty = str_of(m, &["controlType", "control_type", "role", "type", "localizedControlType"]);
    if ty.is_empty() || !(m.contains_key("name") || m.contains_key("automationId") || m.contains_key("automation_id")) {
        return None;
    }
    let mut e = Element {
        control_type: norm_type(&ty),
        name: str_of(m, &["name"]),
        automation_id: str_of(m, &["automationId", "automation_id", "id"]),
        is_password: m.get("isPassword").or_else(|| m.get("is_password")).and_then(|x| x.as_bool()),
        toggle_state: m
            .get("toggleState")
            .or_else(|| m.get("toggle_state"))
            .or_else(|| m.get("checked"))
            .map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())),
        enabled: m.get("isEnabled").or_else(|| m.get("enabled")).and_then(|x| x.as_bool()),
        invokable: m.get("invokable").and_then(|x| x.as_bool()),
        settable: m.get("settable").and_then(|x| x.as_bool()),
        rect: m.get("rect").and_then(rect_xywh),
        center: m.get("rect").and_then(rect_center),
        by_find: false,
        has_value: m.get("value").and_then(|x| x.as_str()).map(|v| !v.is_empty()),
    };
    // THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no
    // `isPassword` key): an explicit password flag, OR an edit whose name /
    // automation id says password.
    if e.control_type == "edit" && e.is_password.is_none() {
        let flag = m.get("password").and_then(|x| x.as_bool());
        let named = looks_like_password(&e.name) || looks_like_password(&e.automation_id);
        e.is_password = Some(flag == Some(true) || named);
    }
    Some(e)
}

/// Every element in ab's tree reply, depth first. Lenient about the reply's
/// shape (it has not been measured) and STRICT about what is copied: type,
/// name, automation id, IsPassword, toggle state, enabled — never a value.
pub fn parse_inventory(reply: &Value) -> Vec<Element> {
    fn walk(v: &Value, out: &mut Vec<Element>) {
        match v {
            Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
            Value::Object(m) => {
                if let Some(e) = element_of(m) {
                    out.push(e);
                }
                for (k, x) in m {
                    if matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls") {
                        walk(x, out);
                    }
                }
            }
            _ => {}
        }
    }
    let mut out = Vec::new();
    let top = unwrap_ab(reply);
    walk(&top, &mut out);
    // A TRUNCATED TREE IS NEVER A GUESS: marked, and recognize() refuses it.
    if top.get("truncated").and_then(|t| t.as_bool()) == Some(true) || reply.get("truncated").and_then(|t| t.as_bool()) == Some(true) {
        out.push(Element {
            control_type: "truncated".into(),
            name: "ab truncated the accessible tree; not every element was read".into(),
            ..Default::default()
        });
    }
    out
}

/// /pass(word)?|mot de passe|contrase/i — the measured rule for a password edit
/// whose node carries no password flag.
pub fn looks_like_password(s: &str) -> bool {
    let s = s.to_lowercase();
    s.contains("pass") || s.contains("mot de passe") || s.contains("contrase")
}

thread_local! {
    static WINDOW_RECT: std::cell::RefCell<Option<[i64; 4]>> = const { std::cell::RefCell::new(None) };
}

fn wake_ms() -> u64 {
    std::env::var("SWX_SIGN_IN_WAKE_MS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(700)
}

fn rect_xywh(r: &Value) -> Option<[i64; 4]> {
    let n = |k: &str| r.get(k).and_then(|v| v.as_f64());
    let (x, y) = (n("x").or(n("left"))?, n("y").or(n("top"))?);
    let w = n("w").or(n("width")).or_else(|| n("right").map(|r| r - x))?;
    let h = n("h").or(n("height")).or_else(|| n("bottom").map(|b| b - y))?;
    Some([x as i64, y as i64, w as i64, h as i64])
}

fn rect_center(r: &Value) -> Option<(i64, i64)> {
    let n = |k: &str| r.get(k).and_then(|v| v.as_f64());
    if let (Some(x), Some(y)) = (n("centerX"), n("centerY")) {
        return Some((x as i64, y as i64));
    }
    rect_xywh(r).map(|[x, y, w, h]| (x + w / 2, y + h / 2))
}

fn overlaps(a: [i64; 4], b: [i64; 4]) -> bool {
    a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]
}

/// One read: the elements, ab's node count, and the window's centre if known.
fn read_once(hwnd: i64) -> Result<(Vec<Element>, u64, Option<(i64, i64)>), String> {
    let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);
    if !ab_ok(&r) {
        return Err(format!("desktop_ui_tree failed: {}", ab_error(&r)));
    }
    let top = unwrap_ab(&r);
    let els = parse_inventory(&r);
    let count = top.get("nodeCount").and_then(|v| v.as_u64()).unwrap_or(els.len() as u64);
    let wr = top.pointer("/window/rect").and_then(rect_xywh).or_else(|| top.pointer("/root/rect").and_then(rect_xywh));
    let center = wr.map(|[x, y, w, h]| (x + w / 2, y + h / 2));
    WINDOW_RECT.with(|c| *c.borrow_mut() = wr);
    Ok((els, count, center))
}

/// The login window, read — woken first if it is lazy.
pub struct Read {
    pub elements: Vec<Element>,
    /// The window's screen rectangle, for turning an element's screen point
    /// into a point on a window screenshot.
    pub window_rect: Option<[i64; 4]>,
    /// ab's node count on every read, cold first.
    pub node_counts: Vec<u64>,
    pub wakes: u32,
}

impl Read {
    pub fn wake_json(&self) -> Value {
        json!({"nodeCounts": self.node_counts, "wakes": self.wakes})
    }
}

/// Does this read show a form (or a question) yet?
fn shows_something(els: &[Element]) -> bool {
    els.iter().any(|e| e.is("edit")) || has_marker(els, SECOND_STEP).is_some() || has_marker(els, REJECTED).is_some()
}

/// READ THE LOGIN WINDOW, WAKING IT IF IT IS LAZY (0.9.15). MEASURED
/// 2026-10-02 on a 3DEXPERIENCE ID login window: the first `desktop_ui_tree`
/// returned 13 nodes with ONE control (Button "Close"); after a
/// `desktop_ui_from_point` query on the page the same call returned 38 nodes
/// with the form. So a read that shows no form queries a point inside the
/// window (read-only) and reads again — up to three times — and both counts
/// are recorded.
pub fn inventory(hwnd: i64) -> Result<Read, String> {
    if !crate::caller::ab_available() {
        return Err("ab's direct API is not available to this bridge (it was not spawned by Adom Bridge)".into());
    }
    let (mut els, n, mut center) = read_once(hwnd)?;
    let mut counts = vec![n];
    let mut wakes = 0u32;
    while !shows_something(&els) && wakes < 3 {
        let mut args = json!({"hwnd": hwnd, "reason": "waking the 3DEXPERIENCE sign-in page's accessibility tree (read-only)"});
        if let (Some((x, y)), Value::Object(m)) = (center, &mut args) {
            m.insert("x".into(), json!(x));
            m.insert("y".into(), json!(y));
        }
        let _ = crate::caller::ad_callback("desktop_ui_from_point", args, AB_TIMEOUT);
        wakes += 1;
        std::thread::sleep(Duration::from_millis(wake_ms()));
        let (e, n, c) = read_once(hwnd)?;
        els = e;
        counts.push(n);
        if c.is_some() {
            center = c;
        }
    }
    if els.is_empty() {
        return Err("desktop_ui_tree answered, but no element with a control type could be read from it".into());
    }
    let window_rect = WINDOW_RECT.with(|c| *c.borrow());
    Ok(Read { elements: els, window_rect, node_counts: counts, wakes })
}

pub fn inventory_json(inv: &[Element]) -> Value {
    json!(inv.iter().map(|e| e.to_json()).collect::<Vec<_>>())
}

// ── the page the tree does not show: desktop_find_control (0.9.23) ──────────
//
// MEASURED 2026-10-12 on adom_drew2: a THIRD login host — a window titled just
// "3DEXPERIENCE", class `WindowsForms10.Window.8.app.0.30495d1_r6_ad1`, UIA
// automationId `SWXDesktopUpdateForm`, owned by `SWXDesktopUpdate.exe` running
// from %TEMP%\SWXDesktopUpdate\ (started the moment the platform shortcut was
// invoked). Its content is the same 3DEXPERIENCE ID sign-in, but
// `desktop_ui_tree` returns only the frame (14 nodes: Window, MenuBar,
// Minimize/Maximize/Close) even at maxNodes 600 / depth 40 and after a
// `desktop_ui_from_point` wake. `desktop_find_control` DOES reach the page:
//   page 1: Edit automationId "username" (name "Email or username",
//           settable:true), Button "Continue" (invokable);
//   page 2: Edit "username" (prefilled), Edit automationId "password" whose
//           NAME is "Password " + a private-use icon glyph (an exact-name query
//           does NOT match it; `{automationId:"password"}` does), Text
//           "Password", Text "Remember me" (no CheckBox found by name),
//           Button "Log in", Button "Back".
//   `{role:"Edit"}` with no name matched "Address and search bar" (view_1012)
//   somewhere else entirely — so a query NEVER goes out with a role alone.
// So a control is located by automation id first, then by role + exact name,
// then by role + name-contains, and a match is accepted only if its role and
// its id or (cleaned) name say it is the control asked for.

/// One control to look for: its role, its automation id (if known), its name.
pub struct Want {
    pub role: &'static str,
    pub automation_id: Option<&'static str>,
    pub name: &'static str,
}

const PAGE_WANTS: &[Want] = &[
    Want { role: "Edit", automation_id: Some("username"), name: "Email or username" },
    Want { role: "Edit", automation_id: Some("password"), name: "Password" },
    Want { role: "Button", automation_id: None, name: "Continue" },
    Want { role: "Button", automation_id: None, name: "Log in" },
    Want { role: "Button", automation_id: None, name: "Back" },
    Want { role: "CheckBox", automation_id: None, name: "Remember me" },
    Want { role: "Text", automation_id: None, name: "Remember me" },
    Want { role: "Button", automation_id: None, name: "Accept All" },
    Want { role: "Button", automation_id: None, name: "Manage my preferences" },
];

/// Lower case, letters/digits/spaces only, trimmed — "Password \u{e9a0}" and
/// "Remember me…" compare as "password" and "remember me".
fn clean(s: &str) -> String {
    let t: String = s.chars().filter(|c| c.is_ascii_alphanumeric() || *c == ' ').collect::<String>().to_lowercase();
    t.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Every element-like object anywhere in a find_control reply.
fn found_elements(reply: &Value) -> Vec<Element> {
    fn walk(v: &Value, out: &mut Vec<Element>) {
        match v {
            Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
            Value::Object(m) => {
                if let Some(e) = element_of(m) {
                    out.push(e);
                }
                m.values().for_each(|x| walk(x, out));
            }
            _ => {}
        }
    }
    let mut out = Vec::new();
    if !ab_ok(reply) {
        return out;
    }
    let top = unwrap_ab(reply);
    if top.get("found").and_then(|f| f.as_bool()) == Some(false) {
        return out;
    }
    walk(&top, &mut out);
    out
}

/// Is `e` the control `w` asks for?
fn is_wanted(e: &Element, w: &Want) -> bool {
    if e.control_type != norm_type(w.role) {
        return false;
    }
    if let Some(id) = w.automation_id {
        if e.automation_id.eq_ignore_ascii_case(id) {
            return true;
        }
    }
    let (n, want) = (clean(&e.name), clean(w.name));
    !want.is_empty() && (n == want || n.starts_with(&want))
}

/// LOCATE ONE CONTROL by `desktop_find_control`: automation id, then role +
/// name, then role + name-contains. Never a role alone. Each attempt is
/// returned (the query and what came back), for the reply.
pub fn find_one(hwnd: i64, w: &Want) -> (Option<Element>, Vec<Value>) {
    let mut queries: Vec<Value> = Vec::new();
    if let Some(id) = w.automation_id {
        queries.push(json!({"hwnd": hwnd, "automationId": id}));
    }
    queries.push(json!({"hwnd": hwnd, "role": w.role, "name": w.name}));
    queries.push(json!({"hwnd": hwnd, "role": w.role, "contains": w.name}));
    let mut tried = Vec::new();
    for q in queries {
        let r = crate::caller::ad_callback("desktop_find_control", q.clone(), Duration::from_secs(20));
        let hit = found_elements(&r).into_iter().find(|e| is_wanted(e, w));
        let mut q_report = q.clone();
        if let Value::Object(m) = &mut q_report {
            m.remove("hwnd");
        }
        tried.push(json!({"query": q_report, "matched": hit.as_ref().map(|e| e.to_json())}));
        if let Some(mut e) = hit {
            e.by_find = true;
            if e.control_type == "edit" && w.automation_id == Some("password") {
                e.is_password = Some(true);
            }
            return (Some(e), tried);
        }
    }
    (None, tried)
}

/// The page, control by control. Off-screen edits (a hidden input) are not
/// the page's fields.
pub fn read_by_find(hwnd: i64) -> (Vec<Element>, Value) {
    let mut els: Vec<Element> = Vec::new();
    let mut report = serde_json::Map::new();
    for w in PAGE_WANTS {
        let (hit, _tried) = find_one(hwnd, w);
        let key = format!("{} \"{}\"", w.role, w.name);
        match hit {
            Some(e) if !(e.control_type == "edit" && e.hidden()) => {
                report.insert(key, json!({"found": true, "name": e.name, "automationId": e.automation_id,
                                          "center": e.center.map(|(x, y)| json!([x, y])), "settable": e.settable, "invokable": e.invokable}));
                if !els.iter().any(|x| x.control_type == e.control_type && x.name == e.name && x.automation_id == e.automation_id) {
                    els.push(e);
                }
            }
            _ => {
                report.insert(key, json!({"found": false}));
            }
        }
    }
    (els, Value::Object(report))
}

/// IS THIS WINDOW A 3DEXPERIENCE ID LOGIN, BY ITS CONTENT? An Edit
/// "Email or username" (page 1) or "Password" (page 2) found in it. Some(1|2)
/// = the page; None = neither field. Needs ab.
pub fn probe_login(hwnd: i64) -> Option<u8> {
    if !crate::caller::ab_available() {
        return None;
    }
    let (pw, _) = find_one(hwnd, &PAGE_WANTS[1]);
    if pw.filter(|e| !e.hidden()).is_some() {
        return Some(2);
    }
    let (user, _) = find_one(hwnd, &PAGE_WANTS[0]);
    user.filter(|e| !e.hidden()).map(|_| 1)
}

static PROBES: std::sync::Mutex<Option<std::collections::HashMap<i64, (std::time::Instant, Option<u8>)>>> = std::sync::Mutex::new(None);

fn probe_ttl() -> Duration {
    Duration::from_millis(std::env::var("SWX_SIGN_IN_PROBE_TTL_MS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(3000))
}

/// `probe_login`, remembered for a few seconds per window (a census runs on
/// every swx_status and every launch poll).
pub fn probe_login_cached(hwnd: i64) -> Option<u8> {
    if let Some((at, v)) = PROBES.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|m| m.get(&hwnd).cloned()) {
        if at.elapsed() < probe_ttl() {
            return v;
        }
    }
    let v = probe_login(hwnd);
    PROBES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(hwnd, (std::time::Instant::now(), v));
    v
}

/// The platform's updater, which hosts the third login (measured 2026-10-12).
pub const DESKTOP_UPDATE_IMAGE: &str = "SWXDesktopUpdate.exe";

/// Worth a content probe? Visible, titled, and not a window the probe would be
/// wasted on: SOLIDWORKS's own (its login is hosted by the launcher) and
/// Visualize's main window (its dialogs are classified by their words).
pub fn probe_eligible(image: &str, w: &crate::launchos::WindowInfo) -> bool {
    w.visible
        && !w.title.trim().is_empty()
        && !image.eq_ignore_ascii_case("SLDWORKS.exe")
        && !SHELLS.iter().any(|s| image.eq_ignore_ascii_case(s))
        && !w.class_name.to_ascii_lowercase().starts_with("hwndwrapper[sldworksvisualize.exe;")
}

/// Is this window a login: by its class / title (the fast path) or by its
/// CONTENT? `Some("titleOrClass" | "content")`.
pub fn window_is_login(image: &str, w: &crate::launchos::WindowInfo) -> Option<(&'static str, Option<u8>)> {
    if !w.visible {
        return None;
    }
    if crate::launch::is_sign_in_window(&w.class_name, &w.title) {
        return Some(("titleOrClass", None));
    }
    if probe_eligible(image, w) {
        if let Some(page) = probe_login_cached(w.hwnd) {
            return Some(("content", Some(page)));
        }
    }
    None
}

/// A login window hosted by SWXDesktopUpdate.exe, anywhere on the machine —
/// the platform's own sign-in, which parks every app start behind it.
pub fn desktop_update_login(os: &dyn crate::launchos::LaunchOs) -> Option<(i64, String)> {
    let census = os.processes().ok()?;
    for p in census.iter().filter(|p| p.image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE)) {
        if let Ok(ws) = os.windows_of_pid(p.pid) {
            if let Some(w) = ws.iter().find(|w| window_is_login(&p.image, w).is_some()) {
                return Some((w.hwnd, w.title.clone()));
            }
        }
    }
    None
}

/// The tree holds no page for this window (remembered so the next read skips
/// the wakes that never help on this host).
static TREE_BLIND: std::sync::Mutex<Vec<i64>> = std::sync::Mutex::new(Vec::new());

/// A page, read: from the tree when the tree shows it, otherwise control by
/// control through desktop_find_control.
pub struct Page {
    pub elements: Vec<Element>,
    pub source: &'static str,
    pub wake: Value,
    pub found: Value,
}

impl Page {
    pub fn report(&self) -> Value {
        json!({"source": self.source, "wake": self.wake, "findControl": self.found})
    }
}

/// READ THE LOGIN PAGE — the tree first; when the tree has no text field (the
/// SWXDesktopUpdate host, measured), `desktop_find_control` by name.
pub fn read_page(hwnd: i64) -> Result<Page, String> {
    let blind = TREE_BLIND.lock().unwrap_or_else(|e| e.into_inner()).contains(&hwnd);
    let tree: Result<(Vec<Element>, Value), String> = if blind {
        if !crate::caller::ab_available() {
            Err("ab's direct API is not available to this bridge (it was not spawned by Adom Bridge)".into())
        } else {
            read_once(hwnd).map(|(e, n, _)| (e, json!({"nodeCounts": [n], "wakes": 0, "treeBlind": true})))
        }
    } else {
        inventory(hwnd).map(|r| (r.elements.clone(), r.wake_json()))
    };
    if let Ok((els, wake)) = &tree {
        if els.iter().any(|e| e.is("edit")) || has_marker(els, SECOND_STEP).is_some() || has_marker(els, REJECTED).is_some() {
            return Ok(Page { elements: els.clone(), source: "tree", wake: wake.clone(), found: Value::Null });
        }
    }
    if !crate::caller::ab_available() {
        return tree.map(|(e, w)| Page { elements: e, source: "tree", wake: w, found: Value::Null });
    }
    let (found, report) = read_by_find(hwnd);
    if found.iter().any(|e| e.is("edit")) {
        let mut b = TREE_BLIND.lock().unwrap_or_else(|e| e.into_inner());
        if !b.contains(&hwnd) {
            b.push(hwnd);
        }
        let wake = tree.as_ref().map(|(_, w)| w.clone()).unwrap_or(Value::Null);
        return Ok(Page { elements: found, source: "desktop_find_control", wake, found: report });
    }
    match tree {
        Ok((e, w)) => Ok(Page { elements: e, source: "tree", wake: w, found: report }),
        Err(e) if !found.is_empty() => Ok(Page { elements: found, source: "desktop_find_control", wake: json!({"treeError": e}), found: report }),
        Err(e) => Err(e),
    }
}

// ── what the window is asking ───────────────────────────────────────────────

const SECOND_STEP: &[&str] = &[
    "verification code", "security code", "one-time", "one time", "two-step", "2-step", "two-factor",
    "2fa", "authenticator", "captcha", "not a robot", "new device", "verify your identity",
    "confirm your identity", "enter the code",
];
const REJECTED: &[&str] = &[
    "incorrect", "invalid", "wrong password", "does not match", "do not match", "not recognized",
    "authentication failed", "login failed", "sign in failed", "couldn't sign", "could not sign",
];
const SUBMIT_PRIMARY: &[&str] = &["log in", "login", "sign in", "sign-in", "signin", "connect", "log on"];
const SUBMIT_SECONDARY: &[&str] = &["continue", "next", "submit", "ok"];
const REMEMBER: &[&str] = &["remember", "stay signed", "keep me signed", "keep me logged"];
const USER_FIELD: &[&str] = &["email", "user", "login", "identifier", "account"];
/// The cookie banner's buttons — a consent choice that is the USER's: never
/// clicked (MEASURED 2026-10-02: "Accept All", "Manage my preferences").
const COOKIE_CHOICES: &[&str] = &["accept all", "accept all cookies", "manage my preferences", "reject all", "reject", "accept", "allow all"];

/// Is a cookie banner on the page? Reported, never answered. A banner is an
/// accept / reject / preferences BUTTON (measured 2026-10-02 on the Visualize
/// host: "Accept All", "Manage my preferences"). A lone "Manage cookies"
/// HYPERLINK is page furniture, not a banner — MEASURED 2026-10-12 on the
/// SWXDesktopUpdate host, where 0.9.25 called that link a banner and toasted
/// the user to answer a banner that did not exist.
pub fn cookie_banner(inv: &[Element]) -> bool {
    inv.iter().any(|e| {
        let n = e.name.trim().trim_end_matches(['.', '…']).to_lowercase();
        e.is("button") && (COOKIE_CHOICES.contains(&n.as_str()) || (n.contains("cookie") && (n.contains("accept") || n.contains("reject") || n.contains("allow"))))
    })
}

/// The recognized form.
#[derive(Debug, Clone)]
pub struct Form {
    pub user: Option<Element>,
    pub password: Option<Element>,
    pub submit: Element,
    pub remember: Option<Element>,
}

#[derive(Debug, Clone, PartialEq)]
pub enum Reading {
    Form,
    SecondStep(String),
    Rejected(String),
    Unrecognized(String),
}

fn has_marker(inv: &[Element], markers: &[&str]) -> Option<String> {
    inv.iter()
        .map(|e| format!("{} {}", e.name, e.automation_id).to_lowercase())
        .find_map(|s| markers.iter().find(|m| s.contains(*m)).map(|m| m.to_string()))
}

/// What the window is asking for, from its inventory alone.
pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) {
    if inv.iter().any(|e| e.is("truncated")) {
        return (Reading::Unrecognized("the accessible tree was truncated, so the form cannot be read whole".into()), None);
    }
    if let Some(m) = has_marker(inv, SECOND_STEP) {
        return (Reading::SecondStep(m), None);
    }
    // Only edits that can be SET (ab's `settable`, when it says) count.
    let editable = |e: &&Element| e.is("edit") && e.settable != Some(false);
    let passwords: Vec<&Element> = inv.iter().filter(editable).filter(|e| e.is_password == Some(true)).collect();
    let users: Vec<&Element> = inv.iter().filter(editable).filter(|e| e.is_password != Some(true)).collect();
    if passwords.len() > 1 {
        return (Reading::Unrecognized(format!("{} password fields", passwords.len())), None);
    }
    if users.len() > 1 {
        return (Reading::Unrecognized(format!("{} non-password text fields (expected at most one: the user name)", users.len())), None);
    }
    if passwords.is_empty() && users.is_empty() {
        return (Reading::Unrecognized("no text field at all".into()), None);
    }
    // PAGE ONE of a two-page sign-in (MEASURED 2026-10-02: "Email or username"
    // + Continue, no password field): the one field must SAY it is the user name.
    if passwords.is_empty() {
        let u = users[0];
        let says = format!("{} {}", u.name, u.automation_id).to_lowercase();
        if !USER_FIELD.iter().any(|w| says.contains(w)) {
            return (Reading::Unrecognized(format!("one text field (\"{}\") that does not say it is the user name, and no password field", u.name)), None);
        }
    }
    // Buttons that can be INVOKED (ab's `invokable`, when it says).
    // (A cookie-consent button is never a candidate: that choice is the user's.)
    let buttons: Vec<&Element> = inv
        .iter()
        .filter(|e| e.is("button") && e.invokable != Some(false))
        .filter(|e| !COOKIE_CHOICES.contains(&e.name.trim().to_lowercase().as_str()))
        .collect();
    let named = |list: &[&str]| -> Vec<&Element> {
        buttons
            .iter()
            .filter(|b| {
                let n = b.name.trim().trim_end_matches(['.', '…']).to_lowercase();
                list.contains(&n.as_str())
            })
            .copied()
            .collect()
    };
    let primary = named(SUBMIT_PRIMARY);
    let submit = match (primary.len(), named(SUBMIT_SECONDARY)) {
        (1, _) => primary[0].clone(),
        (0, s) if s.len() == 1 => s[0].clone(),
        (0, _) => return (Reading::Unrecognized("no single button named like a sign-in (Log in / Sign in / Connect / Continue / Next)".into()), None),
        (n, _) => return (Reading::Unrecognized(format!("{n} buttons named like a sign-in")), None),
    };
    let remember = inv
        .iter()
        .find(|e| e.is("checkbox") && REMEMBER.iter().any(|r| e.name.to_lowercase().contains(r)))
        .cloned();
    (
        Reading::Form,
        Some(Form {
            user: users.first().map(|e| (*e).clone()),
            password: passwords.first().map(|e| (*e).clone()),
            submit,
            remember,
        }),
    )
}

/// ab targets a control by `name` / `contains` (MEASURED 2026-10-02: a click
/// with no name answers "control found but has NO InvokePattern"). The
/// automation id is sent too, as a reported field — never relied on.
fn target(e: &Element, role: &str) -> Value {
    // LOCATED BY find_control (0.9.23): by automation id when it has one —
    // MEASURED 2026-10-12: `desktop_ui_set {hwnd, automationId:"username",
    // text}` works on the SWXDesktopUpdate host, and the password edit's NAME
    // ("Password " + an icon glyph) does not match an exact-name query.
    if e.by_find && !e.automation_id.is_empty() {
        return json!({"automationId": e.automation_id});
    }
    if e.by_find {
        let _ = role;
        return json!({"name": e.name});
    }
    json!({
        "name": e.name,
        "contains": e.name,
        "automationId": if e.automation_id.is_empty() { Value::Null } else { json!(e.automation_id) },
        "role": role,
    })
}

fn merge(mut a: Value, b: Value) -> Value {
    if let (Value::Object(x), Value::Object(y)) = (&mut a, b) {
        for (k, v) in y {
            x.insert(k, v);
        }
    }
    a
}

// ── the vault ───────────────────────────────────────────────────────────────

enum Vault {
    Got(Secret, Value),
    Missing(String),
    Denied(String),
    Pending(String),
    RelayForbidden(String),
    /// ab's POLICY refused the reveal (0.9.25): the secret goes only to a
    /// VERIFIED LOCAL caller, and a bridge ab hosts calls back with origin
    /// "bridge". Not a bug, and never worked around.
    Policy(String),
    Unavailable(String),
}

/// What the user sees on pup's consent toast (it is shown to them — MEASURED
/// 2026-10-02: "reason 8+ chars; it is shown to the user").
pub const CONSENT_REASON: &str = "Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login";

fn consent_secs() -> u64 {
    std::env::var("SWX_SIGN_IN_CONSENT_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(120)
}

/// The hosts the vault holds a login for, from `pup_credentials {}` — host and
/// username only, never a secret, and NO consent toast. `Err` = could not ask.
/// The vault's host list — each host with the user name pup lists beside it
/// (host + username only: no secret, no toast).
fn vault_hosts() -> Result<Vec<(String, Option<String>)>, String> {
    let r = crate::caller::ad_callback("pup_credentials", json!({}), AB_TIMEOUT);
    if !ab_ok(&r) {
        return Err(ab_error(&r));
    }
    fn walk(v: &Value, out: &mut Vec<(String, Option<String>)>) {
        match v {
            Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
            Value::Object(m) => {
                if let Some(h) = m.get("host").and_then(|h| h.as_str()) {
                    let user = ["username", "user", "login", "email"]
                        .iter()
                        .find_map(|k| m.get(*k).and_then(|u| u.as_str()))
                        .filter(|u| !u.is_empty())
                        .map(String::from);
                    out.push((h.to_lowercase(), user));
                }
                for (k, x) in m {
                    if k != "host" {
                        walk(x, out);
                    }
                }
            }
            _ => {}
        }
    }
    let mut out = Vec::new();
    walk(&unwrap_ab(&r), &mut out);
    out.sort();
    out.dedup_by(|a, b| a.0 == b.0);
    Ok(out)
}

/// The vault host for the 3DEXPERIENCE login: an exact `SWX_SIGN_IN_HOST`, or
/// any host on its registrable domain — preferring the identity service.
fn pick_host(hosts: &[String], want: &str) -> Option<String> {
    let want = want.to_lowercase();
    let on_domain: Vec<&String> = hosts.iter().filter(|h| *h == &want || h.ends_with(&format!(".{want}"))).collect();
    on_domain
        .iter()
        .find(|h| h.contains("iam."))
        .or_else(|| on_domain.iter().find(|h| h.contains("3dexperience")))
        .or_else(|| on_domain.first())
        .map(|h| h.to_string())
}

fn error_code_of(r: &Value) -> String {
    let d = unwrap_ab(r);
    [r.get("errorCode"), r.get("code"), r.get("status"), d.get("errorCode"), d.get("code"), d.get("status")]
        .iter()
        .flatten()
        .filter_map(|v| v.as_str())
        .map(|s| s.to_lowercase())
        .collect::<Vec<_>>()
        .join(" ")
        + " "
        + &ab_error(r).to_lowercase()
}

/// EXISTENCE FIRST, without a toast (M5): a missing login never asks the user
/// to consent to anything. The vault host, and the user name listed with it.
fn lookup() -> Result<(String, Option<String>), Vault> {
    let hosts = match vault_hosts() {
        Ok(h) => h,
        Err(e) => return Err(Vault::Unavailable(format!("the vault's host list could not be read: {e}"))),
    };
    let names: Vec<String> = hosts.iter().map(|(h, _)| h.clone()).collect();
    let Some(host) = pick_host(&names, &vault_host()) else {
        return Err(Vault::Missing(format!("no saved login on {} ({} host(s) in the vault)", vault_host(), hosts.len())));
    };
    let user = hosts.iter().find(|(h, _)| *h == host).and_then(|(_, u)| u.clone());
    Ok((host, user))
}

/// Is this reveal refusal ab's VERIFIED-LOCAL-ORIGIN policy? MEASURED
/// 2026-10-12 (hosted 0.9.24): "pup_credential_reveal requires a VERIFIED local
/// transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived
/// with origin \"bridge\"".
pub fn is_origin_policy(message: &str) -> bool {
    let m = message.to_lowercase();
    m.contains("verified-origin") || (m.contains("verified") && (m.contains("local") || m.contains("origin"))) || m.contains("arrived with origin")
}

fn reveal_at(host: &str) -> Vault {
    // THE REVEAL: gated by a STICKY CONSENT TOAST the user must click (measured:
    // pup's own text — consent is PER USE). A `consent_pending` answer
    // re-attaches when called again, with no second prompt.
    let deadline = std::time::Instant::now() + Duration::from_secs(consent_secs());
    loop {
        let r = crate::caller::ad_callback(
            "pup_credential_reveal",
            json!({"host": host, "reason": CONSENT_REASON}),
            Duration::from_secs(120),
        );
        let d = unwrap_ab(&r);
        let pass = ["password", "secret"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));
        let user = ["username", "user", "login", "email"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));
        let consent = d.get("consent").or_else(|| r.get("consent")).cloned().unwrap_or(json!("perUse"));
        if let Some(p) = pass.filter(|p| !p.is_empty()) {
            if ab_ok(&r) {
                return Vault::Got(
                    Secret { user, pass: p },
                    json!({"host": host, "consent": consent,
                           "consentBasis": "pup_credential_reveal's own hint: \"gated by a sticky consent toast the user must click\""}),
                );
            }
        }
        let code = error_code_of(&r);
        let err = scrub(&json!(ab_error(&r)), &[]).as_str().unwrap_or("").to_string();
        if code.contains("relay_forbidden") {
            return Vault::RelayForbidden(err);
        }
        // ab's POLICY: never retried, never worked around.
        if is_origin_policy(&code) {
            return Vault::Policy(err);
        }
        if code.contains("consent_pending") || code.contains("pending") {
            if std::time::Instant::now() >= deadline {
                return Vault::Pending(err);
            }
            std::thread::sleep(Duration::from_millis(1500));
            continue;
        }
        let e = err.to_lowercase();
        return if e.contains("not found") || e.contains("no credential") || e.contains("no entry") || e.contains("no saved") || e.contains("no login") || e.contains("no match") {
            Vault::Missing(err)
        } else if e.contains("consent") || e.contains("denied") || e.contains("declined") || e.contains("cancel") {
            Vault::Denied(err)
        } else {
            Vault::Unavailable(err)
        };
    }
}

// ── the rejection record: never retried ─────────────────────────────────────

fn rejected_path() -> std::path::PathBuf {
    crate::scratch::root().join("sign-in-rejected.json")
}

pub fn rejected_before() -> Option<Value> {
    std::fs::read_to_string(rejected_path()).ok().and_then(|t| serde_json::from_str(&t).ok())
}

fn record_rejection(why: &str) {
    let _ = crate::launch::write_atomic(
        &rejected_path(),
        &json!({"atMs": crate::launchos::epoch_ms() as u64, "why": why, "host": vault_host()}).to_string(),
    );
}

// ── the sign-in ─────────────────────────────────────────────────────────────

/// A vault answer that is not the login, as the reply. `prepared` = what the
/// bridge had already done to the page (`None`: nothing typed).
fn vault_refusal(v: Vault, extra: Value, prepared: Option<Value>) -> Value {
    let typed_user = prepared.as_ref().and_then(|p| p.get("userNameFilled")).and_then(|b| b.as_bool()) == Some(true);
    let nothing = if typed_user { "The password was not typed; the user name was filled (see pagePrepared)." } else { "Nothing was typed." };
    let extra = merge(extra, json!({"pagePrepared": prepared}));
    match v {
        Vault::Got(..) => err("sign_in_vault_unavailable", "internal: a revealed login reached the refusal path", nothing, extra),
        Vault::Missing(e) => err(
            "sign_in_credential_missing",
            format!("the vault has no 3DEXPERIENCE login for {}: {e}", vault_host()),
            format!(
                "{nothing} The user's vault on their laptop needs the login, in one of three ways (each on their desktop, by them — never a password in chat): \
                 (1) import it from their browser: ab {IMPORT_COMMAND} — measured 2026-10-02 on Drew's machine it answered no_data (his browser holds none); \
                 (2) sign in to 3DEXPERIENCE once in a pup window, so pup captures the login; \
                 (3) store it themselves with pup's credential_set on their desktop."
            ),
            merge(extra, json!({"importCommand": IMPORT_COMMAND,
                   "growTheVault": ["pup_import_browser_logins {\"hostFilter\":\"3ds.com\"}", "sign in once in a pup window (pup captures it)", "credential_set on the user's desktop, by the user"]})),
        ),
        Vault::Denied(e) => err("sign_in_consent_denied", format!("the user did not allow the saved login to be used: {e}"), format!("The user declined the consent toast. {nothing} Do not ask again unless they say so."), extra),
        Vault::Pending(e) => err("sign_in_consent_pending", format!("the consent toast was not answered within {}s: {e}", consent_secs()), format!("The toast \"Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login\" is waiting on the user's desktop. Tell them; calling swx_sign_in again re-attaches to it without a second prompt. {nothing}"), merge(extra, json!({"consentReason": CONSENT_REASON}))),
        Vault::RelayForbidden(e) => err("sign_in_relay_forbidden", format!("ab refused the reveal as a RELAYED call: {e}"), format!("This is a bridge wiring bug, not a user problem: the bridge runs on the desktop and must call ab as a LOCAL caller. Report it (see _reportIssues). {nothing}"), extra),
        Vault::Policy(e) => {
            tell_user(if typed_user {
                "Please type your 3DEXPERIENCE password in the sign-in window. The bridge filled your user name; Adom Bridge does not hand saved passwords to bridges."
            } else {
                "Please sign in to 3DEXPERIENCE in the sign-in window. Adom Bridge does not hand saved passwords to bridges."
            });
            err(
                "sign_in_vault_policy",
                format!("ab's policy keeps the saved password from this bridge: {e}"),
                format!("ab gives a saved password only to a verified local caller, and a bridge is not one; the bridge prepared the page (pagePrepared) and the USER types the password — until ab issue #237 (a fill verb that keeps the secret inside ab) lands. {nothing} Not retried."),
                merge(extra, json!({
                    "abSaid": e,
                    "waysOut": [
                        if typed_user {
                            "the user types the password in the window (the bridge has filled the user name and advanced to page 2)"
                        } else {
                            "the user signs in by hand in the window (the vault lists no user name, so the bridge could not prepare the page)"
                        },
                        "ab issue #237: a fill verb that keeps the secret inside ab"
                    ],
                })),
            )
        }
        Vault::Unavailable(e) => err("sign_in_vault_unavailable", format!("the vault could not be asked: {e}"), format!("{nothing} The user can sign in by hand."), extra),
    }
}

fn err(code: &str, msg: impl Into<String>, hint: impl Into<String>, extra: Value) -> Value {
    merge(crate::verbs::err_code(code, msg, hint), extra)
}

fn tell_user(what: &str) {
    let _ = crate::caller::ad_callback(
        "notify_user",
        json!({"title": "SOLIDWORKS sign-in needs you", "message": what}),
        Duration::from_secs(10),
    );
}

/// SIGN IN, unattended. `who` is the caller's identity for the ledger.
/// Which login window, and on whose authority.
#[derive(Clone, Debug, Default)]
pub struct Opts {
    pub user_confirmed_new_password: bool,
    /// The user authorized signing in to ANY 3DEXPERIENCE ID login window on
    /// this machine (0.9.16) — not only one a launch of this bridge raised.
    pub user_authorized: bool,
    /// "visualize" | "solidworks" | an hwnd, when several are up.
    pub window: Option<String>,
}

pub fn sign_in(reason: &str, opts: Opts, who: Value) -> Value {
    let started = std::time::Instant::now();
    let out = sign_in_inner(reason, &opts);
    let outcome = out.get("errorCode").and_then(|c| c.as_str()).unwrap_or(if out.get("signedIn") == Some(&json!(true)) { "signedIn" } else { "unknown" }).to_string();
    crate::ledger::record(
        "solidworks_sign_in",
        json!({
            "caller": who,
            "reason": reason,
            "outcome": outcome,
            "elapsedSec": started.elapsed().as_secs(),
            "consent": out.pointer("/vault/consent").cloned().unwrap_or(Value::Null),
            "rememberTicked": out.get("rememberTicked").cloned().unwrap_or(Value::Null),
            "window": out.get("window").cloned().unwrap_or(Value::Null),
            "userAuthorized": opts.user_authorized,
        }),
    );
    out
}

/// A 3DEXPERIENCE ID login window, and the process that owns it.
#[derive(Clone, Debug)]
pub struct LoginWindow {
    pub hwnd: i64,
    pub title: String,
    pub owner_pid: u32,
    pub owner_image: String,
    /// Owned by a process of THIS bridge's launch chain.
    pub ours: bool,
    /// How it was recognised: "titleOrClass" (the fast path) or "content" (an
    /// Edit "Email or username" / "Password" found in it — 0.9.23).
    pub found_by: &'static str,
    /// The page a content probe saw (1 = user name, 2 = password).
    pub page: Option<u8>,
}

impl LoginWindow {
    pub fn host(&self) -> &'static str {
        if self.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe") {
            "visualize"
        } else if self.owner_image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE) {
            "desktopUpdate"
        } else if self.owner_image.is_empty() {
            "unknown"
        } else {
            "solidworks"
        }
    }
    pub fn to_json(&self) -> Value {
        json!({"hwnd": self.hwnd, "title": self.title, "ownerPid": self.owner_pid,
               "ownerImage": self.owner_image, "ownedByThisBridgesLaunch": self.ours,
               "host": self.host(), "recognizedBy": self.found_by, "page": self.page})
    }
}

const SHELLS: &[&str] = &["explorer.exe", "cmd.exe", "powershell.exe", "pwsh.exe"];

/// Every 3DEXPERIENCE ID login window: those of OUR launch chain, and — with
/// `all` — those of every Dassault process in the census (SOLIDWORKS, its
/// launcher, SOLIDWORKS Visualize…). `Err` = nothing could be read.
pub fn login_windows(os: &dyn crate::launchos::LaunchOs, all: bool) -> Result<Vec<LoginWindow>, String> {
    let ours = crate::launch::our_chain_pids();
    let census = os.processes();
    let mut candidates: Vec<(u32, String, bool)> = Vec::new();
    let image_of = |pid: u32| -> String {
        census.as_ref().ok().and_then(|c| c.iter().find(|p| p.pid == pid).map(|p| p.image.clone())).unwrap_or_default()
    };
    for p in &ours {
        candidates.push((*p, image_of(*p), true));
    }
    // THE PLATFORM UPDATER (0.9.23): SWXDesktopUpdate.exe starts the moment a
    // platform shortcut is invoked (measured 2026-10-12) and is not a child the
    // chain census links. One started at or after THIS bridge's invoke, while
    // its launch is in flight, is that launch's.
    if let (Some(a), Ok(c)) = (crate::launch::attempt().filter(|a| !a.finished), &census) {
        for p in c.iter().filter(|p| p.image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE)) {
            let since_invoke = p.created_at.map(|t| t + 2 >= a.invoked_at).unwrap_or(false);
            if since_invoke && !candidates.iter().any(|(q, _, _)| *q == p.pid) {
                candidates.push((p.pid, p.image.clone(), true));
            }
        }
    }
    if all {
        match &census {
            Ok(c) => {
                for p in c.iter().filter(|p| !SHELLS.iter().any(|s| p.image.eq_ignore_ascii_case(s))) {
                    if !candidates.iter().any(|(q, _, _)| *q == p.pid) {
                        candidates.push((p.pid, p.image.clone(), false));
                    }
                }
            }
            Err(e) if candidates.is_empty() => return Err(format!("the process census could not be read: {e}")),
            Err(_) => {}
        }
    }
    let mut out: Vec<LoginWindow> = Vec::new();
    let mut read_any = false;
    for (pid, image, is_ours) in candidates {
        if let Ok(ws) = os.windows_of_pid(pid) {
            read_any = true;
            for w in ws.iter() {
                if out.iter().any(|o| o.hwnd == w.hwnd) {
                    continue;
                }
                if let Some((how, page)) = window_is_login(&image, w) {
                    out.push(LoginWindow { hwnd: w.hwnd, title: w.title.clone(), owner_pid: pid, owner_image: image.clone(), ours: is_ours, found_by: how, page });
                }
            }
        }
    }
    if !read_any && !out.is_empty() {
        return Ok(out);
    }
    if !read_any {
        return Err("no candidate process's windows could be read".into());
    }
    Ok(out)
}

/// Is that login window still up? `None` = cannot tell.
fn still_up(os: &dyn crate::launchos::LaunchOs, lw: &LoginWindow) -> Option<bool> {
    if lw.owner_pid == 0 {
        // A window the census does not hold (named by the caller): ab's list.
        return crate::abwin::list_windows(Duration::from_secs(10))
            .ok()
            .map(|ws| ws.iter().any(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(lw.hwnd)));
    }
    os.windows_of_pid(lw.owner_pid).ok().map(|ws| ws.iter().any(|w| w.hwnd == lw.hwnd && w.visible))
}

/// THE WINDOW THE CALLER NAMED, when no census holds it as a login (0.9.23).
/// MEASURED 2026-10-12: `swx_sign_in {userAuthorized:true, window:857684}`
/// was refused "no 3DEXPERIENCE ID login window matching the request" while
/// that window WAS the sign-in (SWXDesktopUpdate.exe, title "3DEXPERIENCE").
/// A named window is judged by its CONTENT; refused only when neither field is
/// in it, with what was found.
fn named_window(os: &dyn crate::launchos::LaunchOs, hwnd: i64) -> Result<LoginWindow, Value> {
    let (found, report) = read_by_find(hwnd);
    let user = found.iter().any(|e| e.is("edit") && is_wanted(e, &PAGE_WANTS[0]));
    let pass = found.iter().any(|e| e.is("edit") && is_wanted(e, &PAGE_WANTS[1]));
    // Who owns it, if the census can say.
    let mut owner: Option<(u32, String, String)> = None;
    if let Ok(c) = os.processes() {
        for p in &c {
            if let Ok(ws) = os.windows_of_pid(p.pid) {
                if let Some(w) = ws.iter().find(|w| w.hwnd == hwnd) {
                    owner = Some((p.pid, p.image.clone(), w.title.clone()));
                    break;
                }
            }
        }
    }
    let title = owner.as_ref().map(|o| o.2.clone()).or_else(|| {
        crate::abwin::list_windows(Duration::from_secs(10)).ok().and_then(|ws| {
            ws.iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd)).and_then(|w| w.get("title").and_then(|t| t.as_str()).map(String::from))
        })
    });
    if !user && !pass {
        return Err(err(
            "sign_in_window_not_a_login",
            format!("window {hwnd} shows neither an \"Email or username\" nor a \"Password\" field"),
            "Nothing was typed. `found` lists every control looked for and what desktop_find_control answered.",
            json!({"hwnd": hwnd, "title": title, "found": report}),
        ));
    }
    let (pid, image) = owner.map(|o| (o.0, o.1)).unwrap_or((0, String::new()));
    Ok(LoginWindow { hwnd, title: title.unwrap_or_default(), owner_pid: pid, owner_image: image, ours: false, found_by: "content", page: Some(if pass { 2 } else { 1 }) })
}

fn sign_in_inner(reason: &str, opts: &Opts) -> Value {
    let os = crate::launchos::os();
    let user_confirmed_new_password = opts.user_confirmed_new_password;
    let attempt = crate::launch::attempt();
    // WHICH WINDOW. Without the user's authorization: only the sign-in window
    // of a launch THIS bridge started, owned by its own chain. With it
    // (0.9.16): any 3DEXPERIENCE ID login window on the machine — the
    // launcher's, SOLIDWORKS Visualize's (which asks separately, measured
    // 2026-10-02) — and `window` picks one when several are up.
    if !opts.user_authorized && attempt.is_none() {
        return err("sign_in_not_waiting", "no launch by this bridge is recorded", "swx_start begins one; swx_sign_in answers only the sign-in window of a launch this bridge started — or, with userAuthorized:true (the user's say-so), any 3DEXPERIENCE ID login window on this machine.", json!({}));
    }
    let named_hwnd = opts.window.as_deref().and_then(|w| w.trim().parse::<i64>().ok()).filter(|_| opts.user_authorized);
    let found = match login_windows(os.as_ref(), opts.user_authorized) {
        Ok(f) => f,
        // A window the CALLER NAMED is judged by its content, not refused
        // because a census could not be taken (0.9.23).
        Err(_) if named_hwnd.is_some() => Vec::new(),
        Err(e) => return err("sign_in_not_waiting", format!("the login windows could not be read: {e}"), "Nothing was typed anywhere.", json!({})),
    };
    let pool: Vec<LoginWindow> = if opts.user_authorized { found } else { found.into_iter().filter(|w| w.ours).collect() };
    let picked: Vec<LoginWindow> = match opts.window.as_deref().map(|w| w.trim().to_lowercase()) {
        None => pool.clone(),
        Some(w) if w == "visualize" => pool.iter().filter(|l| l.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe")).cloned().collect(),
        Some(w) if w == "solidworks" => pool.iter().filter(|l| l.host() == "solidworks").cloned().collect(),
        Some(w) if w == "desktopupdate" => pool.iter().filter(|l| l.host() == "desktopUpdate").cloned().collect(),
        Some(w) => match w.parse::<i64>() {
            Ok(h) => {
                let hit: Vec<LoginWindow> = pool.iter().filter(|l| l.hwnd == h).cloned().collect();
                if hit.is_empty() && opts.user_authorized {
                    // NAMED BY THE CALLER: never refused on census grounds.
                    match named_window(os.as_ref(), h) {
                        Ok(lw) => vec![lw],
                        Err(e) => return e,
                    }
                } else {
                    hit
                }
            }
            Err(_) => return err("sign_in_bad_window", format!("window {w:?} is not \"visualize\", \"solidworks\", \"desktopUpdate\" or an hwnd"), "Pass window:\"visualize\", \"solidworks\", \"desktopUpdate\" or a window handle.", json!({})),
        },
    };
    let listed = json!(pool.iter().map(|l| l.to_json()).collect::<Vec<_>>());
    let lw = match picked.as_slice() {
        [one] => one.clone(),
        [] => {
            return err(
                "sign_in_not_waiting",
                if opts.user_authorized { "no 3DEXPERIENCE ID login window matching the request is open on this machine" } else { "no 3DEXPERIENCE sign-in window is open for this bridge's launch" },
                "Nothing to do. swx_status says what a launch is waiting on; `loginWindows` lists what is open.",
                json!({"loginWindows": listed, "launchStage": attempt.as_ref().map(|a| a.stage.name())}),
            )
        }
        many => {
            return err(
                "sign_in_window_ambiguous",
                format!("{} 3DEXPERIENCE ID login windows are open", many.len()),
                "Pick one: window:\"visualize\", window:\"solidworks\" or its hwnd. Nothing was typed.",
                json!({"loginWindows": listed}),
            )
        }
    };
    let hwnd = lw.hwnd;
    let window_j = lw.to_json();
    // NEVER RETRIED after a rejection (lockout risk).
    if attempt.as_ref().map(|a| a.sign_in_rejected).unwrap_or(false) || (rejected_before().is_some() && !user_confirmed_new_password) {
        return err(
            "sign_in_rejected_before",
            "a sign-in with the saved password was REJECTED before, and it is never retried (an account lockout is worse than a manual sign-in)",
            "Ask the user to sign in by hand, or to update the saved 3DEXPERIENCE login in the vault; only after THEY say it has changed may swx_sign_in be called with userConfirmedNewPassword:true.",
            json!({"rejectedBefore": rejected_before()}),
        );
    }
    // 1. THE INVENTORY, captured every time (woken first if the page is lazy).
    //    When the tree holds only the frame (the SWXDesktopUpdate host,
    //    measured 2026-10-12), the page is located control by control.
    let read = match read_page(hwnd) {
        Ok(r) => r,
        Err(e) => return err("sign_in_inventory_unreadable", e, "Nothing was typed. The user can sign in by hand.", json!({"hwnd": hwnd})),
    };
    crate::launch::note_sign_in_inventory(inventory_json(&read.elements));
    crate::launch::note_sign_in_wake(read.wake.clone());
    let inv_j = inventory_json(&read.elements);
    let wake_1 = read.wake.clone();
    let located_1 = read.report();
    let banner = cookie_banner(&read.elements);
    let (reading, form) = recognize(&read.elements);
    let form = match (reading, form) {
        (Reading::Form, Some(f)) => f,
        (Reading::SecondStep(m), _) => {
            tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window — the bridge will not."));
            return err("sign_in_needs_user", format!("the sign-in window asks for a second step (\"{m}\") that only the user can answer"), "The user has been notified. Nothing was typed.", json!({"inventory": inv_j, "wake": wake_1}));
        }
        (r, _) => {
            let why = match r { Reading::Unrecognized(w) | Reading::Rejected(w) => w, _ => "unrecognized".into() };
            return err("sign_in_window_unrecognized", format!("the sign-in window does not look like the form this bridge fills: {why}"), "Nothing was typed. The inventory is attached so the form can be recognized next time; the user can sign in by hand.", json!({"inventory": inv_j, "wake": wake_1, "located": located_1, "cookieBanner": banner, "window": window_j}));
        }
    };
    // 2. THE LOGIN'S HOST AND USER NAME — no toast, no secret: pup_credentials
    //    lists host + username. 0.9.25: THE PAGE IS PREPARED BEFORE THE
    //    PASSWORD IS ASKED FOR. MEASURED 2026-10-12 (hosted 0.9.24): ab refused
    //    the reveal to this bridge by POLICY ("requires a VERIFIED local
    //    transport … arrived with origin \"bridge\"") — so the user name is
    //    filled and Continue pressed first, and a refused reveal leaves the
    //    window on the password page with the name in place, for the user.
    let _ = reason; // the AI's reason is ledgered; the user's toast carries CONSENT_REASON
    let (host, listed_user) = match lookup() {
        Ok(x) => x,
        Err(v) => return vault_refusal(v, json!({"inventory": inv_j}), None),
    };
    let mut needles: Vec<String> = listed_user.iter().cloned().collect();
    let mut steps: Vec<Value> = Vec::new();
    // A page with the PASSWORD edit is page 2 (or a one-page form), whatever
    // else it holds (0.9.26: measured, page 2 also shows the user-name edit and
    // was called page 1).
    let first_page: u8 = if form.password.is_some() { 2 } else { 1 };
    let mut pages: Vec<Value> = vec![json!({"page": first_page, "inventory": inv_j, "wake": wake_1, "located": located_1, "cookieBanner": banner})];
    let mut remember_ticked = Value::Null;
    let mut vault = json!({"host": host});
    // No user name listed beside the host: the reveal is the only source of
    // it, so it comes first (the old order).
    let mut secret: Option<Secret> = None;
    let mut user_name: Option<Vec<u8>> = listed_user.as_ref().map(|u| u.as_bytes().to_vec());
    if user_name.is_none() {
        match reveal_at(&host) {
            Vault::Got(sec, v) => {
                needles.extend(sec.needles());
                user_name = sec.user.clone();
                vault = v;
                secret = Some(sec);
            }
            other => return vault_refusal(other, json!({"inventory": inv_j}), Some(json!({"page": 1, "userNameFilled": false}))),
        }
    }
    let mut user_filled = false;
    let mut two_page = false;
    let mut closed_early = false;
    let mut pw_els: Vec<Element> = read.elements.clone();
    let mut pw_form: Form = form.clone();
    let mut pw_page: u8 = if lw.page == Some(2) { 2 } else { first_page };
    // 3. PAGE ONE when it asks only for the user name (MEASURED 2026-10-02:
    //    "Email or username" + Continue): the name, Continue, then wait for the
    //    page to CHANGE and recognize the password page.
    if form.password.is_none() {
        two_page = true;
        if let Err(fe) = fill_page(hwnd, &read.elements, &form, user_name.as_deref(), None, &mut steps, &mut remember_ticked) {
            drop(secret);
            return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);
        }
        user_filled = true;
        let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs());
        let mut last: Vec<Element> = Vec::new();
        let mut page2: Option<Form> = None;
        let mut page2_els: Vec<Element> = Vec::new();
        loop {
            if still_up(os.as_ref(), &lw) == Some(false) {
                break; // the window closed after the user name alone
            }
            if let Ok(r2) = read_page(hwnd) {
                last = r2.elements.clone();
                match recognize(&r2.elements) {
                    (Reading::Form, Some(f2)) if f2.password.is_some() => {
                        pages.push(json!({"page": 2, "inventory": inventory_json(&r2.elements), "wake": r2.wake.clone(),
                                          "located": r2.report(), "cookieBanner": cookie_banner(&r2.elements)}));
                        crate::launch::note_sign_in_inventory(inventory_json(&r2.elements));
                        page2_els = r2.elements.clone();
                        page2 = Some(f2);
                        break;
                    }
                    (Reading::SecondStep(_), _) => break,
                    _ if has_marker(&r2.elements, REJECTED).is_some() => break,
                    _ => {}
                }
            }
            if std::time::Instant::now() >= deadline {
                break;
            }
            std::thread::sleep(Duration::from_millis(500));
        }
        match page2 {
            Some(f2) => {
                // The user name was typed on page one; page two takes the password.
                pw_form = Form { user: None, ..f2 };
                pw_els = page2_els;
                pw_page = 2;
            }
            None if still_up(os.as_ref(), &lw) == Some(false) => closed_early = true,
            None => {
                drop(secret);
                let last_j = inventory_json(&last);
                let out = if let Some(m) = has_marker(&last, SECOND_STEP) {
                    tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window."));
                    err("sign_in_needs_user", format!("after the user name, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified. The password was not typed.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))
                } else if let Some(m) = has_marker(&last, REJECTED) {
                    crate::launch::note_sign_in_rejected();
                    record_rejection(&m);
                    tell_user("The 3DEXPERIENCE sign-in did not accept the saved user name. The bridge will not try again — please sign in by hand.");
                    err("sign_in_rejected", format!("the sign-in window rejected the saved user name (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and check the saved login in the vault.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))
                } else {
                    err("sign_in_unconfirmed", format!("the user name was submitted, but no password page appeared within {}s", verify_secs()), "Not retried, and the password was not typed. The inventory says what the window shows now; the user can finish by hand.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))
                };
                return scrub(&out, &needles);
            }
        }
    } else if let Some(u_el) = &form.user {
        // A password page that also holds the user-name box (a one-page form,
        // or page two with the name prefilled): the name goes in first.
        let Some(u) = user_name.as_deref() else {
            drop(secret);
            return scrub(&fill_failure(FillErr::Failed { step: "user name".into(), why: "the saved login has no user name to type".into(), observed: None, may_be_banner: false }, banner, &pages, &steps, &vault), &needles);
        };
        // PAGE 2 (MEASURED 2026-10-12): the name is PREFILLED and READ-ONLY —
        // a set answers "Cannot call SetValue on read only value". A prefilled
        // name is left alone; a read-only refusal means prefilled; and the name
        // step never blocks the password step.
        if u_el.has_value == Some(true) {
            steps.push(json!({"step": "user name", "ok": true, "nameField": "prefilled", "via": "skipped (the field already holds a name)"}));
            user_filled = true;
        } else {
            match fill_field(hwnd, u_el, u, true, &mut steps) {
                Ok(()) => user_filled = true,
                Err(FillErr::ReadOnly(why)) => {
                    steps.push(json!({"step": "user name", "ok": true, "nameField": "prefilled", "via": "read-only", "abSaid": why}));
                    user_filled = true;
                }
                Err(FillErr::NeedsIdle(why)) => {
                    drop(secret);
                    return scrub(&fill_failure(FillErr::NeedsIdle(why), banner, &pages, &steps, &vault), &needles);
                }
                Err(FillErr::Failed { step, why, .. }) => {
                    steps.push(json!({"step": "user name", "ok": false, "nameField": "notSet", "failedAt": step, "why": why,
                                      "effect": "not blocking: the password page goes on"}));
                }
            }
        }
        pw_form = Form { user: None, ..form.clone() };
    }
    // 4. THE PASSWORD: the reveal, ONCE, only now — the page is prepared.
    if !closed_early {
        let sec = match secret.take() {
            Some(sec) => sec,
            None => match reveal_at(&host) {
                Vault::Got(sec, v) => {
                    needles.extend(sec.needles());
                    vault = v;
                    sec
                }
                other => {
                    let prepared = json!({"page": pw_page, "userNameFilled": user_filled});
                    let out = vault_refusal(other, json!({"pages": pages, "steps": steps, "vault": vault, "cookieBanner": banner}), Some(prepared));
                    return scrub(&out, &needles);
                }
            },
        };
        let filled = fill_page(hwnd, &pw_els, &pw_form, None, Some(&sec.pass), &mut steps, &mut remember_ticked);
        drop(sec); // ZEROED. Only the needles (for scrubbing) remain, below.
        if let Err(fe) = filled {
            return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);
        }
    }
    drop(secret);
    // 5. VERIFY BY EFFECT: the window goes away.
    let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs());
    loop {
        match still_up(os.as_ref(), &lw) {
            Some(false) => {
                let out = crate::verbs::ok(json!({
                    "signedIn": true,
                    "window": window_j,
                    "verifiedBy": "the sign-in window closed after the submit",
                    "twoPage": two_page,
                    "cookieBanner": banner,
                    "rememberTicked": remember_ticked,
                    "steps": steps,
                    "vault": vault,
                    "inventory": inv_j,
                    "pages": pages,
                    "_hint": "Signed in. The launch continues on its own; poll swx_status for `ready`.",
                }));
                return scrub(&out, &needles);
            }
            _ if std::time::Instant::now() >= deadline => break,
            _ => std::thread::sleep(Duration::from_millis(500)),
        }
    }
    // STILL UP: read it again and say what it is asking.
    let after = read_page(hwnd).map(|r| r.elements).unwrap_or_default();
    let after_j = inventory_json(&after);
    let out = if let Some(m) = has_marker(&after, REJECTED) {
        crate::launch::note_sign_in_rejected();
        record_rejection(&m);
        tell_user("The saved 3DEXPERIENCE password was rejected. The bridge will not try it again — please sign in by hand, and update the saved login.");
        err("sign_in_rejected", format!("the sign-in window rejected the saved password (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and update the saved login in the vault.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))
    } else if let Some(m) = has_marker(&after, SECOND_STEP) {
        tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window."));
        err("sign_in_needs_user", format!("after the password, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))
    } else {
        err("sign_in_unconfirmed", format!("the form was submitted, but the sign-in window is still open after {}s and says nothing this bridge recognizes", verify_secs()), "Not retried. swx_status shows whether the launch advanced; the inventory says what the window shows now.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))
    };
    scrub(&out, &needles)
}

const FILL_REASON: &str = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch this bridge started";

/// Fill ONE page: the user name (if the page has the field), the password (if
/// it has that one), "Remember me" (if present and off), then press its submit
/// as a background Invoke (MEASURED 2026-10-02: `desktop_ui_click {name:
/// "Continue"}` works without focus). Never touches a cookie-consent button.
fn fill_page(hwnd: i64, page: &[Element], form: &Form, user: Option<&[u8]>, pass: Option<&[u8]>, steps: &mut Vec<Value>, remember_ticked: &mut Value) -> Result<(), FillErr> {
    if let Some(u_el) = &form.user {
        let Some(u) = user else {
            return Err(FillErr::Failed { step: "user name".into(), why: "the saved login has no user name to type".into(), observed: None, may_be_banner: false });
        };
        fill_field(hwnd, u_el, u, true, steps)?;
    }
    if let Some(p_el) = &form.password {
        let Some(p) = pass else {
            return Err(FillErr::Failed { step: "password".into(), why: "no password was revealed for this page".into(), observed: None, may_be_banner: false });
        };
        fill_field(hwnd, p_el, p, false, steps)?;
    }
    if let Some(rm) = &form.remember {
        *remember_ticked = tick_remember(hwnd, page, rm);
    } else if form.password.is_some() {
        // THE SWXDesktopUpdate HOST (MEASURED 2026-10-12): page 2 shows a Text
        // "Remember me" and no CheckBox could be found by name. The label is
        // clicked (as on the Visualize host) only while the user is idle and the
        // login window is in front; its state is NOT readable, so
        // `rememberTicked` stays null and the step says what was done.
        if let Some(label) = page.iter().find(|e| e.by_find && e.is("text") && clean(&e.name).starts_with("remember me") && e.center.is_some() && !e.offscreen()) {
            steps.push(click_remember_label(hwnd, label));
        }
    }
    let r = crate::caller::ad_callback("desktop_ui_click", merge(target(&form.submit, "button"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);
    steps.push(json!({"step": format!("press \"{}\"", form.submit.name), "ok": ab_ok(&r), "via": "uiaInvoke"}));
    if !ab_ok(&r) {
        return Err(FillErr::Failed { step: format!("press of \"{}\"", form.submit.name), why: ab_error(&r), observed: None, may_be_banner: true });
    }
    Ok(())
}

/// "REMEMBER ME". MEASURED 2026-10-02: the CheckBox "Remember me " reports
/// toggleState off and an OFF-SCREEN rect (centerX -14493) — the native input is
/// hidden; the visible control is its Text label "Remember me…". So: already
/// on → nothing; an on-screen, invokable checkbox → a background Invoke; an
/// off-screen one → a real click on its LABEL, only while the user is idle and
/// with the login window in front, then the toggle state read again. A tick that
/// cannot be made safely is skipped and said — it never fails the sign-in.
fn tick_remember(hwnd: i64, page: &[Element], rm: &Element) -> Value {
    let is_on = |t: Option<&str>| t.map(|t| t.to_lowercase()).map(|t| t == "on" || t == "true" || t == "1" || t == "checked") == Some(true);
    if is_on(rm.toggle_state.as_deref()) {
        return json!("alreadyOn");
    }
    // READ THE TOGGLE BACK: a tick is claimed only if the box says so.
    let read_toggle = || {
        read_page(hwnd)
            .ok()
            .and_then(|r| r.elements.into_iter().find(|e| e.is("checkbox") && e.name == rm.name))
            .and_then(|e| e.toggle_state)
    };
    let settle_on = |first: Option<String>| {
        let until = std::time::Instant::now() + Duration::from_millis(1500);
        let mut after = first;
        while !is_on(after.as_deref()) && std::time::Instant::now() < until {
            std::thread::sleep(Duration::from_millis(150));
            after = read_toggle();
        }
        after
    };
    // AN ON-SCREEN CHECKBOX (MEASURED 2026-10-12 on the SWXDesktopUpdate host:
    // a real CheckBox "Remember me ", settable, with a toggle pattern): the
    // Toggle pattern in the background first, then an Invoke; the label click
    // below only if neither ticks it.
    if !rm.offscreen() {
        let args = merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": FILL_REASON}));
        let mut via = "uiaToggle";
        let mut r = crate::caller::ad_callback("desktop_ui_toggle", args.clone(), AB_TIMEOUT);
        if !ab_ok(&r) && rm.invokable != Some(false) {
            via = "uiaInvoke";
            r = crate::caller::ad_callback("desktop_ui_click", args, AB_TIMEOUT);
        }
        if ab_ok(&r) {
            let after = settle_on(read_toggle());
            if is_on(after.as_deref()) {
                return json!({"ticked": true, "via": via, "toggleStateAfter": after});
            }
            if after.is_none() {
                return json!({"ticked": Value::Null, "via": via, "why": "the toggle state could not be read back"});
            }
        }
    }
    let stem = rm.name.trim().trim_end_matches(['.', '…']).to_lowercase();
    let Some(label) = page.iter().find(|e| {
        !e.is("checkbox") && !e.offscreen() && e.center.is_some() && {
            let n = e.name.trim().trim_end_matches(['.', '…']).to_lowercase();
            !stem.is_empty() && n.starts_with(&stem)
        }
    }) else {
        return json!({"ticked": false, "why": "the checkbox is off-screen and no visible label for it was found"});
    };
    if let Err(why) = user_idle() {
        return json!({"ticked": false, "why": format!("skipped: {why} (a real click is needed for the label)")});
    }
    let _ = crate::caller::ad_callback("desktop_raise_window", json!({"hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
    let settle = std::time::Instant::now() + Duration::from_millis(1500);
    while login_in_front(hwnd).is_err() && std::time::Instant::now() < settle {
        std::thread::sleep(Duration::from_millis(100));
    }
    if let Err(why) = login_in_front(hwnd) {
        return json!({"ticked": false, "why": format!("skipped: {why}")});
    }
    let (x, y) = label.center.unwrap_or_default();
    let r = crate::caller::ad_callback("desktop_click", json!({"x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
    // READ THE TOGGLE AGAIN: the label click is evidence only if the box says so.
    let after = settle_on(read_toggle());
    json!({"ticked": ab_ok(&r) && is_on(after.as_deref()), "via": "labelClick", "label": label.name, "toggleStateAfter": after})
}

/// A real click on the "Remember me" label, guarded like every real input.
fn click_remember_label(hwnd: i64, label: &Element) -> Value {
    if let Err(why) = user_idle() {
        return json!({"step": "remember me (label)", "ok": false, "why": format!("skipped: {why}"), "state": "unreadable"});
    }
    let _ = crate::caller::ad_callback("desktop_raise_window", json!({"hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
    let settle = std::time::Instant::now() + Duration::from_millis(1500);
    while login_in_front(hwnd).is_err() && std::time::Instant::now() < settle {
        std::thread::sleep(Duration::from_millis(100));
    }
    if let Err(why) = login_in_front(hwnd) {
        return json!({"step": "remember me (label)", "ok": false, "why": format!("skipped: {why}"), "state": "unreadable"});
    }
    let (x, y) = label.center.unwrap_or_default();
    let r = crate::caller::ad_callback("desktop_click", json!({"x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
    json!({"step": "remember me (label)", "ok": ab_ok(&r), "via": "labelClick", "label": label.name,
           "state": "unreadable — no CheckBox was found on this host, so whether it is ticked cannot be read"})
}

/// "Cannot call SetValue on read only value" (measured), and its spellings.
fn is_read_only(why: &str) -> bool {
    let w = why.to_lowercase();
    w.contains("read only") || w.contains("readonly") || w.contains("read-only")
}

pub(crate) enum FillErr {
    /// The field refused a set as READ-ONLY (MEASURED 2026-10-12: page 2's
    /// prefilled user name — "UIA SetValue failed: Cannot call SetValue on read
    /// only value"). Evidence of a prefilled field, never of a banner.
    ReadOnly(String),
    /// The user is typing (or the idle time cannot be read): no keystroke sent.
    NeedsIdle(String),
    /// `may_be_banner`: a failure a cookie banner covering the form could cause
    /// (a set or a click that did not land) — never a focus, idle or read-back
    /// stop, which say exactly what happened.
    Failed { step: String, why: String, observed: Option<Value>, may_be_banner: bool },
}

fn idle_secs_needed() -> u64 {
    std::env::var("SWX_SIGN_IN_IDLE_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(5)
}

/// IS THE USER'S KEYBOARD QUIET? MEASURED 2026-10-02: real keystrokes sent
/// while Drew was typing in another window left "ogin" — fragments of HIS
/// keystrokes — in the login box, and the bridge's text did not land.
fn user_idle() -> Result<u64, String> {
    let os = crate::launchos::os();
    match os.seconds_since_last_input() {
        Ok(n) if n >= idle_secs_needed() => Ok(n),
        Ok(n) => Err(format!("the user gave input {n}s ago (needs {}s of quiet)", idle_secs_needed())),
        Err(e) => Err(format!("the time since the user's last input could not be read ({e})")),
    }
}

/// Is the login window the foreground window, right now?
fn login_in_front(hwnd: i64) -> Result<(), String> {
    match crate::launchos::os().foreground_window() {
        Ok(Some(w)) if w.hwnd == hwnd => Ok(()),
        Ok(Some(w)) => Err(format!("the foreground moved to another window (\"{}\")", w.title)),
        Ok(None) => Err("no window has the foreground".into()),
        Err(e) => Err(format!("the foreground window could not be read ({e})")),
    }
}

/// THE USER NAME FIELD'S CONTENT, read back from the tree (the tree DOES carry
/// values). Used ONLY for the user-name field — the password field is never
/// read back.
fn field_value(hwnd: i64, el: &Element) -> Option<String> {
    let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);
    fn walk(v: &Value, el: &Element) -> Option<String> {
        match v {
            Value::Array(a) => a.iter().find_map(|x| walk(x, el)),
            Value::Object(m) => {
                let role = str_of(m, &["controlType", "control_type", "role", "type"]);
                if norm_type(&role) == "edit"
                    && str_of(m, &["name"]) == el.name
                    && str_of(m, &["automationId", "automation_id", "id"]) == el.automation_id
                {
                    if let Some(v) = m.get("value").and_then(|v| v.as_str()) {
                        return Some(v.to_string());
                    }
                }
                m.iter().filter(|(k, _)| matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls")).find_map(|(_, x)| walk(x, el))
            }
            _ => None,
        }
    }
    if let Some(v) = walk(&unwrap_ab(&r), el) {
        return Some(v);
    }
    if !el.by_find {
        return None;
    }
    // Located by find_control: its reply's `value`, IF ab ever carries one
    // (measured 2026-10-12: it does not).
    let q = if el.automation_id.is_empty() { json!({"hwnd": hwnd, "role": "Edit", "name": el.name}) } else { json!({"hwnd": hwnd, "automationId": el.automation_id}) };
    let r = crate::caller::ad_callback("desktop_find_control", q, AB_TIMEOUT);
    fn val(v: &Value) -> Option<String> {
        match v {
            Value::Array(a) => a.iter().find_map(val),
            Value::Object(m) => m.get("value").and_then(|x| x.as_str()).map(String::from).or_else(|| m.values().find_map(val)),
            _ => None,
        }
    }
    if ab_ok(&r) { val(&unwrap_ab(&r)) } else { None }
}

/// Where a field is NOW: its centre (ab's own), whether a cookie-banner
/// element overlaps it, and the page's render widget if the tree names one.
struct Spot {
    center: (i64, i64),
    covered: bool,
    render_widget: Option<i64>,
}

fn locate(hwnd: i64, el: &Element) -> Option<Spot> {
    if el.by_find {
        // Fresh geometry by find_control; the banner's buttons too.
        let q = if el.automation_id.is_empty() { json!({"hwnd": hwnd, "role": "Edit", "name": el.name}) } else { json!({"hwnd": hwnd, "automationId": el.automation_id}) };
        let r = crate::caller::ad_callback("desktop_find_control", q, AB_TIMEOUT);
        let field = found_elements(&r).into_iter().find(|e| e.is("edit") && (e.automation_id == el.automation_id || e.name == el.name))?;
        let center = field.center?;
        let covered = match field.rect {
            Some(fr) => ["Accept All", "Manage my preferences"].iter().any(|n| {
                let (b, _) = find_one(hwnd, &Want { role: "Button", automation_id: None, name: n });
                b.and_then(|b| b.rect).map(|br| overlaps(fr, br)).unwrap_or(false)
            }),
            None => false,
        };
        return Some(Spot { center, covered, render_widget: None });
    }
    let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);
    if !ab_ok(&r) {
        return None;
    }
    let els = parse_inventory(&r);
    let field = els.iter().find(|e| e.is("edit") && e.name == el.name && e.automation_id == el.automation_id)?;
    let center = field.center?;
    let covered = match field.rect {
        Some(fr) => els
            .iter()
            .filter(|e| {
                let n = e.name.trim().to_lowercase();
                COOKIE_CHOICES.contains(&n.as_str()) || n.contains("cookie")
            })
            .filter_map(|e| e.rect)
            .any(|br| overlaps(fr, br)),
        None => false,
    };
    fn widget(v: &Value) -> Option<i64> {
        match v {
            Value::Array(a) => a.iter().find_map(widget),
            Value::Object(m) => {
                let class = ["className", "class", "class_name"].iter().find_map(|k| m.get(*k).and_then(|c| c.as_str())).unwrap_or("");
                if class == "Chrome_RenderWidgetHostHWND" {
                    if let Some(h) = ["hwnd", "nativeWindowHandle", "handle"].iter().find_map(|k| m.get(*k).and_then(|h| h.as_i64())) {
                        return Some(h);
                    }
                }
                m.values().find_map(widget)
            }
            _ => None,
        }
    }
    Some(Spot { center, covered, render_widget: widget(&unwrap_ab(&r)) })
}

/// CLEAR, THEN SET, a find_control-located field (0.9.23). MEASURED
/// 2026-10-12 on the SWXDesktopUpdate host: the first `desktop_ui_set` of the
/// user name left the name plus a stray trailing "s" (a leftover keystroke
/// or SetValue inserting, origin unknown); setting "" first and then the value
/// fixed it. So: set "", read back (when readable) that it is empty, set the
/// value, read the user name back (when readable). `desktop_find_control`'s
/// reply carries NO `value` (measured), and this host's tree holds no page, so
/// the read-back is usually UNREADABLE there — said in the step, never
/// assumed. The password is never read back. `Ok(false)` = no ValuePattern
/// (fall back to keystrokes).
fn set_cleared(hwnd: i64, el: &Element, typed: &str, read_back: bool, label: &str, steps: &mut Vec<Value>) -> Result<bool, FillErr> {
    let set = |text: &str| {
        crate::caller::ad_callback(
            "desktop_ui_set",
            merge(target(el, "edit"), json!({"hwnd": hwnd, "text": text, "sensitive": true, "reason": FILL_REASON})),
            AB_TIMEOUT,
        )
    };
    let r = set("");
    if !ab_ok(&r) {
        let why = ab_error(&r);
        if why.to_lowercase().contains("no valuepattern") {
            return Ok(false);
        }
        steps.push(json!({"step": format!("{label}: clear"), "ok": false, "via": "uiaValuePattern"}));
        if is_read_only(&why) {
            return Err(FillErr::ReadOnly(why));
        }
        return Err(FillErr::Failed { step: format!("{label} (UIA clear)"), why, observed: None, may_be_banner: false });
    }
    let cleared = field_value(hwnd, el);
    if read_back {
        if let Some(v) = cleared.as_deref().filter(|v| !v.is_empty()) {
            return Err(FillErr::Failed {
                step: format!("{label}: clear"),
                why: "the field was set to empty but still holds text".into(),
                observed: Some(json!({"fieldText": v, "length": v.chars().count()})),
                may_be_banner: false,
            });
        }
    }
    let r = set(typed);
    if !ab_ok(&r) {
        steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern", "cleared": true}));
        let why = ab_error(&r);
        if is_read_only(&why) {
            return Err(FillErr::ReadOnly(why));
        }
        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: false });
    }
    if !read_back {
        steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true, "readBack": "notReadPassword"}));
        return Ok(true);
    }
    let got = field_value(hwnd, el);
    match got {
        Some(g) if g == typed => {
            steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true, "readBack": "matched"}));
            Ok(true)
        }
        Some(g) => {
            steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern", "cleared": true, "readBack": "mismatch"}));
            Err(FillErr::Failed {
                step: format!("{label}: read-back"),
                why: "the user-name field does not hold what was set".into(),
                observed: Some(json!({"fieldText": g, "length": g.chars().count()})),
                may_be_banner: false,
            })
        }
        None => {
            steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true,
                              "readBack": "unreadable (desktop_find_control carries no value; the tree holds no page on this host)"}));
            Ok(true)
        }
    }
}

/// FILL ONE FIELD. First the background UIA ValuePattern set; MEASURED
/// 2026-10-02 the 3DEXPERIENCE ID page refuses it ("control found but has NO
/// ValuePattern … try a foregrounded desktop_type") even on an edit the tree
/// reports `settable:true`. Then real keystrokes, and only safely:
///   * only while the user's keyboard has been quiet (`SWX_SIGN_IN_IDLE_SECS`,
///     5 s) — checked before every batch;
///   * a real click on the field (it brings the login window to the front), and
///     the login window confirmed as the FOREGROUND window before every batch —
///     aborted if focus moved;
///   * select-all first (a failed attempt leaves the field dirty — measured:
///     "ogin" was still in it), then type;
///   * the user-name field READ BACK and compared before anything continues; a
///     mismatch stops with what was observed. The password is never read back.
/// Enter is never pressed: the submit is a background Invoke.
fn fill_field(hwnd: i64, el: &Element, text: &[u8], read_back: bool, steps: &mut Vec<Value>) -> Result<(), FillErr> {
    let label = if read_back { "user name" } else { "password" };
    let typed = String::from_utf8_lossy(text).to_string();
    if el.by_find {
        match set_cleared(hwnd, el, &typed, read_back, label, steps)? {
            true => return Ok(()),
            false => {} // no ValuePattern: real keystrokes, below
        }
    } else {
    let r = crate::caller::ad_callback(
        "desktop_ui_set",
        merge(target(el, "edit"), json!({"hwnd": hwnd, "text": typed, "sensitive": true, "reason": FILL_REASON})),
        AB_TIMEOUT,
    );
    if ab_ok(&r) {
        steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern"}));
        return Ok(());
    }
    let why = ab_error(&r);
    // The measured refusal: "control found but has NO ValuePattern".
    if !why.to_lowercase().contains("no valuepattern") {
        steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern"}));
        // A FAILED SET IS NEVER EVIDENCE OF A COOKIE BANNER (0.9.26): measured
        // 2026-10-12, "Cannot call SetValue on read only value" on a page with
        // no banner was reported as one, and the user was told to answer it.
        if is_read_only(&why) {
            return Err(FillErr::ReadOnly(why));
        }
        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: false });
    }
    }
    // ── REAL KEYSTROKES ──────────────────────────────────────────────────────
    user_idle().map_err(FillErr::NeedsIdle)?;
    // FRESH GEOMETRY (MEASURED 2026-10-02: moving the window re-laid the page
    // and the cookie banner then COVERED the email box; a click at the old
    // point hit the banner and the typing went nowhere). Re-read the tree and
    // click at the field's OWN centre, in screen px.
    let mut spot = locate(hwnd, el);
    if let Some(sp) = &spot {
        if sp.covered {
            // Make the window tall enough that the field clears the banner.
            // MEASURED 2026-10-02 on ab 2.1.163: desktop_set_window_bounds
            // {hwnd, x:300, y:30, w:1100, h:1450, reason} replied "ok" and the
            // email box then sat at y=787, the banner at y=1020 (keys are w/h).
            let _ = crate::caller::ad_callback(
                "desktop_set_window_bounds",
                json!({"hwnd": hwnd, "x": 300, "y": 30, "w": 1100, "h": 1450, "reason": FILL_REASON}),
                AB_TIMEOUT,
            );
            std::thread::sleep(Duration::from_millis(wake_ms()));
            spot = locate(hwnd, el);
        }
    }
    let Some(sp) = spot else {
        return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: "the field's position could not be read from the window's tree".into(), observed: None, may_be_banner: false });
    };
    if sp.covered {
        return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: "the cookie banner covers the field, even after making the window taller".into(), observed: None, may_be_banner: true });
    }
    // FOCUS (MEASURED: a plain click does not take focus while the user's app is
    // active): raise the login window, then confirm IT is in front.
    let _ = crate::caller::ad_callback("desktop_raise_window", json!({"hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
    let settle = std::time::Instant::now() + Duration::from_millis(1500);
    while login_in_front(hwnd).is_err() && std::time::Instant::now() < settle {
        std::thread::sleep(Duration::from_millis(100));
    }
    let guard = |what: &str| -> Result<(), FillErr> {
        user_idle().map_err(FillErr::NeedsIdle)?;
        login_in_front(hwnd).map_err(|e| FillErr::Failed { step: format!("{label}: {what}"), why: e, observed: None, may_be_banner: false })
    };
    guard("before the click")?;
    let (x, y) = sp.center;
    let r = crate::caller::ad_callback("desktop_click", json!({"x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
    if !ab_ok(&r) {
        return Err(FillErr::Failed { step: format!("click on the {label} field"), why: ab_error(&r), observed: None, may_be_banner: true });
    }
    // Keystrokes go to the page's render widget when the tree names it
    // (MEASURED: desktop_type {text, hwnd:<Chrome_RenderWidgetHostHWND child>}).
    let target_hwnd = sp.render_widget.unwrap_or(hwnd);
    guard("before select-all")?;
    let r = crate::caller::ad_callback("desktop_press_key", json!({"keys": ["ctrl+a"], "hwnd": target_hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
    if !ab_ok(&r) {
        return Err(FillErr::Failed { step: format!("{label}: select-all"), why: ab_error(&r), observed: None, may_be_banner: false });
    }
    guard("before typing")?;
    let r = crate::caller::ad_callback("desktop_type", json!({"text": typed, "hwnd": target_hwnd, "sensitive": true, "reason": FILL_REASON}), AB_TIMEOUT);
    if !ab_ok(&r) {
        return Err(FillErr::Failed { step: format!("{label}: typing"), why: ab_error(&r), observed: None, may_be_banner: false });
    }
    // A keystroke that went astray is evidence too: still in front?
    login_in_front(hwnd).map_err(|e| FillErr::Failed { step: format!("{label}: after typing"), why: e, observed: None, may_be_banner: false })?;
    if read_back {
        // The page updates its field asynchronously: read back for a moment.
        let until = std::time::Instant::now() + Duration::from_millis(1500);
        let mut got = field_value(hwnd, el);
        while got.as_deref() != Some(typed.as_str()) && std::time::Instant::now() < until {
            std::thread::sleep(Duration::from_millis(150));
            got = field_value(hwnd, el);
        }
        if got.is_none() && el.by_find {
            steps.push(json!({"step": label, "ok": true, "via": "keystrokes", "readBack": "unreadable (desktop_find_control carries no value; the tree holds no page on this host)"}));
            return Ok(());
        }
        if got.as_deref() != Some(typed.as_str()) {
            steps.push(json!({"step": label, "ok": false, "via": "keystrokes", "readBack": "mismatch"}));
            return Err(FillErr::Failed {
                step: format!("{label}: read-back"),
                why: "the user-name field does not hold what was typed — keystrokes may have collided with the user's, or gone to another window".into(),
                observed: Some(json!({"fieldText": got, "length": got.as_ref().map(|g| g.chars().count())})),
                may_be_banner: false,
            });
        }
    }
    steps.push(json!({"step": label, "ok": true, "via": "keystrokes", "readBack": if read_back { json!("matched") } else { json!("notReadPassword") }}));
    Ok(())
}

fn fill_failure(fe: FillErr, banner: bool, pages: &[Value], steps: &[Value], vault: &Value) -> Value {
    let (step, e, observed, may_be_banner) = match fe {
        FillErr::NeedsIdle(why) => {
            tell_user("The bridge is ready to sign you in to 3DEXPERIENCE, but it types real keystrokes. Please take your hands off the keyboard and mouse for five seconds, then ask again.");
            return err(
                "sign_in_needs_user_idle",
                format!("no keystroke was sent: {why}. Typing while the user types collides (measured 2026-10-02: the user's keystrokes landed in the login box)"),
                "The user has been asked for five seconds hands-off; call swx_sign_in again after that (it re-attaches to the same consent).",
                json!({"pages": pages, "steps": steps, "vault": vault}),
            );
        }
        FillErr::Failed { step, why, observed, may_be_banner } => (step, why, observed, may_be_banner),
        FillErr::ReadOnly(why) => ("set of a read-only field".to_string(), why, None, false),
    };
    let (step, e, observed, may_be_banner) = (step, e, observed, may_be_banner);
    if banner && may_be_banner {
        // THE COOKIE BANNER MAY BE IN THE WAY — and its choice is the user's.
        tell_user("The 3DEXPERIENCE sign-in's cookie banner may be covering the form. Please choose your cookie setting in the sign-in window — the bridge will not choose it for you.");
        return err(
            "sign_in_needs_user",
            format!("the {step} failed with a cookie banner on the page ({e}); it may be covering the form, and choosing a cookie setting is the user's decision"),
            "The user has been notified to answer the cookie banner. Nothing was submitted.",
            json!({"cookieBanner": true, "pages": pages, "steps": steps, "vault": vault, "observed": observed}),
        );
    }
    err(
        "sign_in_fill_failed",
        format!("the {step} in the sign-in window failed: {e}"),
        "Nothing further was submitted and Enter was never pressed. The user can sign in by hand.",
        json!({"pages": pages, "steps": steps, "vault": vault, "observed": observed, "cookieBanner": banner}),
    )
}

#[cfg(test)]
mod ab_envelope_tests {
    use super::*;
    #[test]
    fn an_empty_error_string_is_success_measured_pup_envelope() {
        let r = json!({"ok": true, "status": "ok", "error": "", "credentials": [{"host": "iam.3ds.com", "username": "u"}]});
        assert!(ab_ok(&r), "pup_credentials answers error:\"\" on success (measured 2026-10-12)");
        assert!(ab_ok(&json!({"success": true})));
        assert!(!ab_ok(&json!({"success": false})));
        assert!(!ab_ok(&json!({"ok": false, "error": ""})));
        assert!(!ab_ok(&json!({"error": "relay_forbidden"})));
        assert!(!ab_ok(&json!({"error": {"code": 7}})));
        assert_eq!(ab_error(&json!({"error": ""})), "ab reported a failure");
        assert_eq!(ab_error(&json!({"error": "nope"})), "nope");
    }
}

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

    fn el(t: &str, name: &str, pw: Option<bool>) -> Element {
        Element { control_type: t.into(), name: name.into(), is_password: pw, ..Default::default() }
    }

    #[test]
    fn a_plain_form_is_recognized_and_extra_fields_are_not() {
        let inv = vec![el("edit", "Email", Some(false)), el("edit", "Password", Some(true)), el("checkbox", "Remember me", None), el("button", "Log in", None), el("button", "Forgot password?", None)];
        let (r, f) = recognize(&inv);
        assert_eq!(r, Reading::Form);
        let f = f.unwrap();
        assert_eq!(f.submit.name, "Log in");
        assert!(f.remember.is_some());
        let mut extra = inv.clone();
        extra.push(el("edit", "Company", Some(false)));
        assert!(matches!(recognize(&extra).0, Reading::Unrecognized(_)));
        let mut code = inv.clone();
        code.push(el("edit", "Verification code", Some(false)));
        assert!(matches!(recognize(&code).0, Reading::SecondStep(_)));
    }

    #[test]
    fn page_one_is_a_user_name_box_and_continue_and_nothing_else() {
        // The measured page one: "Email or username" + Continue, no password.
        let p1 = vec![el("edit", "Email or username", Some(false)), el("button", "Continue", None), el("button", "Accept All", None)];
        let (r, f) = recognize(&p1);
        assert_eq!(r, Reading::Form);
        let f = f.unwrap();
        assert!(f.password.is_none() && f.user.is_some() && f.submit.name == "Continue");
        // One box that does NOT say it is the user name is not page one.
        let other = vec![el("edit", "Search", Some(false)), el("button", "Continue", None)];
        assert!(matches!(recognize(&other).0, Reading::Unrecognized(_)));
        // The cookie banner is reported, and never the submit.
        assert!(cookie_banner(&p1));
    }

    #[test]
    fn scrub_removes_the_secret_everywhere() {
        let v = json!({"error": "could not set 'hunter2-xyz'", "value": "hunter2-xyz", "nested": [{"a": "x hunter2-xyz y"}]});
        let s = scrub(&v, &["hunter2-xyz".to_string()]).to_string();
        assert!(!s.contains("hunter2-xyz"), "{s}");
    }

    #[test]
    fn the_inventory_never_copies_a_value() {
        let reply = json!({"success": true, "data": {"controlType": "Window", "name": "Login", "children": [
            {"controlType": "Edit", "name": "Password", "isPassword": true, "value": "SHOULD-NOT-COPY"}]}});
        let inv = parse_inventory(&reply);
        assert_eq!(inv.len(), 2);
        assert!(!inventory_json(&inv).to_string().contains("SHOULD-NOT-COPY"));
    }
}