master
John Lauer 1.0.50: 1.0.50: the background guard (backgroundGuard) is OFF by default. John, after watching it live: 'you just are t… cad51ac 1mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450
//! The foreground etiquette loop (phase 4 of docs/rust-port-plan.md), ported from
//! handlers/win_focus.py 0.9.185. The rules below are John's, with their dates, and the
//! code keeps them in the order the Python learned them:
//!
//! - "ALWAYS open in the background" (2026-08-14). A KiCad window the bridge opened may
//!   never sit in the user's foreground.
//! - "never fucking minimize it, cuz then you can't take screenshots of the window when
//!   it's in the background" (0.9.181). Nothing here minimizes.
//! - "ONLY background the window once IF IT APPEARS IN THE FOREGROUND. then do nothing
//!   else" (0.9.181). One `push_to_background` per (hwnd, pid), recorded in a ledger that
//!   expires when the window dies (hwnd values are recycled: ConfRoomROG, 2026-09-08).
//!   Repetition is what strobed (wiki #33, Colby: "epileptic" flicker at 10 Hz).
//! - "if you are waiting 2 seconds to let the user activate the window that's rude as
//!   fuck" (0.9.181). No WS_EX_NOACTIVATE is ever SET by this loop: it blocks the user's
//!   own click. The 3 s bounce hold exists so a stale style is cleared 3 s after a bounce,
//!   by inspection, and `BOUNCE_HOLD_NOACTIVATE` documents where a hold would set it.
//! - Off-screen parking (0.9.138) was removed in 0.9.181: every move is a visible frame.
//!   Nothing here moves a window except the heal that drags one back ON screen.
//! - "always let the user choose to fg a window" (2026-08-16): a click landing on the
//!   window or on the shell (taskbar, Start: 0.9.155, John bounced 8 times launching his
//!   own KiCad) or an alt-tab in progress outranks every policy.
//! - THE IDLE RULE (0.9.172, 2026-08-19: "when I click the taskbar icon to bring the app
//!   to the fg, you don't let me"): with no verb in flight and no guard armed the bridge
//!   is not opening anything, so a KiCad window reaching the foreground is the user,
//!   whether or not the click was observed. Leave it alone, always.
//! - The 60 s spawn watch (0.9.185: "you opened the kicad main menu and it came to the
//!   foreground - you did nothing to even attempt once to put it in the background"). A
//!   spawned app shows its first window seconds after the verb returned; for 60 s after
//!   a spawn a window that appears in front is ours to put behind, once.
//! - NO SYSTEM-WIDE HOOKS. EVER. (0.9.184). No SetWindowsHookEx, no global input
//!   listener. A WinEventHook (out of context, skip own process) is a notification, not
//!   an input hook, and is the one thing the Python kept.
//! - The ownership ledger (2026-08-17: "what happens if the user opens their own kicad?
//!   i don't want us ruining that"). Ownership is by PROCESS: pids the bridge spawned
//!   plus their children (Toolhelp parent walk), persisted to
//!   `%USERPROFILE%/.adom/kicad-bridge-owned.json` in the Python's format so a restart
//!   neither orphans nor mis-adopts. Unknown windows default to user-owned.
//! - The 0.9.165 boot deadlock: SetWindowLongPtr sends WM_STYLECHANGING cross-process and
//!   never returns on a hung KiCad. Every style write and every SetWindowPos here is gated
//!   on the WM_NULL probe, and desktop maintenance runs on the loop thread 2 s after
//!   boot, never on the boot path.
//! - Wiki #48 (multi-monitor): the on-screen heal measures against ANY monitor, and only
//!   moves a window when no part of it is on one.
//!
//! Threads: the sentinel (`sentinel_loop`, 120 ms) and the WinEvent pump (`hook_pump`).
//! Both funnel into `decide`, the pure decision function, which is what the tests cover.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use serde_json::{json, Value};
use windows::Win32::Foundation::{HWND, POINT};
use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK};
use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON, VK_MENU, VK_RBUTTON};
use windows::Win32::UI::WindowsAndMessaging::{
    DispatchMessageW, GetAncestor, GetCursorPos, GetMessageW, GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPos,
    SystemParametersInfoW, TranslateMessage, WindowFromPoint, EVENT_OBJECT_SHOW, EVENT_SYSTEM_FOREGROUND, GA_ROOT,
    GWL_EXSTYLE, MSG, OBJID_WINDOW, SPI_GETFOREGROUNDLOCKTIMEOUT, SWP_NOACTIVATE, SWP_NOZORDER,
    SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, WINDOW_EX_STYLE, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, WS_EX_APPWINDOW,
    WS_EX_NOACTIVATE,
};

use super::enumerate::{class_name, minimized, on_a_monitor, pid_of, rect, title_nohang, top_level_windows, visible, ExeCache};
use super::{focus, hwnd, hwnd_u64, is_kicad_exe, is_window, process, responsive};

// ── Tunables, every one of them a number the Python carried ──────────────────

/// User input this recent, with the cursor on the shell or on the window, is the user's own click.
const USER_INPUT_GRACE_SECS: f64 = 1.5;
/// A window created by a verb stays "fresh" (pushable) this long after that verb ends.
const FRESH_AFTER_VERB: Duration = Duration::from_secs(3);
/// Sentinel poll period.
const POLL: Duration = Duration::from_millis(120);
/// A click counts as "the user brought this up" for this long after it landed.
const CLICK_WINDOW: Duration = Duration::from_millis(1200);
/// An alt-tab in progress counts for this long.
const ALT_WINDOW: Duration = Duration::from_secs(2);
/// Guard after a window-spawning verb, and the long one for the slow flows.
const GUARD_SHORT: Duration = Duration::from_secs(20);
const GUARD_LONG: Duration = Duration::from_secs(120);
/// For this long after a spawn, a new window in front is ours to put behind (0.9.185).
const SPAWN_WATCH: Duration = Duration::from_secs(60);
/// After a bounce, the window sits in the hold table this long, then any stale
/// WS_EX_NOACTIVATE on it is cleared.
const BOUNCE_HOLD: Duration = Duration::from_secs(3);
/// Idle heals: usability and on-screen every 20 s, session sanitizing every 60 s.
const HEAL_EVERY: Duration = Duration::from_secs(20);
const SANITIZE_EVERY: Duration = Duration::from_secs(60);
/// Desktop maintenance never runs on the boot path (0.9.165).
const BOOT_DELAY: Duration = Duration::from_secs(2);
/// Focus events kept for kicad_state (Python: deque(maxlen=200)).
const EVENT_RING: usize = 200;
/// WM_NULL probe deadline before a style write.
const PROBE_MS: u32 = 150;
/// Parent-chain walk depth when deciding ownership.
const PARENT_DEPTH: usize = 6;
/// Whether a bounce SETS WS_EX_NOACTIVATE for the hold. John, 0.9.181: "NEVER set
/// WS_EX_NOACTIVATE. It blocks the user's own click, and a user may want the window in
/// front the very next second. Their click must work immediately, with no hold-off
/// window." The Python's sentinel kept the hold table but nothing set the style after
/// 0.9.181; this build does the same. Flip to true to reinstate the 10.0.5 experiment.
const BOUNCE_HOLD_NOACTIVATE: bool = false;

/// Verbs whose guard is 120 s (server.py, 2026-08-15 background audit: a cold show_3d_*
/// is board-open plus 3D open plus a render self-raise 30 s out).
const SLOW_VERBS: &[&str] = &["open_3d_viewer", "place_footprint", "demo", "launch", "show_3d_chip", "show_3d_board"];

/// Shell classes a "bring this window up" click lands on (0.9.155).
const SHELL_CLASSES: &[&str] = &[
    "Shell_TrayWnd",
    "Shell_SecondaryTrayWnd",
    "Windows.UI.Core.CoreWindow",
    "XamlExplorerHostIslandWindow",
    "TopLevelWindowForOverflowXamlIsland",
    "Progman",
    "WorkerW",
];

/// Exe prefixes that count as the KiCad family for the ownership ledger.
const OWNED_EXE_PREFIXES: &[&str] = &["kicad", "eeschema", "pcbnew", "gerbview", "pl_editor", "pcb_calculator", "bitmap2component"];

// ── The pure decision ─────────────────────────────────────────────────────────

/// Everything `decide` needs to classify one foreground transition. Built by the
/// sentinel and by the hook from live Win32 reads; built by hand in the tests.
#[derive(Clone, Debug, Default)]
pub struct Transition {
    /// The new foreground window belongs to a KiCad exe.
    pub is_kicad: bool,
    /// Inside a `bring_to_front` window the bridge announced (expect_foreground).
    pub sanctioned: bool,
    /// A mouse button went down within CLICK_WINDOW and the cursor is over the window
    /// or over the shell (taskbar, Start).
    pub clicked_here: bool,
    /// Alt was down within ALT_WINDOW.
    pub alt_tabbed: bool,
    /// In the bridge's spawn tree (pid ledger).
    pub owned: bool,
    /// Was the foreground window when the current verb began: the user was already in
    /// it, so the verb stole nothing.
    pub foreground_at_verb_start: bool,
    /// Already pushed once (the once-per-(hwnd, pid) ledger).
    pub already_bounced: bool,
    /// The window the bridge is OPENING RIGHT NOW: it did not exist when the verb in flight began
    /// (or the verb ended under 3 s ago). Only such a window may ever be pushed back. Every other
    /// KiCad window is the user's to raise by click, Alt-Tab, Win+number or anything else, with no
    /// guessing about what caused the foreground change (John, 2026-09-21: "i just tried clicking to
    /// bring kicad to the foreground and then you yank it to the background on me").
    pub fresh: bool,
    pub guards: usize,
    pub inflight: usize,
    pub spawn_watch: bool,
    /// The user has been idle longer than the foreground lock timeout, so Windows
    /// granted the activation instead of refusing it.
    pub user_idle_past_lock: bool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// Not a KiCad window: not logged, not touched.
    Ignore,
    /// Logged with this classification, never touched.
    Leave(&'static str),
    /// Logged with this classification and pushed to the bottom of the z-order once.
    Bounce(&'static str),
}

/// The decision table. The order is the Python sentinel's, with the spec's two additions
/// (baseline foreground, once-ledger) as explicit rows so the log says why nothing
/// happened instead of pretending a bounce that the ledger then skipped.
///
/// `user_idle_past_lock` sits BELOW the guard and spawn-watch rows on purpose: the
/// idle-grant rule says "when the user is idle, Windows lets KiCad take the foreground",
/// but the runs John measures (the verb runner from his phone, ConfRoomROG sitting in a
/// conference room) are exactly the ones where nobody has touched the box for minutes,
/// and the Python bounced there regardless. Putting it above the guards would turn every
/// unattended run into a stream of steals. It only decorates the bridge-idle verdict.
pub fn decide(t: &Transition) -> Verdict {
    if !t.is_kicad {
        return Verdict::Ignore;
    }
    if t.sanctioned {
        return Verdict::Leave("sanctioned-foreground (allowed)");
    }
    if t.clicked_here || t.alt_tabbed {
        return Verdict::Leave("user-foregrounded (left alone)");
    }
    if !t.owned {
        return Verdict::Leave("user-owned window (left alone)");
    }
    if t.foreground_at_verb_start {
        return Verdict::Leave("baseline foreground (user was already in it, left alone)");
    }
    if !t.fresh {
        return Verdict::Leave("an existing window, the user's to raise (left alone)");
    }
    // The bridge is the cause (a verb in flight, a guard, our fresh spawn): bounce even if
    // this hwnd was bounced before. A box with the foreground lock off (arav-rog) lets a
    // KiCad window come forward twice while it loads, and the once-ledger left the second
    // one on top of the user's window (gate on 2026-09-12). User-initiated foregrounds are
    // already excluded above (clicked_here, alt_tabbed, sanctioned, baseline).
    if t.guards > 0 {
        return Verdict::Bounce("steal-during-guard (bounced)");
    }
    if t.inflight > 0 {
        return Verdict::Bounce("self-raise (bounced to background)");
    }
    if t.spawn_watch {
        return Verdict::Bounce("our freshly spawned window took the foreground (bounced)");
    }
    if t.already_bounced {
        return Verdict::Leave("already backgrounded once (hands off)");
    }
    if t.user_idle_past_lock {
        return Verdict::Leave("foreground while bridge idle (user idle past the lock timeout, Windows granted it, left alone)");
    }
    Verdict::Leave("foreground while bridge idle (left alone)")
}

/// Guard length for a verb name (with or without the `kicad_` prefix).
pub fn guard_for(verb: &str) -> Duration {
    let bare = verb.strip_prefix("kicad_").unwrap_or(verb);
    if SLOW_VERBS.contains(&bare) {
        GUARD_LONG
    } else {
        GUARD_SHORT
    }
}

/// Is this a path the bridge itself put into KiCad's session (session sanitizing)?
/// Either an entry of the owned-project ledger (case-insensitive, slash-normalised) or the
/// legacy demo naming that predates the ledger: a DIRECTORY component `adom-<slug>` or
/// `in-<part>-demo` (the Python regex `\\(adom-[a-z0-9-]+|in-[a-z0-9]+-demo)\\`).
pub fn is_bridge_project_path(p: &str, owned_projects: &HashSet<String>) -> bool {
    let q = p.replace('/', "\\").to_lowercase();
    if owned_projects.iter().any(|o| o.replace('/', "\\").to_lowercase() == q) {
        return true;
    }
    let parts: Vec<&str> = q.split('\\').collect();
    // A directory component: something follows it.
    parts.iter().take(parts.len().saturating_sub(1)).any(|seg| {
        if let Some(rest) = seg.strip_prefix("adom-") {
            return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
        }
        if let Some(rest) = seg.strip_prefix("in-").and_then(|r| r.strip_suffix("-demo")) {
            return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
        }
        false
    })
}

// ── Shared state ──────────────────────────────────────────────────────────────

#[derive(Clone, Debug)]
struct Guard {
    verb: String,
    deadline: Instant,
}

#[derive(Default)]
struct Inner {
    started: bool,
    sentinel_started: bool,
    hook_started: bool,
    hook_fg_armed: bool,
    hook_show_armed: bool,
    /// Window verbs in flight (begin/end pairs).
    inflight: usize,
    guards: Vec<Guard>,
    /// KiCad windows that existed when the current run of verbs began.
    baseline: HashSet<u64>,
    /// KiCad windows that existed when the verb now in flight began. Refreshed at EVERY verb start
    /// (`baseline` above is only refreshed when no guard is armed, which during back-to-back verbs
    /// is never, so it cannot answer "is this window new").
    verb_baseline: HashSet<u64>,
    last_verb_end: Option<Instant>,
    /// The foreground window when the current run of verbs began, if KiCad.
    baseline_fg: Option<u64>,
    /// The user's most recent non-KiCad foreground window; a bounce hands the activation
    /// back to it, because a Z-order push alone does not hold while KiCad stays active.
    last_user_fg: u64,
    /// Windows the bridge created (persisted; survives verbs).
    bridge_windows: HashSet<u64>,
    /// (hwnd, pid) -> title at the push. One push per key, ever.
    backgrounded_once: HashMap<(u64, u32), String>,
    /// hwnd -> when the hold ends.
    bounce_holds: HashMap<u64, Instant>,
    /// Windows this process set WS_EX_NOACTIVATE on (cleared when the bridge goes idle).
    noactivated: HashSet<u64>,
    owned_pids: HashSet<u32>,
    owned_projects: HashSet<String>,
    /// pid -> (owned?, decided at). Negative answers expire; positives are in owned_pids.
    owned_cache: HashMap<u32, (bool, Instant)>,
    spawn_watch_until: Option<Instant>,
    expected_fg_until: Option<Instant>,
    sanctioned_hwnd: u64,
    last_click: Option<Instant>,
    last_alt: Option<Instant>,
    events: Vec<Value>,
    sweep_ticks: u64,
    last_heal: Option<Instant>,
    last_sanitize: Option<Instant>,
    heals: u64,
    sanitized: u64,
    bounces: u64,
    last_error: String,
}

static STATE: OnceLock<Mutex<Inner>> = OnceLock::new();

fn state() -> &'static Mutex<Inner> {
    STATE.get_or_init(|| Mutex::new(Inner::default()))
}

fn lock() -> std::sync::MutexGuard<'static, Inner> {
    state().lock().unwrap_or_else(|e| e.into_inner())
}

fn log(line: &str) {
    eprintln!("{line}");
}

fn epoch_secs() -> f64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)
}

fn within(since: Option<Instant>, window: Duration, now: Instant) -> bool {
    since.map(|t| now.saturating_duration_since(t) <= window).unwrap_or(false)
}

fn active_until(until: Option<Instant>, now: Instant) -> bool {
    until.map(|u| now < u).unwrap_or(false)
}

fn seconds_left(until: Option<Instant>, now: Instant) -> f64 {
    until.map(|u| u.saturating_duration_since(now).as_secs_f64()).unwrap_or(0.0)
}

// ── Ownership ledger ──────────────────────────────────────────────────────────

fn ledger_path() -> PathBuf {
    let base = std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty()).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
    let dir = base.join(".adom");
    let _ = std::fs::create_dir_all(&dir);
    dir.join("kicad-bridge-owned.json")
}

fn pid_is_kicad(pid: u32) -> bool {
    let base = process::exe_base_name(pid);
    !base.is_empty() && OWNED_EXE_PREFIXES.iter().any(|p| base.starts_with(p))
}

/// Write the ledger in the Python's format: live KiCad pids, live bridge windows with
/// their pids, owned projects. Called with the lock NOT held (it opens processes).
fn save_ledger(pids: &HashSet<u32>, windows: &HashSet<u64>, projects: &HashSet<String>) {
    let mut live: Vec<u32> = pids.iter().copied().filter(|p| pid_is_kicad(*p)).collect();
    live.sort_unstable();
    let wins: Vec<Value> = windows
        .iter()
        .copied()
        .filter(|h| is_window(hwnd(*h)))
        .map(|h| json!({"hwnd": h, "pid": pid_of(hwnd(h))}))
        .collect();
    let mut projects: Vec<&String> = projects.iter().collect();
    projects.sort();
    let doc = json!({"pids": live, "windows": wins, "projects": projects});
    if let Err(e) = std::fs::write(ledger_path(), doc.to_string()) {
        lock().last_error = format!("ledger write: {e}");
    }
}

fn snapshot_and_save() {
    let (pids, wins, projects) = {
        let g = lock();
        (g.owned_pids.clone(), g.bridge_windows.clone(), g.owned_projects.clone())
    };
    save_ledger(&pids, &wins, &projects);
}

/// Boot: re-own only what provably survived (a ledger hwnd whose pid still matches and
/// is KiCad, ledger pids still running a KiCad exe). Anything the user launched while
/// the bridge was down stays user-owned.
fn load_ledger() {
    let Ok(text) = std::fs::read_to_string(ledger_path()) else { return };
    let Ok(doc) = serde_json::from_str::<Value>(&text) else { return };
    let mut wins = HashSet::new();
    let mut pids = HashSet::new();
    let mut projects = HashSet::new();
    for e in doc["windows"].as_array().into_iter().flatten() {
        let h = e["hwnd"].as_u64().unwrap_or(0);
        let p = e["pid"].as_u64().unwrap_or(0) as u32;
        if h != 0 && is_window(hwnd(h)) && pid_of(hwnd(h)) == p && pid_is_kicad(p) {
            wins.insert(h);
            pids.insert(p);
        }
    }
    for p in doc["pids"].as_array().into_iter().flatten() {
        let p = p.as_u64().unwrap_or(0) as u32;
        if p != 0 && pid_is_kicad(p) {
            pids.insert(p);
        }
    }
    for p in doc["projects"].as_array().into_iter().flatten() {
        if let Some(s) = p.as_str() {
            projects.insert(s.to_string());
        }
    }
    let mut g = lock();
    g.bridge_windows.extend(wins);
    g.owned_pids.extend(pids);
    g.owned_projects.extend(projects);
}

/// Is this window the bridge's (spawned by us, or a child of our spawn)? Cheap when the
/// pid is already known; otherwise one Toolhelp snapshot and a bounded parent walk. A
/// child of our spawn is adopted into the pid set (and the ledger) on first sight.
fn is_owned_window(h: u64) -> bool {
    if h == 0 {
        return false;
    }
    let pid = pid_of(hwnd(h));
    if pid == 0 {
        return false;
    }
    {
        let g = lock();
        if g.bridge_windows.contains(&h) || g.owned_pids.contains(&pid) {
            return true;
        }
        if let Some((owned, at)) = g.owned_cache.get(&pid) {
            if at.elapsed() < Duration::from_secs(2) {
                return *owned;
            }
        }
    }
    let parents: HashMap<u32, u32> = process::snapshot().into_iter().map(|(p, pp, _)| (p, pp)).collect();
    let owned = {
        let g = lock();
        let mut p = pid;
        let mut found = false;
        for _ in 0..PARENT_DEPTH {
            let Some(&pp) = parents.get(&p) else { break };
            if pp == 0 || pp == p {
                break;
            }
            p = pp;
            if g.owned_pids.contains(&p) {
                found = true;
                break;
            }
        }
        found
    };
    let mut g = lock();
    g.owned_cache.insert(pid, (owned, Instant::now()));
    if owned {
        g.owned_pids.insert(pid);
        drop(g);
        snapshot_and_save();
    }
    owned
}

pub fn owned(h: u64) -> bool {
    is_owned_window(h)
}

// ── Win32 reads the loop needs ────────────────────────────────────────────────

fn cursor_root() -> HWND {
    let mut pt = POINT::default();
    // SAFETY: `pt` outlives the call.
    if unsafe { GetCursorPos(&mut pt) }.is_err() {
        return HWND::default();
    }
    // SAFETY: plain hit test, no cross-thread message.
    let under = unsafe { WindowFromPoint(pt) };
    if under.is_invalid() {
        return HWND::default();
    }
    // SAFETY: table read.
    unsafe { GetAncestor(under, GA_ROOT) }
}

fn cursor_over(h: u64) -> bool {
    let root = cursor_root();
    !root.is_invalid() && hwnd_u64(root) == h
}

/// A window that belongs to the Windows shell: by class, or because explorer.exe owns it. The bridge
/// never forces one of these to the foreground and never treats one as "the user's window".
fn is_shell_window(h: u64, cache: &mut ExeCache) -> bool {
    if h == 0 {
        return false;
    }
    if SHELL_CLASSES.contains(&class_name(hwnd(h)).as_str()) {
        return true;
    }
    let exe = cache.exe(pid_of(hwnd(h))).to_lowercase();
    exe.ends_with("explorer.exe") || exe.ends_with("shellexperiencehost.exe") || exe.ends_with("startmenuexperiencehost.exe") || exe.ends_with("searchhost.exe")
}

fn cursor_over_shell() -> bool {
    let root = cursor_root();
    !root.is_invalid() && SHELL_CLASSES.contains(&class_name(root).as_str())
}

/// Sample the mouse buttons and Alt. `0x8001`: down now, or pressed since the last call
/// (both threads sample, both write the shared timestamps, so it does not matter which
/// one consumes the latched bit).
fn sample_input(g: &mut Inner, now: Instant) {
    // SAFETY: GetAsyncKeyState reads the async key table only.
    let (l, r, alt) = unsafe { (GetAsyncKeyState(VK_LBUTTON.0 as i32), GetAsyncKeyState(VK_RBUTTON.0 as i32), GetAsyncKeyState(VK_MENU.0 as i32)) };
    if (l as u16 & 0x8001) != 0 || (r as u16 & 0x8001) != 0 {
        g.last_click = Some(now);
    }
    if (alt as u16 & 0x8000) != 0 {
        g.last_alt = Some(now);
    }
}

fn exe_of(h: u64, cache: &mut ExeCache) -> String {
    cache.exe(pid_of(hwnd(h)))
}

/// Windows' foreground lock timeout in ms (SPI_GETFOREGROUNDLOCKTIMEOUT). 0 means the
/// lock is off (some boxes are tweaked that way), in which case the idle-grant rule
/// carries no information and is not applied.
fn foreground_lock_timeout_ms() -> u32 {
    let mut ms: u32 = 0;
    // SAFETY: pvParam points at a u32 that outlives the call, as the action requires.
    let ok = unsafe { SystemParametersInfoW(SPI_GETFOREGROUNDLOCKTIMEOUT, 0, Some(&mut ms as *mut u32 as *mut _), SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0)) };
    if ok.is_err() {
        return 0;
    }
    ms
}

fn user_idle_secs() -> f64 {
    super::messages::seconds_since_input().unwrap_or(1e9)
}

fn user_idle_past_lock() -> bool {
    let ms = foreground_lock_timeout_ms();
    ms > 0 && user_idle_secs() * 1000.0 > ms as f64
}

fn exstyle(h: HWND) -> WINDOW_EX_STYLE {
    // SAFETY: GetWindowLongPtr reads the window's own record; no message is sent.
    WINDOW_EX_STYLE(unsafe { GetWindowLongPtrW(h, GWL_EXSTYLE) } as u32)
}

/// The one style write in this module. Refused on a window that fails the WM_NULL probe
/// (0.9.165: SetWindowLongPtr on a hung window never returns).
fn set_exstyle(h: HWND, want: WINDOW_EX_STYLE) -> Result<(), String> {
    super::require_responsive(h, "set_exstyle", PROBE_MS)?;
    // SAFETY: responsive window; the style value is a plain integer.
    unsafe { SetWindowLongPtrW(h, GWL_EXSTYLE, want.0 as isize) };
    Ok(())
}

/// Suppress activation (WS_EX_NOACTIVATE paired with WS_EX_APPWINDOW so the taskbar
/// button survives: 0.9.160, "why do i see no windows taskbar icons"). Only reachable when
/// `BOUNCE_HOLD_NOACTIVATE` is true.
fn suppress_activation(h: u64) {
    let w = hwnd(h);
    if !is_window(w) {
        return;
    }
    let cur = exstyle(w);
    let want = cur | WS_EX_NOACTIVATE | WS_EX_APPWINDOW;
    if want != cur {
        match set_exstyle(w, want) {
            Ok(()) => {
                lock().noactivated.insert(h);
            }
            Err(e) => lock().last_error = e,
        }
    } else {
        lock().noactivated.insert(h);
    }
}

/// Clear WS_EX_NOACTIVATE from one window if it carries it (inspection, so a style left
/// by an older bridge is healed too: 0.9.164). APPWINDOW stays: re-toggling it makes the
/// shell rebuild the button.
fn allow_activation(h: u64) -> bool {
    let w = hwnd(h);
    let mut cleared = false;
    if is_window(w) {
        let cur = exstyle(w);
        if cur.contains(WS_EX_NOACTIVATE) {
            let want = WINDOW_EX_STYLE(cur.0 & !WS_EX_NOACTIVATE.0) | WS_EX_APPWINDOW;
            match set_exstyle(w, want) {
                Ok(()) => cleared = true,
                Err(e) => {
                    lock().last_error = e;
                    return false;
                }
            }
        }
    }
    lock().noactivated.remove(&h);
    cleared
}

fn allow_activation_all() -> usize {
    let list: Vec<u64> = lock().noactivated.iter().copied().collect();
    let mut n = 0;
    for h in list {
        if allow_activation(h) {
            n += 1;
        }
    }
    n
}

/// Every visible KiCad top-level window.
fn kicad_hwnds(cache: &mut ExeCache) -> Vec<u64> {
    top_level_windows()
        .into_iter()
        .filter(|&h| visible(h) && is_kicad_exe(&cache.exe(pid_of(h))))
        .map(hwnd_u64)
        .collect()
}

// ── The action ────────────────────────────────────────────────────────────────

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Source {
    Sentinel,
    Hook,
}

/// Build the transition for `h`, decide, act, log. Shared by both threads. The decision
/// and the once-ledger claim happen under the lock; the push happens outside it, so a
/// 300 ms probe never stalls the other thread.
fn handle_foreground(h: u64, source: Source, cache: &mut ExeCache) {
    if h == 0 {
        return;
    }
    let exe = exe_of(h, cache);
    if !is_kicad_exe(&exe) {
        return;
    }
    let now = Instant::now();
    let pid = pid_of(hwnd(h));
    // Reads that touch other processes' state (cursor hit test, Toolhelp) before the lock.
    let owned = is_owned_window(h);
    let idle_past_lock = user_idle_past_lock();
    let ago = user_idle_secs();
    let (verdict, t) = {
        let mut g = lock();
        sample_input(&mut g, now);
        expire_guards(&mut g, now);
        // A click is the polled button state (120 ms sentinel, so a quick click is missed and the
        // user's own window was bounced "sometimes") OR any user input in the last 1.5 s. Either one
        // only counts with the cursor on the shell (a taskbar button, Start) or on this window:
        // someone typing in their editor while KiCad raises itself has the cursor elsewhere.
        // Two kinds of evidence, and they do NOT get the same hit test (1.0.46):
        // - a mouse BUTTON seen down (polled): the cursor on this window or on the shell is a click on it;
        // - merely RECENT INPUT of any kind: only the cursor on the SHELL counts (a taskbar button,
        //   Start). 1.0.40 also accepted "cursor over this window", which is true whenever a new KiCad
        //   window happens to open underneath the cursor of someone who is just using their machine:
        //   on AdomLapper the Schematic Editor was judged "user-foregrounded" and left on top of John's
        //   browser with the guard ON (2026-09-21 11:50:36).
        let button = within(g.last_click, CLICK_WINDOW, now);
        let recent_input = ago < USER_INPUT_GRACE_SECS;
        let clicked_here = (button && (cursor_over(h) || cursor_over_shell())) || (recent_input && cursor_over_shell());
        let t = Transition {
            is_kicad: true,
            sanctioned: active_until(g.expected_fg_until, now),
            clicked_here,
            alt_tabbed: within(g.last_alt, ALT_WINDOW, now),
            owned,
            foreground_at_verb_start: (g.inflight > 0 || !g.guards.is_empty()) && g.baseline_fg == Some(h),
            already_bounced: g.backgrounded_once.contains_key(&(h, pid)),
            fresh: !g.verb_baseline.contains(&h) && (g.inflight > 0 || within(g.last_verb_end, FRESH_AFTER_VERB, now)),
            guards: g.guards.len(),
            inflight: g.inflight,
            spawn_watch: active_until(g.spawn_watch_until, now),
            user_idle_past_lock: idle_past_lock,
        };
        let v = decide(&t);
        if let Verdict::Bounce(_) = v {
            // Claim the once-per-window slot before acting so the other thread cannot
            // push the same window a second time.
            let title = title_nohang(hwnd(h));
            g.backgrounded_once.insert((h, pid), title);
            g.bounces += 1;
        }
        (v, t)
    };
    let title = title_nohang(hwnd(h));
    // Guard switched off by the user: decide as usual so the log still says what WOULD have
    // happened (that is the point of an A/B), but touch nothing.
    let verdict = match verdict {
        Verdict::Bounce(_) if !GUARD_ENABLED.load(std::sync::atomic::Ordering::Relaxed) => {
            let mut g = lock();
            g.backgrounded_once.remove(&(h, pid));
            g.bounces = g.bounces.saturating_sub(1);
            Verdict::Leave("guard OFF: would have pushed this window (left alone)")
        }
        v => v,
    };
    let action: String = match verdict {
        Verdict::Ignore => return,
        Verdict::Leave(label) => label.to_string(),
        Verdict::Bounce(label) => {
            let result = match focus::push_to_background(hwnd(h)) {
                Ok(()) => "pushed",
                Err(e) => {
                    // 1.0.38 gave the once-per-window slot back here so a refused push would be retried.
                    // REVERTED in 1.0.39: on winvm the same window was then pushed again and again within
                    // seconds, and every push also forces the user's previous window to the front. When
                    // that window was the taskbar John had just clicked, Explorer was hammered and died.
                    // One push per window, landed or not, until the retry can be done without touching
                    // the user's window (wiki #112).
                    lock().last_error = e;
                    "push refused (window hung)"
                }
            };
            if BOUNCE_HOLD_NOACTIVATE {
                suppress_activation(h);
            }
            if SUPPRESS_FLASH.load(std::sync::atomic::Ordering::Relaxed) {
                focus::stop_flash(hwnd(h));
            }
            // Hand the activation back to the user's window (restoring what they had);
            // without this KiCad keeps the activation and raises itself again.
            let user = lock().last_user_fg;
            let shell = user != 0 && is_shell_window(user, &mut ExeCache::default());
            // ASK, never force (John, 2026-09-21: "just push the kicad window to the background but you
            // never force the previous window to the foreground"). One plain SetForegroundWindow so the
            // keyboard does not stay in a KiCad window the user cannot see; no thread-input attach, no
            // ShowWindow, no BringWindowToTop, never a shell window. If Windows says no, that is the end.
            let restored = user != 0 && user != h && !shell && focus::request_foreground(hwnd(user));
            lock().bounce_holds.insert(h, now + BOUNCE_HOLD);
            log(&format!("[bg] {h}: sent behind ({result}); user window {user} {}.", if restored { "restored" } else { "not restored" }));
            match source {
                Source::Hook => format!("guarded-steal (hook-bounced, ms; {result})"),
                Source::Sentinel => {
                    if result == "pushed" {
                        label.to_string()
                    } else {
                        format!("{label}; {result}")
                    }
                }
            }
        }
    };
    let ev = json!({
        "t": epoch_secs(),
        "action": action,
        "title": title,
        "secondsSinceUserInput": (ago.min(9999.0) * 100.0).round() / 100.0,
        "hwnd": h,
        "pid": pid,
        "exe": exe,
        "source": match source { Source::Hook => "hook", Source::Sentinel => "sentinel" },
        "owned": t.owned,
        "guards": t.guards,
        "inflight": t.inflight,
        "spawnWatch": t.spawn_watch,
        "userIdlePastLockTimeout": t.user_idle_past_lock,
    });
    let tag = match source {
        Source::Hook => "[focus-hook]",
        Source::Sentinel => "[focus-sentinel]",
    };
    log(&format!("{tag} {}: '{}' (user input {:.2}s ago)", ev["action"].as_str().unwrap_or(""), ev["title"].as_str().unwrap_or(""), ago.min(9999.0)));
    push_event(ev);
}

fn push_event(ev: Value) {
    let mut g = lock();
    g.events.push(ev);
    if g.events.len() > EVENT_RING {
        let n = g.events.len() - EVENT_RING;
        g.events.drain(..n);
    }
}

/// Drop guards past their deadline. A guard is a deadline, not a thread, so the 0.9.160
/// watchdog (a leaked guard kept a window parked for hours) has nothing left to catch.
/// Returns true when the last guard left with no verb in flight (the bridge went idle).
fn expire_guards(g: &mut Inner, now: Instant) -> bool {
    let before = g.guards.len();
    g.guards.retain(|gd| now < gd.deadline);
    let dropped = before - g.guards.len();
    dropped > 0 && g.guards.is_empty() && g.inflight == 0
}

// ── Idle heals ────────────────────────────────────────────────────────────────

/// Every 20 s while idle (0.9.164, "why is it that when i try to click to see one of
/// your kicad windows it doesn't work?"): by INSPECTION, clear WS_EX_NOACTIVATE from every
/// responsive KiCad window, and drag a window back on screen when no part of it is on any
/// monitor (wiki #48: a second monitor counts exactly like the primary). Size preserved,
/// no activation, z-order untouched. A hung window is skipped entirely.
fn heal_windows(cache: &mut ExeCache) -> usize {
    let mut healed = 0;
    for h in kicad_hwnds(cache) {
        let w = hwnd(h);
        if minimized(w) {
            continue; // not ours to restore, and its rect is meaningless
        }
        if !responsive(w, PROBE_MS) {
            continue; // hung: touching its style or position would block us too
        }
        let cur = exstyle(w);
        if cur.contains(WS_EX_NOACTIVATE) && allow_activation(h) {
            healed += 1;
            log(&format!("[focus] usability heal: cleared stale suppression on {h}"));
        }
        let r = rect(w);
        if r.2 > 0 && r.3 > 0 && !on_a_monitor(r) {
            let (x, y) = (120, 90);
            let (wd, ht) = (r.2.max(640), r.3.max(480));
            // SAFETY: responsive window; flags forbid activation and z-order change.
            match unsafe { SetWindowPos(w, None, x, y, wd, ht, SWP_NOACTIVATE | SWP_NOZORDER) } {
                Ok(()) => {
                    healed += 1;
                    log(&format!("[focus] healed off-screen window {h} -> ({x},{y})"));
                }
                Err(e) => lock().last_error = format!("SetWindowPos({h}) heal: {e}"),
            }
        }
    }
    healed
}

fn any_kicad_running() -> bool {
    process::snapshot().into_iter().any(|(pid, _, exe)| pid != 0 && OWNED_EXE_PREFIXES.iter().any(|p| exe.starts_with(p)))
}

/// Remove BRIDGE projects from KiCad's restore-on-launch list (2026-08-17: every launch of
/// John's KiCad auto-opened our demo and hit the project lock warning). `kicad.json`
/// `system.open_projects` is rewritten by KiCad at exit, so edits only stick while NO
/// KiCad runs; the loop retries every 60 s until that window opens. file_history is left
/// alone (honest recents).
fn sanitize_session() -> usize {
    if any_kicad_running() {
        return 0;
    }
    let owned = lock().owned_projects.clone();
    let Some(root) = std::env::var("APPDATA").ok().filter(|s| !s.is_empty()).map(|a| PathBuf::from(a).join("kicad")) else { return 0 };
    let Ok(rd) = std::fs::read_dir(&root) else { return 0 };
    let mut removed = 0;
    for e in rd.flatten() {
        let cfg = e.path().join("kicad.json");
        if !cfg.is_file() {
            continue;
        }
        removed += sanitize_file(&cfg, &owned);
    }
    removed
}

fn sanitize_file(cfg: &Path, owned: &HashSet<String>) -> usize {
    let Ok(text) = std::fs::read_to_string(cfg) else { return 0 };
    let Ok(mut data) = serde_json::from_str::<Value>(&text) else { return 0 };
    let Some(op) = data.get("system").and_then(|s| s.get("open_projects")).and_then(Value::as_array).cloned() else { return 0 };
    let keep: Vec<Value> = op.iter().filter(|p| !p.as_str().map(|s| is_bridge_project_path(s, owned)).unwrap_or(false)).cloned().collect();
    let n = op.len() - keep.len();
    if n == 0 {
        return 0;
    }
    if !data["system"].is_object() {
        data["system"] = json!({});
    }
    data["system"]["open_projects"] = Value::Array(keep);
    match serde_json::to_string_pretty(&data).map_err(|e| e.to_string()).and_then(|s| std::fs::write(cfg, s).map_err(|e| e.to_string())) {
        Ok(()) => {
            log(&format!("[session] scrubbed {n} bridge project(s) from {}", cfg.display()));
            n
        }
        Err(e) => {
            lock().last_error = format!("sanitize {}: {e}", cfg.display());
            0
        }
    }
}

// ── The sentinel thread ───────────────────────────────────────────────────────

fn sentinel_loop() {
    let boot = Instant::now();
    let mut last_fg: u64 = focus::foreground();
    let mut cache = ExeCache::default();
    let mut cache_born = Instant::now();
    let mut booted = false;
    let mut flash_tick: u32 = 0;
    loop {
        let now = Instant::now();
        if cache_born.elapsed() > Duration::from_secs(5) {
            cache = ExeCache::default(); // pids are recycled; do not trust a stale exe name
            cache_born = now;
        }
        // Boot maintenance, off the boot path (0.9.165): ledger, stale styles, off-screen
        // windows, session, in that order, once the port has been answering for 2 s.
        if !booted && now.saturating_duration_since(boot) >= BOOT_DELAY {
            booted = true;
            load_ledger();
            let n = heal_windows(&mut cache);
            if n > 0 {
                log(&format!("[boot] healed {n} window(s)"));
            }
            let s = sanitize_session();
            let mut g = lock();
            g.last_heal = Some(now);
            g.last_sanitize = Some(now);
            g.heals += n as u64;
            g.sanitized += s as u64;
        }
        // 1. Input sample, guard expiry, hold expiry.
        let (went_idle, expired_holds, active) = {
            let mut g = lock();
            sample_input(&mut g, now);
            let went_idle = expire_guards(&mut g, now);
            let expired: Vec<u64> = g.bounce_holds.iter().filter(|(_, until)| now >= **until).map(|(h, _)| *h).collect();
            for h in &expired {
                g.bounce_holds.remove(h);
            }
            let active = g.inflight > 0 || !g.guards.is_empty();
            if active {
                g.sweep_ticks += 1;
            }
            (went_idle, expired, active)
        };
        for h in expired_holds {
            allow_activation(h);
        }
        if went_idle {
            let n = allow_activation_all();
            if n > 0 {
                log(&format!("[focus] bridge idle: cleared activation block on {n} window(s)"));
            }
        }
        // 1b. Attention flash: about once a second, clear it on the windows the bridge owns.
        flash_tick = flash_tick.wrapping_add(1);
        if flash_tick % 8 == 0 && SUPPRESS_FLASH.load(std::sync::atomic::Ordering::Relaxed) {
            for h in kicad_hwnds(&mut cache) {
                if is_owned_window(h) {
                    focus::stop_flash(hwnd(h));
                }
            }
        }
        // 2. The transition.
        let fg = focus::foreground();
        // NEVER a shell window. Clicking a taskbar button makes the taskbar the foreground window for
        // a moment; recording it here meant the next bounce "restored" it with bring_to_front, which
        // attaches to the target's input thread and calls ShowWindow / BringWindowToTop /
        // SetForegroundWindow on it. Done to the Windows 11 taskbar that crashed Explorer inside
        // Taskbar.View.dll (0xc0000005), twice in a row, the moment John clicked a KiCad taskbar
        // button (AdomLapper, 2026-09-21; the same thing killed Explorer on winvm the night before).
        if fg != 0 && !is_kicad_exe(&exe_of(fg, &mut cache)) && !is_shell_window(fg, &mut cache) {
            lock().last_user_fg = fg;
        }
        if fg != last_fg {
            handle_foreground(fg, Source::Sentinel, &mut cache);
            last_fg = fg;
        }
        // 3. While the bridge works: adopt owned windows that appeared since the baseline
        //    into the bridge-window set (the ledger), so a restart still knows them. The
        //    transition above already decided about the foreground; nothing else is touched.
        if active {
            let baseline = lock().baseline.clone();
            let mut adopted = false;
            for h in kicad_hwnds(&mut cache) {
                if baseline.contains(&h) || lock().bridge_windows.contains(&h) {
                    continue;
                }
                if is_owned_window(h) {
                    lock().bridge_windows.insert(h);
                    adopted = true;
                }
            }
            if adopted {
                snapshot_and_save();
            }
        } else if booted {
            // 4. Idle heals.
            let (heal_due, sanitize_due) = {
                let g = lock();
                (
                    g.last_heal.map(|t| now.saturating_duration_since(t) >= HEAL_EVERY).unwrap_or(true),
                    g.last_sanitize.map(|t| now.saturating_duration_since(t) >= SANITIZE_EVERY).unwrap_or(true),
                )
            };
            if heal_due {
                let n = heal_windows(&mut cache);
                let mut g = lock();
                g.last_heal = Some(now);
                g.heals += n as u64;
                // The once-ledger expires with the windows (hwnd values are recycled).
                g.backgrounded_once.retain(|(h, _), _| is_window(hwnd(*h)));
                g.bridge_windows.retain(|h| is_window(hwnd(*h)));
            }
            if sanitize_due {
                let s = sanitize_session();
                let mut g = lock();
                g.last_sanitize = Some(now);
                g.sanitized += s as u64;
            }
        }
        std::thread::sleep(POLL);
    }
}

// ── The WinEvent hook thread ──────────────────────────────────────────────────

/// Windows delivers EVENT_SYSTEM_FOREGROUND and EVENT_OBJECT_SHOW here, on the pump
/// thread, synchronously with the event (0.9.137: the 120 ms poll latency IS the visible
/// flash on 10.0.5). Only the foreground matters: a SHOW is acted on only when the shown
/// window is the foreground (John: "ONLY background the window once IF IT APPEARS IN THE
/// FOREGROUND"). The same decision function as the sentinel, so a user click still wins.
unsafe extern "system" fn on_event(_hook: HWINEVENTHOOK, event: u32, h: HWND, id_object: i32, id_child: i32, _tid: u32, _time: u32) {
    if id_object != OBJID_WINDOW.0 || id_child != 0 || h.is_invalid() {
        return;
    }
    let hu = hwnd_u64(h);
    let fg = focus::foreground();
    match event {
        EVENT_SYSTEM_FOREGROUND => {}
        EVENT_OBJECT_SHOW if fg == hu => {}
        _ => return,
    }
    // Fast reject: while the bridge is idle and no spawn is being watched, the polling
    // sentinel owns classification (and it will log the transition).
    {
        let g = lock();
        let now = Instant::now();
        if g.inflight == 0 && g.guards.is_empty() && !active_until(g.spawn_watch_until, now) {
            return;
        }
        if active_until(g.expected_fg_until, now) {
            return;
        }
    }
    let mut cache = ExeCache::default();
    handle_foreground(hu, Source::Hook, &mut cache);
}

fn hook_pump() {
    // SAFETY: out-of-context hook with a 'static callback; no module handle needed.
    let (h1, h2) = unsafe {
        (
            SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_event), 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS),
            SetWinEventHook(EVENT_OBJECT_SHOW, EVENT_OBJECT_SHOW, None, Some(on_event), 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS),
        )
    };
    {
        let mut g = lock();
        g.hook_fg_armed = !h1.is_invalid();
        g.hook_show_armed = !h2.is_invalid();
    }
    log(&format!("[focus-hook] armed fg={} show={}", !h1.is_invalid(), !h2.is_invalid()));
    if h1.is_invalid() && h2.is_invalid() {
        return;
    }
    let mut msg = MSG::default();
    // SAFETY: a plain message pump on the thread that installed the hooks; `msg` outlives
    // every call. GetMessageW returns 0 on WM_QUIT and -1 on error; both end the pump.
    unsafe {
        while GetMessageW(&mut msg, None, 0, 0).0 > 0 {
            let _ = TranslateMessage(&msg);
            DispatchMessageW(&msg);
        }
    }
}

// ── Trait entry points ────────────────────────────────────────────────────────

/// Start both threads once. The ledger load and the boot heals run on the sentinel
/// thread after BOOT_DELAY, never here.
pub fn start() {
    {
        let mut g = lock();
        if g.started {
            return;
        }
        g.started = true;
    }
    match std::thread::Builder::new().name("kicad-focus-sentinel".into()).spawn(sentinel_loop) {
        Ok(_) => lock().sentinel_started = true,
        Err(e) => lock().last_error = format!("sentinel thread: {e}"),
    }
    match std::thread::Builder::new().name("kicad-focus-event-hook".into()).spawn(hook_pump) {
        Ok(_) => lock().hook_started = true,
        Err(e) => lock().last_error = format!("hook thread: {e}"),
    }
}

/// A window verb begins. The baseline (every KiCad window up right now, and the foreground
/// if it is one) is taken only when the bridge was idle: a window that finished its own
/// verb is settled and belongs in the baseline like the user's own (0.9.161). A spawning
/// verb also arms its guard (20 s, or 120 s for the slow flows); the deadline is re-armed
/// at end_verb so the guard covers the late self-raise AFTER the verb returned, which is
/// what restore_foreground_persistent did.
pub fn begin_verb(verb: &str, spawns: bool) {
    let now = Instant::now();
    let mut cache = ExeCache::default();
    let fg = focus::foreground();
    let fg_is_kicad = fg != 0 && is_kicad_exe(&exe_of(fg, &mut cache));
    let mut g = lock();
    expire_guards(&mut g, now);
    if g.inflight == 0 && g.guards.is_empty() {
        g.bridge_windows.retain(|h| is_window(hwnd(*h)));
        g.baseline = kicad_hwnds(&mut cache).into_iter().collect();
        g.baseline_fg = if fg_is_kicad { Some(fg) } else { None };
    }
    if g.inflight == 0 {
        g.verb_baseline = kicad_hwnds(&mut cache).into_iter().collect();
    }
    g.inflight += 1;
    if spawns {
        g.guards.push(Guard { verb: verb.to_string(), deadline: now + guard_for(verb) });
    }
}

/// A window verb ends. Its guard (if any) restarts its clock now, so the post-verb window
/// is the full 20 s or 120 s. When nothing is in flight and no guard is armed the bridge
/// hands every window back (0.9.180: a stale WS_EX_NOACTIVATE must never outlive the work
/// that justified it).
pub fn end_verb(verb: &str) {
    let now = Instant::now();
    let idle = {
        let mut g = lock();
        g.inflight = g.inflight.saturating_sub(1);
        if g.inflight == 0 {
            g.last_verb_end = Some(now);
        }
        if let Some(gd) = g.guards.iter_mut().rev().find(|gd| gd.verb == verb) {
            gd.deadline = now + guard_for(verb);
        }
        g.inflight == 0 && g.guards.is_empty()
    };
    if idle {
        allow_activation_all();
    }
}

/// Every KiCad-family process the bridge starts, and every project it creates.
pub fn register_pid(pid: u32, project: Option<&Path>) {
    {
        let mut g = lock();
        if pid != 0 {
            g.owned_pids.insert(pid);
            g.owned_cache.remove(&pid);
            g.spawn_watch_until = Some(Instant::now() + SPAWN_WATCH);
        }
        if let Some(p) = project {
            g.owned_projects.insert(p.to_string_lossy().to_string());
        }
    }
    snapshot_and_save();
}

/// The bridge is about to foreground a window legitimately (bring_to_front with a
/// reason, always announced first): exempt bounces for `seconds`.
pub fn sanction(h: u64, seconds: f64) {
    let until = Instant::now() + Duration::from_secs_f64(seconds.max(0.0));
    let mut g = lock();
    g.expected_fg_until = Some(match g.expected_fg_until {
        Some(cur) if cur > until => cur,
        _ => until,
    });
    g.sanctioned_hwnd = h;
}

/// Cancel Windows' attention flash on bridge-owned KiCad windows (setting `activityIndicator`:
/// "progress" and "none" suppress it, "flash" and "both" leave it alone).
static SUPPRESS_FLASH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);

pub fn set_flash_suppressed(on: bool) {
    SUPPRESS_FLASH.store(on, std::sync::atomic::Ordering::Relaxed);
}

/// The user's A/B switch (setting `backgroundGuard`, the dashboard's "Keep KiCad in the background").
static GUARD_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); // default off from 1.0.50 (wiki #114)

pub fn enabled() -> bool {
    GUARD_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
}

pub fn is_shell_hwnd(h: u64) -> bool {
    is_shell_window(h, &mut ExeCache::default())
}

pub fn set_enabled(on: bool) {
    let was = GUARD_ENABLED.swap(on, std::sync::atomic::Ordering::Relaxed);
    if was != on {
        log(&format!("[focus] background guard switched {}", if on { "ON" } else { "OFF: KiCad windows are never pushed" }));
        if !on {
            allow_activation_all(); // nothing the guard ever set may outlive it
        }
    }
}

pub fn events() -> Vec<Value> {
    lock().events.clone()
}

/// The Python's focusDebug block, same field names, plus what this build adds.
pub fn debug() -> Value {
    let now = Instant::now();
    let g = lock();
    let mut owned: Vec<u32> = g.owned_pids.iter().copied().collect();
    owned.sort_unstable();
    let mut projects: Vec<&String> = g.owned_projects.iter().collect();
    projects.sort();
    let guard_deadline = g.guards.iter().map(|gd| gd.deadline).max();
    // Two objects merged: a single json! of this size trips the macro recursion limit.
    let mut out = json!({
        "sweepStarted": g.sentinel_started,
        "hookStarted": g.hook_started,
        "sentinelStarted": g.sentinel_started,
        "verbInflight": g.inflight,
        "guardCount": g.guards.len(),
        "parkedCount": 0,
        "baselineCount": g.baseline.len(),
        "bridgeWindows": g.bridge_windows.len(),
        "parkAttempts": 0,
        "parkLastError": g.last_error,
        "sweepTicks": g.sweep_ticks,
        "ownedPids": owned,
        "parkFights": {},
        "parkGaveUp": 0,
        "guardianDemotes": {},
        "guardianDemoteLimit": 1,
        "backgroundedOnce": g.backgrounded_once.len(),
        "rule": "each window is backgrounded EXACTLY once, then never touched again",
        "parkFightLog": [],
        "parkFightLimit": 0,
    });
    // This build's additions.
    let extra = json!({
        "guards": g.guards.iter().map(|gd| json!({"verb": gd.verb, "secondsLeft": seconds_left(Some(gd.deadline), now)})).collect::<Vec<_>>(),
        "guardSecondsLeft": seconds_left(guard_deadline, now),
        "spawnWatchSecondsLeft": seconds_left(g.spawn_watch_until, now),
        "sanctionedSecondsLeft": seconds_left(g.expected_fg_until, now),
        "sanctionedHwnd": g.sanctioned_hwnd,
        "baselineForeground": g.baseline_fg,
        "hookArmed": {"foreground": g.hook_fg_armed, "show": g.hook_show_armed},
        "bounceHolds": g.bounce_holds.len(),
        "bounceHoldSetsNoActivate": BOUNCE_HOLD_NOACTIVATE,
        "noActivateHeld": g.noactivated.len(),
        "bounces": g.bounces,
        "heals": g.heals,
        "sessionEntriesScrubbed": g.sanitized,
        "secondsSinceHeal": g.last_heal.map(|t| now.saturating_duration_since(t).as_secs_f64()),
        "secondsSinceSanitize": g.last_sanitize.map(|t| now.saturating_duration_since(t).as_secs_f64()),
        "ownedProjects": projects,
        "ledgerPath": ledger_path().to_string_lossy().replace('\\', "/"),
        "foregroundLockTimeoutMs": foreground_lock_timeout_ms(),
        "userIdleSeconds": user_idle_secs().min(1e6),
        "lastClickSecondsAgo": g.last_click.map(|t| now.saturating_duration_since(t).as_secs_f64()),
        "lastAltSecondsAgo": g.last_alt.map(|t| now.saturating_duration_since(t).as_secs_f64()),
        "pollMs": POLL.as_millis() as u64,
    });
    if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
        for (k, v) in e {
            o.insert(k.clone(), v.clone());
        }
    }
    out
}

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

    fn steal() -> Transition {
        Transition { is_kicad: true, owned: true, fresh: true, ..Default::default() }
    }

    #[test]
    fn an_existing_window_is_never_pushed_whatever_the_bridge_is_doing() {
        // A verb in flight, a guard armed, inside the spawn watch, NO click or Alt seen (the poll
        // missed it): the window existed before this verb, so it is the user's to raise.
        let t = Transition { fresh: false, guards: 2, inflight: 1, spawn_watch: true, ..steal() };
        assert_eq!(decide(&t), Verdict::Leave("an existing window, the user's to raise (left alone)"));
        // The window the verb is opening right now is still kept behind.
        let t = Transition { fresh: true, guards: 1, inflight: 1, ..steal() };
        assert!(matches!(decide(&t), Verdict::Bounce(_)));
    }

    #[test]
    fn non_kicad_is_ignored() {
        let mut t = steal();
        t.is_kicad = false;
        t.guards = 1;
        assert_eq!(decide(&t), Verdict::Ignore);
    }

    #[test]
    fn sanctioned_outranks_everything() {
        let mut t = steal();
        t.sanctioned = true;
        t.guards = 2;
        t.inflight = 1;
        assert_eq!(decide(&t), Verdict::Leave("sanctioned-foreground (allowed)"));
    }

    #[test]
    fn user_click_and_alt_tab_win_even_during_a_guard() {
        let mut t = steal();
        t.guards = 1;
        t.clicked_here = true;
        assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("user-foregrounded")));
        let mut t = steal();
        t.inflight = 1;
        t.alt_tabbed = true;
        assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("user-foregrounded")));
    }

    #[test]
    fn the_users_own_kicad_is_never_touched() {
        let mut t = steal();
        t.owned = false;
        t.guards = 1;
        t.inflight = 1;
        t.spawn_watch = true;
        assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("user-owned")));
    }

    #[test]
    fn baseline_foreground_is_never_bounced() {
        let mut t = steal();
        t.guards = 1;
        t.foreground_at_verb_start = true;
        assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("baseline")));
    }

    #[test]
    fn once_per_window() {
        // This test failed silently from 2026-09-12 (the "bounce again during a guard" change) until
        // 2026-09-21, because the win/ tests never run in the Linux container. The contract as it
        // really is: with nothing armed a window is pushed at most once; while the bridge is opening
        // it (guard or verb in flight) it may be pushed again, and `fresh` limits that to the one
        // window being opened.
        let mut t = steal();
        t.already_bounced = true;
        assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("already backgrounded once")));
        t.guards = 1;
        assert!(matches!(decide(&t), Verdict::Bounce(_)));
        t.fresh = false;
        assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("an existing window")));
    }

    #[test]
    fn guard_then_inflight_then_spawn_watch_bounce() {
        let mut t = steal();
        t.guards = 1;
        t.inflight = 1;
        assert_eq!(decide(&t), Verdict::Bounce("steal-during-guard (bounced)"));
        t.guards = 0;
        assert_eq!(decide(&t), Verdict::Bounce("self-raise (bounced to background)"));
        t.inflight = 0;
        t.spawn_watch = true;
        assert_eq!(decide(&t), Verdict::Bounce("our freshly spawned window took the foreground (bounced)"));
    }

    #[test]
    fn the_idle_rule_leaves_the_user_alone() {
        let t = steal();
        assert_eq!(decide(&t), Verdict::Leave("foreground while bridge idle (left alone)"));
        let mut t = steal();
        t.user_idle_past_lock = true;
        assert!(matches!(decide(&t), Verdict::Leave(l) if l.contains("lock timeout")));
    }

    #[test]
    fn user_idle_past_lock_does_not_outrank_a_guard() {
        // Unattended runs (the verb runner, ConfRoomROG from a phone) are idle past the
        // lock timeout by definition; the Python bounced there and so does this.
        let mut t = steal();
        t.guards = 1;
        t.user_idle_past_lock = true;
        assert!(matches!(decide(&t), Verdict::Bounce(_)));
        t.guards = 0;
        t.spawn_watch = true;
        assert!(matches!(decide(&t), Verdict::Bounce(_)));
    }

    #[test]
    fn guard_lengths_match_server_py() {
        assert_eq!(guard_for("kicad_open_3d_viewer"), GUARD_LONG);
        assert_eq!(guard_for("open_3d_viewer"), GUARD_LONG);
        assert_eq!(guard_for("kicad_launch"), GUARD_LONG);
        assert_eq!(guard_for("kicad_show_3d_chip"), GUARD_LONG);
        assert_eq!(guard_for("kicad_place_footprint"), GUARD_LONG);
        assert_eq!(guard_for("kicad_demo"), GUARD_LONG);
        assert_eq!(guard_for("kicad_open_board"), GUARD_SHORT);
        assert_eq!(guard_for("kicad_open_symbol_editor"), GUARD_SHORT);
    }

    #[test]
    fn bridge_project_paths() {
        let mut owned = HashSet::new();
        owned.insert("C:/Users/john/Documents/KiCad/parts/lm317.kicad_pro".to_string());
        assert!(is_bridge_project_path("c:\\users\\john\\documents\\kicad\\parts\\LM317.kicad_pro", &owned));
        assert!(is_bridge_project_path("C:\\Users\\john\\Documents\\adom-demo\\adom-demo.kicad_pro", &owned));
        assert!(is_bridge_project_path("C:\\Users\\john\\Documents\\in-lm317-demo\\in-lm317-demo.kicad_pro", &owned));
        assert!(!is_bridge_project_path("C:\\Users\\john\\Documents\\MyBoard\\MyBoard.kicad_pro", &owned));
        // The legacy match is on a DIRECTORY component, not the file name.
        assert!(!is_bridge_project_path("C:\\Users\\john\\Documents\\adom-demo.kicad_pro", &owned));
        assert!(!is_bridge_project_path("C:\\Users\\john\\Documents\\Adom-Demo Files\\x.kicad_pro", &owned));
    }

    #[test]
    fn sanitize_rewrites_only_bridge_entries() {
        let dir = std::env::temp_dir().join(format!("kicad-bridge-sanitize-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let cfg = dir.join("kicad.json");
        std::fs::write(&cfg, r#"{"system":{"open_projects":["C:\\u\\MyBoard\\MyBoard.kicad_pro","C:\\u\\adom-demo\\adom-demo.kicad_pro"],"file_history":["x"]},"other":1}"#).unwrap();
        let owned = HashSet::new();
        assert_eq!(sanitize_file(&cfg, &owned), 1);
        let data: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
        assert_eq!(data["system"]["open_projects"], json!(["C:\\u\\MyBoard\\MyBoard.kicad_pro"]));
        assert_eq!(data["system"]["file_history"], json!(["x"]));
        assert_eq!(data["other"], json!(1));
        assert_eq!(sanitize_file(&cfg, &owned), 0);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn guards_expire_and_report_idle_once() {
        let mut g = Inner::default();
        let now = Instant::now();
        g.guards.push(Guard { verb: "kicad_open_board".into(), deadline: now - Duration::from_secs(1) });
        assert!(expire_guards(&mut g, now));
        assert!(g.guards.is_empty());
        assert!(!expire_guards(&mut g, now));
        g.guards.push(Guard { verb: "x".into(), deadline: now + Duration::from_secs(5) });
        assert!(!expire_guards(&mut g, now));
        assert_eq!(g.guards.len(), 1);
    }

    #[test]
    fn debug_block_has_the_python_fields() {
        let d = debug();
        for k in ["sweepStarted", "hookStarted", "sentinelStarted", "verbInflight", "guardCount", "parkedCount", "baselineCount", "bridgeWindows", "parkAttempts", "parkLastError", "sweepTicks", "ownedPids", "parkFights", "parkGaveUp", "guardianDemotes", "guardianDemoteLimit", "backgroundedOnce", "rule", "parkFightLog", "parkFightLimit"] {
            assert!(d.get(k).is_some(), "missing {k}");
        }
    }
}