main
John Lauer Publish 0.2.58 e8180ab
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249
//! The live half: everything that touches the desktop goes through the Adom Bridge CLI
//! (`adom-bridge --ai-thread ... --target ... <verb> <json>`), so the same etiquette, approval
//! gates and activity log apply as for any AI thread. Capture recipe, landing, measurement,
//! validation.
use std::path::{Path, PathBuf};
use std::process::Command;

use serde_json::{json, Value};

pub mod altium;
pub mod eda;
pub mod fusion;
pub mod landing;
pub mod recorder;
pub use landing::Landing;

/// Pulls of one clip before the stop gives up and flags it `pullFailed`: the first and two retries.
pub const PULL_ATTEMPTS: usize = 3;

// Waiting cannot repair an explicit identity/configuration refusal. In particular,
// never conceal the requested/open board identities behind a generic no_revision.
fn retryable_read(reply: &Value) -> bool {
    !matches!(reply["errorCode"].as_str(), Some("board_mismatch" | "invalid_socket" | "invalid_path" | "api_server_off" | "unsupported_kicad" | "no_pcb_frame" | "permission_denied"))
}

/// Only IDs returned by successful flow operations establish ownership. Names,
/// area and absence from the original board do not establish ownership.
fn owned_zones_to_replace(live: &Value, planned: &[Value], owned: &[String]) -> Result<Vec<String>, String> {
    let rows=live["zones"].as_array().ok_or("zone readback has no zones array")?;
    let mut replace=Vec::new();
    for row in rows {
        let id=row["id"].as_str().ok_or("zone readback has no item ID")?;
        if row["type"] == "teardrop" { continue; }
        if owned.iter().any(|x|x==id) { replace.push(id.to_string()); continue; }
        let name=row["name"].as_str().unwrap_or("");
        if !name.is_empty() && planned.iter().any(|p|p["name"].as_str()==Some(name)) {
            return Err(format!("planned zone {name:?} conflicts with unowned live ID {id}; preserve it and choose a distinct flow zone name, or reconcile it explicitly through the native bridge"));
        }
    }
    Ok(replace)
}

fn read_zone_state_with(
    refill: bool,
    mut call: impl FnMut(bool) -> Value,
    mut wait: impl FnMut(),
) -> Value {
    let mut reply = call(refill);
    if !retryable_read(&reply) { return reply; }
    if !refill && Bridge::ok(&reply) && reply["filledAreaByLayerMm2"].is_object() && reply["revision"].is_string() && reply["revisionUsable"] != false { return reply; }
    // Discard even old bridges' synchronous-looking refill reply. Mutation is
    // requested only once. Repeated delayed snapshots are observed stability,
    // not a native completion event (KiCad exposes none in this API).
    let mut previous: Option<Value> = None;
    for _ in 0..15 {
        wait();
        reply = call(false);
        if !retryable_read(&reply) { return reply; }
        if Bridge::ok(&reply) && reply["filledAreaByLayerMm2"].is_object() && reply["revision"].is_string() && reply["revisionUsable"] != false {
            if !refill || previous.as_ref().map(|p| p["revision"] == reply["revision"] && p["filledAreaByLayerMm2"] == reply["filledAreaByLayerMm2"]).unwrap_or(false) {
                reply["readbackEvidence"] = json!(if refill { "two delayed matching native snapshots after refill request" } else { "native snapshot" });
                return reply;
            }
            previous = Some(reply.clone());
        } else { previous = None; }
    }
    json!({"success":false,"errorCode":"zone_readback_unsettled","lastReply":reply,"message":"No usable stable zone snapshot; inspect native state. Refill was not replayed."})
}

/// A reply's fields, also when ab wraps them as a JSON string in `output` or under `data`.
fn reply_fields(reply: &Value) -> Value {
    if let Some(out) = reply.get("output").and_then(|o| o.as_str()) {
        if let Ok(v) = serde_json::from_str::<Value>(out) { if v.is_object() { return v; } }
    }
    reply.get("data").filter(|d| d.is_object()).cloned().unwrap_or_else(|| reply.clone())
}

/// Megabytes the caller can still write, from a `desktop_disk_free` reply (availableBytes is
/// quota-aware; freeBytes is the volume's raw free space and is used only when it is absent).
pub fn disk_free_mb(reply: &Value) -> Option<u64> {
    let read = |v: &Value| v.get("availableBytes").or_else(|| v.get("freeBytes")).and_then(|b| b.as_u64());
    read(reply).or_else(|| read(&reply_fields(reply))).map(|b| b / 1_048_576)
}

/// The recording drive's free space through the native verb. LEGACY FALLBACK: only when the box's
/// Adom Bridge is older than 2.1.127 and answers `unknown_verb` does this ask the drive through a
/// human-gated `shell_execute` PowerShell query (a refusal or no answer gives None, and the disk
/// check falls back to the run drive). Remove the fallback once no supported box runs ab < 2.1.127.
fn remote_free_mb_with(mut call: impl FnMut(&str, &Value) -> Value) -> Option<u64> {
    let r = call("desktop_disk_free", &json!({}));
    if let Some(mb) = disk_free_mb(&r) { return Some(mb); }
    if r["errorCode"].as_str() != Some("unknown_verb") { return None; }
    let r = call("shell_execute", &json!({"command": "powershell -NoProfile -Command \"(Get-PSDrive -Name ($env:TEMP.Substring(0,1))).Free\"", "timeoutSeconds": 20, "reason": "aiflow checks the free space on the recording drive before it starts a step's clip (legacy Adom Bridge without desktop_disk_free)"}));
    let out = r.get("stdout").or_else(|| r.get("output")).and_then(|v| v.as_str()).unwrap_or("");
    out.trim().lines().last().and_then(|l| l.trim().parse::<u64>().ok()).map(|b| b / 1_048_576)
}

/// Whether a box's Adom Bridge predates pull_file's configurable whole-pull budget (2.1.175), the
/// only case where the PowerShell part splitter is still used for a clip pull_file could not land.
/// Since then pull_file streams 1 MiB binary frames, verifies sha256 and takes timeoutSeconds, so
/// splitting duplicates it. An unknown version is not treated as legacy.
pub fn legacy_pull_needs_split(ab_version: Option<&str>) -> bool {
    let Some(v) = ab_version else { return false };
    let parts: Vec<u64> = v.trim().trim_start_matches('v').split(['.', '-', '+']).take(3).map_while(|p| p.parse().ok()).collect();
    if parts.len() < 3 { return false; }
    (parts[0], parts[1], parts[2]) < (2, 1, 175)
}

/// What `kicad_model_check` says beyond resolution counts. `missing` lists model references whose
/// file does not exist, read from the complete `modelBindings` (KiCad Bridge 1.0.22+), else from the
/// capped `footprintsWithWarnings` of older bridges. `hidden` names the 3D viewer classes that hide
/// bodies on this board (viewer flag false with footprints of that class carrying a model) and every
/// model the bridge marks hidden. File resolution is not render proof; `evidence` says what was read.
#[derive(Debug, Default, PartialEq)]
pub struct ModelCheck {
    pub missing: Vec<String>,
    pub hidden: Vec<String>,
    pub bindings_complete: bool,
    pub evidence: String,
}

pub fn model_check_summary(d: &Value) -> ModelCheck {
    let d = if d.get("counts").is_some() || d.get("modelBindings").is_some() { d.clone() } else { reply_fields(d) };
    let mut out = ModelCheck { bindings_complete: d["bindingsComplete"].as_bool() == Some(true), evidence: d["evidenceSource"].as_str().unwrap_or("unknown").to_string(), ..Default::default() };
    if let Some(bindings) = d["modelBindings"].as_array() {
        for b in bindings {
            let r = b["reference"].as_str().unwrap_or("");
            let fp = b["footprint"].as_str().unwrap_or("");
            let models = b["models"].as_array().cloned().unwrap_or_default();
            let gone: Vec<String> = models.iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(str::to_string)).collect();
            if !gone.is_empty() { out.missing.push(format!("{fp} ({r}): {}", gone.join(", "))); }
            if models.iter().any(|m| m["hidden"].as_bool() == Some(true)) { out.hidden.push(format!("{r}: model marked hidden in the footprint")); }
        }
    } else {
        for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {
            let fp = f["footprint"].as_str().unwrap_or("");
            let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();
            let gone: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(str::to_string)).collect();
            if !gone.is_empty() { out.missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, gone.join(", "))); }
        }
    }
    if let (Some(flags), Some(by_class)) = (d["viewer"]["flags"].as_object(), d["footprintsWithModelByViewerClass"].as_object()) {
        for (class, n) in by_class {
            let n = n.as_u64().unwrap_or(0);
            if n > 0 && flags.get(class).and_then(|f| f.as_bool()) == Some(false) {
                out.hidden.push(format!("3D viewer hides class {class} ({n} footprint(s) with a model)"));
            }
        }
    }
    out
}

/// What a clip stop brought back: the desktop path, where the pull puts it, and whether it landed.
#[derive(Clone, Debug)]
pub struct ClipStop {
    pub remote: Option<String>,
    pub local: Option<PathBuf>,
    pub pulled: bool,
    pub attempts: usize,
    pub reply: Value,
}

impl ClipStop {
    /// Why the stop brought no file back (issue #98): the recorder no longer knows the take, it
    /// failed to finalise, or it was still finalising when the bounded wait ran out.
    pub fn failure(&self) -> Option<String> { self.reply["aiflowStopError"].as_str().map(str::to_string) }
    /// True when a later stop may still collect the take (it was finalising, or the call timed out).
    pub fn pending(&self) -> bool { self.reply["aiflowStopPending"] == true }
}

/// Peak bit rate of every window recording aiflow starts (issue #98): a 4K window at the recorder's
/// default VBR made 55 Mbit/s and 4.29 GB in ten minutes. The peak bounds the size (12 Mbit/s is at
/// most 90 MB a minute); a still window still costs almost nothing. Override with
/// AIFLOW_CLIP_MAX_BITRATE (bits per second).
pub const WINDOW_MAX_BITRATE: u64 = 12_000_000;
/// The hard length of a window recording (`maxDurationMs`): the recorder stops the take itself and
/// keeps it registered for the stop to collect.
pub const WINDOW_MAX_MS: u64 = 3_600_000;
/// Seconds between `desktop_record_status` polls while a stopped take finalises.
pub const FINALISE_POLL_SECS: u64 = 10;

/// Issue #135: did a KiCad window (the editor, its project manager or splash) take the foreground
/// from the human? Not when KiCad was already in front before the launch: then the human chose it.
pub fn took_foreground(editor: i64, before: Option<&Value>, fg: &Value) -> bool {
    let kicad = |w: &Value| w["hwnd"].as_i64() == Some(editor) || w["title"].as_str().is_some_and(|t| t.contains("PCB Editor") || t.starts_with("KiCad") || t.to_ascii_lowercase().starts_with("pcbnew"));
    kicad(fg) && !before.is_some_and(|b| kicad(b))
}

/// The constant bit rate that keeps a take of `max_ms` under `max_mb` (95 % of it, for the container),
/// never above `peak` and never under 1 Mbit/s.
pub fn sized_bitrate(max_ms: u64, max_mb: u64, peak: u64) -> u64 {
    let secs = (max_ms / 1000).max(1);
    (max_mb * 8_000_000 * 95 / 100 / secs).min(peak).max(1_000_000)
}

#[cfg(test)]
mod sized_bitrate_tests {
    #[test]
    fn a_fields_take_fits_its_size_cap() {
        let b = super::sized_bitrate(15 * 60 * 1000, 1024, super::WINDOW_MAX_BITRATE);
        assert!(b < super::WINDOW_MAX_BITRATE && b > 8_000_000, "{b}");
        assert!(b * 900 / 8 <= 1024 * 1_000_000);
        assert_eq!(super::sized_bitrate(60_000, 100_000, 12_000_000), 12_000_000);
        assert_eq!(super::sized_bitrate(3_600_000, 10, 12_000_000), 1_000_000);
    }
}

fn window_max_bitrate() -> u64 {
    std::env::var("AIFLOW_CLIP_MAX_BITRATE").ok().and_then(|v| v.trim().parse().ok()).filter(|v| *v >= 1_000_000).unwrap_or(WINDOW_MAX_BITRATE)
}

/// How long a stop waits for a finalising take before it flags the clip and moves on (default 300 s,
/// AIFLOW_CLIP_FINALISE_SECONDS). `recut`, `finish` and `deliver` stop it again later.
pub fn finalise_budget_secs() -> u64 {
    std::env::var("AIFLOW_CLIP_FINALISE_SECONDS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(300)
}

/// The recorder no longer has this take registered ("No recording with id ...").
pub fn recording_unknown(reply: &Value) -> bool {
    let f = reply_fields(reply);
    if matches!(f["errorCode"].as_str().or(reply["errorCode"].as_str()), Some("recording_not_found" | "unknown_recording" | "no_recording" | "not_found")) { return true; }
    let text = reply.to_string().to_ascii_lowercase();
    text.contains("no recording with id") || text.contains("no such recording")
}

fn take_path(r: &Value) -> Option<String> {
    let f = reply_fields(r);
    r.get("filePath").or_else(|| r.get("path")).or_else(|| f.get("filePath")).or_else(|| f.get("path"))
        .or_else(|| f.get("data").and_then(|d| d.get("path")))
        .and_then(|v| v.as_str()).filter(|p| !p.is_empty()).map(|s| s.replace('\\', "/"))
}

fn take_state(r: &Value) -> (String, bool) {
    let f = reply_fields(r);
    (f["state"].as_str().or(r["state"].as_str()).unwrap_or("").to_ascii_lowercase(), f["stillRunning"] == true || r["stillRunning"] == true)
}

/// Stop one take and wait, bounded, for its file (issue #98). Ok(desktop path), or Err((why, pending))
/// where `pending` says a later stop may still collect it. `polls` status checks at most, with
/// `wait` between them; an unknown take is answered at once, never slept on.
pub fn stop_take(call: &mut dyn FnMut(&str, &Value) -> Value, wait: &mut dyn FnMut(), rid: &str, polls: usize) -> (Value, Result<String, (String, bool)>) {
    let mut r = call("desktop_record_stop", &json!({"recordingId": rid}));
    for _ in 0..2 {
        let (state, running) = take_state(&r);
        let finalising = state == "finalising" || state == "finalizing" || (running && state != "recording");
        if !finalising { if let Some(p) = take_path(&r) { return (r, Ok(p)); } }
        if recording_unknown(&r) {
            return (r, Err((format!("Adom Bridge no longer knows recording {rid} (\"No recording with id\"): the take was collected or lost on the desktop, so this clip cannot be pulled"), false)));
        }
        if r["errorCode"] == "aiflow_timeout" {
            return (r, Err((format!("desktop_record_stop for {rid} did not answer in time; the clip is flagged and a later stop collects it"), true)));
        }
        if !finalising {
            let said = r["error"].as_str().or(r["message"].as_str()).or(r["errorCode"].as_str()).map(str::to_string).unwrap_or_else(|| r.to_string().chars().take(200).collect());
            return (r, Err((format!("the recorder's stop of {rid} gave no file: {said}"), true)));
        }
        let mut finalised = false;
        for _ in 0..polls {
            wait();
            let st = call("desktop_record_status", &json!({"recordingId": rid}));
            if recording_unknown(&st) {
                return (r, Err((format!("recording {rid} was finalising, then Adom Bridge stopped listing it (\"No recording with id\"): the clip cannot be pulled"), false)));
            }
            let (s, running) = take_state(&st);
            if s == "failed" {
                let f = reply_fields(&st);
                return (r, Err((format!("the recorder failed to finalise {rid}: {}", f["finaliseError"].as_str().or(f["error"].as_str()).unwrap_or("no reason given")), false)));
            }
            if matches!(s.as_str(), "finalised" | "finalized" | "stopped") && !running { finalised = true; break; }
        }
        if !finalised {
            return (r, Err((format!("recording {rid} was still finalising after {polls} status checks; the clip is flagged pullFailed and `adom-aiflow recut` (or finish, deliver) collects it later"), true)));
        }
        r = call("desktop_record_stop", &json!({"recordingId": rid}));
    }
    (r, Err((format!("the recorder finalised {rid} but its stop named no file"), true)))
}

/// Where pull_file puts a desktop file: `save_to` plus the file's own name.
pub fn landed_path(remote: &str, save_to: &str) -> PathBuf {
    let name = remote.rsplit(['/', '\\']).next().unwrap_or(remote);
    Path::new(save_to).join(name)
}

/// A file that exists and holds at least one byte.
pub fn nonempty(p: &Path) -> bool {
    std::fs::metadata(p).map(|m| m.is_file() && m.len() > 0).unwrap_or(false)
}

fn pull_until_landed(local: &Path, attempts: usize, mut pull: impl FnMut(), mut wait: impl FnMut()) -> (bool, usize) {
    for k in 1..=attempts.max(1) {
        pull();
        if nonempty(local) { return (true, k); }
        if k < attempts { wait(); }
    }
    // issue #145: a failed pull can leave an empty file behind; a clip that did not land is missing, not an empty clip
    if std::fs::metadata(local).map(|m| m.is_file() && m.len() == 0).unwrap_or(false) { let _ = std::fs::remove_file(local); }
    (false, attempts.max(1))
}

/// Opt-in endpoint for native read/validate and zone-measurement calls.
/// Zone measurement includes its one refill request and subsequent readbacks.
/// Never redirect placement, routing, zone-edit, or window-control calls.
fn read_socket_args(verb: &str, args: &Value, socket: Option<&str>) -> Value {
    let mut result = args.clone();
    if matches!(verb, "kicad_routing_state" | "kicad_routing_validate" |
        "kicad_placement_state" | "kicad_placement_validate" | "kicad_zone_state") {
        if let (Some(object), Some(socket)) = (result.as_object_mut(), socket) {
            if !socket.trim().is_empty() && !object.contains_key("socketPath") {
                object.insert("socketPath".into(), Value::String(socket.into()));
            }
        }
    }
    result
}

/// The live (IPC) verbs a task-owned, isolated KiCad instance answers, reads and edits alike.
const LIVE_IPC_VERBS: &[&str] = &["kicad_routing_state", "kicad_routing_validate", "kicad_placement_state",
    "kicad_placement_validate", "kicad_zone_state", "kicad_move_footprint", "kicad_route_net", "kicad_remove_route",
    "kicad_apply_tracks", "kicad_apply_zone_plan", "kicad_add_zone", "kicad_select_net", "kicad_select_clear", "kicad_zoom_to"];

/// Issue #159: opt-in endpoint for EVERY live IPC verb, reads and edits (`ADOM_AIFLOW_KICAD_SOCKET`). For a
/// task-owned KiCad launched with its own API pipe (a private TEMP/TMP) while another session's KiCad owns
/// the default pipe; window, capture and file verbs are never redirected. An explicit socketPath in the call
/// wins. `ADOM_AIFLOW_KICAD_READ_SOCKET` keeps its read-only meaning.
fn live_socket_args(verb: &str, args: &Value, socket: Option<&str>) -> Value {
    let mut result = args.clone();
    if LIVE_IPC_VERBS.contains(&verb) {
        if let (Some(object), Some(socket)) = (result.as_object_mut(), socket) {
            if !socket.trim().is_empty() && !object.contains_key("socketPath") {
                object.insert("socketPath".into(), Value::String(socket.into()));
            }
        }
    }
    result
}

fn socket_args(verb: &str, args: &Value) -> Value {
    let live = std::env::var("ADOM_AIFLOW_KICAD_SOCKET").ok();
    let args = live_socket_args(verb, args, live.as_deref());
    let read = std::env::var("ADOM_AIFLOW_KICAD_READ_SOCKET").ok();
    read_socket_args(verb, &args, read.as_deref())
}

/// The PCB editor's window in a `kicad_window_info` reply: the editor whose `kind` is `pcb_editor`.
/// The macOS KiCad bridge (0.11.x) names no kind at all (`{app, hwnd, pid, rect, title}`), so there,
/// and only when no editor carries a kind, it is the first window titled `<board> — PCB Editor`.
/// Before this, a Mac run fell back to the last window it had filmed, which
/// after `tour fields` or `tour 3d` is the closed Fields or 3D Viewer window: the net walkthrough
/// then framed and screenshotted nothing, and the next step's clip did not start.
pub fn pcb_editor_of(reply: &Value) -> Option<i64> { pcb_editor_named(reply, "") }

/// The PCB editor of the board `stem` (its title names the stem as a whole name, `eda::names_board`);
/// with no stem, the first PCB editor (`pcb_editor_of`). Issue #155: another project's PCB editor open
/// on the same desktop is never returned for the run's board.
pub fn pcb_editor_named(reply: &Value, stem: &str) -> Option<i64> {
    let d = reply.get("data").cloned().unwrap_or(reply.clone());
    let eds = d.get("editors").and_then(|e| e.as_array())?;
    let hwnd = |w: &Value| w.get("hwnd").and_then(|h| h.as_i64());
    let title = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
    let ours = |w: &Value| stem.is_empty() || eda::names_board(&title(w), stem);
    if eds.iter().any(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")) {
        return eds.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor") && ours(w)).and_then(hwnd);
    }
    // a bridge that names kinds and lists no PCB editor has none open: unchanged
    if eds.iter().any(|w| w.get("kind").map_or(false, |k| !k.is_null())) {
        return None;
    }
    eds.iter().find(|w| titled_pcb_editor(&title(w)) && ours(w)).and_then(hwnd)
}

/// The title of window `hwnd` in a `kicad_window_info` reply (issue #155: a step's clip names the
/// editor it films, so a wrong window shows at once).
pub fn editor_title(reply: &Value, hwnd: i64) -> Option<String> {
    let d = reply.get("data").cloned().unwrap_or(reply.clone());
    d.get("editors")?.as_array()?.iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd))
        .and_then(|w| w.get("title").and_then(|t| t.as_str())).map(str::to_string)
}

/// "scratch_strip — PCB Editor" or "*board — PCB Editor" (unsaved), never the bare
/// "PCB Editor" strip the macOS bridge also lists, nor the "3D Viewer".
fn titled_pcb_editor(title: &str) -> bool {
    title.trim().strip_suffix("PCB Editor").map_or(false, |head| {
        !head.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '—' | '–' | '-' | '|')).trim_start_matches('*').trim().is_empty()
    })
}

#[derive(Clone)]
pub struct Bridge {
    pub ai_thread: String,
    pub target: String,
}

impl Bridge {
    pub fn call(&self, verb: &str, args: &Value) -> Value {
        // every board operation goes to the run's EDA backend (eda.rs; docs/eda-backends.md): the flow's
        // verbs keep their historical kicad_* names, and the backend answers them in its own tool
        if verb.starts_with("kicad_") {
            let backend = eda::for_call(args);
            return eda::dispatch(&*backend, &|v: &str, a: &Value| self.raw_call(v, a), verb, args);
        }
        self.raw_call(verb, args)
    }

    /// One adom-bridge verb with a hard wall-clock limit on the CLI process (issue #98): past it the
    /// process is killed and the reply is `errorCode: aiflow_timeout`.
    pub fn raw_call_bounded(&self, verb: &str, args: &Value, limit: std::time::Duration) -> Value {
        use std::io::Read;
        let args = socket_args(verb, args);
        let child = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()])
            .stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn();
        let mut child = match child { Ok(c) => c, Err(e) => return json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}) };
        let mut out = child.stdout.take();
        let reader = std::thread::spawn(move || { let mut t = String::new(); if let Some(o) = out.as_mut() { let _ = o.read_to_string(&mut t); } t });
        let start = std::time::Instant::now();
        loop {
            match child.try_wait() {
                Ok(Some(_)) => break,
                Ok(None) if start.elapsed() < limit => std::thread::sleep(std::time::Duration::from_millis(200)),
                _ => { let _ = child.kill(); let _ = child.wait(); return json!({"success": false, "errorCode": "aiflow_timeout", "error": format!("adom-bridge {verb} gave no answer in {} s; aiflow stopped waiting", limit.as_secs())}); }
            }
        }
        let text = reader.join().unwrap_or_default();
        serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))
    }

    /// One adom-bridge verb, as is.
    pub fn raw_call(&self, verb: &str, args: &Value) -> Value {
        let args = socket_args(verb, args);
        let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();
        match out {
            Ok(o) => {
                let text = String::from_utf8_lossy(&o.stdout);
                serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))
            }
            Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),
        }
    }

    pub fn ok(v: &Value) -> bool {
        if v.as_object().map(|o| o.iter().any(|(k,v)| k.starts_with("postCommit") && !v.is_null())).unwrap_or(false) {
            return false;
        }
        v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())
    }

    /// The live board's revision. KiCad answers nothing while its UI thread fills zones after a
    /// commit, and the bridge may report itself busy while a previous verb's refill finishes; the
    /// read is retried for up to about two minutes. The last reply is kept for the log.
    pub fn revision(&self, board: &str) -> Option<String> {
        self.revision_with_reply(board).0
    }

    pub fn revision_with_reply(&self, board: &str) -> (Option<String>, Value) {
        let mut last = Value::Null;
        for attempt in 0..20 {
            let r = self.call("kicad_routing_state", &json!({"filePath": board}));
            if let Some(rev) = r.get("revision").and_then(|r| r.as_str()) {
                return (Some(rev.to_string()), r);
            }
            eprintln!("revision read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());
            last = r;
            if !retryable_read(&last) { break; }
            std::thread::sleep(std::time::Duration::from_secs(6));
        }
        (None, last)
    }

    // -- capture recipe -------------------------------------------------------------------
    /// Open the run's board in KiCad; returns the editor's hwnd and what was done to the window.
    /// `raise` (screen `allowed`, a test box): in front, maximised. Otherwise (issues #132, #135) the
    /// board opens without focus or maximise, and a KiCad window Windows put in front anyway (a newly
    /// launched process gets the foreground) goes to the bottom, the human's window back in front.
    pub fn open_board(&self, remote_board: &str, reason: &str, raise: bool) -> Result<(i64, String), Value> {
        if !raise {
            let before = self.foreground();
            let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": false}));
            let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };
            let mut done: Vec<String> = self.keep_behind(hwnd, before.as_ref()).into_iter().collect();
            std::thread::sleep(std::time::Duration::from_secs(40));
            // KiCad can come forward again when the board finishes loading
            done.extend(self.keep_behind(hwnd, before.as_ref()));
            self.call("kicad_send_key", &json!({"hwnd": hwnd, "menu": "Zoom to Fit"}));
            std::thread::sleep(std::time::Duration::from_secs(3));
            let what = if done.is_empty() { "the human's window stayed in front".to_string() } else { done.join("; ") };
            return Ok((hwnd, format!("opened in the background, not maximised (screen hands-off), zoomed to fit; {what}")));
        }
        let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": true, "foregroundReason": reason}));
        let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };
        self.call("desktop_ui_window", &json!({"hwnd": hwnd, "action": "maximize", "reason": "aiflow capture: fill the monitor"}));
        std::thread::sleep(std::time::Duration::from_secs(40));
        self.call("desktop_bring_to_front", &json!({"hwnd": hwnd, "reason": "aiflow capture: keep the PCB editor on screen on the test box"}));
        std::thread::sleep(std::time::Duration::from_secs(2));
        self.call("kicad_send_key", &json!({"hwnd": hwnd, "key": "ctrl+home"}));
        std::thread::sleep(std::time::Duration::from_secs(3));
        Ok((hwnd, "maximised, zoomed to fit".into()))
    }

    /// The OS foreground window: {hwnd, pid, title}, None when there is none or it cannot be read.
    pub fn foreground(&self) -> Option<Value> {
        let r = self.raw_call("desktop_get_foreground", &json!({}));
        let d = r.get("data").cloned().unwrap_or(r);
        d["hwnd"].as_i64().filter(|h| *h != 0).map(|_| d)
    }

    /// Issue #135: when a KiCad window took the foreground that `before` held (Windows gives a newly
    /// launched process's first window the foreground), send it to the bottom without activating
    /// anything, and when it is still in front put the human's previous window back. Says what it did.
    pub fn keep_behind(&self, editor: i64, before: Option<&Value>) -> Option<String> {
        let fg = self.foreground()?;
        let h = fg["hwnd"].as_i64()?;
        if !took_foreground(editor, before, &fg) { return None; }
        let title = fg["title"].as_str().unwrap_or("").to_string();
        let r = self.raw_call("desktop_send_to_bottom", &json!({"hwnd": h, "force": true, "reason": "aiflow screen hands-off: KiCad was launched in front of the human's window"}));
        let mut did = format!("KiCad came up in front (hwnd {h} {title:?}); sent it to the bottom{}", if r["sentToBack"].as_bool() == Some(false) || r["data"]["sentToBack"].as_bool() == Some(false) { " (the bridge said it did not move)" } else { "" });
        let prev = before.and_then(|b| b["hwnd"].as_i64());
        if let (Some(p), Some(now)) = (prev, self.foreground().and_then(|f| f["hwnd"].as_i64())) {
            if now == h || now == editor {
                let back = self.raw_call("desktop_bring_to_front", &json!({"hwnd": p, "reason": "aiflow screen hands-off: give the human back the window they had before KiCad launched"}));
                did.push_str(&format!(", and put the previous window (hwnd {p}) back in front{}", if back["success"].as_bool() == Some(false) { " (that failed)" } else { "" }));
            }
        }
        Some(did)
    }

    /// Record ONE window (Windows Graphics Capture, background-capturable): the take survives
    /// anything another thread or an updater puts in front of KiCad on a shared test box, and it
    /// never needs the foreground, so nobody at the box is disturbed.
    pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {
        // a hard cap on every recording: no clip may outlive an hour, whatever the AI forgets
        self.record_window_start_capped(hwnd, reason, WINDOW_MAX_MS)
    }

    /// `record_window_start` with its own length cap; every window take also gets the peak bit rate
    /// cap (WINDOW_MAX_BITRATE, issue #98).
    pub fn record_window_start_capped(&self, hwnd: i64, reason: &str, max_ms: u64) -> Result<String, Value> {
        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": max_ms.min(WINDOW_MAX_MS), "maxBitrate": window_max_bitrate()}));
        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)
    }

    /// `record_window_start_capped` with a hard size cap as well (issue #180): the take is recorded at a
    /// constant rate (`bitrate`, CBR) that fits `max_mb` in `max_ms`, at most the usual peak, so its file
    /// can never pass `max_mb` whatever the window shows.
    pub fn record_window_start_sized(&self, hwnd: i64, reason: &str, max_ms: u64, max_mb: u64) -> Result<String, Value> {
        let max_ms = max_ms.min(WINDOW_MAX_MS);
        let bitrate = sized_bitrate(max_ms, max_mb, window_max_bitrate());
        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": max_ms, "bitrate": bitrate, "maxBitrate": bitrate}));
        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)
    }

    /// A background screenshot of one window (owned popups included), copied into `save_dir`
    /// under `name`; returns the local path.
    pub fn screenshot_window(&self, hwnd: i64, save_dir: &str, name: &str) -> Option<String> {
        let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd}));
        let d = r.get("data").cloned().unwrap_or(r.clone());
        let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);
        let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;
        let dest = format!("{save_dir}/{name}.png");
        std::fs::copy(&local, &dest).ok()?;
        Some(dest)
    }

    /// Hydrogen's main window on the box (the human's workspace: chat, webviews).
    pub fn hydrogen_hwnd(&self) -> Option<i64> { fusion::hydrogen_hwnd(&|v, a| self.raw_call(v, a)) }

    /// Bring one window to the front, named in the reason.
    pub fn bring_front(&self, hwnd: Option<i64>, what: &str) -> bool {
        match hwnd { Some(h) => self.raw_call("desktop_bring_to_front", &serde_json::json!({"hwnd": h, "reason": format!("show {what} for this step")}))["success"].as_bool().unwrap_or(true), None => false }
    }

    /// The PCB editor of the run's board (its title names the board's file stem); with no board set,
    /// the first PCB editor. Another project's PCB editor on the same desktop is never returned (#155).
    pub fn pcb_editor_hwnd(&self) -> Option<i64> {
        pcb_editor_named(&self.call("kicad_window_info", &json!({})), &eda::run_kicad_stem())
    }

    pub fn record_start(&self, reason: &str) -> Result<String, Value> {
        let r = self.call("desktop_record_start", &json!({"monitor": 0, "audio": false, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));
        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)
    }

    /// Stop a recording and pull its file into `save_to`, verified: the local copy must exist and
    /// be non-empty, or the pull is tried again (PULL_ATTEMPTS in all). The desktop cleans its
    /// recordings folder, so a clip not pulled here may be gone later; `pulled` says which it is.
    /// Every wait is bounded (issue #98): the stop and status calls have a process time limit, a
    /// finalising take is polled for at most finalise_budget_secs(), and a take the recorder no
    /// longer knows comes back at once with `failure()` set (the caller flags it pullFailed).
    pub fn record_stop(&self, recording_id: &str, save_to: &str) -> ClipStop {
        let polls = (finalise_budget_secs() / FINALISE_POLL_SECS).max(1) as usize;
        let (mut r, got) = stop_take(
            &mut |v: &str, a: &Value| self.raw_call_bounded(v, a, std::time::Duration::from_secs(120)),
            &mut || std::thread::sleep(std::time::Duration::from_secs(FINALISE_POLL_SECS)),
            recording_id, polls);
        match got {
            Ok(path) => {
                let (pulled, attempts) = self.pull_verified(&path, save_to, PULL_ATTEMPTS);
                ClipStop { local: Some(landed_path(&path, save_to)), remote: Some(path), pulled, attempts, reply: r }
            }
            Err((why, pending)) => {
                if !r.is_object() { r = json!({"reply": r}); }
                r["aiflowStopError"] = json!(why);
                r["aiflowStopPending"] = json!(pending);
                ClipStop { remote: None, local: None, pulled: false, attempts: 0, reply: r }
            }
        }
    }

    /// pull_file one desktop file into `save_to` until its local copy exists and is non-empty,
    /// at most `attempts` pulls. Returns (landed, pulls made).
    pub fn pull_verified(&self, remote: &str, save_to: &str, attempts: usize) -> (bool, usize) {
        let local = landed_path(remote, save_to);
        // pull_file streams and sha256-verifies any size; a multi-GB clip over the mesh relay needs
        // the whole-pull budget (default 3600 s since ab 2.1.175, passed explicitly here)
        let (landed, made) = pull_until_landed(&local, attempts,
            || { self.raw_call_bounded("pull_file", &json!({"filePaths": [remote], "saveTo": save_to, "timeoutSeconds": 3600}), std::time::Duration::from_secs(3600 + 120)); },
            || std::thread::sleep(std::time::Duration::from_secs(3)));
        if landed { return (true, made); }
        // LEGACY FALLBACK for ab < 2.1.175 only (fixed pull budget): split on the desktop
        let ab_version = self.raw_call("status", &json!({}))["version"].as_str().map(str::to_string);
        if !legacy_pull_needs_split(ab_version.as_deref()) { return (false, made); }
        (self.pull_in_parts(remote, save_to), made + 1)
    }

    /// LEGACY (ab < 2.1.175): split a Windows file into 200 MB parts beside it with a human-gated
    /// PowerShell shell_execute, pull the parts, join them into the landed path and keep it only
    /// when its size matches the original. The parts are removed on both sides.
    pub fn pull_in_parts(&self, remote: &str, save_to: &str) -> bool {
        let win = remote.replace('/', "\\");
        let dir = format!("{win}.parts");
        let script = format!("$src='{win}'; $dir='{dir}'; New-Item -ItemType Directory -Force -Path $dir | Out-Null; $fs=[System.IO.File]::OpenRead($src); $len=$fs.Length; $buf=New-Object byte[] (200MB); $i=0; while(($n=$fs.Read($buf,0,$buf.Length)) -gt 0){{ $o=[System.IO.File]::Create(('{{0}}\\part{{1:D3}}' -f $dir,$i)); $o.Write($buf,0,$n); $o.Close(); $i++ }}; $fs.Close(); \"parts=$i size=$len\"");
        let enc: String = { let u: Vec<u8> = script.encode_utf16().flat_map(|c| c.to_le_bytes()).collect(); base64_encode(&u) };
        let r = self.call("shell_execute", &json!({"command": format!("powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {enc}"), "timeoutSeconds": 900, "reason": "aiflow: split a large clip into parts so it can be pulled"}));
        let out = r["stdout"].as_str().unwrap_or("").to_string();
        let grab = |k: &str| out.split(&format!("{k}=")).nth(1).and_then(|x| x.split_whitespace().next()).and_then(|x| x.parse::<u64>().ok());
        let (Some(parts), Some(size)) = (grab("parts"), grab("size")) else { return false };
        let local = landed_path(remote, save_to);
        let tmp = std::path::Path::new(save_to).join(format!(".{}.parts", std::path::Path::new(&local).file_name().and_then(|n| n.to_str()).unwrap_or("clip")));
        let _ = std::fs::create_dir_all(&tmp);
        let tmp_s = tmp.display().to_string();
        for i in 0..parts {
            let part = format!("{}/part{i:03}", dir.replace("\\", "/"));
            let lp = tmp.join(format!("part{i:03}"));
            let ok = pull_until_landed(&lp, 3, || { self.call("pull_file", &json!({"filePaths": [part.clone()], "saveTo": tmp_s.clone()})); }, || std::thread::sleep(std::time::Duration::from_secs(3))).0;
            if !ok { return false; }
        }
        let joined = (|| -> std::io::Result<u64> {
            let mut out = std::fs::File::create(&local)?;
            for i in 0..parts { let mut f = std::fs::File::open(tmp.join(format!("part{i:03}")))?; std::io::copy(&mut f, &mut out)?; }
            Ok(out.metadata()?.len())
        })();
        let _ = std::fs::remove_dir_all(&tmp);
        let _ = self.call("shell_execute", &json!({"command": format!("rmdir /s /q \"{dir}\""), "timeoutSeconds": 60, "reason": "aiflow: remove the clip parts"}));
        match joined { Ok(n) if n == size => true, _ => { let _ = std::fs::remove_file(&local); false } }
    }

    // -- landing ------------------------------------------------------------------------------
    /// One kicad_move_footprint per PART, one undo step each, paused between, so the window
    /// recorder catches every part landing in its spot instead of the whole board changing in a
    /// single frame. Returns (replies, accepted, stopped-at).
    pub fn land_moves(&self, board: &str, moves: &Value, pause_ms: u64, progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {
        self.land_moves_allowing(board, moves, pause_ms, &[], progress)
    }

    /// `land_moves`, with `allow_outside` naming the parts whose body may overhang the outline on
    /// purpose (an edge connector, issue #95): their move, and only theirs, carries `allowOutside: true`.
    /// A refusal that names no part (`offendingRefs` null) is given the part being moved.
    pub fn land_moves_allowing(&self, board: &str, moves: &Value, pause_ms: u64, allow_outside: &[String], mut progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {
        let items: Vec<(String, Value)> = moves.as_object().map(|o| o.iter().map(|(r, m)| (r.clone(), m.clone())).collect()).unwrap_or_default();
        let (revision, reply) = self.revision_with_reply(board);
        let mut rev = match revision { Some(r) => r, None => return (vec![reply], 0, Some(0)) };
        let mut replies = Vec::new();
        let mut ok = 0;
        for (i, (reference, m)) in items.iter().enumerate() {
            let mut r = self.call("kicad_move_footprint", &move_args(board, &rev, reference, m, allow_outside.contains(reference)));
            name_offender(&mut r, reference);
            progress(i + 1, items.len(), reference, &r);
            if !Bridge::ok(&r) {
                replies.push(r);
                return (replies, ok, Some(i));
            }
            ok += 1;
            match r.get("revision").and_then(|v| v.as_str()) {
                Some(nr) => rev = nr.to_string(),
                None => if let Some(nr) = self.revision(board) { rev = nr },
            }
            replies.push(r);
            std::thread::sleep(std::time::Duration::from_millis(pause_ms));
        }
        (replies, ok, None)
    }

    /// Replay a plan ONE TRACE AT A TIME: an entry with several paths becomes one kicad_route_net
    /// per path, so the camera sees each trace drawn instead of a whole net appearing at once.
    /// Resumable: a trace already on the live board (same net, same points) is skipped, `from`
    /// (1-based) skips the traces before it, and a reply lost to the transport is answered by
    /// reading the board: a trace that landed counts, one that did not is sent once more.
    /// `precheck` may refuse a trace before anything lands.
    pub fn land_plan(&self, board: &str, entries: &[Value], from: usize, pause_ms: u64,
        precheck: impl FnMut(usize, &Value, &landing::LiveCopper) -> Option<Value>,
        progress: impl FnMut(usize, usize, &Value, &Value)) -> Landing {
        let traces = landing::expand_traces(entries);
        landing::land_traces_with(board, &traces, from,
            |verb, args| self.call(verb, args),
            || self.revision_with_reply(board),
            precheck,
            || std::thread::sleep(std::time::Duration::from_millis(pause_ms)),
            progress)
    }

    /// The live board's copper and pads (one kicad_routing_state read, retried like `revision`).
    pub fn live_copper(&self, board: &str) -> Option<landing::LiveCopper> {
        let (rev, state) = self.revision_with_reply(board);
        rev.map(|_| landing::LiveCopper::from_state(&state))
    }

    /// Take copper items back as one native undo step (kicad_remove_route at the live revision).
    pub fn remove_items(&self, board: &str, item_ids: &[String]) -> Value {
        let (rev, reply) = self.revision_with_reply(board);
        let Some(rev) = rev else { return reply };
        self.call("kicad_remove_route", &json!({"filePath": board, "expectedRevision": rev, "itemIds": item_ids}))
    }

    /// One native transaction owns reconciliation, DRC, equality and Undo.
    /// Requires KiCad Bridge 1.0.23+; never fall back to partial delete/create.
    pub fn land_zones(&self, board: &str, zones: &[Value], _pause_ms: u64, owned_ids: &[String]) -> Vec<(String, Value)> {
        let live = self.zone_state(board, false);
        if !Bridge::ok(&live) { return vec![("zone-readback".into(), live)]; }
        if let Err(reason) = owned_zones_to_replace(&live, zones, owned_ids) {
            return vec![("zone-ownership".into(), json!({"success":false,"errorCode":"zone_ownership_conflict","message":reason}))];
        }
        let Some(revision) = live["revision"].as_str() else {
            return vec![("zone-readback".into(), json!({"success":false,"errorCode":"no_revision","reply":live}))];
        };
        let reply = self.call("kicad_apply_zone_plan", &json!({
            "filePath":board, "expectedRevision":revision,
            "ownedItemIds":owned_ids, "zones":zones, "refill":false
        }));
        // A committed error is not permission to replay; the ledger retains it.
        vec![("native-zone-plan".into(), reply)]
    }

    /// Free megabytes on the test box's recording drive: Adom Bridge's native, read-only,
    /// ungated `desktop_disk_free` (ab 2.1.127+), whose default path is the recorder's output folder.
    pub fn remote_free_mb(&self) -> Option<u64> {
        remote_free_mb_with(|verb, args| self.raw_call(verb, args))
    }

    /// Filled copper per layer. A refill of many zones can outlast the bridge's IPC wait
    /// (ipc_timeout while KiCad's UI thread fills); the fill keeps running, so wait and read again.
    pub fn zone_state(&self, board: &str, refill: bool) -> Value {
        read_zone_state_with(refill,
            |fill| self.call("kicad_zone_state", &json!({"filePath":board,"refill":fill})),
            || std::thread::sleep(std::time::Duration::from_secs(6)))
    }

    /// Vias on the live board as (net, x, y).
    pub fn live_vias(&self, board: &str) -> Vec<(String, f64, f64)> {
        let r = self.call("kicad_routing_state", &json!({"filePath": board}));
        r.get("vias").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| Some((v.get("net_name")?.as_str()?.to_string(), v["position"]["x"].as_f64()?, v["position"]["y"].as_f64()?))).collect()).unwrap_or_default()
    }

    pub fn validate(&self, board: &str) -> Value {
        self.call("kicad_routing_validate", &json!({"filePath": board}))
    }

    pub fn status(&self) -> Value {
        self.call("kicad_status", &json!({}))
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn refill_ack_and_transient_snapshots_are_never_measurements() {
        let mut replies = vec![
            json!({"success":true,"refillRequested":true,"revisionUsable":false}),
            json!({"success":true,"revision":"old","filledAreaByLayerMm2":{"F.Cu":1}}),
            json!({"success":false,"errorCode":"busy"}),
            json!({"success":true,"revision":"new","filledAreaByLayerMm2":{"F.Cu":2}}),
            json!({"success":true,"revision":"new","filledAreaByLayerMm2":{"F.Cu":2}}),
        ].into_iter();
        let mut calls = Vec::new();
        let r = super::read_zone_state_with(true, |fill| {calls.push(fill);replies.next().unwrap()}, || {});
        assert_eq!(r["revision"], "new");
        assert_eq!(calls, vec![true,false,false,false,false]);
    }
    #[test]
    fn committed_failure_is_not_success() {
        assert!(!super::Bridge::ok(&json!({"success":true,"committed":true,"postCommitDeletionError":"read failed"})));
        assert!(super::Bridge::ok(&json!({"success":true,"committed":true})));
    }

    use serde_json::json;

    #[test]
    fn zone_ownership_is_never_inferred_from_name_or_board_age() {
        let live=json!({"zones":[{"id":"foreign","name":"manual","type":"copper"},{"id":"ours","name":"power","type":"copper"}]});
        let plan=vec![json!({"name":"power","polygon":[[0,0],[1,0],[1,1],[0,1]]})];
        assert_eq!(super::owned_zones_to_replace(&live,&plan,&["ours".into()]).unwrap(),vec!["ours"]);
        assert!(super::owned_zones_to_replace(&live,&plan,&[]).unwrap_err().contains("unowned"));
        assert!(super::owned_zones_to_replace(&live,&[],&[]).unwrap().is_empty());
    }

    #[test]
    fn equal_area_is_not_equivalent_zone_geometry() {
        let live=json!({"zones":[{"id":"ours","name":"power","type":"copper","outlineAreaMm2":1.0}]});
        for polygon in [json!([[0,0],[1,0],[1,1],[0,1]]),json!([[10,10],[11,10],[11,11],[10,11]])] {
            let plan=vec![json!({"name":"power","polygon":polygon})];
            assert_eq!(super::owned_zones_to_replace(&live,&plan,&["ours".into()]).unwrap(),vec!["ours"]);
        }
        assert!(super::owned_zones_to_replace(&json!({"success":false}),&[],&[]).is_err());
    }

    #[test]
    fn permanent_identity_refusal_is_not_retried() {
        for code in ["board_mismatch", "invalid_socket", "api_server_off", "no_pcb_frame"] {
            assert!(!super::retryable_read(&json!({"errorCode":code})));
        }
        for code in ["ipc_timeout", "busy", "ipc_unavailable"] {
            assert!(super::retryable_read(&json!({"errorCode":code})));
        }
    }

    /// The rule behind "one trace at a time": an entry with several paths becomes one call per
    /// path, an entry with one path stays one call, so a plan's call count is its path count.
    fn expand(entries: &[serde_json::Value]) -> Vec<serde_json::Value> {
        entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {
            Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<_>>(),
            _ => vec![e.clone()],
        }).collect()
    }

    #[test]
    fn one_call_per_trace() {
        let entries = vec![
            json!({"net": "+VBAT", "width": 1.0, "paths": [["A", [1.0, 1.0], [2.0, 2.0]], ["B", [3.0, 3.0], [4.0, 4.0]], ["C", [5.0, 5.0], [6.0, 6.0]]]}),
            json!({"net": "/SW", "width": 0.2, "paths": [["D", [7.0, 7.0], [8.0, 8.0]]]}),
        ];
        let traces = expand(&entries);
        assert_eq!(traces.len(), 4, "two entries with three and one paths must land as four traces");
        for t in &traces {
            assert_eq!(t["paths"].as_array().unwrap().len(), 1, "every landed call carries exactly one trace");
        }
        assert_eq!(traces[0]["net"], "+VBAT");
        assert_eq!(traces[3]["net"], "/SW");
        assert_eq!(traces[1]["width"], 1.0, "the net's width rides every one of its traces");
    }
}

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

    #[test]
    fn a_launched_kicad_in_front_is_sent_back_only_when_it_took_the_human_s_window() {
        let human = json!({"hwnd": 11, "title": "board.kicad_pcb - Adom Hydrogen"});
        assert!(took_foreground(42, Some(&human), &json!({"hwnd": 42, "title": "esc — PCB Editor"})));
        assert!(took_foreground(42, Some(&human), &json!({"hwnd": 77, "title": "KiCad 10.0"})));
        assert!(took_foreground(42, None, &json!({"hwnd": 42, "title": ""})));
        assert!(!took_foreground(42, Some(&human), &human));
        // the human had KiCad in front already: their choice, left alone
        assert!(!took_foreground(42, Some(&json!({"hwnd": 42, "title": "esc — PCB Editor"})), &json!({"hwnd": 42, "title": "esc — PCB Editor"})));
    }

    fn scratch(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("aiflow-pull-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    #[test]
    fn a_pull_counts_only_when_a_nonempty_file_landed() {
        let d = scratch("retry");
        let local = landed_path("C:/Users/x/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4", &d.display().to_string());
        assert_eq!(local, d.join("window-1.mp4"));
        assert_eq!(landed_path("C:\\rec\\w.mp4", "/run"), Path::new("/run/w.mp4"));
        // the first pull leaves an empty file, the second lands it
        let mut n = 0;
        let (ok, tries) = pull_until_landed(&local, PULL_ATTEMPTS, || { n += 1; std::fs::write(&local, if n == 1 { &b""[..] } else { &b"mp4"[..] }).unwrap(); }, || {});
        assert_eq!((ok, tries), (true, 2));
        // issue #145: every pull leaves an empty file: it is missing, and no empty file stays behind
        let empty = d.join("empty.mp4");
        let (ok, _) = pull_until_landed(&empty, PULL_ATTEMPTS, || { std::fs::write(&empty, b"").unwrap(); }, || {});
        assert!(!ok);
        assert!(!empty.exists(), "a clip that did not land must not leave a 0-byte file");
        // nothing ever arrives: three pulls, then give up
        let never = d.join("never.mp4");
        let mut pulls = 0;
        let (ok, tries) = pull_until_landed(&never, PULL_ATTEMPTS, || pulls += 1, || {});
        assert_eq!((ok, tries, pulls), (false, 3, 3));
        let _ = std::fs::remove_dir_all(&d);
    }
}

/// Standard base64 (for PowerShell -EncodedCommand), no external crate.
fn base64_encode(b: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut s = String::with_capacity((b.len() + 2) / 3 * 4);
    for c in b.chunks(3) {
        let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
        s.push(T[(n >> 18) as usize & 63] as char); s.push(T[(n >> 12) as usize & 63] as char);
        s.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
        s.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });
    }
    s
}

#[cfg(test)]
mod pull_parts_tests {
    #[test]
    fn base64_matches_the_standard() {
        let u: Vec<u8> = "ab\u{e9}cd".encode_utf16().flat_map(|c| c.to_le_bytes()).collect();
        assert_eq!(super::base64_encode(&u), "YQBiAOkAYwBkAA==");
        assert_eq!(super::base64_encode(b"M"), "TQ==");
        assert_eq!(super::base64_encode(b"Ma"), "TWE=");
    }
}

#[cfg(test)]
mod read_socket_tests {
    use super::*;
    #[test]
    fn scoped_reads_preserve_identity_and_explicit_socket() {
        let a=json!({"filePath":"exact-board","expectedRevision":"r1"});
        for verb in ["kicad_routing_state","kicad_routing_validate","kicad_placement_state","kicad_placement_validate"] {
            let b=read_socket_args(verb,&a,Some("ipc://isolated/api.sock"));
            assert_eq!(b["socketPath"],"ipc://isolated/api.sock");
            assert_eq!(b["filePath"],a["filePath"]);
            assert_eq!(b["expectedRevision"],a["expectedRevision"]);
        }
        let explicit=json!({"socketPath":"ipc://explicit/api.sock"});
        assert_eq!(read_socket_args("kicad_placement_state",&explicit,Some("other")),explicit);
    }
    #[test]
    fn zone_refill_and_readbacks_use_the_same_selected_endpoint() {
        for refill in [true, false] {
            let args = json!({"filePath":"exact-board", "refill":refill});
            let routed = read_socket_args("kicad_zone_state", &args, Some("ipc://isolated/api.sock"));
            assert_eq!(routed["socketPath"], "ipc://isolated/api.sock");
            assert_eq!(routed["filePath"], args["filePath"]);
            assert_eq!(routed["refill"], refill);
        }
        let explicit = json!({"filePath":"exact-board", "refill":false, "socketPath":"ipc://explicit/api.sock"});
        assert_eq!(read_socket_args("kicad_zone_state", &explicit, Some("other")), explicit);
    }
    #[test]
    fn absent_empty_and_unrelated_calls_are_unchanged() {
        let a=json!({"filePath":"board"});
        for socket in [None,Some(""),Some("  ")] {
            assert_eq!(read_socket_args("kicad_placement_state",&a,socket),a);
        }
        for verb in ["kicad_move_footprint","kicad_open_board","kicad_apply_tracks","kicad_apply_zone_plan","kicad_add_zone","fusion_routing_state","desktop_record_window_start"] {
            assert_eq!(read_socket_args(verb,&a,Some("ipc://isolated/api.sock")),a);
        }
        assert_eq!(read_socket_args("kicad_placement_state",&Value::Null,Some("x")),Value::Null);
    }
}

#[cfg(test)]
mod native_preflight_tests {
    use serde_json::{json, Value};

    #[test]
    fn disk_free_uses_the_native_verb_and_prefers_available_bytes() {
        // the shape ab 2.1.182 returned on AdomLapper: fields at top level and again in `output`
        let reply = json!({"success":true,"availableBytes":5_856_219_136u64,"freeBytes":6_000_000_000u64,"output":"{\"availableBytes\":5856219136}"});
        assert_eq!(super::disk_free_mb(&reply), Some(5584));
        assert_eq!(super::disk_free_mb(&json!({"output":"{\"freeBytes\":2097152}"})), Some(2));
        let mut verbs = Vec::new();
        let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); reply.clone() });
        assert_eq!((mb, verbs), (Some(5584), vec!["desktop_disk_free".to_string()]));
    }

    #[test]
    fn shell_fallback_only_on_a_bridge_without_the_verb() {
        let mut verbs = Vec::new();
        let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); if v == "desktop_disk_free" { json!({"errorCode":"unknown_verb"}) } else { json!({"stdout":"3145728\r\n"}) } });
        assert_eq!((mb, verbs.len()), (Some(3), 2));
        assert_eq!(verbs[1], "shell_execute");
        // any other refusal of the native verb is not a reason to shell out
        let mut verbs = Vec::new();
        let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); json!({"errorCode":"target_not_found"}) });
        assert_eq!((mb, verbs.len()), (None, 1));
    }

    #[test]
    fn part_splitter_is_legacy_only() {
        assert!(super::legacy_pull_needs_split(Some("2.1.174")));
        assert!(super::legacy_pull_needs_split(Some("1.8.172")));
        assert!(!super::legacy_pull_needs_split(Some("2.1.175")));
        assert!(!super::legacy_pull_needs_split(Some("2.1.182")));
        assert!(!super::legacy_pull_needs_split(Some("3.0.0-beta")));
        assert!(!super::legacy_pull_needs_split(None));
        assert!(!super::legacy_pull_needs_split(Some("garbage")));
    }

    fn binding(r: &str, exists: bool, hidden: bool) -> Value {
        json!({"reference": r, "footprint": "Lib:Fp", "models": [{"reference": format!("${{X}}/{r}.step"), "exists": exists, "hidden": hidden}]})
    }

    #[test]
    fn model_check_reads_complete_bindings_and_viewer_classes() {
        // J1 of kicad-bridge#101: the file resolves, but the viewer hides not-in-POS bodies
        let d = json!({"counts":{"footprints":3},"bindingsComplete":true,"evidenceSource":"saved-board-file",
            "modelBindings":[binding("J1", true, false), binding("U1", false, false), binding("R1", true, true), {"reference":"TP1","models":[],"noModelExpected":true}],
            "footprintsWithWarnings":[],
            "viewer":{"flags":{"smd":true,"notInPosFile":false,"dnp":false}},
            "footprintsWithModelByViewerClass":{"smd":3,"notInPosFile":1,"dnp":0}});
        let s = super::model_check_summary(&d);
        assert_eq!(s.missing, vec!["Lib:Fp (U1): ${X}/U1.step".to_string()]);
        assert!(s.hidden.iter().any(|h| h.contains("notInPosFile (1")));
        assert!(!s.hidden.iter().any(|h| h.contains("dnp")), "a hidden class with no modelled footprints is not reported");
        assert!(s.hidden.iter().any(|h| h.starts_with("R1:")));
        assert!(s.bindings_complete);
        assert_eq!(s.evidence, "saved-board-file");
    }

    #[test]
    fn model_check_falls_back_to_warnings_on_older_bridges() {
        let d = json!({"data":{"counts":{},"footprintsWithWarnings":[{"footprint":"Lib:Fp","references":["U2","U3"],"models":[{"reference":"a.step","exists":false},{"reference":"b.step","exists":true}]}]}});
        let s = super::model_check_summary(&d);
        assert_eq!(s.missing, vec!["Lib:Fp (U2,U3): a.step".to_string()]);
        assert!(s.hidden.is_empty());
        assert!(!s.bindings_complete);
        assert_eq!(s.evidence, "unknown");
    }
}

/// The kicad_move_footprint arguments for one part; `allow_outside` adds `allowOutside: true`.
pub fn move_args(board: &str, rev: &str, reference: &str, m: &Value, allow_outside: bool) -> Value {
    let mut one = json!({"ref": reference, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))});
    // issue #142: the rotation that keeps a Fusion bottom part on the bottom (land_moves::fusion_sides)
    if m["eagleRot"].is_string() { one["eagleRot"] = m["eagleRot"].clone(); }
    let refs = json!([one]);
    let mut a = json!({"filePath": board, "expectedRevision": rev, "refs": refs});
    if allow_outside { a["allowOutside"] = json!(true); }
    a
}

/// A move refusal with no `offendingRefs` names the part aiflow was moving (issue #95: an
/// `outside_outline` came back with `offendingRefs: null`).
pub fn name_offender(reply: &mut Value, reference: &str) {
    if !Bridge::ok(reply) {
        let empty = reply.get("offendingRefs").map(|v| v.is_null() || v.as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(true);
        if empty && reply.is_object() { reply["offendingRefs"] = json!([reference]); reply["offendingRefsFrom"] = json!("adom-aiflow: the part being moved"); }
    }
}

#[cfg(test)]
mod native_preflight_live {
    /// Read-only acceptance on a real box: AIFLOW_LIVE_TARGET=AdomLapper cargo test -p aiflow-bridge -- --ignored native_preflight_live
    #[test]
    #[ignore = "needs a live Adom Bridge target (AIFLOW_LIVE_TARGET) and optionally AIFLOW_LIVE_BOARD"]
    fn native_preflight_live() {
        let target = std::env::var("AIFLOW_LIVE_TARGET").expect("AIFLOW_LIVE_TARGET");
        let br = super::Bridge { ai_thread: "aiflow".into(), target };
        let mb = br.remote_free_mb().expect("desktop_disk_free answered");
        eprintln!("recording drive free: {mb} MB");
        assert!(mb > 0);
        let ver = br.raw_call("status", &serde_json::json!({}))["version"].as_str().map(str::to_string);
        eprintln!("ab {ver:?}, legacy split: {}", super::legacy_pull_needs_split(ver.as_deref()));
        if let Ok(board) = std::env::var("AIFLOW_LIVE_BOARD") {
            let v = br.raw_call("kicad_model_check", &serde_json::json!({"filePath": board}));
            let s = super::model_check_summary(&v);
            eprintln!("{s:?}");
            assert!(s.bindings_complete);
        }
    }
}

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

    fn run(replies: Vec<Value>, polls: usize) -> (Result<String, (String, bool)>, Vec<String>, usize) {
        let mut q = replies.into_iter();
        let mut verbs = Vec::new();
        let mut waits = 0;
        let (_, got) = stop_take(&mut |v: &str, _a: &Value| { verbs.push(v.to_string()); q.next().unwrap_or(json!({"success": false, "error": "no more replies"})) }, &mut || waits += 1, "rec-1", polls);
        (got, verbs, waits)
    }

    #[test]
    fn a_stop_with_a_file_returns_it() {
        let (got, verbs, waits) = run(vec![json!({"success": true, "filePath": "C:\\t\\rec-1.mp4"})], 5);
        assert_eq!(got.unwrap(), "C:/t/rec-1.mp4");
        assert_eq!((verbs.len(), waits), (1, 0));
        let wrapped = json!({"success": true, "output": "{\"data\":{\"path\":\"C:/t/a.mp4\"}}"});
        assert_eq!(run(vec![wrapped], 5).0.unwrap(), "C:/t/a.mp4");
    }

    #[test]
    fn an_unknown_recording_fails_at_once_without_sleeping() {
        let (got, verbs, waits) = run(vec![json!({"success": false, "error": "No recording with id rec-1"})], 1000);
        let (why, pending) = got.unwrap_err();
        assert!(why.contains("No recording with id") && !pending, "{why}");
        assert_eq!((verbs.len(), waits), (1, 0));
    }

    #[test]
    fn a_finalising_take_is_polled_then_collected() {
        let (got, verbs, waits) = run(vec![
            json!({"success": true, "state": "finalising", "stillRunning": true, "bytes": 10}),
            json!({"success": true, "state": "finalising", "stillRunning": true}),
            json!({"success": true, "state": "finalised", "stillRunning": false}),
            json!({"success": true, "filePath": "C:/t/rec-1.mp4"}),
        ], 5);
        assert_eq!(got.unwrap(), "C:/t/rec-1.mp4");
        assert_eq!(verbs, ["desktop_record_stop", "desktop_record_status", "desktop_record_status", "desktop_record_stop"]);
        assert_eq!(waits, 2);
    }

    #[test]
    fn the_finalise_wait_is_bounded_and_a_vanished_take_is_not_slept_on() {
        let fin = json!({"success": true, "state": "finalising", "stillRunning": true});
        let (got, verbs, waits) = run(vec![fin.clone(); 10], 3);
        let (why, pending) = got.unwrap_err();
        assert!(why.contains("still finalising") && pending, "{why}");
        assert_eq!((verbs.len(), waits), (4, 3));
        let (got, _, waits) = run(vec![fin.clone(), json!({"success": false, "errorCode": "recording_not_found", "error": "No recording with id rec-1"})], 100);
        let (why, pending) = got.unwrap_err();
        assert!(why.contains("stopped listing") && !pending, "{why}");
        assert_eq!(waits, 1);
        let (got, _, _) = run(vec![fin, json!({"success": true, "state": "failed", "finaliseError": "sink error"})], 100);
        assert!(got.unwrap_err().0.contains("sink error"));
    }

    #[test]
    fn a_timed_out_stop_is_pending() {
        let (got, _, waits) = run(vec![json!({"success": false, "errorCode": "aiflow_timeout"})], 100);
        assert!(got.unwrap_err().1);
        assert_eq!(waits, 0);
    }
}

#[cfg(test)]
mod edge_overhang_tests {
    use serde_json::json;
    #[test]
    fn allow_outside_rides_only_the_named_parts_move_and_a_bare_refusal_names_the_part() {
        let m = json!({"x": 18.5, "y": 5.0, "rotation": 90});
        let a = super::move_args("C:/b.kicad_pcb", "r1", "J13", &m, true);
        assert_eq!(a["allowOutside"], true);
        assert_eq!(a["refs"][0]["ref"], "J13");
        assert_eq!(a["refs"][0]["rotation"], 90);
        assert!(super::move_args("C:/b.kicad_pcb", "r1", "U1", &m, false).get("allowOutside").is_none());
        // issue #95: outside_outline with offendingRefs null
        let mut r = json!({"success": false, "errorCode": "outside_outline", "offendingRefs": null});
        super::name_offender(&mut r, "J13");
        assert_eq!(r["offendingRefs"], json!(["J13"]));
        // a refusal that names its parts, and a success, are left as they are
        let mut named = json!({"success": false, "errorCode": "courtyard_overlap", "offendingRefs": ["U2"]});
        super::name_offender(&mut named, "J13");
        assert_eq!(named["offendingRefs"], json!(["U2"]));
        let mut fine = json!({"success": true, "revision": "r2"});
        super::name_offender(&mut fine, "J13");
        assert!(fine.get("offendingRefs").is_none());
    }
}

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

    // kicad_window_info on the Mac (KiCad bridge 0.11.3, KiCad 10.0.7) with the 3D Viewer open, as the
    // Rev C strip run read it on 2026-11-02 (.aiflow-run-revc-edge-board/stop/kicad-window-info-3d-viewer-open.json)
    fn mac_reply() -> Value {
        json!({"data": {"autoDismissed": [], "editors": [
            {"app": "PCB Editor", "hwnd": 2893, "pid": 52411, "rect": {"height": 720, "width": 1280}, "title": "3D Viewer"},
            {"app": "PCB Editor", "hwnd": 2858, "pid": 52411, "rect": {"height": 44, "width": 1470}, "title": "PCB Editor"},
            {"app": "PCB Editor", "hwnd": 2846, "pid": 52411, "rect": {"height": 923, "width": 1470}, "title": "edge_board — PCB Editor"}],
            "hasModalDialogs": false, "modalDialogs": [], "projectManager": null},
            "output": "3 editor(s), 0 dialog(s)", "status": "ok", "success": true})
    }

    #[test]
    fn the_mac_editor_is_found_by_its_title() {
        assert_eq!(pcb_editor_of(&mac_reply()), Some(2846));
        // unsaved board: KiCad marks the title with a star
        let star = json!({"data": {"editors": [{"hwnd": 7, "title": "PCB Editor"}, {"hwnd": 8, "title": "*board — PCB Editor"}]}});
        assert_eq!(pcb_editor_of(&star), Some(8));
    }

    #[test]
    fn never_the_viewer_or_the_bare_strip() {
        let only_viewer = json!({"data": {"editors": [{"hwnd": 2893, "title": "3D Viewer"}, {"hwnd": 2858, "title": "PCB Editor"}]}});
        assert_eq!(pcb_editor_of(&only_viewer), None);
        assert!(!titled_pcb_editor("PCB Editor"));
        assert!(!titled_pcb_editor(" — PCB Editor"));
        assert!(!titled_pcb_editor("3D Viewer"));
        assert!(!titled_pcb_editor("board — Schematic Editor"));
        assert!(titled_pcb_editor("scratch_strip — PCB Editor"));
    }

    #[test]
    fn a_bridge_that_names_kinds_is_read_as_before() {
        // the Windows bridge: kind decides, the title is never used
        let win = json!({"data": {"editors": [
            {"kind": "schematic_editor", "hwnd": 1, "title": "board — Schematic Editor"},
            {"kind": "pcb_editor", "hwnd": 2, "title": "board — PCB Editor"}]}});
        assert_eq!(pcb_editor_of(&win), Some(2));
        // kinds named and no PCB editor among them: none, even with a PCB-like title
        let none = json!({"data": {"editors": [{"kind": "3d_viewer", "hwnd": 3, "title": "board — PCB Editor"}]}});
        assert_eq!(pcb_editor_of(&none), None);
        // the reply without a data wrapper, and no editors at all
        assert_eq!(pcb_editor_of(&json!({"editors": [{"kind": "pcb_editor", "hwnd": 9}]})), Some(9));
        assert_eq!(pcb_editor_of(&json!({"status": "error"})), None);
        assert_eq!(pcb_editor_of(&json!({"data": {"editors": []}})), None);
    }

    /// Issue #155: the run's own editor by its board's name, never another project's.
    #[test]
    fn the_runs_editor_is_found_by_its_board_name() {
        let win = json!({"data": {"editors": [
            {"kind": "pcb_editor", "hwnd": 461626, "title": "other-esc — PCB Editor"},
            {"kind": "pcb_editor", "hwnd": 1905110, "title": "esc-2s6s — PCB Editor"}]}});
        assert_eq!(pcb_editor_named(&win, "esc-2s6s"), Some(1905110));
        assert_eq!(pcb_editor_named(&win, "esc"), None);
        assert_eq!(pcb_editor_named(&win, ""), Some(461626));
        assert_eq!(pcb_editor_named(&mac_reply(), "edge_board"), Some(2846));
        assert_eq!(pcb_editor_named(&mac_reply(), "led-strip-500"), None);
        assert_eq!(editor_title(&win, 1905110).as_deref(), Some("esc-2s6s — PCB Editor"));
        assert_eq!(editor_title(&win, 5), None);
    }
}

#[cfg(test)]
mod live_socket_tests {
    use super::*;
    /// Issue #159 (patch by Noah): the isolated editor's pipe reaches every live verb, never a window verb.
    #[test]
    fn live_socket_reaches_edits_and_reads_but_never_windows() {
        let args = json!({"filePath": "C:/b.kicad_pcb"});
        for verb in ["kicad_move_footprint", "kicad_route_net", "kicad_apply_zone_plan", "kicad_routing_state", "kicad_zone_state", "kicad_select_net", "kicad_zoom_to"] {
            assert_eq!(live_socket_args(verb, &args, Some("ipc://iso/api.sock"))["socketPath"], "ipc://iso/api.sock", "{verb}");
        }
        for verb in ["kicad_open_board", "kicad_send_key", "kicad_window_info", "kicad_screenshot_all", "kicad_run_drc", "desktop_screenshot_window"] {
            assert!(live_socket_args(verb, &args, Some("ipc://iso/api.sock")).get("socketPath").is_none(), "{verb}");
        }
        let explicit = json!({"filePath": "C:/b.kicad_pcb", "socketPath": "mine"});
        assert_eq!(live_socket_args("kicad_move_footprint", &explicit, Some("other")), explicit);
        assert_eq!(live_socket_args("kicad_move_footprint", &args, None), args);
        assert_eq!(live_socket_args("kicad_move_footprint", &args, Some("  ")), args);
        // the read socket still answers only reads
        assert!(read_socket_args("kicad_move_footprint", &args, Some("r")).get("socketPath").is_none());
        assert_eq!(read_socket_args("kicad_routing_state", &args, Some("r"))["socketPath"], "r");
    }
}