master
Colby Knox Merge PR #368: kicad-lane: 1.0.96, net_connections, remove_items and doc_params answered fully ef6c1f3 11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771
//! Live board and footprint edits: every `board_edit` change committed into the open PCB
//! editor as ONE native Undo step, with the same guards as the routing verbs (exact board,
//! `expectedRevision`, KiCad's DRC on a snapshot when copper changes, `dryRun`, a verified
//! read-back), plus the editor selection, zone refill and save.
//!
//! A change is committed as: delete the old top-level items by id, then create the new
//! blocks through `ParseAndCreateItemsFromString` (KiCad parses our text with its own
//! parser). Created blocks carry fresh UUIDs so they never collide with an item the same
//! commit deletes. A footprint flip uses KiCad's own `FlipItems` (10.0.6+) when the editor
//! answers it, and the client-side mirror otherwise.

use super::*;
use super::live_create;
use crate::board_edit::{self, Change};
use crate::placement::{parse_fields, varint_value, wire_message, wire_varint};

fn requires_revision(args: &Value) -> RResult<()> {
    if args.get("expectedRevision").and_then(Value::as_str).filter(|s| !s.is_empty()).is_none() {
        return Err(RoutingError::new("revision_required", "Pass expectedRevision: the revision a read of this board returned (kicad_routing_state, kicad_placement_state or a previous edit's reply)").with("mutated", json!(false)));
    }
    Ok(())
}

fn post_save(s: &Session, args: &Value, out: &mut Value) {
    if args.get("save") == Some(&json!(true)) {
        match s.client.save_document() {
            Ok(()) => out["saved"] = json!(true),
            Err(e) => out["postCommitSaveError"] = json!(e.to_string()),
        }
    }
}

fn drc_gate(ctx: &Ctx, s: &Session, text: &str, change: &Change, args: &Value) -> RResult<Value> {
    if !change.copper || args.get("validate") == Some(&json!(false)) {
        return Ok(json!({"ran": false, "why": if change.copper { "validate:false" } else { "no copper changes" }}));
    }
    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), text)?;
    let after = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &change.candidate)?;
    let added = new_errors(&before, &after)?;
    if !added.is_empty() {
        return Err(RoutingError::new("drc_rejected", "The edit adds DRC errors (KiCad's DRC on a snapshot of the live board with the edit applied); nothing was changed")
            .with("violations", json!(added))
            .with("mutated", json!(false))
            .with("_hint", json!("Each violation names the rule and the items. Move the part or pick another spot (a dryRun is cheap), or pass validate:false to commit anyway and fix it afterwards.")));
    }
    Ok(after)
}

/// The live counterpart of a file edit: build the change on the editor's own text, check
/// it, commit it as one Undo step, read it back.
pub fn commit_change(ctx: &Ctx, args: &Value, build: impl FnOnce(&str) -> RResult<Change>) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let (text, _board, revision) = s.snapshot()?;
    // a verb whose standard form makes expectedRevision optional (`_revisionOptional`) pins the revision read here,
    // so a board that changes while the edit is prepared is still refused
    let pinned;
    let args = if args.get("_revisionOptional") == Some(&json!(true)) && args.get("expectedRevision").and_then(Value::as_str).filter(|r| !r.is_empty()).is_none() {
        let mut a = args.clone();
        a["expectedRevision"] = json!(revision);
        pinned = a;
        &pinned
    } else {
        args
    };
    requires_revision(args)?;
    check_revision(args, &revision)?;
    let change = build(&text)?;
    commit_built(ctx, args, &s, &text, &revision, change, None)
}

/// Commit an already-built change on session `s` (snapshot `text`, `revision`).
/// `native` optionally commits it another way (FlipItems) and answers how many items it
/// handled; None from it falls back to the delete-and-create path.
fn commit_built(ctx: &Ctx, args: &Value, s: &Session, text: &str, revision: &str, change: Change, native: Option<&dyn Fn() -> Result<Option<Value>, IpcFailure>>) -> RResult<Value> {
    let mut out = json!({"success": true, "source": "live-editor", "boardPath": s.file_path, "kicadVersion": s.version.full_version});
    merge(&mut out, &change.report);
    if change.remove.is_empty() && change.create.is_empty() {
        out["mutated"] = json!(false);
        out["undoSteps"] = json!(0);
        out["revision"] = json!(revision);
        out["_hint"] = json!("Nothing to change: the board already matches.");
        return Ok(out);
    }
    let drc = drc_gate(ctx, s, text, &change, args)?;
    out["drc"] = drc;
    if args.get("dryRun") == Some(&json!(true)) {
        out["dryRun"] = json!(true);
        out["mutated"] = json!(false);
        out["undoSteps"] = json!(0);
        out["revision"] = json!(revision);
        out["wouldRemoveIds"] = json!(change.remove);
        out["wouldCreate"] = json!(change.create.len());
        out["_hint"] = json!("Checked without editing (drc is KiCad's verdict on the board with this edit). Submit with dryRun:false and the same expectedRevision to commit it as one Undo step.");
        return Ok(out);
    }
    // DRC ran outside KiCad: an edit in between invalidates this change.
    check_revision(args, &s.revision()?)?;
    let before_ids: HashSet<String> = board_edit::top_level_ids(text).into_iter().collect();
    // footprints already on the board, as templates for new ones of the same library footprint
    let templates: Vec<kicad_ipc_rs::FootprintItem> = if change.create.iter().any(|b| b.trim_start().starts_with("(footprint")) {
        s.client.get_editable_items_by_type_codes(vec![PcbObjectTypeCode::new_footprint().code]).map_err(map_err)?
            .into_iter().filter_map(|i| match i { EditablePcbItem::Footprint(f) => Some(f), _ => None }).collect()
    } else {
        Vec::new()
    };
    let wrapped = board_edit::create_text(text, &change.create);
    let want = change.create.len();
    let remove = change.remove.clone();
    let mut how = "delete-and-create";
    let text_path_why = std::cell::RefCell::new(String::new());
    let mut native_detail = Value::Null;
    transaction(s, &change.label, || {
        if let Some(f) = native {
            if let Some(v) = f()? {
                how = "native";
                native_detail = v;
                return Ok(());
            }
        }
        if !remove.is_empty() {
            s.client.delete_items(remove.clone())?;
        }
        if want > 0 {
            // KiCad's protobuf CreateItems, for every block live_create.rs can encode (board graphics today)
            match live_create::encode_blocks(&change.create, &|block| live_create::find_template(block, &templates, text, &remove)) {
                live_create::Encoded::Items(items) => {
                    let created = s.client.create_items(items, None)?;
                    if created.len() != want {
                        return Err(IpcFailure::from(format!("KiCad created {} of {want} items; rollback requested", created.len())));
                    }
                    how = "create-items";
                    return Ok(());
                }
                live_create::Encoded::Failed(e) => return Err(IpcFailure::from(format!("could not encode the new items: {e}"))),
                live_create::Encoded::TextPath(why) => *text_path_why.borrow_mut() = why,
            }
            // decoded here, not by the client: KiCad 10.0.4 answers ParseAndCreateItemsFromString with the
            // request status unset (IRS_UNKNOWN) while every item reports ISC_OK, and the client turns that
            // into an error, so a good create was rolled back (1.0.61's kicad_add_footprint, kicad_import_svg)
            let any = s.client.parse_and_create_items_from_string_raw(wrapped.clone())?;
            let (status, items) = decode_create_response(&any.value).map_err(IpcFailure::from)?;
            if status == IRS_UNKNOWN && items.is_empty() {
                // KiCad 10.0.x (checked 10.0.4 and 10.0.7, wrapped board text and bare item text alike) answers this
                // call with an empty reply and creates nothing
                return Err(IpcFailure::from(format!("{LIVE_CREATE_UNSUPPORTED}: KiCad answered ParseAndCreateItemsFromString with an empty reply and created nothing")));
            }
            let all_ok = !items.is_empty() && items.iter().all(|(c, _)| *c == ISC_OK);
            if !(status == IRS_OK || status == IRS_UNKNOWN && all_ok) || items.len() != want || !all_ok {
                let why: Vec<String> = items.iter().filter(|(c, _)| *c != ISC_OK).map(|(c, m)| format!("item status {c}{}", if m.is_empty() { String::new() } else { format!(": {m}") })).collect();
                return Err(IpcFailure::from(format!("KiCad did not create the items (request status {status}, {} of {want} answered{}); rollback requested",
                    items.len(), if why.is_empty() { String::new() } else { format!(", {}", why.join("; ")) })));
            }
        }
        Ok(())
    })
    .map_err(|e| {
        if e.message.contains(LIVE_CREATE_UNSUPPORTED) {
            let why = text_path_why.borrow().clone();
            let e = if why.is_empty() { e } else { e.with("liveCreateWhy", json!(format!("Not created through KiCad's protobuf API: {why}. The bridge creates a footprint live by copying KiCad's own encoding of the same library footprint on the board, else by building it from the library text; it refuses custom-shape pads, mask or paste margins and zones inside a footprint."))) };
            let mut e = e.with("_hint", json!("KiCad's API does not create board items from text yet (ParseAndCreateItemsFromString answers empty on KiCad 10.0.4 and 10.0.7). Nothing was changed. Save the board in KiCad and close it (kicad_save_board, then kicad_close), then call this verb again: with no editor holding the board, the bridge edits the closed file (a backup is kept) and KiCad shows the change when the board is reopened."))
                .with("kicadVersion", json!(s.version.full_version)).with("mutated", json!(false));
            e.code = LIVE_CREATE_UNSUPPORTED.into();
            e
        } else if e.message.contains("AS_UNIMPLEMENTED") || e.message.contains("AS_UNHANDLED") {
            e.with("_hint", json!("This KiCad does not serve the IPC call this edit needs (ParseAndCreateItemsFromString). Nothing was changed. Save and close the board, then call again: the bridge edits the closed file instead (a backup is kept)."))
        } else {
            e
        }
    })?;
    out["mutated"] = json!(true);
    out["undoSteps"] = json!(1);
    out["undoLabel"] = json!(change.label);
    out["commit"] = json!(how);
    if !native_detail.is_null() {
        out["native"] = native_detail;
    }
    out["saved"] = json!(false);
    out["_hint"] = json!("Committed live as one KiCad Undo step (Edit > Undo reverts it). verified is the read-back of the live board. Use the returned revision for the next edit. The file is saved only with save:true or kicad_save_board.");
    match s.snapshot() {
        Ok((after_text, after, rev)) => {
            out["revision"] = json!(rev);
            let after_ids: Vec<String> = board_edit::top_level_ids(&after_text);
            let created: Vec<&String> = after_ids.iter().filter(|i| !before_ids.contains(*i)).collect();
            let lingering: Vec<&String> = change.remove.iter().filter(|r| after_ids.contains(r)).collect();
            let (refs_ok, rows) = board_edit::verify(&after, &change.expect);
            let counts_ok = how == "native" || (created.len() == want && lingering.is_empty());
            out["createdIds"] = json!(created);
            out["removedIds"] = json!(change.remove);
            out["readback"] = json!(rows);
            out["verified"] = json!(refs_ok && counts_ok);
            if !(refs_ok && counts_ok) {
                out["postCommitError"] = json!(format!("the live board does not show the edit as planned ({} created of {want}, {} removed item(s) still present, footprint checks {})", created.len(), lingering.len(), if refs_ok { "ok" } else { "failed" }));
                out["_hint"] = json!("Committed, but the read-back differs from the plan: inspect the board (kicad_placement_state), use Edit > Undo in KiCad if it is wrong, and report the reply. Never replay the edit blindly.");
            }
        }
        Err(e) => out["postCommitError"] = json!(e.message),
    }
    post_save(s, args, &mut out);
    Ok(out)
}


// ---------------------------------------------------------------------------
// FlipItems (KiCad 10.0.6+), on the wire
// ---------------------------------------------------------------------------

pub const FLIP_ITEMS_TYPE: &str = "type.googleapis.com/kiapi.board.commands.FlipItems";

/// `kiapi.common.types.DocumentSpecifier` for a board: type DOCTYPE_PCB (3), board_filename
/// (4), project {name 1, path 2} (5).
pub fn document_bytes(board_filename: &str, project_name: Option<&str>, project_path: Option<&str>) -> Vec<u8> {
    let mut d = Vec::new();
    wire_varint(&mut d, 1, 3);
    wire_message(&mut d, 4, board_filename.as_bytes());
    let mut p = Vec::new();
    if let Some(n) = project_name.filter(|s| !s.is_empty()) {
        wire_message(&mut p, 1, n.as_bytes());
    }
    if let Some(pp) = project_path.filter(|s| !s.is_empty()) {
        wire_message(&mut p, 2, pp.as_bytes());
    }
    if !p.is_empty() {
        wire_message(&mut d, 5, &p);
    }
    d
}

/// `kiapi.board.commands.FlipItems {header 1 {document 1}, items 2 (KIID {value 1}), direction 3}`;
/// direction BFD_LEFT_RIGHT 1, BFD_TOP_BOTTOM 2.
pub fn flip_items_bytes(document: &[u8], ids: &[String], left_right: bool) -> Vec<u8> {
    let mut header = Vec::new();
    wire_message(&mut header, 1, document);
    let mut out = Vec::new();
    wire_message(&mut out, 1, &header);
    for id in ids {
        let mut k = Vec::new();
        wire_message(&mut k, 1, id.as_bytes());
        wire_message(&mut out, 2, &k);
    }
    wire_varint(&mut out, 3, if left_right { 1 } else { 2 });
    out
}

/// `FlipItemsResponse {header 1, status 2, flipped_items 3 {status 1 {code 1}, item 2}}`:
/// (request status, per-item status codes). ISC_OK is 1, IRS_OK is 1.
pub fn decode_flip_response(b: &[u8]) -> Result<(u64, Vec<u64>), String> {
    let mut status = 0;
    let mut codes = Vec::new();
    for f in parse_fields(b)? {
        match (f.number, f.wire) {
            (2, 0) => status = varint_value(f.data)?,
            (3, 2) => {
                let mut code = 0;
                for g in parse_fields(f.data)? {
                    if g.number == 1 && g.wire == 2 {
                        for h in parse_fields(g.data)? {
                            if h.number == 1 && h.wire == 0 {
                                code = varint_value(h.data)?;
                            }
                        }
                    }
                }
                codes.push(code);
            }
            _ => {}
        }
    }
    Ok((status, codes))
}

/// The error code a live edit answers when KiCad cannot create items over its API.
pub const LIVE_CREATE_UNSUPPORTED: &str = "live_create_unsupported";

/// kiapi ItemRequestStatus / ItemStatusCode values.
const IRS_UNKNOWN: u64 = 0;
const IRS_OK: u64 = 1;
const ISC_OK: u64 = 1;

/// A CreateItemsResponse on the wire: the request status (field 2) and, per item (field 3), its status
/// code and message (field 1: code 1, message 2).
pub fn decode_create_response(b: &[u8]) -> Result<(u64, Vec<(u64, String)>), String> {
    let mut status = 0;
    let mut items = Vec::new();
    for f in parse_fields(b)? {
        match (f.number, f.wire) {
            (2, 0) => status = varint_value(f.data)?,
            (3, 2) => {
                let (mut code, mut msg) = (0, String::new());
                for g in parse_fields(f.data)? {
                    if g.number == 1 && g.wire == 2 {
                        for h in parse_fields(g.data)? {
                            match (h.number, h.wire) {
                                (1, 0) => code = varint_value(h.data)?,
                                (2, 2) => msg = String::from_utf8_lossy(h.data).to_string(),
                                _ => {}
                            }
                        }
                    }
                }
                items.push((code, msg));
            }
            _ => {}
        }
    }
    Ok((status, items))
}

fn board_document(s: &Session) -> Result<Vec<u8>, KiCadError> {
    let docs = s.client.get_open_documents(DocumentType::Pcb)?;
    let wanted = normalize_existing(&s.file_path);
    let d = docs.iter().find(|d| normalize_existing(&document_path(d)) == wanted).or(docs.first()).ok_or(KiCadError::BoardNotOpen)?;
    Ok(document_bytes(
        d.board_filename.as_deref().unwrap_or(""),
        d.project.name.as_deref(),
        d.project.path.as_ref().map(|p| p.to_string_lossy().to_string()).as_deref(),
    ))
}

/// `kicad_flip_footprint`, live: KiCad's FlipItems when it answers (10.0.6+), else the
/// client-side mirror committed as delete-and-create; KiCad's DRC first either way.
pub fn flip_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    requires_revision(args)?;
    let (text, _board, revision) = s.snapshot()?;
    check_revision(args, &revision)?;
    let mut change = board_edit::flip_footprints(&text, args)?;
    let left_right = change.report["direction"] == json!("left_right");
    let native_ok = (s.version.major, s.version.minor, s.version.patch) >= (10, 0, 6) && args.get("native") != Some(&json!(false));
    let ids = change.remove.clone();
    // The native flip's orientation is KiCad's own: check side and anchor, not the angle.
    if native_ok {
        for x in change.expect.iter_mut() {
            x.pose = None;
        }
    }
    let attempted = std::cell::RefCell::new(Value::Null);
    let native = || -> Result<Option<Value>, IpcFailure> {
        if !native_ok {
            return Ok(None);
        }
        let doc = board_document(&s)?;
        let cmd = prost_types::Any { type_url: FLIP_ITEMS_TYPE.into(), value: flip_items_bytes(&doc, &ids, left_right) };
        match s.client.send_raw_command(cmd) {
            Ok(reply) => {
                let (status, codes) = decode_flip_response(&reply.value).map_err(IpcFailure::from)?;
                let ok = codes.iter().filter(|c| **c == 1).count();
                if status != 1 || ok != ids.len() {
                    return Err(IpcFailure::from(format!("FlipItems flipped {ok} of {} footprints (request status {status}, item codes {codes:?})", ids.len())));
                }
                Ok(Some(json!({"command": "FlipItems", "flipped": ok})))
            }
            Err(KiCadError::ApiStatus { code, message }) if code == "AS_UNHANDLED" || code == "AS_UNIMPLEMENTED" || code == "AS_BAD_REQUEST" => {
                *attempted.borrow_mut() = json!({"command": "FlipItems", "apiStatus": code, "message": message});
                Ok(None)
            }
            Err(e) => Err(IpcFailure::from(e)),
        }
    };
    let mut out = commit_built(ctx, args, &s, &text, &revision, change, Some(&native))?;
    if !attempted.borrow().is_null() {
        out["nativeAttempt"] = attempted.borrow().clone();
    }
    Ok(out)
}

// ---------------------------------------------------------------------------
// kicad_edit_footprint, live: the footprint proto updated in place
// ---------------------------------------------------------------------------

/// Only these keys can go through the proto update (fields need the text path).
const PROTO_KEYS: &[&str] = &["value", "newReference", "reference", "attributes", "locked", "excludeFromPosFiles", "excludeFromBom", "dnp", "boardOnly", "allowMissingCourtyard", "allowSoldermaskBridges"];

macro_rules! set_field_text {
    ($field:expr, $v:expr) => {
        match $field.as_mut().and_then(|f| f.text.as_mut()).and_then(|t| t.text.as_mut()) {
            Some(t) => {
                t.text = $v.to_string();
                true
            }
            None => false,
        }
    };
}

macro_rules! apply_attr_flags {
    ($a:expr, $flags:expr) => {{
        let mut res: RResult<()> = Ok(());
        for (k, v) in $flags.iter() {
            let Some(b) = v.as_bool() else {
                res = Err(RoutingError::new("invalid_args", format!("{k} must be true or false")).with("mutated", json!(false)));
                break;
            };
            match k.as_str() {
                "excludeFromPosFiles" => $a.exclude_from_position_files = b,
                "excludeFromBom" => $a.exclude_from_bill_of_materials = b,
                "dnp" => $a.do_not_populate = b,
                "allowMissingCourtyard" => $a.exempt_from_courtyard_requirement = b,
                "allowSoldermaskBridges" => $a.allow_soldermask_bridges = b,
                "boardOnly" => $a.not_in_schematic = b,
                other => {
                    res = Err(RoutingError::new("invalid_args", format!("unknown attribute {other}")).with("mutated", json!(false)));
                    break;
                }
            }
        }
        res
    }};
}

/// Apply value, reference, attribute flags and lock to a FootprintInstance proto (the
/// attribute `type` and custom fields take the delete-and-create path instead).
pub fn edit_footprint_proto(item: &mut EditablePcbItem, args: &Value) -> RResult<()> {
    let EditablePcbItem::Footprint(fp) = item else {
        return Err(RoutingError::new("footprint_not_found", "the item is not a footprint").with("mutated", json!(false)));
    };
    let p = fp.proto_mut();
    if let Some(v) = args.get("value").and_then(Value::as_str) {
        if !set_field_text!(p.value_field, v) {
            return Err(RoutingError::new("ipc_error", "KiCad sent the footprint without a value field").with("mutated", json!(false)));
        }
    }
    if let Some(v) = args.get("newReference").or_else(|| args.get("reference")).and_then(Value::as_str) {
        if !set_field_text!(p.reference_field, v) {
            return Err(RoutingError::new("ipc_error", "KiCad sent the footprint without a reference field").with("mutated", json!(false)));
        }
    }
    let mut flags = args.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default();
    for (k, _) in crate::fp_text::ATTR_FLAGS {
        if let Some(v) = args.get(*k) {
            flags.insert(k.to_string(), v.clone());
        }
    }
    if !flags.is_empty() {
        if p.attributes.is_none() {
            p.attributes = Some(Default::default());
        }
        if let Some(a) = p.attributes.as_mut() {
            apply_attr_flags!(a, flags)?;
        }
        if let Some(a) = p.definition.as_mut().and_then(|d| d.attributes.as_mut()) {
            apply_attr_flags!(a, flags)?;
        }
    }
    if let Some(l) = args.get("locked").and_then(Value::as_bool) {
        p.locked = if l { 2 } else { 1 };
    }
    Ok(())
}

/// `kicad_edit_footprint`, live. Value, reference, attribute flags and lock go through the
/// footprint proto (UpdateItems, the footprint keeps its UUID); field edits go through the
/// delete-and-create path.
pub fn edit_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let edits = board_edit::edits_by_ref(args)?;
    let proto_only = edits.iter().all(|(_, a)| {
        a.as_object().map(|o| o.keys().all(|k| PROTO_KEYS.contains(&k.as_str()) || !is_change_key(k))).unwrap_or(false)
            && a.get("attributes").and_then(Value::as_object).map(|x| !x.contains_key("type")).unwrap_or(true)
    });
    if !proto_only {
        return commit_change(ctx, args, |text| board_edit::edit_footprints(text, args));
    }
    let s = connect(ctx, args)?;
    requires_revision(args)?;
    let (text, _board, revision) = s.snapshot()?;
    check_revision(args, &revision)?;
    let change = board_edit::edit_footprints(&text, args)?;
    let mut out = json!({"success": true, "source": "live-editor", "boardPath": s.file_path});
    merge(&mut out, &change.report);
    if args.get("dryRun") == Some(&json!(true)) {
        out["dryRun"] = json!(true);
        out["mutated"] = json!(false);
        out["undoSteps"] = json!(0);
        out["revision"] = json!(revision);
        return Ok(out);
    }
    check_revision(args, &s.revision()?)?;
    let mut items = s.client.get_editable_items_by_id(change.remove.clone()).map_err(map_err)?;
    if items.len() != change.remove.len() {
        return Err(RoutingError::new("footprint_not_found", "The editor no longer holds every footprint; read the board again").with("mutated", json!(false)));
    }
    for it in items.iter_mut() {
        // each footprint takes its own edit (edits: [{ref, ...}]), else the call's arguments
        let own = match it {
            EditablePcbItem::Footprint(fp) => {
                let r = fp.proto().reference_field.as_ref().and_then(|f| f.text.as_ref()).and_then(|t| t.text.as_ref()).map(|t| t.text.clone()).unwrap_or_default();
                edits.iter().find(|(e, _)| e.eq_ignore_ascii_case(&r)).map(|(_, a)| a).unwrap_or(args)
            }
            _ => args,
        };
        edit_footprint_proto(it, own)?;
    }
    let n_items = items.len();
    transaction(&s, &change.label, || {
        let updated = s.client.update_editable_items(items)?;
        if updated.len() != n_items {
            return Err(IpcFailure::from("KiCad did not update every footprint".to_string()));
        }
        Ok(())
    })?;
    out["mutated"] = json!(true);
    out["undoSteps"] = json!(1);
    out["undoLabel"] = json!(change.label);
    out["commit"] = json!("update-items");
    out["saved"] = json!(false);
    // read back: every edited footprint shows the planned fields, attributes and lock
    match s.snapshot() {
        Ok((after_text, _after, rev)) => {
            out["revision"] = json!(rev);
            let root = crate::sxedit::parse(&after_text).map_err(|e| RoutingError::new("board_unparsable", e))?;
            let mut ok = true;
            let mut rows = Vec::new();
            for row in change.report["edited"].as_array().into_iter().flatten() {
                let want = &row["after"];
                let got = root.kids_tagged("footprint").find(|f| f.uuid() == want["uuid"].as_str()).map(board_edit::footprint_info);
                let good = got.as_ref().map(|g| g["ref"] == want["ref"] && g["fields"]["Value"] == want["fields"]["Value"] && g["attributes"] == want["attributes"] && g["locked"] == want["locked"]).unwrap_or(false);
                ok &= good;
                rows.push(json!({"ref": want["ref"], "ok": good, "live": got}));
            }
            out["readback"] = json!(rows);
            out["verified"] = json!(ok);
            if !ok {
                out["postCommitError"] = json!("the live footprint does not show every planned change; inspect it and use Edit > Undo if it is wrong");
            }
            let ids: Vec<String> = change.report["edited"].as_array().into_iter().flatten().filter_map(|r| r["after"]["uuid"].as_str().map(String::from)).collect();
            let dropped = crate::silk_fields::dropped_closing_vertices(&text, &after_text, &ids);
            if !dropped.is_empty() {
                out["closingVerticesDroppedByKiCad"] = json!(dropped);
                out["_libraryParityHint"] = json!(crate::silk_fields::CLOSING_VERTEX_HINT);
            }
        }
        Err(e) => out["postCommitError"] = json!(e.message),
    }
    out["_hint"] = json!("Edited live as one KiCad Undo step; the footprint keeps its UUID. Use the returned revision for the next edit.");
    post_save(&s, args, &mut out);
    Ok(out)
}

fn is_change_key(k: &str) -> bool {
    matches!(k, "fields" | "value" | "newReference" | "reference" | "attributes" | "locked") || crate::fp_text::ATTR_FLAGS.iter().any(|(f, _)| *f == k)
}

// ---------------------------------------------------------------------------
// Selection, refill, save
// ---------------------------------------------------------------------------

fn item_row(it: &PcbItem) -> Value {
    let net = |n: &Option<BoardNet>| n.as_ref().map(|n| json!(n.name)).unwrap_or(Value::Null);
    let mm = |v: &Option<Vector2Nm>| v.map(|p| json!([p.x_nm as f64 / 1e6, p.y_nm as f64 / 1e6])).unwrap_or(Value::Null);
    match it {
        PcbItem::Footprint(f) => json!({"type": "footprint", "id": f.id, "ref": f.reference, "value": f.value, "layer": f.layer.name, "at": mm(&f.position_nm), "rotation": f.orientation_deg, "locked": f.locked.is_locked()}),
        PcbItem::Pad(p) => json!({"type": "pad", "id": p.id, "number": p.number, "net": net(&p.net), "at": mm(&p.position_nm)}),
        PcbItem::Track(t) => json!({"type": "track", "id": t.id, "net": net(&t.net), "layer": t.layer.name, "start": mm(&t.start_nm), "end": mm(&t.end_nm), "widthMm": t.width_nm.map(|w| w as f64 / 1e6)}),
        PcbItem::Arc(a) => json!({"type": "arc", "id": a.id, "net": net(&a.net), "layer": a.layer.name}),
        PcbItem::Via(v) => json!({"type": "via", "id": v.id, "net": net(&v.net), "at": mm(&v.position_nm)}),
        PcbItem::Zone(z) => json!({"type": "zone", "id": z.id, "name": z.name, "layers": z.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>()}),
        PcbItem::BoardGraphicShape(g) => json!({"type": "graphic", "id": g.id, "layer": g.layer.name, "shape": g.geometry_kind, "net": net(&g.net)}),
        PcbItem::BoardText(t) => json!({"type": "text", "id": t.id, "layer": t.layer.name, "text": t.text}),
        PcbItem::BoardTextBox(t) => json!({"type": "textbox", "id": t.id}),
        PcbItem::Field(_) => json!({"type": "field"}),
        PcbItem::Dimension(d) => json!({"type": "dimension", "id": d.id}),
        PcbItem::ReferenceImage(r) => json!({"type": "image", "id": r.id}),
        PcbItem::Barcode(b) => json!({"type": "barcode", "id": b.id}),
        PcbItem::Group(g) => json!({"type": "group", "id": g.id}),
        PcbItem::Unknown(_) => json!({"type": "unknown"}),
    }
}

fn selection_reply(items: &[PcbItem]) -> Value {
    let rows: Vec<Value> = items.iter().map(item_row).collect();
    let mut by: std::collections::BTreeMap<String, usize> = Default::default();
    for r in &rows {
        *by.entry(r["type"].as_str().unwrap_or("unknown").to_string()).or_insert(0) += 1;
    }
    let mut refs: Vec<String> = rows.iter().filter_map(|r| r["ref"].as_str().map(str::to_string)).collect();
    refs.sort_by_key(|r| placement::ref_key(r));
    let mut nets: Vec<String> = rows.iter().filter_map(|r| r["net"].as_str().map(str::to_string)).filter(|n| !n.is_empty()).collect();
    nets.sort();
    nets.dedup();
    json!({"count": rows.len(), "byType": by, "references": refs, "nets": nets, "items": rows})
}

/// `kicad_get_selection`: what is selected in the PCB editor.
pub fn get_selection(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let items = s.client.get_selection(Vec::new()).map_err(map_err)?;
    let mut out = json!({"success": true, "mutated": false, "source": "live-editor", "boardPath": s.file_path,
        "_hint": "The editor's selection right now (the user's or one set with kicad_select_items / kicad_select_net). Footprints carry ref; copper carries net. Selection is not an edit: no revision change."});
    merge(&mut out, &selection_reply(&items));
    Ok(out)
}

/// `kicad_select_items`: select by item ids and/or footprint references; `mode` replace
/// (default), add or remove.
pub fn select_items(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let mut ids: Vec<String> = args.get("itemIds").or_else(|| args.get("ids")).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
    let refs: Vec<String> = args.get("refs").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
    let mut missing = Vec::new();
    if !refs.is_empty() {
        let (_text, board, _rev) = s.snapshot()?;
        for r in &refs {
            match board.footprints.iter().find(|f| f.reference.eq_ignore_ascii_case(r)).and_then(|f| f.uuid.clone()) {
                Some(u) => ids.push(u),
                None => missing.push(r.clone()),
            }
        }
    }
    if !missing.is_empty() {
        return Err(RoutingError::new("unknown_footprint", format!("No footprint with reference {}", missing.join(", "))).with("missing", json!(missing)));
    }
    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("replace");
    if ids.is_empty() && mode != "replace" {
        return Err(RoutingError::new("missing_arg", "Pass itemIds and/or refs"));
    }
    if ids.len() > 10_000 {
        return Err(RoutingError::new("invalid_args", "At most 10000 items"));
    }
    match mode {
        "replace" => {
            s.client.clear_selection().map_err(map_err)?;
            if !ids.is_empty() {
                s.client.add_to_selection(ids.clone()).map_err(map_err)?;
            }
        }
        "add" => {
            s.client.add_to_selection(ids.clone()).map_err(map_err)?;
        }
        "remove" => {
            s.client.remove_from_selection(ids.clone()).map_err(map_err)?;
        }
        other => return Err(RoutingError::new("invalid_args", format!("mode must be replace, add or remove, not '{other}'"))),
    }
    let now = s.client.get_selection(Vec::new()).map_err(map_err)?;
    let selected: HashSet<String> = now.iter().filter_map(|i| item_id(i).map(str::to_string)).collect();
    let not_selected: Vec<&String> = if mode == "remove" { ids.iter().filter(|i| selected.contains(*i)).collect() } else { ids.iter().filter(|i| !selected.contains(*i)).collect() };
    let mut out = json!({"success": true, "mutated": false, "source": "live-editor", "mode": mode, "requested": ids.len(), "verified": not_selected.is_empty(),
        "_hint": "The selection is set (kicad_get_selection reads it back; Zoom to Selected frames it). Selection is not an edit: nothing to undo, the revision is unchanged."});
    if !not_selected.is_empty() {
        out["notApplied"] = json!(not_selected);
        out["_hint"] = json!("Some ids did not take: KiCad selects only items that exist on the board (and not children of a locked or hidden layer). notApplied lists them.");
    }
    merge(&mut out, &selection_reply(&now));
    Ok(out)
}

/// `kicad_refill_zones`: refill every zone (or the listed ones when KiCad accepts a list),
/// wait for the asynchronous fill to settle, and report the filled copper.
pub fn refill_zones(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let ids: Vec<String> = args.get("zoneIds").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
    let before = s.revision().ok();
    let mut note = Value::Null;
    let mut all = ids.is_empty();
    if !ids.is_empty() {
        match s.client.refill_zones(ids.clone()) {
            Ok(()) => {}
            Err(KiCadError::ApiStatus { code, message }) if code == "AS_UNIMPLEMENTED" || code == "AS_BAD_REQUEST" => {
                note = json!(format!("KiCad answered {code} ({message}) to a zone list; every zone was refilled instead"));
                s.client.refill_zones(Vec::new()).map_err(map_err)?;
                all = true;
            }
            Err(e) => return Err(map_err(e)),
        }
    } else {
        s.client.refill_zones(Vec::new()).map_err(map_err)?;
    }
    let (_, settled) = settle_revision(before.as_deref(), || s.revision().map_err(|e| e.message));
    let (_text, data, revision) = s.snapshot()?;
    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;
    let mut read = Vec::with_capacity(raws.len());
    for a in &raws {
        read.push(zones::decode_zone(&a.value).map_err(|e| RoutingError::new("zone_undecodable", e))?);
    }
    let mut out = json!({"success": true, "mutated": true, "source": "live-editor", "boardPath": s.file_path, "refilledAll": all, "requestedZoneIds": ids,
        "revision": revision, "revisionSettled": settled, "saved": false,
        "_hint": if settled { "Zones refilled (one KiCad Undo step of its own). filledAreaByLayerMm2 is the poured copper per layer. Use this revision for the next edit; the file is saved only with save:true or kicad_save_board." } else { "Refill requested but KiCad was still filling after 12 s; the revision may already be stale. Call kicad_zone_state (refill:false) for a settled revision before the next edit." }});
    if !note.is_null() {
        out["note"] = note;
    }
    merge(&mut out, &zones::state_json(&read, &data));
    post_save(&s, args, &mut out);
    Ok(out)
}

/// `kicad_save_board`: save the open board through the IPC API and confirm the file.
pub fn save_board(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let path = s.source_path();
    let mtime = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
    let before = mtime();
    let live = s.client.get_board_as_string().map_err(map_err)?;
    s.client.save_document().map_err(map_err)?;
    let after = mtime();
    let file = std::fs::read_to_string(&path).ok();
    let model = |t: &str| crate::board_model::revision_of_text(t).ok();
    let matches = file.as_deref().and_then(model).zip(model(&live)).map(|(a, b)| a == b);
    Ok(json!({
        "success": true, "mutated": true, "saved": true, "source": "live-editor", "boardPath": s.file_path,
        "fileWritten": before != after || before.is_none(), "fileMatchesEditor": matches, "revision": revision_of(&live),
        "_hint": "Saved by KiCad itself (File > Save). fileMatchesEditor compares the copper, parts and outline of the file on disk with the editor's board; null means the file could not be read from here (a different machine path).",
    }))
}

#[cfg(test)]
mod create_response_tests {
    #[test]
    fn an_unset_request_status_with_ok_items_decodes_as_such() {
        // field 3 { field 1 { code 1 } field 2 { any } } twice, no field 2: KiCad 10.0.4's reply
        let item = |code: u8| vec![0x1a, 0x06, 0x0a, 0x02, 0x08, code, 0x12, 0x00];
        let mut b = item(1);
        b.extend(item(1));
        let (status, items) = super::decode_create_response(&b).unwrap();
        assert_eq!(status, 0);
        assert_eq!(items, vec![(1, String::new()), (1, String::new())]);
        let refused = [0x10, 0x02, 0x1a, 0x09, 0x0a, 0x07, 0x08, 0x03, 0x12, 0x03, b'b', b'a', b'd'];
        let (status, items) = super::decode_create_response(&refused).unwrap();
        assert_eq!((status, items), (2, vec![(3, "bad".to_string())]));
    }
}

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

    #[test]
    fn flip_items_is_encoded_the_way_kicad_10_0_6_reads_it() {
        let doc = document_bytes("b.kicad_pcb", Some("b"), Some("C:/p"));
        let bytes = flip_items_bytes(&doc, &["id-1".into(), "id-2".into()], true);
        let fields = parse_fields(&bytes).unwrap();
        assert_eq!(fields.iter().map(|f| f.number).collect::<Vec<_>>(), vec![1, 2, 2, 3]);
        assert_eq!(varint_value(fields[3].data).unwrap(), 1);
        let header = parse_fields(fields[0].data).unwrap();
        let docf = parse_fields(header[0].data).unwrap();
        assert_eq!(varint_value(docf[0].data).unwrap(), 3);
        assert_eq!(docf[1].data, b"b.kicad_pcb");
        let kiid = parse_fields(fields[1].data).unwrap();
        assert_eq!(kiid[0].data, b"id-1");
        // a response: IRS_OK and two ISC_OK rows
        let mut resp = Vec::new();
        wire_varint(&mut resp, 2, 1);
        for _ in 0..2 {
            let mut code = Vec::new();
            wire_varint(&mut code, 1, 1);
            let mut st = Vec::new();
            wire_message(&mut st, 1, &code);
            wire_message(&mut resp, 3, &st);
        }
        assert_eq!(decode_flip_response(&resp).unwrap(), (1, vec![1, 1]));
    }

    #[test]
    fn the_footprint_proto_takes_value_attributes_and_lock() {
        let mut item = EditablePcbItem::Footprint(kicad_ipc_rs::FootprintItem::from_proto(Default::default()));
        if let EditablePcbItem::Footprint(fp) = &mut item {
            let p = fp.proto_mut();
            p.value_field = Some(Default::default());
            let f = p.value_field.as_mut().unwrap();
            f.text = Some(Default::default());
            f.text.as_mut().unwrap().text = Some(Default::default());
        }
        edit_footprint_proto(&mut item, &json!({"value": "10k", "dnp": true, "excludeFromBom": true, "locked": true})).unwrap();
        let EditablePcbItem::Footprint(fp) = &mut item else { unreachable!() };
        let p = fp.proto_mut();
        assert_eq!(p.value_field.as_ref().unwrap().text.as_ref().unwrap().text.as_ref().unwrap().text, "10k");
        let a = p.attributes.as_ref().unwrap();
        assert!(a.do_not_populate && a.exclude_from_bill_of_materials && !a.exclude_from_position_files);
        assert_eq!(p.locked, 2);
        // no reference field: refused, not silently ignored
        assert_eq!(edit_footprint_proto(&mut item, &json!({"newReference": "R9"})).unwrap_err().code, "ipc_error");
    }
}