main
Colby Knox 0.2.58 source (not yet released, needs signing): autoroute, layer_stack, rule descriptors, sch_zoom f82b5b5 10h ago
//! Editing an existing design's schematic, the way a person does at the sheet: place a library part,
//! connect pins (wire stub + net label or power port), move/rotate/rename/re-comment/delete parts,
//! annotate `?` designators, the title block, the change order to the board, and a schematic PDF.
use crate::support::{self, s, timeout_of};
use crate::{ab, agent, instance};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use std::time::Duration;

fn st(v: &Value) -> String { v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() }) }

fn clean(v: &str) -> String { v.replace(['|', '\n', '\r'], " ") }

fn need_sheet(args: &Value) -> Result<String, Value> {
    let Some(sheet) = s(args, "sheet").or_else(|| s(args, "filePath")) else {
        return Err(json!({"success": false, "errorCode": "bad_args", "error": "sheet (.SchDoc path) is required"}));
    };
    if !sheet.to_lowercase().ends_with(".schdoc") {
        return Err(json!({"success": false, "errorCode": "not_a_sheet", "error": format!("{sheet} is not a .SchDoc")}));
    }
    Ok(sheet)
}

/// Save the sheet unless save:false; the reply says which.
fn maybe_save(args: &Value, sheet: &str) -> Value {
    if args.get("save").and_then(|b| b.as_bool()) == Some(false) { return json!(false); }
    let r = crate::surfaces::save(&json!({"filePath": sheet}));
    json!(r.get("success").and_then(|b| b.as_bool()) != Some(false))
}

fn agent_data(r: &Value) -> Result<Value, Value> {
    match r.get("data") {
        Some(d) if d["ok"].as_bool() == Some(true) => Ok(d.clone()),
        Some(d) => Err(json!({"success": false, "errorCode": d.get("errorCode").cloned().unwrap_or(json!("agent_refused")), "error": d.get("error"), "reply": r})),
        None => Err(json!({"success": false, "errorCode": r.get("errorCode").cloned().unwrap_or(json!("agent_failed")), "error": r.get("error"), "reply": r})),
    }
}

/// altium_sch_connect {sheet, connections:[{designator, pin, net, kind: label|power|wire, stubMil}], save}
pub fn connect(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let sheet = match need_sheet(args) { Ok(x) => x, Err(e) => return e };
    let Some(list) = args.get("connections").and_then(|c| c.as_array()).filter(|a| !a.is_empty()) else {
        return json!({"success": false, "errorCode": "bad_args", "error": "connections is required: [{designator, pin, net, kind?}]", "_hint": "kind: label (a net label, default), power (a power port; GND-like nets get a ground symbol), wire (a stub only)"});
    };
    let lines: Vec<String> = list.iter().map(|c| format!("{}|{}|{}|{}|{}", clean(&st(&c["designator"])), clean(&st(&c["pin"])), clean(&st(&c["net"])),
        clean(&st(&c["kind"])), c.get("stubMil").and_then(|v| v.as_i64()).unwrap_or(200))).collect();
    if let Some(e) = support::must_open(&sheet, 60) { return e; }
    let r = agent::call("sch_connect", &[], Some(&lines.join("\n")), timeout_of(args, 90));
    let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };
    let failed: Vec<Value> = d["results"].as_array().cloned().unwrap_or_default().into_iter().filter(|x| x["result"] != json!("ok")).collect();
    let saved = maybe_save(args, &sheet);
    json!({"success": failed.is_empty(), "errorCode": if failed.is_empty() { Value::Null } else { json!("some_not_connected") },
        "sheet": sheet, "connected": d["connected"], "results": d["results"], "failed": failed, "saved": saved,
        "_hint": "Each pin got a wire stub from its outer end and a net label or power port on it. Check with altium_compile (ERC) or altium_schematic_data; a net label is matched by its text across the project, a power port by its net name globally."})
}

/// altium_sch_edit {sheet, edits:[{designator, action: move|moveBy|rotate|designator|comment|delete, x,y | dx,dy | degrees | to | text}], save}
pub fn edit(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let sheet = match need_sheet(args) { Ok(x) => x, Err(e) => return e };
    let Some(list) = args.get("edits").and_then(|c| c.as_array()).filter(|a| !a.is_empty()) else {
        return json!({"success": false, "errorCode": "bad_args", "error": "edits is required: [{designator, action, ...}]"});
    };
    let mut lines = Vec::new();
    for e in list {
        let des = clean(&st(&e["designator"]));
        let action = st(&e["action"]).to_lowercase();
        let n = |k: &str| e.get(k).and_then(|v| v.as_f64()).map(|v| (v.round() as i64).to_string()).unwrap_or_default();
        let (a, b) = match action.as_str() {
            "move" => (n("x"), n("y")),
            "moveby" => (n("dx"), n("dy")),
            "rotate" => (n("degrees"), String::new()),
            "designator" => (clean(&st(&e["to"])), String::new()),
            "comment" => (clean(&st(&e["text"])), String::new()),
            "delete" => (String::new(), String::new()),
            other => return json!({"success": false, "errorCode": "bad_args", "error": format!("unknown action {other}: move, moveBy, rotate, designator, comment, delete")}),
        };
        lines.push(format!("{des}|{action}|{a}|{b}"));
    }
    if let Some(e) = support::must_open(&sheet, 60) { return e; }
    let r = agent::call("sch_edit", &[], Some(&lines.join("\n")), timeout_of(args, 90));
    let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };
    let failed: Vec<Value> = d["results"].as_array().cloned().unwrap_or_default().into_iter().filter(|x| x["result"] != json!("ok")).collect();
    let saved = maybe_save(args, &sheet);
    json!({"success": failed.is_empty(), "errorCode": if failed.is_empty() { Value::Null } else { json!("some_not_applied") },
        "sheet": sheet, "applied": d["applied"], "results": d["results"], "failed": failed, "saved": saved,
        "_hint": "Positions are mils (the part's origin). A moved part's wires do not follow: reconnect with altium_sch_connect. The board learns about renames and deletions with altium_update_pcb."})
}

/// altium_sch_place_part {sheet, library, symbol, designator?, x?, y?, rotation?, comment?, partId?, footprint?, pcbLib?, save}
pub fn place_part(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let sheet = match need_sheet(args) { Ok(x) => x, Err(e) => return e };
    let (Some(lib), Some(sym)) = (s(args, "library"), s(args, "symbol").or_else(|| s(args, "libref"))) else {
        return json!({"success": false, "errorCode": "bad_args", "error": "library (.SchLib path) and symbol (its component name) are required", "_hint": "altium_list_library {filePath} names a library's components; altium_list_installed_libraries lists the installed ones."});
    };
    if let Some(e) = support::must_open(&lib, 60) { return e; }
    let p = |k: &str| s(args, k).map(|v| clean(&v)).unwrap_or_default();
    let n = |k: &str| args.get(k).and_then(|v| v.as_f64()).map(|v| (v.round() as i64).to_string()).unwrap_or_default();
    let r = agent::call("sch_place_part", &[("libref", sym.clone()), ("sch", sheet.clone()), ("designator", p("designator")), ("x", n("x")), ("y", n("y")),
        ("rotation", n("rotation")), ("comment", p("comment")), ("partId", n("partId")), ("footprint", p("footprint")), ("pcblib", p("pcbLib"))], None, timeout_of(args, 90));
    let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };
    let saved = maybe_save(args, &sheet);
    json!({"success": true, "sheet": sheet, "designator": d["designator"], "libref": d["libref"], "pins": d["pins"], "footprints": d["footprints"], "atMil": d["atMil"], "saved": saved,
        "_hint": "Placed with its library parameters and footprint link. Wire it with altium_sch_connect {designator, pin, net}; a '?' designator is filled by altium_annotate; the board gets the part with altium_update_pcb."})
}

/// altium_sch_zoom {sheet, designators}: select those parts on the sheet and frame them, with a proof frame.
pub fn zoom(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let sheet = match need_sheet(args) { Ok(x) => x, Err(e) => return e };
    let des: Vec<String> = match args.get("designators").or_else(|| args.get("designator")) {
        Some(Value::Array(a)) => a.iter().map(st).collect(),
        Some(Value::String(t)) => t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),
        _ => vec![],
    };
    if des.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "designators is required"}); }
    if let Some(e) = support::must_open(&sheet, 60) { return e; }
    let r = agent::call("sch_zoom", &[("designators", des.iter().map(|d| clean(d)).collect::<Vec<_>>().join(","))], None, timeout_of(args, 60));
    let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };
    std::thread::sleep(Duration::from_millis(600));
    let shot = if args.get("shot").and_then(|b| b.as_bool()) == Some(false) { Value::Null } else { crate::shot::shot_of_main() };
    let found: Vec<String> = d["parts"].as_array().cloned().unwrap_or_default().iter().map(|p| st(&p["designator"])).collect();
    let missing: Vec<&String> = des.iter().filter(|x| !found.iter().any(|f| f.eq_ignore_ascii_case(x))).collect();
    json!({"success": missing.is_empty(), "errorCode": if missing.is_empty() { Value::Null } else { json!("not_on_sheet") }, "sheet": sheet, "parts": d["parts"], "notOnSheet": missing, "shot": shot,
        "_hint": "The parts are selected and framed on the sheet (the board side: altium_zoom_to {designators})."})
}

/// altium_doc_params {sheet, set?:{Title, DocumentNumber, Revision, DrawnBy, ...}, save}
pub fn doc_params(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let sheet = match need_sheet(args) { Ok(x) => x, Err(e) => return e };
    let set: Vec<String> = args.get("set").and_then(|o| o.as_object()).map(|o| o.iter().map(|(k, v)| format!("{}={}", clean(k).replace(['=', ';'], " "), clean(&st(v)).replace(';', ","))).collect()).unwrap_or_default();
    if let Some(e) = support::must_open(&sheet, 60) { return e; }
    let r = agent::call("doc_params", &[("params", set.join(";"))], None, timeout_of(args, 60));
    let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };
    let saved = if set.is_empty() { json!(false) } else { maybe_save(args, &sheet) };
    json!({"success": true, "sheet": sheet, "set": d["set"], "params": d["params"], "saved": saved,
        "_hint": "The title block shows these through its =Title / =DocumentNumber / =Revision special strings. Set them on every sheet of a project to keep the drawings consistent."})
}

/// The `?` designators of a project and the numbers they get: the lowest unused per prefix, in sheet
/// order, then left to right, top to bottom. Pure, for the tests.
pub fn plan_annotation(sheets: &[(String, Vec<(String, i64, i64)>)]) -> Vec<(String, String, String, i64, i64)> {
    let mut used: HashMap<String, HashSet<u64>> = HashMap::new();
    for (_, comps) in sheets {
        for (d, _, _) in comps {
            let pre: String = d.chars().take_while(|c| !c.is_ascii_digit()).collect();
            if let Ok(n) = d[pre.len()..].parse::<u64>() { used.entry(pre.to_ascii_uppercase()).or_default().insert(n); }
        }
    }
    let mut out = Vec::new();
    for (sheet, comps) in sheets {
        let mut q: Vec<&(String, i64, i64)> = comps.iter().filter(|(d, _, _)| d.ends_with('?')).collect();
        q.sort_by_key(|(_, x, y)| (-*y / 100, *x));
        for (d, x, y) in q {
            let pre = d.trim_end_matches('?').to_string();
            let set = used.entry(pre.to_ascii_uppercase()).or_default();
            let mut n = 1;
            while set.contains(&n) { n += 1; }
            set.insert(n);
            out.push((sheet.clone(), d.clone(), format!("{pre}{n}"), *x, *y));
        }
    }
    out
}

/// altium_annotate {projectPath, dryRun?}: number every `?` designator across the project's sheets.
pub fn annotate(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let Some(prj) = s(args, "projectPath") else { return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required"}) };
    let text = match std::fs::read(&prj) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj}: {e}")}) };
    let project = crate::bom::parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new(".")));
    let mut sheets = Vec::new();
    let mut skipped = Vec::new();
    for sheet in &project.sheets {
        if let Some(e) = support::must_open(sheet, 60) { return json!({"success": false, "errorCode": "sheet_open_failed", "sheet": sheet, "reply": e}); }
        let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "0".into()), ("limit", "5000".into())], None, timeout_of(args, 120));
        let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };
        let all = d["components"].as_array().cloned().unwrap_or_default();
        // a multi-part component's parts share one designator and cannot be told apart here: left to
        // Altium's own Tools > Annotation, and said so
        for c in all.iter().filter(|c| st(&c["designator"]).ends_with('?') && c["partId"].as_i64().unwrap_or(1) > 1) {
            skipped.push(json!({"sheet": sheet, "designator": st(&c["designator"]), "libReference": st(&c["libReference"]), "partId": c["partId"]}));
        }
        let comps: Vec<(String, i64, i64)> = all.iter().filter(|c| c["partId"].as_i64().unwrap_or(1) <= 1)
            .map(|c| (st(&c["designator"]), c["xMil"].as_i64().unwrap_or(0), c["yMil"].as_i64().unwrap_or(0))).collect();
        sheets.push((sheet.clone(), comps));
    }
    let plan = plan_annotation(&sheets);
    let planned: Vec<Value> = plan.iter().map(|(sh, from, to, x, y)| json!({"sheet": sh, "from": from, "to": to, "atMil": [x, y]})).collect();
    if plan.is_empty() { return json!({"success": true, "renamed": [], "skippedMultiPart": skipped, "_hint": "Every single-part designator already has a number."}); }
    if args.get("dryRun").and_then(|b| b.as_bool()) == Some(true) { return json!({"success": true, "dryRun": true, "planned": planned, "skippedMultiPart": skipped}); }
    // each rename names its part by position: several parts on a sheet can all read 'R?'
    let mut by_sheet: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (sh, from, to, x, y) in &plan { by_sheet.entry(sh.clone()).or_default().push(format!("{}|{}|{}|{}", clean(from), clean(to), x, y)); }
    let mut done = Vec::new();
    for (sheet, lines) in by_sheet {
        if let Some(e) = support::must_open(&sheet, 60) { return e; }
        let r = agent::call("sch_rename_at", &[], Some(&lines.join("\n")), Duration::from_secs(90));
        match agent_data(&r) {
            Ok(d) => done.extend(d["results"].as_array().cloned().unwrap_or_default().into_iter().map(|mut x| { x["sheet"] = json!(sheet); x })),
            Err(e) => return json!({"success": false, "errorCode": "rename_failed", "renamed": done, "sheet": sheet, "reply": e}),
        }
        let _ = maybe_save(args, &sheet);
    }
    let failed: Vec<&Value> = done.iter().filter(|x| x["result"] != json!("ok")).collect();
    json!({"success": failed.is_empty(), "renamed": done, "skippedMultiPart": skipped,
        "_hint": "Numbers are the lowest free per prefix across the project, top-left first on each sheet. Multi-part components with '?' are left to Altium's Tools > Annotation (skippedMultiPart). The board gets the new designators with altium_update_pcb."})
}

/// The designators on the project's sheets (graphical parts left out: they never reach a board) and on its board.
fn sides(project: &crate::bom::Project, board: &str, args: &Value) -> Result<(Vec<String>, Vec<String>), Value> {
    let mut sheet_refs = Vec::new();
    for sheet in &project.sheets {
        if let Some(e) = support::must_open(sheet, 60) { return Err(e); }
        let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "0".into()), ("limit", "5000".into())], None, timeout_of(args, 120));
        let d = agent_data(&r)?;
        for c in d["components"].as_array().cloned().unwrap_or_default() {
            let des = st(&c["designator"]);
            if c["kind"].as_i64() != Some(2) && !des.is_empty() && !sheet_refs.contains(&des) { sheet_refs.push(des); }
        }
    }
    if let Some(e) = support::must_open(board, 60) { return Err(e); }
    let r = agent::call("bom_placement", &[], None, timeout_of(args, 120));
    let d = agent_data(&r)?;
    let board_refs: Vec<String> = d["comps"].as_array().cloned().unwrap_or_default().iter().map(|c| st(&c[0])).collect();
    Ok((sheet_refs, board_refs))
}

/// What the change order will do to the parts, from the two sides: (add, remove). Pure, for the tests.
pub fn part_diff(sheet: &[String], board: &[String]) -> (Vec<String>, Vec<String>) {
    let up = |v: &[String]| v.iter().map(|x| x.to_ascii_uppercase()).collect::<HashSet<String>>();
    let (s, b) = (up(sheet), up(board));
    let mut add: Vec<String> = sheet.iter().filter(|x| !b.contains(&x.to_ascii_uppercase())).cloned().collect();
    let mut remove: Vec<String> = board.iter().filter(|x| !s.contains(&x.to_ascii_uppercase())).cloned().collect();
    add.sort_by_key(|d| crate::bom::natural_key(d)); remove.sort_by_key(|d| crate::bom::natural_key(d));
    add.dedup(); remove.dedup();
    (add, remove)
}

/// altium_update_pcb {projectPath, execute?, allowRemovals?}: the schematic's changes to the board (Altium's
/// engineering change order). Without execute it only previews; it never removes board parts unless told.
pub fn update_pcb(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let Some(prj) = s(args, "projectPath") else { return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required"}) };
    let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();
    let project = crate::bom::parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new(".")));
    if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no sheet"}); }
    let Some(board) = project.boards.first().cloned() else { return json!({"success": false, "errorCode": "no_board", "error": "the project has no .PcbDoc to update", "_hint": "Add a board to the project first (altium_build_board makes one)."}) };
    let t0 = support::now();
    for sh in &project.sheets { let _ = crate::surfaces::save(&json!({"filePath": sh})); }
    let (sheet_refs, board_refs) = match sides(&project, &board, args) { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "read_failed", "reply": e}) };
    let (add, remove) = part_diff(&sheet_refs, &board_refs);
    let preview = json!({"addParts": add, "removeParts": remove, "sheetParts": sheet_refs.len(), "boardParts": board_refs.len()});
    let execute = args.get("execute").and_then(|b| b.as_bool()) == Some(true);
    if !execute {
        return json!({"success": true, "executed": false, "preview": preview, "elapsedSec": support::elapsed_round(t0),
            "_hint": if remove.is_empty() { "Preview only. Parameter, footprint and net changes are not listed here; altium_update_pcb {execute:true} runs the change order.".to_string() }
                     else { format!("Preview only. The change order would REMOVE {} part(s) from the board that are on no sheet ({}): board-only test pads, fiducials or mounting holes usually. Put them on a sheet, or run with allowRemovals:true if they really should go.", remove.len(), remove.iter().take(10).cloned().collect::<Vec<_>>().join(", ")) }});
    }
    if !remove.is_empty() && args.get("allowRemovals").and_then(|b| b.as_bool()) != Some(true) {
        return json!({"success": false, "errorCode": "would_remove_parts", "executed": false, "preview": preview,
            "_hint": format!("Not run: the change order would delete {} board part(s) that are on no sheet ({}). Add them to a sheet (altium_sch_place_part), or pass allowRemovals:true to delete them.", remove.len(), remove.iter().take(10).cloned().collect::<Vec<_>>().join(", "))});
    }
    // from the board, Import Changes (Action=UpdateMe) is the change order; from a sheet, Action=UpdateOther
    // opened nothing on AD 26.11.1 (2026-10-21)
    if let Some(e) = support::must_open(&board, 60) { return e; }
    agent::call("run_process", &[("process", "WorkspaceManager:Compare".into()), ("params", "ObjectKind=Project|Action=UpdateMe".into())], None, Duration::from_secs(6));
    let find = |needle: &str| -> Option<Value> {
        instance::invalidate_snapshot();
        instance::altium_windows().into_iter().find(|w| w.get("kind").and_then(|k| k.as_str()) != Some("main")
            && w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains(needle)).unwrap_or(false))
    };
    let mut steps = Vec::new();
    let mut eco = None;
    // a change order opens within seconds (the link prompt and its Information box come first on a new
    // board); with no differences Altium opens nothing at all, so the wait is short
    let t_wait = std::time::Instant::now();
    while t_wait.elapsed() < Duration::from_secs(35) {
        if let Some(w) = find("engineering change order") { eco = Some(w); break; }
        // a board built by script has no component links yet: Altium asks to match by designator, once
        if let Some(w) = find("component links") {
            let h = w.get("hwnd").cloned().unwrap_or(Value::Null);
            let c = ab::call("desktop_ui_click", json!({"hwnd": h, "contains": "Automatically Create Component Links"}), &[], 20);
            std::thread::sleep(Duration::from_secs(2));
            if find("component links").is_some() {
                // its buttons answer Invoke without acting (measured): a real click on the button's centre
                if let Some(r) = c.get("control").and_then(|c| c.get("rect")) {
                    let _ = ab::call("desktop_click", json!({"hwnd": h, "x": r["centerX"], "y": r["centerY"], "reason": "link schematic and board parts by designator for the change order"}), &[], 20);
                }
            }
            steps.push(json!({"componentLinks": "matched by designator"}));
            std::thread::sleep(Duration::from_secs(2));
            continue;
        }
        // an Information box comes after the links are made (and alone when nothing differs): read it,
        // press OK and keep waiting; the change order follows it when there is one
        if let Some(w) = find("information").or_else(|| find("confirm")) {
            let dl = crate::dialogs::scan_scoped(true);
            steps.push(json!({"dialog": w.get("title"), "text": dl.iter().filter_map(|d| d.get("text").or_else(|| d.get("body")).cloned()).collect::<Vec<_>>()}));
            if let Some(h) = w.get("hwnd") { let _ = ab::call("desktop_ui_click", json!({"hwnd": h, "contains": "OK", "role": "button"}), &[], 15); }
            std::thread::sleep(Duration::from_secs(3));
            continue;
        }
        std::thread::sleep(Duration::from_millis(500));
    }
    let Some(eco) = eco else {
        let dl = crate::dialogs::scan_scoped(true);
        let boxed = steps.iter().any(|s| s.get("dialog").is_some());
        return json!({"success": boxed, "errorCode": if boxed { Value::Null } else { json!("eco_dialog_not_found") }, "executed": false, "preview": preview,
            "steps": steps, "dialogs": dl, "elapsedSec": support::elapsed_round(t0),
            "_hint": "No Engineering Change Order opened: Altium found no differences between the sheets and the board that its change order carries. On AD 26.11.1 a comment-only change on an existing part is one of those (measured). Parts, nets and parameters are carried. If you expected changes, check the sheets are saved and that no second project with the same file names is open."});
    };
    let hwnd = eco.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);
    // the change order's first page, as Altium lists it (group rows carry counts: 'Add Components(1)')
    let tree = ab::call("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 14, "maxNodes": 400}), &[], 30);
    let rows = eco_rows(&tree);
    let execute_r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Execute Changes", "role": "button"}), &[], 60);
    steps.push(json!({"uiClick": "Execute Changes", "success": execute_r.get("success")}));
    // done when the list stops changing (Altium marks each row Done and scrolls to the end); the agent
    // stays unresponsive while the modal is up, so it cannot be the signal
    let wait = timeout_of(args, 300);
    let t1 = std::time::Instant::now();
    std::thread::sleep(Duration::from_secs(3));
    let mut rows_after = Vec::new();
    let mut stable = 0;
    while t1.elapsed() < wait {
        let now = eco_rows(&ab::call("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 14, "maxNodes": 400}), &[], 30));
        if !now.is_empty() && now == rows_after { stable += 1; if stable >= 2 { break; } } else { stable = 0; }
        rows_after = now;
        std::thread::sleep(Duration::from_secs(2));
    }
    let close = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Close", "role": "button"}), &[], 30);
    steps.push(json!({"uiClick": "Close", "success": close.get("success")}));
    // the agent loop parked while the modal was up: bring it back for the verbs that follow
    for _ in 0..20 { if agent::is_responsive() { break; } std::thread::sleep(Duration::from_millis(500)); }
    if !agent::is_responsive() { let _ = crate::lifecycle::recover("after the change order"); }
    // the change order edits the board in memory: save it, or closing it loses every change (measured)
    let saved = crate::surfaces::save(&json!({"filePath": board}));
    steps.push(json!({"saveBoard": saved.get("success")}));
    let dl = crate::dialogs::scan_scoped(true);
    json!({"success": execute_r.get("success").and_then(|b| b.as_bool()) == Some(true), "executed": true, "projectPath": prj, "board": board, "preview": preview,
        "changeOrder": rows, "afterExecute": rows_after, "steps": steps, "dialogs": dl, "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",
        "_hint": "The change order ran. New parts land beside the board outline: place them (altium_move_parts) and check with altium_routing_drc. changeOrder lists the dialog's first page (groups carry counts)."})
}

/// The change order's visible rows: their text, as the grid shows it.
fn eco_rows(tree: &Value) -> Vec<String> {
    fn walk(n: &Value, out: &mut Vec<String>) {
        if n.get("role").and_then(|r| r.as_str()) == Some("ListItem") {
            if let Some(v) = n.get("value").and_then(|v| v.as_str()) { let v = v.split_whitespace().collect::<Vec<_>>().join(" "); if !v.is_empty() { out.push(v); } }
        }
        for c in n.get("children").and_then(|c| c.as_array()).into_iter().flatten() { walk(c, out); }
    }
    let root = tree.get("root").cloned().or_else(|| tree.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v.get("root").cloned())).unwrap_or(Value::Null);
    let mut out = Vec::new();
    walk(&root, &mut out);
    out
}

/// The OutJob for a schematic PDF (all the project's sheets, optionally a board composite print).
pub fn pdf_outjob(file_name: &str, board: bool) -> String {
    let page = "Record=PageOptions|CenterHorizontal=True|CenterVertical=True|PrintScale=1.00|XCorrection=1.00|YCorrection=1.00|PrintKind=0|BorderSize=5000000|LeftOffset=0|BottomOffset=0|Orientation=2|PaperLength=1000|PaperWidth=1000|Scale=100|PaperSource=7|PrintQuality=-4|MediaType=1|DitherType=10|PaperKind=A4|PrintScaleMode=1";
    let sch_view = "Record=SchPrintView|ShowNoERC=True|ShowParamSet=True|ShowProbe=True|ShowBlanket=True|ShowNote=True|ShowNoteCollapsed=True|ExpandDesignator=True|ExpandNetLabel=False|ExpandPort=False|ExpandSheetNum=False|ExpandDocNum=False|PrintArea=0";
    let mut l: Vec<String> = ["[OutputJobFile]", "Version=1.0", "Caption=", "Description=Adom altium_export_pdf", "",
        "[OutputGroup1]", "Name=AdomPdf.OutJob", "Description=", "TargetOutputMedium=PDF", "VariantName=[No Variations]", "VariantScope=0",
        "CurrentConfigurationName=", "TargetPrinter=", "PrinterOptions=Record=PrinterOptions|Copies=1|Duplex=1|TrueTypeOptions=3|Collate=1|PrintWhat=1",
        "OutputMedium1=PDF", "OutputMedium1_Type=Publish",
        "OutputType1=Schematic Print", "OutputName1=Schematic Prints", "OutputCategory1=Documentation", "OutputDocumentPath1=[Project Physical Documents]",
        "OutputVariantName1=", "OutputEnabled1=1", "OutputEnabled1_OutputMedium1=1", "OutputDefault1=0"].iter().map(|x| x.to_string()).collect();
    l.push(format!("PageOptions1={page}"));
    l.push("Configuration1_Name1=OutputConfigurationParameter1".into());
    l.push(format!("Configuration1_Item1={sch_view}"));
    if board {
        l.extend(["OutputType2=Composite", "OutputName2=Composite Drawing", "OutputCategory2=Documentation", "OutputDocumentPath2=", "OutputVariantName2=",
            "OutputEnabled2=1", "OutputEnabled2_OutputMedium1=2", "OutputDefault2=0"].iter().map(|x| x.to_string()));
        l.push(format!("PageOptions2={page}"));
        l.push("Configuration2_Name1=OutputConfigurationParameter1".into());
        l.push("Configuration2_Item1=DesignatorDisplayMode=Physical|PrintArea=DesignExtent|PrintAreaLowerLeftCornerX=0|PrintAreaLowerLeftCornerY=0|PrintAreaUpperRightCornerX=0|PrintAreaUpperRightCornerY=0|Record=PcbPrintView".into());
        l.push("Configuration2_Name2=OutputConfigurationParameter2".into());
        l.push("Configuration2_Item2=IncludeBottomLayerComponents=True|IncludeMultiLayerComponents=True|IncludeTopLayerComponents=True|IncludeViewports=True|Index=0|Mirror=False|Name=Multilayer Composite Print|PadNumberFontSize=14|Record=PcbPrintOut|ShowHoles=False|ShowPadNets=False|ShowPadNumbers=False|SubstituteFonts=False".into());
        for (i, layer) in ["TopOverlay", "TopLayer", "BottomLayer", "MultiLayer", "Mechanical1"].iter().enumerate() {
            l.push(format!("Configuration2_Name{}=OutputConfigurationParameter{}", i + 3, i + 3));
            l.push(format!("Configuration2_Item{}=CArc=Full|CFill=Full|Comment=Full|Coordinate=Full|CPad=Full|CRegion=Full|CText=Full|CTrack=Full|CVia=Full|DDSymbolKind=0|DDSymbolSize=500000|DDSymbolSortKind=0|Designator=Full|Dimension=Full|DLayer1=TopLayer|DLayer2=BottomLayer|FArc=Full|FFill=Full|FPad=Full|FRegion=Full|FText=Full|FTrack=Full|FVia=Full|Layer={layer}|Polygon=Full|PrintOutIndex=0|Record=PcbPrintLayer", i + 3));
        }
    }
    l.extend(["".into(), "[PublishSettings]".into(), format!("OutputFilePath1=\\{file_name}"), "ReleaseManaged1=0".into(), "OutputBasePath1=".into(),
        "OutputPathMedia1=".into(), "OutputPathMediaValue1=".into(), "OutputPathOutputer1=".into(), "OutputPathOutputerPrefix1=".into(), "OutputPathOutputerValue1=".into(),
        format!("OutputFileName1={file_name}"), "OutputFileNameMulti1=".into(), "UseOutputNameForMulti1=0".into(), "OutputFileNameSpecial1=".into(),
        "OpenOutput1=0".into(), "PromptOverwrite1=0".into(), "PublishMethod1=0".into(), "ZoomLevel1=50".into(), "FitSCHPrintSizeToDoc1=1".into(), "FitPCBPrintSizeToDoc1=1".into(),
        "GenerateNetsInfo1=1".into(), "MarkPins1=1".into(), "MarkNetLabels1=1".into(), "MarkPortsId1=1".into(), "GenerateTOC=1".into(), "ShowComponentParameters1=1".into(),
        "".into(), "[GeneratedFilesSettings]".into(), "RelativeOutputPath1=".into(), "OpenOutputs1=0".into(), "".into()]);
    l.join("\r\n")
}

/// altium_export_pdf {projectPath, output?, board?}: the schematic (and optionally the board) as one PDF,
/// from an OutJob in a throwaway project that lists the project's documents; the user's project is not touched.
pub fn export_pdf(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let t0 = support::now();
    let Some(prj) = s(args, "projectPath") else { return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required"}) };
    let text = match std::fs::read(&prj) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj}: {e}")}) };
    let dir = Path::new(&prj).parent().unwrap_or(Path::new(".")).to_path_buf();
    let project = crate::bom::parse_project(&text, &dir);
    if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no sheet"}); }
    let stem = Path::new(&prj).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or("schematic".into());
    let out = s(args, "output").unwrap_or_else(|| dir.join(format!("{stem}.pdf")).to_string_lossy().to_string());
    let with_board = args.get("board").and_then(|b| b.as_bool()) == Some(true) && !project.boards.is_empty();
    for sh in &project.sheets { let _ = crate::surfaces::save(&json!({"filePath": sh})); }
    let tmp = std::env::temp_dir().join("adom-altium-pdf").join(format!("{}", (support::now() * 1000.0) as u64));
    let proj = tmp.join("AdomPdfExport.PrjPcb");
    let oj = tmp.join("AdomPdf.OutJob");
    let mut prj_text = String::from("[Design]\r\nVersion=1.0\r\nHierarchyMode=0\r\n\r\n");
    let mut docs: Vec<String> = project.sheets.clone();
    if with_board { docs.extend(project.boards.iter().take(1).cloned()); }
    for (i, d) in docs.iter().enumerate() { prj_text.push_str(&format!("[Document{}]\r\nDocumentPath={}\r\nAnnotationEnabled=1\r\n\r\n", i + 1, d)); }
    prj_text.push_str(&format!("[Document{}]\r\nDocumentPath=AdomPdf.OutJob\r\n\r\n", docs.len() + 1));
    let file_name = "AdomExport.pdf";
    if let Err(e) = std::fs::create_dir_all(&tmp).and_then(|_| std::fs::write(&oj, pdf_outjob(file_name, with_board))).and_then(|_| std::fs::write(&proj, prj_text)) {
        return json!({"success": false, "errorCode": "temp_write_failed", "error": e.to_string()});
    }
    let proj_s = proj.to_string_lossy().to_string();
    let r = crate::outputs::run_outjob(&json!({"outjobPath": oj.to_string_lossy(), "medium": "PDF", "projectPath": proj_s, "timeout": timeout_of(args, 300).as_secs()}));
    let found = walk_pdf(&tmp);
    let _ = agent::call("close_document", &[("path", oj.to_string_lossy().to_string())], None, Duration::from_secs(30));
    let _ = agent::call("close_document", &[("path", proj_s.clone())], None, Duration::from_secs(30));
    let Some(src) = found else {
        return json!({"success": false, "errorCode": "pdf_not_written", "outjob": r, "tempDir": tmp.to_string_lossy(), "elapsedSec": support::elapsed_round(t0),
            "_hint": "Altium ran the PDF output but wrote no .pdf. Read outjob.dialogs[]; altium_check_dialogs shows any modal."});
    };
    if let Some(parent) = Path::new(&out).parent() { let _ = std::fs::create_dir_all(parent); }
    if let Err(e) = std::fs::copy(&src, &out) { return json!({"success": false, "errorCode": "copy_failed", "error": e.to_string(), "produced": src.to_string_lossy()}); }
    let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
    let _ = std::fs::remove_dir_all(&tmp);
    json!({"success": bytes > 0, "output": out, "outputBytes": bytes, "sheets": project.sheets.len(), "board": with_board, "healthCheck": r.get("healthCheck"), "elapsedSec": support::elapsed_round(t0),
        "windowEtiquette": "background", "_hint": "One PDF of every sheet (and the board's composite print with board:true). Pull it with ab's pull_file."})
}

fn walk_pdf(dir: &Path) -> Option<std::path::PathBuf> {
    let rd = std::fs::read_dir(dir).ok()?;
    for e in rd.flatten() {
        let p = e.path();
        if p.is_dir() { if let Some(f) = walk_pdf(&p) { return Some(f); } }
        else if p.extension().map(|x| x.eq_ignore_ascii_case("pdf")).unwrap_or(false) { return Some(p); }
    }
    None
}

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

    #[test]
    fn question_marks_get_the_lowest_free_numbers() {
        let sheets = vec![
            ("A.SchDoc".to_string(), vec![("R1".to_string(), 0, 0), ("R?".to_string(), 500, 1000), ("R?".to_string(), 100, 1000), ("C?".to_string(), 0, 0)]),
            ("B.SchDoc".to_string(), vec![("R3".to_string(), 0, 0), ("R?".to_string(), 0, 0)]),
        ];
        let plan = plan_annotation(&sheets);
        let names: Vec<(String, String)> = plan.iter().map(|(_, f, t, _, _)| (f.clone(), t.clone())).collect();
        assert_eq!(names, vec![("R?".into(), "R2".into()), ("R?".into(), "R4".into()), ("C?".into(), "C1".into()), ("R?".into(), "R5".into())]);
    }

    #[test]
    fn the_change_order_preview_names_additions_and_removals() {
        let (add, remove) = part_diff(&["R1".into(), "R9".into(), "U1".into()], &["U1".into(), "r1".into(), "TP2".into(), "TP10".into()]);
        assert_eq!(add, vec!["R9".to_string()]);
        assert_eq!(remove, vec!["TP2".to_string(), "TP10".to_string()]);
    }

    #[test]
    fn the_pdf_outjob_prints_every_sheet_to_one_file() {
        let t = pdf_outjob("X.pdf", false);
        assert!(t.contains("OutputType1=Schematic Print"));
        assert!(t.contains("OutputDocumentPath1=[Project Physical Documents]"));
        assert!(t.contains("OutputMedium1=PDF\r\nOutputMedium1_Type=Publish"));
        assert!(t.contains("OutputFileName1=X.pdf"));
        assert!(!t.contains("Composite"));
        assert!(pdf_outjob("X.pdf", true).contains("OutputType2=Composite"));
    }
}