main
Colby Knox 0.2.45 source (not yet released): save-into-missing-folder fix + saved check, sheet grows on append, board_shape/pcb_re… 23ad0df 6d ago
//! Modal detection, classification and the STANDING REAPER — ported from the Python bridge's
//! `dialogs.py`. A dialog on Altium's main process FREEZES the resident script loop (the engine is
//! single-threaded), so "the agent stopped answering" and "a modal is up" are the same event.
//!
//! Scoped by PID via the instance snapshot, never by title: a title match once swept up four
//! "RemoteApp security warning" windows that had nothing to do with Altium (2026-09-01) — and
//! pressing buttons in someone else's application is exactly what this bridge must never do.
//! When an instance is selected, the sweep is scoped to it: with two Altiums running, an unscoped
//! sweep lets one AI thread see — and clear — a modal belonging to the other thread's Altium.
use crate::{ab, instance};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;

// TThemedConfirmDialog: Altium's licence chooser ("Connect to Workspace" / "You are not currently
// using an Altium Designer license" — Choose a license | Continue). It was invisible to the scan
// until 2026-09-02, when John was left staring at it: "you should be handling this automatically".
// TFailedToRenewWarningForm: "License Renewal Failed" (the stranded-seat re-firer). UIA enumerates
// NO buttons on it, yet its OK is clickable by name — so it is admitted by class and cleared by
// the title rule, never by button count.
const DIALOG_CLASSES: [&str; 7] = ["TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters", "TThemedConfirmDialog", "TFailedToRenewWarningForm"];

/// title/body substring -> (category, safe default action or "" = none, meaning).
/// A safeAction of "" means the reaper must NEVER touch it (save prompts carry decisions).
const CATALOG: &[(&str, &str, &str, &str)] = &[
    ("another script executing", "script_busy", "OK",
     "A -R script request arrived while the agent loop (or another script) was resident. Dismiss; route work through the agent."),
    ("continue execution?", "script_runtime_error", "Cancel",
     "A script hit a runtime error. Cancel stops it cleanly; OK continues past the error."),
    ("undeclared identifier", "script_compile_error", "OK",
     "The script failed to compile; the engine is not halted after OK."),
    ("unknown identifier", "script_compile_error", "OK",
     "Compile error in RunScriptText text (statements only, no Var)."),
    ("error reading form", "script_form_load_error", "Cancel",
     "A script form's .dfm references a class or property this engine does not know (e.g. 'Class TApplicationEvents not found'). Cancel aborts the form load cleanly; Ignore would half-load the form. Fires on open and again on each editor touch until the project is closed."),
    ("select item to run", "run_script_picker", "Cancel",
     "Altium could not resolve the -R RunScript target (unit did not compile, or wrong ProcName) and opened its Run Script picker. Cancel it, fix the script."),
    ("another instance of", "second_instance_busy", "OK",
     "A helper X2 could not hand its -R request to the running instance because that instance is busy (a modal is open)."),
    ("confirm save locally", "save_prompt", "",
     "Altium's close-time 'Confirm Save Locally for (N) Modified Documents' (Save All | Save None | OK | Cancel). It names the documents: READ the shot. Never auto-answered; altium_close {discardChanges:true} presses Save None for documents the caller owns."),
    ("confirm not saving", "save_prompt", "",
     "The follow-up to Save None: 'Confirm Not Saving (N) Modified Documents'. OK discards. Never auto-answered."),
    ("zoom value", "input_prompt", "Cancel",
     "A process asked for a number (PCB:Zoom with no value). A verb must never launch a process that prompts; Cancel is always safe here."),
    ("do you want to save", "save_prompt", "",
     "Unsaved changes on close. Decide: Yes saves, No discards. Never default this."),
    ("save changes", "save_prompt", "", "Unsaved changes. Decide explicitly."),
    ("could not start vault explorer", "altium_extension_broken", "OK",
     "Altium's own VaultExplorer extension fails to load at startup. NOT caused by the bridge. Informational — dismissing it costs nothing, and the standing reaper does so automatically."),
    ("file type not recognised", "unsupported_document", "OK",
     "Altium was handed a path it does not open. From the bridge that means a bad open call (the bridge refuses unsupported extensions up front); at STARTUP it means Altium is restoring a document from the last session that it can no longer open. Safe to dismiss."),
    ("file type not recognized", "unsupported_document", "OK", "Same as 'not recognised' (US spelling)."),
    ("license maximum usage reached", "licence_seat_stranded", "OK",
     "The seat is held by an instance that never released it — almost always an Altium that was KILLED rather than closed. The licence page will still show Seats 0/1, which looks free and is not. FIX: close every other Altium POLITELY (altium_close, never process_kill), then altium_license {action:'use_license'}. Waiting does not clear it."),
    ("has expired or been deactivated", "licence_expired", "OK",
     "The licence (Develop entitlement or a lease) is dead server-side: Altium runs but refuses scripting and can wedge in a modal loop on close. The bridge cannot renew an entitlement - the human renews or re-activates it in the Altium workspace. NOTE: Develop shows NO title licence tail in this state either, so the absent-tail 'Licensed' inference is WRONG while this box is up (measured 2026-09-21)."),
    ("license renewal failed", "licence_seat_stranded", "OK",
     "Altium could not renew the On-Demand lease. Same cause and fix as 'maximum usage reached': a killed instance is still holding the seat. This dialog RE-FIRES on a timer until the seat is reclaimed, so clearing it is not the fix."),
    ("cannot focus a disabled or invisible window", "altium_internal", "Send and Close",
     "Altium's own crash-report dialog, usually raised when a window's state changed while it was focusing a document view. Its only button SENDS a report to Altium — try the title-bar X first if the user's data matters."),
    ("unsaved changes", "save_prompt", "",
     "Names the document(s) at risk — READ IT. On a shared machine the document may belong to ANOTHER AI thread; Save preserves their work, Don't Save destroys it. Never default this."),
    ("license", "licence", "", "Licence prompt — read it; usually informational."),
    ("trial", "licence", "", "Trial notice."),
];

pub fn classify(title: &str, body: &str) -> (String, Option<String>, String) {
    let hay = format!("{title} {body}").to_lowercase();
    for (needle, cat, action, meaning) in CATALOG {
        if hay.contains(needle) {
            let act = if action.is_empty() { None } else { Some(action.to_string()) };
            return (cat.to_string(), act, meaning.to_string());
        }
    }
    ("unknown".into(), None, "Not in the catalog — read the text and decide; never blind-dismiss.".into())
}

/// The painted message via UIA (Altium's TMessageForm has no Static children; WM_GETTEXT sees
/// only button labels, which is why classification must go through ab's desktop_ui_text).
fn dialog_text(hwnd: i64) -> String {
    let r = ab::call("desktop_ui_text", json!({"hwnd": hwnd}), &[], 10);
    let s = match r.get("text").or_else(|| r.get("texts")) {
        Some(Value::String(s)) => s.clone(),
        Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" "),
        _ => String::new(),
    };
    if !s.trim().is_empty() {
        return s;
    }
    // Fallback: some dialogs (the script debugger's "Continue execution?" prompt, title "X2")
    // paint their message only as Text-role control NAMES; ui_text sees nothing and the empty
    // body defeated classification (measured 2026-09-02 during the StopServer probes, #7).
    let fc = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "text"}), &[], 10);
    fc.get("matches")
        .and_then(|a| a.as_array())
        .map(|a| a.iter()
            .filter_map(|m| m.get("name").and_then(|n| n.as_str()))
            .collect::<Vec<_>>().join(" "))
        .unwrap_or_default()
}

fn buttons(hwnd: i64) -> Vec<String> {
    let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "button"}), &[], 10);
    button_names(&r)
}

/// The button labels out of ab's `desktop_find_controls` reply. ab returns them under `matches`
/// (each with `name`); the first port read `controls` and so saw NO buttons on ANY dialog — the
/// lone-OK acknowledgement rule could never fire, and the VaultExplorer startup warning reached
/// John's screen again on 2026-09-02 ("u have not solved this error yet. you MUST"). Title-bar
/// controls (Close/Minimize/Maximize) are dropped so a dialog whose only real button is OK still
/// reads as lone-OK, matching what the Python bridge's child-window enumeration returned.
pub fn button_names(reply: &Value) -> Vec<String> {
    let arr = reply.get("matches").or_else(|| reply.get("controls")).and_then(|a| a.as_array());
    arr.map(|a| {
        a.iter()
            .filter_map(|c| c.get("name").or_else(|| c.get("text")).and_then(|t| t.as_str()))
            .map(|t| t.replace('&', "").trim().to_string())
            .filter(|t| !t.is_empty())
            .filter(|t| !matches!(t.to_lowercase().as_str(), "close" | "minimize" | "maximize" | "restore" | "system"))
            .collect()
    })
    .unwrap_or_default()
}

/// Every dialog owned by an Altium process — classified, instance-scoped when one is selected.
pub fn scan() -> Vec<Value> {
    scan_scoped(false)
}

/// `main_pid_only` keeps only dialogs owned by the selected/first MAIN instance's process —
/// the ones that freeze the agent (helper X2 dialogs are flagged, not blocking).
pub fn scan_scoped(main_pid_only: bool) -> Vec<Value> {
    scan_impl(main_pid_only, instance::selected().and_then(|i| i.get("pid").and_then(|p| p.as_u64())))
}

fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {
    let mut wins = instance::altium_windows();
    if let Some(pid) = sel_pid {
        wins.retain(|w| w.get("pid").and_then(|p| p.as_u64()) == Some(pid));
    }
    let main = wins.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main")).cloned();
    let main_pid = main.as_ref().and_then(|m| m.get("pid").and_then(|p| p.as_u64()));
    let main_disabled = main
        .as_ref()
        .map(|m| m.get("enabled").and_then(|b| b.as_bool()) == Some(false))
        .unwrap_or(false);
    let mut out = Vec::new();
    for w in wins {
        if w.get("kind").and_then(|k| k.as_str()) == Some("main") {
            continue;
        }
        let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("");
        let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("other");
        let pid = w.get("pid").and_then(|p| p.as_u64());
        // Any enabled top-level window while the MAIN window is disabled IS the modal, whatever
        // its class (measured: TForm_RunScriptParameters "Select Item To Run").
        let modal_by_state = main_disabled
            && w.get("enabled").and_then(|b| b.as_bool()).unwrap_or(true)
            && pid == main_pid
            && !matches!(class, "TClientBarControl" | "TThemedNotificationPopup" | "TAnimatedNotificationControl");
        let hwnd = match w.get("hwnd").and_then(|h| h.as_i64()) {
            Some(h) => h,
            None => continue,
        };
        let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();
        let dialog_class = DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(class))
            || matches!(kind, "dialog" | "script_error_modal")
            || modal_by_state;
        // Altium's themed licence dialogs ("License Renewal Failed", the licence chooser) do
        // not use the catalogued VCL classes, so a class allowlist let them reach John's screen
        // (2026-09-02: "you should pre-empt all of these dialog boxes btw"). The BUTTON SET is
        // the trustworthy signal: ask UIA for every titled, visible, non-main window and let a
        // lone OK qualify it regardless of class. Untitled tool windows are skipped for cost.
        if title.trim().is_empty() && !dialog_class {
            continue;
        }
        let btns = buttons(hwnd);
        let lone_ok = btns.len() == 1 && btns[0].eq_ignore_ascii_case("ok");
        let title_known = classify(&title, "").0 != "unknown";
        // The allowlists above missed every box that mattered on 2026-09-20: 'Zoom Value' (OK|Cancel),
        // 'Confirm Save Locally' (Save All|Save None|OK|Cancel) and 'Confirm Not Saving' were all
        // dropped here, so check_dialogs said "none" while they blocked altium_close for minutes.
        // A titled top-level window that carries real DIALOG BUTTONS is a dialog, whatever its class.
        // Reported as category unknown/save_prompt, which the reaper NEVER presses.
        if !dialog_class && !lone_ok && !title_known && !is_licence_chooser(&btns) && !has_dialog_buttons(&btns) {
            continue;
        }
        let body = dialog_text(hwnd);
        let (mut category, mut safe_action, mut meaning) = classify(&title, &body);
        if category == "unknown" && is_licence_chooser(&btns) {
            category = "licence_chooser".into();
            safe_action = Some("Choose a license".into());
            meaning = "Altium's 'You are not currently using an Altium Designer license' prompt (Choose a license | Continue). Choose a license opens the License Management page; the reaper then presses Use License via UIA. 'Continue' would leave Altium in Viewer mode, where scripting is refused — never the right answer for a bridge user.".into();
        }
        if category == "unknown" && title.trim() == "X2" && btns.len() == 2
            && btns.iter().any(|b| b.eq_ignore_ascii_case("ok")) && btns.iter().any(|b| b.eq_ignore_ascii_case("cancel")) {
            // the script debugger's "<error> at Line:N ... Continue execution?" box: its text is
            // painted, so it scanned as unknown (seen 2026-10-15 after a bad altium_eval)
            category = "script_runtime_error".into();
            safe_action = Some("Cancel".into());
            meaning = "A script hit a runtime error and Altium's debugger asks 'Continue execution?'. Cancel stops the script cleanly; the resident agent survives. Photograph it first: the error text is only in the picture.".into();
        }
        if category == "unknown" && title.to_lowercase().contains("renewal failed") {
            category = "licence_seat_stranded".into();
            safe_action = Some("OK".into());
            meaning = "License Renewal Failed: the seat is leased to a session that is gone (an Altium that was killed, not closed). Dismissing is safe; it re-fires on a timer until the lease expires or the user signs out and back in.".into();
        }
        let owned_by_main = main_pid.is_some() && pid == main_pid;
        if main_pid_only && !owned_by_main {
            continue;
        }
        out.push(json!({"hwnd": hwnd, "pid": pid, "title": title, "className": class,
                        "body": body, "buttons": btns,
                        "category": category, "safeAction": safe_action, "meaning": meaning,
                        "ownedByMainInstance": owned_by_main,
                        "rect": w.get("rect").cloned().unwrap_or(Value::Null)}));
    }
    out
}

/// Two or more of the labels every Windows/VCL dialog is built from. One alone is not enough (a
/// floating panel can carry a lone "Close").
pub fn has_dialog_buttons(btns: &[String]) -> bool {
    const WORDS: [&str; 12] = ["ok", "cancel", "yes", "no", "save all", "save none", "retry", "abort", "ignore", "continue", "apply", "close"];
    btns.iter().filter(|b| WORDS.contains(&b.trim().to_lowercase().as_str())).count() >= 2
}

/// The licence chooser is recognisable by its BUTTONS even though its text is painted.
fn is_licence_chooser(btns: &[String]) -> bool {
    let l: Vec<String> = btns.iter().map(|b| b.to_lowercase()).collect();
    l.iter().any(|b| b == "choose a license") && l.iter().any(|b| b == "continue")
}

/// Press a named button on a dialog through UIA (no focus steal, no cursor). When the button is
/// an acknowledgement (OK) and its UIA Invoke refuses - some Information boxes expose no pattern
/// (measured 2026-09-21 on "licence has expired or been deactivated") - fall back to a native
/// WM_CLOSE on the dialog: the same dismissal its titlebar X performs. Never for any other
/// button: closing a question box would silently answer it with its cancel path.
pub fn press(hwnd: i64, button: &str) -> Value {
    let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15);
    let ok = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false);
    if !ok && button.eq_ignore_ascii_case("ok") && crate::win32::post_message_close(hwnd) {
        return json!({"success": true, "clicked": button, "hwnd": hwnd, "via": "wm_close_fallback",
            "note": "the OK exposed no UIA Invoke; dismissed with a native WM_CLOSE (same as its titlebar X)", "ab": r});
    }
    json!({"success": ok, "clicked": button, "hwnd": hwnd, "via": "uia", "ab": r})
}

/// Press a named button on every detected dialog. `only_titles` narrows it (e.g. just "Error").
pub fn dismiss(button: &str, only_titles: Option<&[String]>) -> Value {
    let found = scan();
    let mut pressed: Vec<Value> = Vec::new();
    for d in &found {
        // A SWEEP (no hwnd) never answers a question: save prompts and any dialog with more than one
        // button are skipped unless the caller named them by title. Pass {hwnd, button} to answer one.
        let cat = d.get("category").and_then(|c| c.as_str()).unwrap_or("unknown");
        let nbtn = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len()).unwrap_or(0);
        if only_titles.is_none() && (cat == "save_prompt" || nbtn > 1) {
            pressed.push(json!({"title": d.get("title"), "skipped": "asks a question: answer it explicitly with {hwnd, button}"}));
            continue;
        }
        let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");
        if let Some(filter) = only_titles {
            if !filter.iter().any(|f| title.contains(f.as_str())) {
                continue;
            }
        }
        if let Some(hwnd) = d.get("hwnd").and_then(|h| h.as_i64()) {
            let r = press(hwnd, button);
            pressed.push(json!({"title": title, "hwnd": hwnd, "button": button,
                                "ok": r.get("success").cloned().unwrap_or(json!(false))}));
        }
    }
    json!({"success": true, "detected": found.len(), "pressed": pressed,
           "_hint": "John's rule: a dialog the USER has to dismiss is a defect in this bridge — pre-empt it, don't clean up after it."})
}

// ---- the standing reaper (John, 2026-09-01: "always pre-empt these from ever showing up") ----
//
// Runs continuously from bridge start and presses the catalog's SAFE action the moment a known
// nuisance appears — no verb needs to be in flight (Altium's own startup warnings appear when
// nothing else would ever catch them). HARD RULES: only catalog-safe actions — never `unknown`,
// never a save prompt, never anything naming a document; one press per hwnd; everything logged.

static REAPED: Mutex<Vec<Value>> = Mutex::new(Vec::new());
static REAPED_HWNDS: Mutex<Vec<i64>> = Mutex::new(Vec::new());
static REAPER_RUNNING: AtomicBool = AtomicBool::new(false);
/// The last lone-OK box titled "Error" the reaper pressed: {atMs, title, shot}. A DelphiScript
/// runtime error ("Undeclared identifier ...") is exactly this box, and pressing OK leaves the
/// script STOPPED ON A BREAKPOINT in Altium's script debugger: the agent loop is dead, Altium
/// refuses to close, and the caller used to see nothing but a timeout (found on AD 26.9.1,
/// 2026-09-20). agent::call watches this so the halt is reported and cleared, never silent.
static LAST_SCRIPT_ERROR: Mutex<Option<Value>> = Mutex::new(None);
/// Pictures of the last 8 boxes the reaper pressed (newest last): `altium_dialog_log {shots:true}`.
static REAPED_SHOTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());

pub fn reaped_shots() -> Vec<Value> {
    REAPED_SHOTS.lock().map(|g| g.clone()).unwrap_or_default()
}

fn now_ms() -> u64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_millis() as u64).unwrap_or(0)
}

/// The script Error box reaped at or after `since_ms` (unix ms), if any.
pub fn script_error_since(since_ms: u64) -> Option<Value> {
    LAST_SCRIPT_ERROR.lock().ok().and_then(|g| g.clone())
        .filter(|e| e.get("atMs").and_then(|a| a.as_u64()).unwrap_or(0) >= since_ms)
}

/// Record a service event (close watchdog etc.) in the same ring `altium_dialog_log` shows.
pub fn note_event(mut ev: Value) {
    ev["at"] = json!(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0));
    if let Ok(mut g) = REAPED.lock() {
        g.push(ev);
        let len = g.len();
        if len > 200 { g.drain(..len - 200); }
    }
}

pub fn reaped_log(limit: usize) -> Vec<Value> {
    REAPED
        .lock()
        .map(|g| g.iter().rev().take(limit).rev().cloned().collect())
        .unwrap_or_default()
}

pub fn reap_once() -> usize {
    let mut n = 0;
    // The reaper is a background service: it sweeps EVERY Altium pid, deliberately ignoring the
    // per-verb instance selection (mutating that global from this thread would race a mid-flight
    // verb). Catalog-safe actions carry no decisions, so acting on another thread's nuisance
    // dialog helps rather than harms.
    let dialogs = scan_impl(false, None);
    for d in dialogs {
        let mut act = d.get("safeAction").and_then(|a| a.as_str()).map(str::to_string);
        let mut cat = d.get("category").and_then(|c| c.as_str()).unwrap_or("unknown").to_string();
        // Altium PAINTS its dialog messages: when even UIA text is empty, classification cannot
        // see them. What CAN be trusted is the button set: a dialog whose ONLY button is OK
        // carries NO DECISION — dismissing it can acknowledge, never choose. Every dialog that
        // asks something has >= 2 buttons.
        let labels: Vec<String> = d
            .get("buttons")
            .and_then(|b| b.as_array())
            .map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_lowercase()).collect())
            .unwrap_or_default();
        let lone_ok = labels == ["ok"];
        if act.is_none() && lone_ok {
            act = Some("OK".into());
            cat = "single_ok_acknowledgement".into();
        }
        let act = match act {
            Some(a) if cat != "unknown" && cat != "save_prompt" => a,
            _ => continue,
        };
        if cat != "single_ok_acknowledgement" && labels.len() > 1 && !labels.contains(&act.to_lowercase()) {
            continue; // the catalog's action is not even on this dialog — do not improvise
        }
        let hwnd = match d.get("hwnd").and_then(|h| h.as_i64()) {
            Some(h) => h,
            None => continue,
        };
        if REAPED_HWNDS.lock().map(|g| g.contains(&hwnd)).unwrap_or(true) {
            continue;
        }
        // A lone-OK "Error" is how a script runtime error looks. Altium PAINTS the message, so
        // photograph it BEFORE pressing OK: the picture is the only copy of the error text.
        let title_str = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
        let is_script_error = cat == "single_ok_acknowledgement" && title_str.trim().eq_ignore_ascii_case("error");
        // Altium PAINTS its message text, so the picture taken here is the ONLY copy of what the box
        // said. Until 0.2.34 only "Error" boxes were photographed: an "Information" box that explained
        // why Altium refused to quit was pressed away five times in a row, unread (2026-09-21).
        let mut err_shot = Value::Null;
        {
            let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10);
            if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {
                err_shot = json!(img);
            }
        }
        if let Ok(mut g) = REAPED_SHOTS.lock() {
            g.push(json!({"atMs": now_ms(), "title": title_str, "shot": err_shot.clone()}));
            let len = g.len();
            if len > 8 { g.drain(..len - 8); }
        }
        let ok = press(hwnd, &act).get("success").and_then(|b| b.as_bool()).unwrap_or(false);
        if is_script_error {
            if let Ok(mut g) = LAST_SCRIPT_ERROR.lock() {
                *g = Some(json!({"atMs": now_ms(), "title": title_str, "shot": err_shot}));
            }
        }
        let mut followup = Value::Null;
        if ok && cat == "licence_chooser" {
            // The chooser only opens the License Management page; the seat is claimed by the
            // page's UseLicenseButton (UIA, invokable — measured 2026-09-01). Give the WebView2
            // a moment to render, then press it on the SAME instance's main window.
            std::thread::sleep(Duration::from_millis(2500));
            let pid = d.get("pid").and_then(|p| p.as_u64());
            let main_hwnd = instance::altium_windows().into_iter()
                .find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main") && w.get("pid").and_then(|p| p.as_u64()) == pid)
                .and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()));
            followup = match main_hwnd {
                Some(mh) => {
                    let r = ab::call("desktop_ui_click", json!({"hwnd": mh, "automationId": "UseLicenseButton", "name": "Use License", "role": "button"}), &[], 45);
                    json!({"useLicense": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
                           "note": "if the seat is stranded (held by a killed Altium) this claim fails with 'License maximum usage reached' — the bridge keeps that dialog off the screen, but only a sign-out/sign-in or the lease expiring frees the seat"})
                }
                None => json!({"useLicense": false, "note": "no main window found for the chooser's pid"}),
            };
        }
        if let Ok(mut g) = REAPED_HWNDS.lock() {
            g.push(hwnd);
            let len = g.len();
            if len > 500 {
                g.drain(..len - 500);
            }
        }
        if let Ok(mut g) = REAPED.lock() {
            g.push(json!({"at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0),
                          "title": d.get("title").cloned().unwrap_or(Value::Null), "category": cat,
                          "pressed": act, "ok": ok, "followup": followup,
                          "note": "pre-empted by the standing reaper — the user never had to deal with it"}));
            let len = g.len();
            if len > 200 {
                g.drain(..len - 200);
            }
        }
        n += 1;
    }
    n
}

/// Start the standing reaper. Idempotent.
pub fn start_reaper() -> bool {
    if REAPER_RUNNING.swap(true, Ordering::SeqCst) {
        return false;
    }
    std::thread::Builder::new()
        .name("altium-dialog-reaper".into())
        .spawn(|| loop {
            let _ = std::panic::catch_unwind(|| reap_once());
            std::thread::sleep(Duration::from_millis(1500));
        })
        .is_ok()
}

#[cfg(test)]
mod tests {
    use super::{button_names, classify, has_dialog_buttons};
    use serde_json::json;

    /// The exact reply shape ab gave for the VaultExplorer warning (2026-09-02): buttons live
    /// under `matches`, not `controls`. Reading the wrong key made the reaper blind.
    #[test]
    fn button_names_read_ab_matches_shape() {
        let reply = json!({"count": 1, "hwnd": 69126, "matches": [
            {"automationId": "WinXPButtons.TXPBitBtn", "invokable": true, "name": "OK", "nth": 0, "role": "Button", "value": "OK"}
        ]});
        assert_eq!(button_names(&reply), vec!["OK".to_string()]);
        // title-bar controls never count toward the lone-OK decision
        let reply = json!({"matches": [{"name": "OK"}, {"name": "Close"}, {"name": "Minimize"}]});
        assert_eq!(button_names(&reply), vec!["OK".to_string()]);
        // a real decision dialog keeps all its buttons
        let reply = json!({"matches": [{"name": "Save"}, {"name": "Don't Save"}, {"name": "Cancel"}]});
        assert_eq!(button_names(&reply).len(), 3);
        // legacy shape still parses
        let reply = json!({"controls": [{"text": "&OK"}]});
        assert_eq!(button_names(&reply), vec!["OK".to_string()]);
        assert!(button_names(&json!({"success": false})).is_empty());
    }

    #[test]
    fn catalog_classification() {
        let (cat, act, _) = classify("Warning", "Could not start Vault Explorer because ...");
        assert_eq!(cat, "altium_extension_broken");
        assert_eq!(act.as_deref(), Some("OK"));
        let (cat, act, _) = classify("Confirm", "Do you want to save changes to Board1.PcbDoc?");
        assert_eq!(cat, "save_prompt");
        assert!(act.is_none(), "a save prompt must never carry a safe action");
        let (cat, act, _) = classify("Information", "License renewal failed. ...");
        assert_eq!(cat, "licence_seat_stranded");
        assert_eq!(act.as_deref(), Some("OK"));
        let (cat, act, _) = classify("Something", "never seen before");
        assert_eq!(cat, "unknown");
        assert!(act.is_none());
    }

    #[test]
    fn button_bearing_windows_are_dialogs() {
        let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
        assert!(has_dialog_buttons(&v(&["Save All", "Save None", "OK", "Cancel"])));
        assert!(has_dialog_buttons(&v(&["OK", "Cancel"])));
        assert!(!has_dialog_buttons(&v(&["Close"])), "a floating panel's lone Close is not a dialog");
        assert!(!has_dialog_buttons(&v(&["Place", "Properties"])));
        assert_eq!(classify("Confirm Save Locally for (3) Modified Documents", "").0, "save_prompt");
        assert_eq!(classify("Confirm Not Saving (3) Modified Documents", "").0, "save_prompt");
        assert_eq!(classify("Zoom Value (0.001x..4x)", "").0, "input_prompt");
    }
}