master
John Lauer 1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati… baad156 14d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025
//! Schematic files and hierarchy: a blank KiCad 10 sheet, paper and title block, the sheet
//! tree of a project, and the sheet operations (add, edit, move, delete, duplicate, sheet
//! pins, page renumbering).
//!
//! The tree is walked from the root sheet. Every file is loaded once (`Hierarchy::docs`); a
//! file used by two sheet symbols appears as two instances that share one document, which
//! is how eeschema stores reuse: one file, one `(instances (project (path ..)))` entry per
//! use. Instance paths are eeschema's: `/<root uuid>` for the root, then one `/<sheet
//! uuid>` per level.
//!
//! Edits work on the in-memory documents; the caller writes the changed ones
//! (`Hierarchy::changed`) with `Schematic::save_with_backup`, or skips that for a dry run.

use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};

use serde_json::{json, Value};

use crate::schematic::{fmt_num, is_symbol_instance, new_uuid, pretty, symbol_property, Kind, Node, Schematic};
use crate::sch_model::{self, SheetRef};

/// The `(version ..)` eeschema 10.0 writes.
pub const SCH_VERSION: &str = "20260306";

// ---------------------------------------------------------------------------
// Paper and title block
// ---------------------------------------------------------------------------

/// KiCad's paper names with their landscape size in millimetres.
pub const PAPERS: &[(&str, f64, f64)] = &[
    ("A5", 210.0, 148.0),
    ("A4", 297.0, 210.0),
    ("A3", 420.0, 297.0),
    ("A2", 594.0, 420.0),
    ("A1", 841.0, 594.0),
    ("A0", 1189.0, 841.0),
    ("A", 279.4, 215.9),
    ("B", 431.8, 279.4),
    ("C", 558.8, 431.8),
    ("D", 863.6, 558.8),
    ("E", 1117.6, 863.6),
    ("USLetter", 279.4, 215.9),
    ("USLegal", 355.6, 215.9),
    ("USLedger", 431.8, 279.4),
];

#[derive(Clone, Debug, PartialEq)]
pub struct Paper {
    /// KiCad's name (A4, USLetter, ...) or "User" for a custom size.
    pub name: String,
    /// Custom size in mm (User only).
    pub custom: Option<(f64, f64)>,
    pub portrait: bool,
}

impl Paper {
    /// Accepts KiCad's names case-insensitively plus Letter, Legal, Ledger, Tabloid and
    /// "custom"/"user" with a width and height in mm.
    pub fn parse(name: &str, width: Option<f64>, height: Option<f64>, portrait: bool) -> Result<Paper, String> {
        let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], "");
        let alias = match n.as_str() {
            "letter" | "usletter" => "USLetter",
            "legal" | "uslegal" => "USLegal",
            "ledger" | "tabloid" | "usledger" => "USLedger",
            "custom" | "user" => "User",
            _ => "",
        };
        if alias == "User" || (n.is_empty() && width.is_some()) {
            let (Some(w), Some(h)) = (width, height) else {
                return Err("a custom paper needs width and height in millimetres".into());
            };
            if !(10.0..=4000.0).contains(&w) || !(10.0..=4000.0).contains(&h) {
                return Err(format!("custom paper {w} x {h} mm is out of range (10 to 4000 mm per side)"));
            }
            return Ok(Paper { name: "User".into(), custom: Some((w, h)), portrait: false });
        }
        let key = if alias.is_empty() { name.trim() } else { alias };
        let found = PAPERS.iter().find(|(p, _, _)| p.eq_ignore_ascii_case(key)).ok_or_else(|| {
            format!("unknown paper {name}; use one of {} or custom with width and height", PAPERS.iter().map(|p| p.0).collect::<Vec<_>>().join(", "))
        })?;
        Ok(Paper { name: found.0.into(), custom: None, portrait })
    }

    /// Size as drawn (portrait swaps), in mm.
    pub fn size_mm(&self) -> (f64, f64) {
        if let Some(c) = self.custom {
            return c;
        }
        let (w, h) = PAPERS.iter().find(|p| p.0 == self.name).map(|p| (p.1, p.2)).unwrap_or((297.0, 210.0));
        if self.portrait { (h, w) } else { (w, h) }
    }

    pub fn node(&self) -> Node {
        let mut c = vec![Node::quoted(&self.name)];
        if let Some((w, h)) = self.custom {
            c.push(Node::number(w));
            c.push(Node::number(h));
        } else if self.portrait {
            c.push(Node::atom("portrait"));
        }
        let mut n = Node::list("paper", c);
        pretty(&mut n, 1);
        n
    }

    pub fn of(sch: &Schematic) -> Paper {
        let Some(p) = sch.doc.root.child("paper") else { return Paper { name: "A4".into(), custom: None, portrait: false } };
        let name = p.arg(0).unwrap_or_else(|| "A4".into());
        if name == "User" {
            return Paper { name, custom: Some((p.num(1).unwrap_or(297.0), p.num(2).unwrap_or(210.0))), portrait: false };
        }
        let portrait = p.children().iter().any(|c| c.raw() == Some("portrait"));
        Paper { name, custom: None, portrait }
    }

    pub fn json(&self) -> Value {
        let (w, h) = self.size_mm();
        json!({"paper": self.name, "portrait": self.portrait, "widthMm": w, "heightMm": h})
    }
}

/// Title block fields to set; None leaves a field alone, Some("") removes it.
#[derive(Clone, Debug, Default)]
pub struct TitleEdit {
    pub title: Option<String>,
    pub date: Option<String>,
    pub rev: Option<String>,
    pub company: Option<String>,
    /// (comment number 1..9, text)
    pub comments: Vec<(u32, String)>,
}

impl TitleEdit {
    pub fn is_empty(&self) -> bool {
        self.title.is_none() && self.date.is_none() && self.rev.is_none() && self.company.is_none() && self.comments.is_empty()
    }
}

pub fn title_block_json(sch: &Schematic) -> Value {
    let mut v = json!({});
    if let Some(tb) = sch.doc.root.child("title_block") {
        for k in ["title", "date", "rev", "company"] {
            if let Some(x) = tb.child_value(k) {
                v[k] = json!(x);
            }
        }
        let mut comments = serde_json::Map::new();
        for c in tb.children().iter().filter(|c| c.head() == Some("comment")) {
            if let (Some(n), Some(t)) = (c.arg(0), c.arg(1)) {
                comments.insert(n, json!(t));
            }
        }
        if !comments.is_empty() {
            v["comments"] = Value::Object(comments);
        }
    }
    v
}

/// Index after which a header item goes: after the last of `after` present at the top.
fn header_slot(top: &[Node], after: &[&str]) -> usize {
    let mut idx = 1;
    for (i, n) in top.iter().enumerate() {
        if n.head().map_or(false, |h| after.contains(&h)) {
            idx = i + 1;
        }
    }
    idx
}

/// Replace or insert the `(paper ..)` header.
pub fn set_paper(sch: &mut Schematic, p: &Paper) {
    let mut node = p.node();
    pretty(&mut node, 1);
    let top = sch.doc.root.children_mut().expect("root list");
    if let Some(i) = top.iter().position(|n| n.head() == Some("paper")) {
        node.lead = top[i].lead.clone();
        top[i] = node;
    } else {
        let i = header_slot(top, &["version", "generator", "generator_version", "uuid"]);
        top.insert(i, node);
    }
}

/// Apply title block edits; returns the list of changes made.
pub fn set_title_block(sch: &mut Schematic, e: &TitleEdit) -> Vec<String> {
    let mut changes = Vec::new();
    let top = sch.doc.root.children_mut().expect("root list");
    if !top.iter().any(|n| n.head() == Some("title_block")) {
        let mut tb = Node::list("title_block", vec![]);
        pretty(&mut tb, 1);
        let i = header_slot(top, &["version", "generator", "generator_version", "uuid", "paper"]);
        top.insert(i, tb);
    }
    let tb = sch.doc.root.child_mut("title_block").expect("ensured");
    let order = ["title", "date", "rev", "company", "comment"];
    let mut set = |tb: &mut Node, key: &str, num: Option<u32>, val: &str| {
        let children = tb.children_mut().expect("list");
        let pos = children.iter().position(|c| c.head() == Some(key) && num.map_or(true, |n| c.arg(0).as_deref() == Some(n.to_string().as_str())));
        if val.is_empty() {
            if let Some(i) = pos {
                children.remove(i);
                changes.push(format!("removed {}", num.map(|n| format!("comment {n}")).unwrap_or_else(|| key.to_string())));
            }
            return;
        }
        let mut args = Vec::new();
        if let Some(n) = num {
            args.push(Node::atom(n.to_string()));
        }
        args.push(Node::quoted(val));
        let mut node = Node::list(key, args);
        pretty(&mut node, 2);
        let what = num.map(|n| format!("comment {n}")).unwrap_or_else(|| key.to_string());
        match pos {
            Some(i) => {
                children[i] = node;
                changes.push(format!("set {what}"));
            }
            None => {
                // keep eeschema's field order: title, date, rev, company, comment 1..9
                let rank = |c: &Node| -> (usize, u32) {
                    let h = c.head().unwrap_or("");
                    (order.iter().position(|o| *o == h).unwrap_or(9), c.arg(0).and_then(|a| a.parse().ok()).unwrap_or(0))
                };
                let mine = (order.iter().position(|o| *o == key).unwrap_or(9), num.unwrap_or(0));
                let at = children.iter().enumerate().skip(1).find(|(_, c)| rank(c) > mine).map(|(i, _)| i).unwrap_or(children.len());
                children.insert(at, node);
                changes.push(format!("set {what}"));
            }
        }
        if let Kind::List { close_lead, children } = &mut tb.kind {
            if close_lead.is_empty() && children.len() > 1 {
                *close_lead = "\n\t".into();
            }
        }
    };
    for (k, v) in [("title", &e.title), ("date", &e.date), ("rev", &e.rev), ("company", &e.company)] {
        if let Some(v) = v {
            set(tb, k, None, v);
        }
    }
    for (n, t) in &e.comments {
        set(tb, "comment", Some(*n), t);
    }
    changes
}

/// A blank KiCad 10 sheet: header, paper, optional title block, empty lib_symbols, the root
/// sheet_instances entry and embedded_fonts. Returns (text, root uuid).
pub fn blank_sheet(paper: &Paper, title: &TitleEdit) -> (String, String) {
    let uuid = new_uuid();
    let text = format!(
        "(kicad_sch\n\t(version {SCH_VERSION})\n\t(generator \"eeschema\")\n\t(generator_version \"10.0\")\n\t(uuid \"{uuid}\")\n\t(paper \"A4\")\n\t(lib_symbols)\n\t(sheet_instances\n\t\t(path \"/\"\n\t\t\t(page \"1\")\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n"
    );
    let mut sch = Schematic::parse(&text).expect("blank sheet parses");
    set_paper(&mut sch, paper);
    if !title.is_empty() {
        set_title_block(&mut sch, title);
    }
    (sch.to_text(), uuid)
}

/// A minimal `.kicad_pro` KiCad opens and fills in on its first save.
pub fn blank_project(name: &str) -> String {
    let v = json!({
        "meta": {"filename": format!("{name}.kicad_pro"), "version": 3},
        "sheets": [],
        "boards": [],
        "libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []},
        "text_variables": {}
    });
    serde_json::to_string_pretty(&v).unwrap_or_default() + "\n"
}

// ---------------------------------------------------------------------------
// The hierarchy
// ---------------------------------------------------------------------------

#[derive(Clone, Debug)]
pub struct Instance {
    /// eeschema instance path: `/<root uuid>` for the root, `/<root uuid>/<sheet uuid>` ...
    pub path: String,
    /// Human path from sheet names: `/`, `/Power/`, `/Power/Filter/`.
    pub human: String,
    pub file: PathBuf,
    pub name: String,
    pub sheet_uuid: Option<String>,
    pub page: Option<String>,
    pub parent: Option<usize>,
    pub depth: usize,
    /// Index into `Hierarchy::docs`; None when the file is missing or unreadable.
    pub doc: Option<usize>,
    pub problem: Option<String>,
    /// The sheet symbol in the parent that opens this instance.
    pub sheet: Option<SheetRef>,
}

pub struct Hierarchy {
    pub root: PathBuf,
    pub docs: Vec<(PathBuf, Schematic)>,
    pub instances: Vec<Instance>,
    pub changed: BTreeSet<usize>,
}

fn same_file(a: &Path, b: &Path) -> bool {
    let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf());
    let cb = std::fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf());
    ca == cb
}

/// Resolve a Sheetfile value against the directory of the file that names it.
pub fn resolve_child(parent_file: &Path, sheetfile: &str) -> PathBuf {
    let rel = sheetfile.replace('\\', "/");
    let p = Path::new(&rel);
    if p.is_absolute() || rel.chars().nth(1) == Some(':') {
        return PathBuf::from(rel);
    }
    parent_file.parent().unwrap_or(Path::new(".")).join(p)
}

impl Hierarchy {
    pub fn load(root: &Path) -> Result<Hierarchy, String> {
        let sch = Schematic::load(root)?;
        let root_uuid = sch.root_uuid().ok_or("the root sheet has no (uuid ..)")?;
        let page = sch.doc.root.child("sheet_instances").and_then(|si| si.children().iter().find(|p| p.head() == Some("path") && p.arg(0).as_deref() == Some("/")).and_then(|p| p.child_value("page")));
        let mut h = Hierarchy { root: root.to_path_buf(), docs: vec![(root.to_path_buf(), sch)], instances: Vec::new(), changed: BTreeSet::new() };
        h.instances.push(Instance {
            path: format!("/{root_uuid}"),
            human: "/".into(),
            file: root.to_path_buf(),
            name: "Root".into(),
            sheet_uuid: None,
            page,
            parent: None,
            depth: 0,
            doc: Some(0),
            problem: None,
            sheet: None,
        });
        h.expand(0, &mut vec![root.to_path_buf()]);
        Ok(h)
    }

    fn doc_index(&mut self, file: &Path) -> Result<usize, String> {
        if let Some(i) = self.docs.iter().position(|(p, _)| same_file(p, file)) {
            return Ok(i);
        }
        let s = Schematic::load(file)?;
        self.docs.push((file.to_path_buf(), s));
        Ok(self.docs.len() - 1)
    }

    fn expand(&mut self, inst: usize, stack: &mut Vec<PathBuf>) {
        if self.instances.len() > 2000 {
            return;
        }
        let Some(d) = self.instances[inst].doc else { return };
        let parent_path = self.instances[inst].path.clone();
        let parent_human = self.instances[inst].human.clone();
        let parent_file = self.instances[inst].file.clone();
        let depth = self.instances[inst].depth;
        let sheets: Vec<SheetRef> = self.docs[d].1.doc.root.children().iter().filter(|n| n.head() == Some("sheet")).map(sch_model::sheet_ref).collect();
        for s in sheets {
            let file = resolve_child(&parent_file, &s.file);
            let page = s.pages.iter().find(|(p, _)| *p == parent_path).map(|(_, pg)| pg.clone()).filter(|p| !p.is_empty());
            let mut problem = None;
            let mut doc = None;
            if s.file.trim().is_empty() {
                problem = Some("the sheet names no file".to_string());
            } else if stack.iter().any(|p| same_file(p, &file)) {
                problem = Some("recursive sheet: the file is already an ancestor".to_string());
            } else if !file.is_file() {
                problem = Some("file missing".to_string());
            } else {
                match self.doc_index(&file) {
                    Ok(i) => doc = Some(i),
                    Err(e) => problem = Some(e),
                }
            }
            self.instances.push(Instance {
                path: format!("{parent_path}/{}", s.uuid),
                human: format!("{parent_human}{}/", s.name),
                file: file.clone(),
                name: s.name.clone(),
                sheet_uuid: Some(s.uuid.clone()),
                page,
                parent: Some(inst),
                depth: depth + 1,
                doc,
                problem,
                sheet: Some(s),
            });
            let me = self.instances.len() - 1;
            if doc.is_some() {
                stack.push(file);
                self.expand(me, stack);
                stack.pop();
            }
        }
    }

    pub fn sch(&self, inst: usize) -> Option<&Schematic> {
        self.instances[inst].doc.map(|d| &self.docs[d].1)
    }

    /// Instance paths under which `file` is used.
    pub fn paths_of(&self, file: &Path) -> Vec<String> {
        self.instances.iter().filter(|i| i.doc.is_some() && same_file(&i.file, file)).map(|i| i.path.clone()).collect()
    }

    pub fn doc_of(&self, file: &Path) -> Option<usize> {
        self.docs.iter().position(|(p, _)| same_file(p, file))
    }

    /// Every reference used anywhere in the design, per instance.
    pub fn references(&self) -> BTreeSet<String> {
        let mut out = BTreeSet::new();
        for (i, inst) in self.instances.iter().enumerate() {
            let Some(s) = self.sch(i) else { continue };
            for n in s.doc.root.children().iter().filter(|n| is_symbol_instance(n)) {
                out.insert(sch_model::instance_reference(n, Some(&inst.path)));
            }
        }
        out
    }

    pub fn max_page(&self) -> u32 {
        self.instances.iter().filter_map(|i| i.page.as_deref().and_then(|p| p.parse::<u32>().ok())).max().unwrap_or(1)
    }

    pub fn project_name(&self) -> String {
        if let Some(stem) = self.root.file_stem().map(|s| s.to_string_lossy().to_string()) {
            if self.root.with_extension("kicad_pro").is_file() {
                return stem;
            }
        }
        self.docs[0].1.project_name()
    }

    pub fn tree_json(&self) -> Value {
        fn node(h: &Hierarchy, i: usize) -> Value {
            let inst = &h.instances[i];
            let children: Vec<Value> = h.instances.iter().enumerate().filter(|(_, c)| c.parent == Some(i)).map(|(k, _)| node(h, k)).collect();
            let mut v = json!({
                "name": inst.name,
                "file": inst.file.to_string_lossy().replace('\\', "/"),
                "path": inst.path,
                "sheetPath": inst.human,
                "page": inst.page,
                "depth": inst.depth,
                "exists": inst.doc.is_some(),
            });
            if let Some(u) = &inst.sheet_uuid {
                v["uuid"] = json!(u);
            } else if let Some(s) = h.sch(i) {
                v["uuid"] = json!(s.root_uuid());
            }
            if let Some(s) = &inst.sheet {
                v["at"] = json!([s.at.0, s.at.1]);
                v["size"] = json!([s.size.0, s.size.1]);
                v["fileName"] = json!(s.file);
                v["pins"] = json!(s.pins.iter().map(|p| json!({"name": p.name, "shape": p.shape, "at": [p.at.0, p.at.1], "uuid": p.uuid})).collect::<Vec<_>>());
            }
            if let Some(p) = &inst.problem {
                v["problem"] = json!(p);
            }
            if let Some(s) = h.sch(i) {
                v["symbolCount"] = json!(s.doc.root.children().iter().filter(|n| is_symbol_instance(n) && !symbol_property(n, "Reference").unwrap_or_default().starts_with('#')).count());
            }
            v["sheets"] = json!(children);
            v
        }
        node(self, 0)
    }

    /// Set the page of instance `i` in the document that stores it (the parent's sheet
    /// symbol, or the root's sheet_instances). Returns the previous value.
    pub fn set_page(&mut self, i: usize, page: &str) -> Option<String> {
        let inst = self.instances[i].clone();
        let Some(parent) = inst.parent else {
            let d = self.instances[0].doc?;
            let sch = &mut self.docs[d].1;
            let old = set_root_page(sch, page);
            self.changed.insert(d);
            self.instances[i].page = Some(page.to_string());
            return old;
        };
        let pd = self.instances[parent].doc?;
        let parent_path = self.instances[parent].path.clone();
        let project = self.project_name();
        let sch = &mut self.docs[pd].1;
        let sheet_uuid = inst.sheet_uuid.clone()?;
        let idx = sch.find_uuid_index(&sheet_uuid)?;
        let node = &mut sch.doc.root.children_mut().expect("list")[idx];
        let old = set_sheet_instance_page(node, &project, &parent_path, page);
        self.changed.insert(pd);
        self.instances[i].page = Some(page.to_string());
        old
    }

    /// Depth-first page numbers: root 1, then every sheet in the order the walk met it
    /// (file order within a sheet). Returns (path, old, new) for every instance.
    pub fn renumber(&mut self) -> Vec<(String, Option<String>, String)> {
        let mut order = Vec::new();
        fn dfs(h: &Hierarchy, i: usize, out: &mut Vec<usize>) {
            out.push(i);
            for (k, c) in h.instances.iter().enumerate() {
                if c.parent == Some(i) && c.doc.is_some() {
                    dfs(h, k, out);
                }
            }
        }
        dfs(self, 0, &mut order);
        let mut out = Vec::new();
        for (n, i) in order.into_iter().enumerate() {
            let new = (n + 1).to_string();
            let old = self.instances[i].page.clone();
            if old.as_deref() != Some(new.as_str()) {
                self.set_page(i, &new);
            }
            out.push((self.instances[i].path.clone(), old, new));
        }
        out
    }
}

fn set_root_page(sch: &mut Schematic, page: &str) -> Option<String> {
    let top = sch.doc.root.children_mut().expect("list");
    if !top.iter().any(|n| n.head() == Some("sheet_instances")) {
        let mut si = Node::list("sheet_instances", vec![Node::list("path", vec![Node::quoted("/"), Node::list("page", vec![Node::quoted(page)])])]);
        pretty(&mut si, 1);
        let at = top.iter().position(|n| n.head() == Some("embedded_fonts")).unwrap_or(top.len());
        top.insert(at, si);
        return None;
    }
    let si = sch.doc.root.child_mut("sheet_instances").expect("ensured");
    let children = si.children_mut().expect("list");
    if let Some(p) = children.iter_mut().find(|p| p.head() == Some("path") && p.arg(0).as_deref() == Some("/")) {
        if let Some(pg) = p.child_mut("page") {
            let old = pg.arg(0);
            pg.set_arg(0, Node::quoted(page));
            return old;
        }
    }
    let mut p = Node::list("path", vec![Node::quoted("/"), Node::list("page", vec![Node::quoted(page)])]);
    pretty(&mut p, 2);
    children.push(p);
    None
}

/// Set the page for `path` in a sheet node's instances block, creating entries as needed.
pub fn set_sheet_instance_page(sheet: &mut Node, project: &str, path: &str, page: &str) -> Option<String> {
    let children = sheet.children_mut().expect("list");
    if !children.iter().any(|c| c.head() == Some("instances")) {
        let mut inst = Node::list("instances", vec![Node::list("project", vec![Node::quoted(project), Node::list("path", vec![Node::quoted(path), Node::list("page", vec![Node::quoted(page)])])])]);
        pretty(&mut inst, 2);
        children.push(inst);
        return None;
    }
    let insts = children.iter_mut().find(|c| c.head() == Some("instances")).expect("ensured");
    for proj in insts.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("project")) {
        for p in proj.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("path")) {
            if p.arg(0).as_deref() == Some(path) {
                if let Some(pg) = p.child_mut("page") {
                    let old = pg.arg(0);
                    pg.set_arg(0, Node::quoted(page));
                    return old;
                }
                let mut pg = Node::list("page", vec![Node::quoted(page)]);
                pretty(&mut pg, 5);
                let pc = p.children_mut().expect("list");
                pc.push(pg);
                return None;
            }
        }
    }
    // no entry for this path: add one under the first project (or a new project)
    let ic = insts.children_mut().expect("list");
    let mut newp = Node::list("path", vec![Node::quoted(path), Node::list("page", vec![Node::quoted(page)])]);
    if let Some(proj) = ic.iter_mut().find(|n| n.head() == Some("project")) {
        pretty(&mut newp, 4);
        proj.children_mut().expect("list").push(newp);
    } else {
        let mut proj = Node::list("project", vec![Node::quoted(project), newp]);
        pretty(&mut proj, 3);
        ic.push(proj);
    }
    None
}

/// Insert a new top-level item before the trailing blocks, laid out like eeschema.
pub fn insert_top(sch: &mut Schematic, mut node: Node) {
    pretty(&mut node, 1);
    let top = sch.doc.root.children_mut().expect("root list");
    let idx = top.iter().position(|n| n.head().map_or(false, |h| ["sheet_instances", "symbol_instances", "embedded_fonts"].contains(&h))).unwrap_or(top.len());
    top.insert(idx, node);
}

fn font() -> Node {
    Node::list("font", vec![Node::list("size", vec![Node::number(1.27), Node::number(1.27)])])
}

fn field(name: &str, value: &str, at: (f64, f64), justify: &[&str]) -> Node {
    Node::list(
        "property",
        vec![
            Node::quoted(name),
            Node::quoted(value),
            Node::list("at", vec![Node::number(at.0), Node::number(at.1), Node::number(0.0)]),
            Node::list("effects", vec![font(), Node::list("justify", justify.iter().map(|j| Node::atom(*j)).collect())]),
        ],
    )
}

/// Where eeschema puts the two sheet fields for a sheet at `at` of `size`.
fn field_spots(at: (f64, f64), size: (f64, f64)) -> ((f64, f64), (f64, f64)) {
    ((at.0, r(at.1 - 0.7116)), (at.0, r(at.1 + size.1 + 0.5846)))
}

fn r(v: f64) -> f64 {
    (v * 1e4).round() / 1e4 + 0.0
}

/// A new `(sheet ..)` node with its instances entries (one per parent instance path).
pub fn sheet_node(name: &str, file: &str, at: (f64, f64), size: (f64, f64), project: &str, pages: &[(String, String)]) -> (Node, String) {
    let uuid = new_uuid();
    let (name_at, file_at) = field_spots(at, size);
    let paths: Vec<Node> = pages.iter().map(|(p, pg)| Node::list("path", vec![Node::quoted(p), Node::list("page", vec![Node::quoted(pg)])])).collect();
    let mut proj = vec![Node::quoted(project)];
    proj.extend(paths);
    let node = Node::list(
        "sheet",
        vec![
            Node::list("at", vec![Node::number(at.0), Node::number(at.1)]),
            Node::list("size", vec![Node::number(size.0), Node::number(size.1)]),
            Node::list("exclude_from_sim", vec![Node::atom("no")]),
            Node::list("in_bom", vec![Node::atom("yes")]),
            Node::list("on_board", vec![Node::atom("yes")]),
            Node::list("dnp", vec![Node::atom("no")]),
            Node::list("fields_autoplaced", vec![Node::atom("yes")]),
            Node::list("stroke", vec![Node::list("width", vec![Node::number(0.1524)]), Node::list("type", vec![Node::atom("solid")])]),
            Node::list("fill", vec![Node::list("color", vec![Node::atom("0"), Node::atom("0"), Node::atom("0"), Node::atom("0.0000")])]),
            Node::list("uuid", vec![Node::quoted(&uuid)]),
            field("Sheetname", name, name_at, &["left", "bottom"]),
            field("Sheetfile", file, file_at, &["left", "top"]),
            Node::list("instances", vec![Node::list("project", proj)]),
        ],
    );
    (node, uuid)
}

pub fn find_sheet<'a>(sch: &'a Schematic, key: &str) -> Result<usize, String> {
    let top = sch.doc.root.children();
    let hits: Vec<usize> = top
        .iter()
        .enumerate()
        .filter(|(_, n)| n.head() == Some("sheet"))
        .filter(|(_, n)| n.child_value("uuid").as_deref() == Some(key) || symbol_property(n, "Sheetname").map_or(false, |s| s.eq_ignore_ascii_case(key)))
        .map(|(i, _)| i)
        .collect();
    match hits.len() {
        1 => Ok(hits[0]),
        0 => {
            let names: Vec<String> = top.iter().filter(|n| n.head() == Some("sheet")).filter_map(|n| symbol_property(n, "Sheetname")).collect();
            Err(format!("no sheet named or with uuid {key} on this sheet (sheets here: {})", if names.is_empty() { "none".into() } else { names.join(", ") }))
        }
        _ => Err(format!("{} sheets are named {key}; pass the sheet's uuid instead", hits.len())),
    }
}

fn shift(at: &mut Node, dx: f64, dy: f64) {
    let x = at.num(0).unwrap_or(0.0) + dx;
    let y = at.num(1).unwrap_or(0.0) + dy;
    at.set_arg(0, Node::number(r(x)));
    at.set_arg(1, Node::number(r(y)));
}

/// Move a sheet node, its fields and its pins by (dx, dy).
pub fn move_sheet_node(sheet: &mut Node, dx: f64, dy: f64) {
    if let Some(a) = sheet.child_mut("at") {
        shift(a, dx, dy);
    }
    for c in sheet.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("property") || c.head() == Some("pin")) {
        if let Some(a) = c.child_mut("at") {
            shift(a, dx, dy);
        }
    }
}

/// Resize a sheet; pins on the right and bottom edges follow their edge, fields re-anchor.
pub fn resize_sheet_node(sheet: &mut Node, w: f64, h: f64) -> Result<(), String> {
    if !(2.54..=2000.0).contains(&w) || !(2.54..=2000.0).contains(&h) {
        return Err(format!("sheet size {w} x {h} mm is out of range"));
    }
    let s = sch_model::sheet_ref(sheet);
    let (dx, dy) = (w - s.size.0, h - s.size.1);
    for p in &s.pins {
        let below = p.at.1 > s.at.1 + h + sch_model::EPS && (p.at.1 - (s.at.1 + s.size.1)).abs() > sch_model::EPS;
        if below {
            return Err(format!("pin {} at y {} would fall outside the new size; move or delete it first", p.name, p.at.1));
        }
    }
    if let Some(sz) = sheet.child_mut("size") {
        sz.set_arg(0, Node::number(w));
        sz.set_arg(1, Node::number(h));
    }
    for c in sheet.children_mut().expect("list").iter_mut() {
        match c.head() {
            Some("pin") => {
                let Some(a) = c.child_mut("at") else { continue };
                let (px, py) = (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0));
                if (px - (s.at.0 + s.size.0)).abs() <= sch_model::EPS {
                    shift(a, dx, 0.0);
                } else if (py - (s.at.1 + s.size.1)).abs() <= sch_model::EPS {
                    shift(a, 0.0, dy);
                }
            }
            Some("property") if c.arg(0).as_deref() == Some("Sheetfile") => {
                if let Some(a) = c.child_mut("at") {
                    shift(a, 0.0, dy);
                }
            }
            _ => {}
        }
    }
    Ok(())
}

pub fn set_sheet_field(sheet: &mut Node, name: &str, value: &str) -> Option<String> {
    let p = sheet.children_mut()?.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(name))?;
    let old = p.arg(1);
    p.set_arg(1, Node::quoted(value));
    old
}

// ---------------------------------------------------------------------------
// Sheet pins
// ---------------------------------------------------------------------------

pub const PIN_SHAPES: &[&str] = &["input", "output", "bidirectional", "tri_state", "passive"];

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
    Left,
    Right,
    Top,
    Bottom,
}

impl Side {
    pub fn parse(s: &str) -> Option<Side> {
        match s.trim().to_ascii_lowercase().as_str() {
            "left" | "l" | "west" => Some(Side::Left),
            "right" | "r" | "east" => Some(Side::Right),
            "top" | "t" | "north" => Some(Side::Top),
            "bottom" | "b" | "south" => Some(Side::Bottom),
            _ => None,
        }
    }
    pub fn name(self) -> &'static str {
        match self {
            Side::Left => "left",
            Side::Right => "right",
            Side::Top => "top",
            Side::Bottom => "bottom",
        }
    }
    /// eeschema's pin angle and text justification on this edge.
    fn angle_justify(self) -> (f64, &'static str) {
        match self {
            Side::Left => (180.0, "left"),
            Side::Right => (0.0, "right"),
            Side::Top => (90.0, "right"),
            Side::Bottom => (270.0, "left"),
        }
    }
    /// The edge nearest to `p` (the edge a pin sits on, or the one a requested point is closest to).
    pub fn of(s: &SheetRef, p: (f64, f64)) -> Side {
        let d = [
            (Side::Left, (p.0 - s.at.0).abs()),
            (Side::Right, (p.0 - (s.at.0 + s.size.0)).abs()),
            (Side::Top, (p.1 - s.at.1).abs()),
            (Side::Bottom, (p.1 - (s.at.1 + s.size.1)).abs()),
        ];
        d.iter().fold(d[0], |best, x| if x.1 < best.1 { *x } else { best }).0
    }
}

/// The default edge for a pin of this shape: inputs left, outputs right.
pub fn default_side(shape: &str) -> Side {
    if shape == "output" || shape == "tri_state" { Side::Right } else { Side::Left }
}

/// Snap a requested point onto the sheet edge `side` (2.54 mm steps from the corner), or pick
/// the first free 2.54 mm slot on that edge.
pub fn pin_spot(s: &SheetRef, side: Side, want: Option<(f64, f64)>) -> Result<(f64, f64), String> {
    let step = 2.54;
    let along_len = match side {
        Side::Left | Side::Right => s.size.1,
        _ => s.size.0,
    };
    let point = |t: f64| -> (f64, f64) {
        match side {
            Side::Left => (s.at.0, r(s.at.1 + t)),
            Side::Right => (r(s.at.0 + s.size.0), r(s.at.1 + t)),
            Side::Top => (r(s.at.0 + t), s.at.1),
            Side::Bottom => (r(s.at.0 + t), r(s.at.1 + s.size.1)),
        }
    };
    let taken = |p: (f64, f64)| s.pins.iter().any(|q| sch_model::dist(q.at, p) < 1.27);
    if let Some(w) = want {
        let t = match side {
            Side::Left | Side::Right => w.1 - s.at.1,
            _ => w.0 - s.at.0,
        };
        let t = (t / step).round() * step;
        if t <= 0.0 || t >= along_len {
            return Err(format!("the point is off the {} edge (the edge runs {} mm from the sheet corner)", side.name(), fmt_num(along_len)));
        }
        let p = point(t);
        if taken(p) {
            return Err(format!("another pin already sits at ({}, {})", fmt_num(p.0), fmt_num(p.1)));
        }
        return Ok(p);
    }
    let mut t = step;
    while t < along_len - 0.01 {
        let p = point(t);
        if !taken(p) {
            return Ok(p);
        }
        t += step;
    }
    Err(format!("no free 2.54 mm slot left on the {} edge; enlarge the sheet (kicad_sch_sheet action edit, size) or pick another side", side.name()))
}

pub fn sheet_pin_node(name: &str, shape: &str, at: (f64, f64), side: Side) -> (Node, String) {
    let uuid = new_uuid();
    let (angle, justify) = side.angle_justify();
    let node = Node::list(
        "pin",
        vec![
            Node::quoted(name),
            Node::atom(shape),
            Node::list("at", vec![Node::number(at.0), Node::number(at.1), Node::number(angle)]),
            Node::list("uuid", vec![Node::quoted(&uuid)]),
            Node::list("effects", vec![font(), Node::list("justify", vec![Node::atom(justify)])]),
        ],
    );
    (node, uuid)
}

/// Append a pin node to a sheet node (before its instances block), laid out at depth 2.
pub fn push_sheet_pin(sheet: &mut Node, mut pin: Node) {
    pretty(&mut pin, 2);
    let children = sheet.children_mut().expect("list");
    let at = children.iter().position(|c| c.head() == Some("instances")).unwrap_or(children.len());
    children.insert(at, pin);
}

/// Hierarchical labels of a sheet file: (name, shape), first occurrence order.
pub fn hier_labels(sch: &Schematic) -> Vec<(String, String, String)> {
    let mut out: Vec<(String, String, String)> = Vec::new();
    for n in sch.doc.root.children().iter().filter(|n| n.head() == Some("hierarchical_label")) {
        let name = n.arg(0).unwrap_or_default();
        if !out.iter().any(|(x, _, _)| *x == name) {
            out.push((name, n.child_value("shape").unwrap_or_else(|| "input".into()), n.child_value("uuid").unwrap_or_default()));
        }
    }
    out
}

/// Pins vs child labels: (labels with no pin, pins with no label, shape mismatches).
pub fn pin_label_diff(sheet: &SheetRef, labels: &[(String, String, String)]) -> (Vec<Value>, Vec<Value>, Vec<Value>) {
    let mut no_pin = Vec::new();
    let mut no_label = Vec::new();
    let mut mismatch = Vec::new();
    for (name, shape, uuid) in labels {
        match sheet.pins.iter().find(|p| p.name == *name) {
            None => no_pin.push(json!({"name": name, "shape": shape, "labelUuid": uuid})),
            Some(p) if p.shape != *shape => mismatch.push(json!({"name": name, "pinShape": p.shape, "labelShape": shape, "pinUuid": p.uuid})),
            _ => {}
        }
    }
    for p in &sheet.pins {
        if !labels.iter().any(|(n, _, _)| *n == p.name) {
            no_label.push(json!({"name": p.name, "shape": p.shape, "at": [p.at.0, p.at.1], "pinUuid": p.uuid}));
        }
    }
    (no_pin, no_label, mismatch)
}

// ---------------------------------------------------------------------------
// Duplicate support
// ---------------------------------------------------------------------------

/// Give every `(uuid ..)` in the document a fresh value; returns old to new.
pub fn refresh_uuids(sch: &mut Schematic) -> HashMap<String, String> {
    fn walk(n: &mut Node, map: &mut HashMap<String, String>) {
        if n.head() == Some("uuid") {
            if let Some(old) = n.arg(0) {
                let new = map.entry(old).or_insert_with(new_uuid).clone();
                n.set_arg(0, Node::quoted(&new));
            }
            return;
        }
        if let Some(c) = n.children_mut() {
            for k in c.iter_mut() {
                walk(k, map);
            }
        }
    }
    let mut map = HashMap::new();
    walk(&mut sch.doc.root, &mut map);
    map
}

/// Allocates references that are not used yet: R3 taken, R? asks, R4 comes back.
pub struct RefAllocator {
    pub used: BTreeSet<String>,
}

impl RefAllocator {
    pub fn next(&mut self, like: &str) -> String {
        let prefix: String = like.chars().take_while(|c| !c.is_ascii_digit() && *c != '?').collect();
        let mut n = 1;
        loop {
            let cand = format!("{prefix}{n}");
            if !self.used.contains(&cand) {
                self.used.insert(cand.clone());
                return cand;
            }
            n += 1;
        }
    }
}

/// Point every nested sheet's instance entry at `new_path` (the copy's own path) and give it
/// `page`.
pub fn retarget_sheet_instances(sch: &mut Schematic, new_path: &str, project: &str, next_page: &mut u32) {
    for n in sch.doc.root.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("sheet")) {
        let children = n.children_mut().expect("list");
        if let Some(i) = children.iter().position(|c| c.head() == Some("instances")) {
            children.remove(i);
        }
        *next_page += 1;
        set_sheet_instance_page(n, project, new_path, &next_page.to_string());
    }
}

/// A free file name beside `base`: `<stem>_copy.kicad_sch`, `<stem>_copy2.kicad_sch`, ...
pub fn free_copy_name(dir: &Path, stem: &str) -> String {
    let mut n = 1;
    loop {
        let name = if n == 1 { format!("{stem}_copy.kicad_sch") } else { format!("{stem}_copy{n}.kicad_sch") };
        if !dir.join(&name).exists() {
            return name;
        }
        n += 1;
    }
}

/// The project root sheet for a file: `<stem>.kicad_sch` of the `.kicad_pro` in its folder
/// (when that root exists), else None.
pub fn project_root_for(file: &Path) -> Option<PathBuf> {
    let dir = file.parent()?;
    let mut pros: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.extension().and_then(|x| x.to_str()) == Some("kicad_pro")).collect();
    pros.sort();
    let stem = file.file_stem()?.to_string_lossy().to_string();
    if let Some(p) = pros.iter().find(|p| p.file_stem().map(|s| s.to_string_lossy() == stem).unwrap_or(false)) {
        let root = p.with_extension("kicad_sch");
        if root.is_file() {
            return Some(root);
        }
    }
    for p in pros {
        let root = p.with_extension("kicad_sch");
        if root.is_file() {
            return Some(root);
        }
    }
    None
}

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

    #[test]
    fn paper_parse() {
        assert_eq!(Paper::parse("a3", None, None, false).unwrap().name, "A3");
        assert_eq!(Paper::parse("Letter", None, None, true).unwrap().size_mm(), (215.9, 279.4));
        assert_eq!(Paper::parse("tabloid", None, None, false).unwrap().name, "USLedger");
        let c = Paper::parse("custom", Some(200.0), Some(100.0), false).unwrap();
        assert_eq!(c.node().to_text().trim(), "(paper \"User\" 200 100)");
        assert!(Paper::parse("A9", None, None, false).is_err());
        assert!(Paper::parse("custom", Some(200.0), None, false).is_err());
        assert_eq!(Paper::parse("A4", None, None, true).unwrap().node().to_text().trim(), "(paper \"A4\" portrait)");
    }

    #[test]
    fn blank_parses_with_title() {
        let (text, uuid) = blank_sheet(&Paper::parse("A3", None, None, false).unwrap(), &TitleEdit { title: Some("T".into()), comments: vec![(2, "c2".into()), (1, "c1".into())], ..Default::default() });
        let s = Schematic::parse(&text).unwrap();
        assert_eq!(s.root_uuid().unwrap(), uuid);
        assert_eq!(s.version().unwrap(), SCH_VERSION);
        assert_eq!(Paper::of(&s).name, "A3");
        let tb = title_block_json(&s);
        assert_eq!(tb["title"], "T");
        assert_eq!(tb["comments"]["1"], "c1");
        let i1 = text.find("(comment 1").unwrap();
        let i2 = text.find("(comment 2").unwrap();
        assert!(i1 < i2, "comments ordered");
        assert!(text.contains("\t(title_block\n\t\t(title \"T\")"), "{text}");
    }

    #[test]
    fn ref_allocator_skips_used() {
        let mut a = RefAllocator { used: ["R1", "R2", "C1"].iter().map(|s| s.to_string()).collect() };
        assert_eq!(a.next("R1"), "R3");
        assert_eq!(a.next("R?"), "R4");
        assert_eq!(a.next("C5"), "C2");
    }
}