master
Colby Knox Merge PR #421: 1.0.103: PCM verbs refuse an identifier that is a path 32349ae 5d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846
//! KiCad Plugin and Content Manager (PCM) automation: install packages from a PCM
//! repository WITHOUT the dialog, the way PCM itself does it on disk.
//!
//! Port of handlers/pcm.py. Three verbs: pcm_install, pcm_list, pcm_uninstall. PCM has
//! no CLI and its dialog is an owned window the bridge's input verbs cannot target, but
//! PCM is only files:
//!
//!   1. extract  <zip>/<folder>/<contents>  ->  <3rdparty>/<folder>/<pkgid>/<contents>
//!      (kicad/pcm/pcm_task_manager.cpp extract(): folder must be one of
//!      plugins/footprints/symbols/3dmodels/resources/colors; identifier dots become
//!      underscores on disk so Python packages import flat)
//!   2. record   <config>/installed_packages.json  {"packages":[entry...]}
//!      (pcm_data.cpp to_json(PCM_INSTALLATION_ENTRY): package, current_version,
//!      repository_id, repository_name, install_timestamp, pinned)
//!   3. NOTHING ELSE. KiCad registers the libraries itself: PCM_SYM_LIB_TRAVERSER and
//!      PCM_FP_LIB_TRAVERSER scan $KICADn_3RD_PARTY/symbols/<pkgid>/*.kicad_sym and
//!      footprints/<pkgid>/*.pretty when the GLOBAL TABLE LOADS and insert rows named
//!      <lib_prefix><name> (default "PCM_"), as long as pcm.lib_auto_add is on (the
//!      default). That scan happens at editor load, which is why a running editor needs
//!      a restart to see a new package: reported here as restartNeeded, never done.
//!
//! repository_id is sha256(repository url)[:16] (pcm.cpp SetRepositoryList), so KiCad's
//! own PCM dialog shows a bridge-installed package as Installed, offers updates for it,
//! and can uninstall it. The bridge is an automation of PCM, not a parallel mechanism.
//!
//! Repositories are the other half of PCM's on-disk contract: kicad.json
//! `pcm.repositories` is `[{"name": ..., "url": ...}]` (kicad_settings.cpp), read at
//! KiCad start. `add_repository` / `remove_repository` edit it the way the Manage
//! dialog does, and `list` uses it to answer "is there an update" per installed
//! package by fetching each package's own repository (matched by repository_id) and
//! comparing versions the way PCM orders them. `install {update:true}` is the headless
//! Update button: it replaces the installed version and its registry row.
//!
//! Neither `ring` nor `flate2` is a declared dependency of this crate, so the SHA-256
//! and the zip reader (stored and deflate entries, no zip64) live here on std alone.

use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

use serde_json::{json, Map, Value};

use crate::model_check::{norm, Ctx};

pub const FOLDERS: &[&str] = &["plugins", "footprints", "symbols", "3dmodels", "resources", "colors"];
pub const MAX_ZIP: u64 = 512 * 1024 * 1024;
const UA: &str = "adom-kicad-bridge pcm";

/// KiCad's built-in repository list (kicad_settings.cpp PCM_DEFAULT_REPOSITORY_NAME/URL):
/// what `pcm.repositories` means when kicad.json does not carry the key at all.
pub const DEFAULT_REPOSITORY_NAME: &str = "KiCad official repository";
pub const DEFAULT_REPOSITORY_URL: &str = "https://repository.kicad.org/repository.json";

/// Bytes fetcher: (url, timeout) -> bytes. Production uses `http_fetch`; tests hand in a table.
pub type Fetch<'a> = &'a dyn Fn(&str, Duration) -> Result<Vec<u8>, String>;

/// GET a URL over HTTPS (rustls, bundled roots) with the PCM user agent.
#[cfg(not(feature = "net"))]
pub fn http_fetch(url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {
    if let Some(p) = file_url_path(url) {
        return std::fs::read(&p).map_err(|e| format!("OSError: {}: {e}", p.display()));
    }
    Err("the net feature is off in this build: no HTTPS client".into())
}

/// `file:///C:/x/repository.json` or `file:///home/x/repository.json` as a local path (wiki #132).
/// KiCad's own PCM accepts file:// repositories and download URLs.
pub fn file_url_path(url: &str) -> Option<PathBuf> {
    let rest = url.strip_prefix("file://")?;
    let rest = rest.strip_prefix("localhost").unwrap_or(rest);
    let decoded = percent_decode(rest);
    // file:///C:/x -> "/C:/x": drop the slash before a drive letter.
    let b = decoded.as_bytes();
    let path = if b.len() > 2 && b[0] == b'/' && b[2] == b':' && b[1].is_ascii_alphabetic() { decoded[1..].to_string() } else { decoded };
    Some(PathBuf::from(path))
}

fn percent_decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' && i + 2 < b.len() {
            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
                out.push(v);
                i += 3;
                continue;
            }
        }
        out.push(b[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// A wiki.adom.inc URL that answered "not found / not allowed" anonymously: a private page.
/// Fetch it again through Adom Bridge's `desktop_wiki_fetch`, which carries the signed-in
/// user's wiki session (wiki #132). The body comes back base64 or as text depending on its type.
fn wiki_session_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
    let reply = crate::ab::call("desktop_wiki_fetch", json!({"url": url, "reason": "install a KiCad PCM package from a private wiki page"}), timeout)
        .map_err(|e| format!("wiki session fetch through Adom Bridge failed: {e:?}"))?;
    if let Some(status) = reply.get("httpStatus").and_then(Value::as_u64) {
        if status >= 400 {
            return Err(format!("HTTPError: http status: {status} (signed in through Adom Bridge)"));
        }
    }
    for key in ["base64", "dataBase64", "bodyBase64", "contentBase64", "b64"] {
        if let Some(s) = reply.get(key).and_then(Value::as_str) {
            return crate::install::b64_decode(s);
        }
    }
    for key in ["text", "body", "content"] {
        if let Some(s) = reply.get(key).and_then(Value::as_str) {
            return Ok(s.as_bytes().to_vec());
        }
    }
    for key in ["savedTo", "path", "fullPath", "localPath"] {
        if let Some(p) = reply.get(key).and_then(Value::as_str) {
            if let Ok(b) = std::fs::read(p) {
                return Ok(b);
            }
        }
    }
    let keys: Vec<String> = reply.raw.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
    Err(format!("wiki session fetch answered without a body field the bridge knows (keys: {})", keys.join(", ")))
}

fn is_wiki_url(url: &str) -> bool {
    url.starts_with("https://wiki.adom.inc/")
}

#[cfg(feature = "net")]
pub fn http_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
    if let Some(p) = file_url_path(url) {
        return std::fs::read(&p).map_err(|e| format!("OSError: {}: {e}", p.display()));
    }
    match anonymous_fetch(url, timeout) {
        Err(e) if is_wiki_url(url) && (e.contains("404") || e.contains("401") || e.contains("403")) => {
            wiki_session_fetch(url, timeout).map_err(|w| format!("{e}; then {w}"))
        }
        r => r,
    }
}

#[cfg(feature = "net")]
fn anonymous_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
    let mut resp = ureq::get(url)
        .header("User-Agent", UA)
        .config()
        .timeout_global(Some(timeout))
        .build()
        .call()
        .map_err(|e| format!("{}: {e}", err_kind(&e)))?;
    resp.body_mut().with_config().limit(MAX_ZIP).read_to_vec().map_err(|e| format!("{}: {e}", err_kind(&e)))
}

#[cfg(feature = "net")]
fn err_kind(e: &ureq::Error) -> &'static str {
    match e {
        ureq::Error::StatusCode(_) => "HTTPError",
        ureq::Error::Timeout(_) => "TimeoutError",
        ureq::Error::Io(_) => "OSError",
        _ => "URLError",
    }
}

// ── SHA-256 (FIPS 180-4), std only ────────────────────────────────────────────

pub fn sha256_hex(data: &[u8]) -> String {
    const K: [u32; 64] = [
        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
        0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
        0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
        0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
        0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
        0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
    ];
    let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
    let mut msg = data.to_vec();
    let bit_len = (data.len() as u64).wrapping_mul(8);
    msg.push(0x80);
    while msg.len() % 64 != 56 {
        msg.push(0);
    }
    msg.extend_from_slice(&bit_len.to_be_bytes());
    let mut w = [0u32; 64];
    for chunk in msg.chunks(64) {
        for i in 0..16 {
            w[i] = u32::from_be_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]);
        }
        for i in 16..64 {
            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
            w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
        }
        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
        for i in 0..64 {
            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
            let ch = (e & f) ^ (!e & g);
            let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
            let maj = (a & b) ^ (a & c) ^ (b & c);
            let t2 = s0.wrapping_add(maj);
            hh = g;
            g = f;
            f = e;
            e = d.wrapping_add(t1);
            d = c;
            c = b;
            b = a;
            a = t1.wrapping_add(t2);
        }
        for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
            *x = x.wrapping_add(y);
        }
    }
    h.iter().map(|x| format!("{x:08x}")).collect()
}

// ── Inflate (RFC 1951), std only ──────────────────────────────────────────────

mod inflate {
    struct Bits<'a> {
        data: &'a [u8],
        pos: usize,
        buf: u64,
        cnt: u32,
    }
    impl<'a> Bits<'a> {
        fn bits(&mut self, n: u32) -> Result<u32, String> {
            while self.cnt < n {
                let b = *self.data.get(self.pos).ok_or("deflate stream truncated")?;
                self.pos += 1;
                self.buf |= (b as u64) << self.cnt;
                self.cnt += 8;
            }
            let v = (self.buf & ((1u64 << n) - 1)) as u32;
            self.buf >>= n;
            self.cnt -= n;
            Ok(v)
        }
        fn align(&mut self) {
            let drop = self.cnt % 8;
            self.buf >>= drop;
            self.cnt -= drop;
        }
    }

    struct Huff {
        count: [u16; 16],
        symbol: Vec<u16>,
    }
    impl Huff {
        fn new(lengths: &[u8]) -> Result<Huff, String> {
            let mut count = [0u16; 16];
            for &l in lengths {
                count[l as usize] += 1;
            }
            count[0] = 0;
            let mut left: i32 = 1;
            for c in &count[1..] {
                left = (left << 1) - *c as i32;
                if left < 0 {
                    return Err("over-subscribed huffman code".into());
                }
            }
            let mut offs = [0u16; 16];
            for i in 1..15 {
                offs[i + 1] = offs[i] + count[i];
            }
            let mut symbol = vec![0u16; lengths.len()];
            for (s, &l) in lengths.iter().enumerate() {
                if l != 0 {
                    symbol[offs[l as usize] as usize] = s as u16;
                    offs[l as usize] += 1;
                }
            }
            Ok(Huff { count, symbol })
        }
        fn decode(&self, b: &mut Bits) -> Result<u16, String> {
            let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);
            for len in 1..16 {
                code |= b.bits(1)? as i32;
                let count = self.count[len] as i32;
                if code - count < first {
                    return self.symbol.get((index + (code - first)) as usize).copied().ok_or_else(|| "bad huffman symbol".to_string());
                }
                index += count;
                first += count;
                first <<= 1;
                code <<= 1;
            }
            Err("invalid huffman code".into())
        }
    }

    const LEN_BASE: [u16; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
    const LEN_EXTRA: [u8; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
    const DIST_BASE: [u16; 30] = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
    const DIST_EXTRA: [u8; 30] = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];

    fn codes(b: &mut Bits, out: &mut Vec<u8>, lit: &Huff, dist: &Huff, cap: usize) -> Result<(), String> {
        loop {
            let sym = lit.decode(b)? as usize;
            if sym < 256 {
                out.push(sym as u8);
            } else if sym == 256 {
                return Ok(());
            } else {
                let i = sym - 257;
                if i >= 29 {
                    return Err("bad length symbol".into());
                }
                let len = LEN_BASE[i] as usize + b.bits(LEN_EXTRA[i] as u32)? as usize;
                let d = dist.decode(b)? as usize;
                if d >= 30 {
                    return Err("bad distance symbol".into());
                }
                let distance = DIST_BASE[d] as usize + b.bits(DIST_EXTRA[d] as u32)? as usize;
                if distance > out.len() {
                    return Err("distance too far back".into());
                }
                let start = out.len() - distance;
                for k in 0..len {
                    let byte = out[start + k];
                    out.push(byte);
                }
            }
            if out.len() > cap {
                return Err("inflated size exceeds the declared size".into());
            }
        }
    }

    /// Inflate a raw deflate stream. `expected` bounds the output so a bogus entry cannot balloon.
    pub fn inflate(data: &[u8], expected: usize) -> Result<Vec<u8>, String> {
        let mut b = Bits { data, pos: 0, buf: 0, cnt: 0 };
        let mut out = Vec::with_capacity(expected);
        let cap = expected;
        loop {
            let last = b.bits(1)?;
            match b.bits(2)? {
                0 => {
                    b.align();
                    let len = b.bits(16)? as usize;
                    let nlen = b.bits(16)? as usize;
                    if len != (!nlen & 0xffff) {
                        return Err("stored block length check failed".into());
                    }
                    let end = b.pos.checked_add(len).ok_or("overflow")?;
                    let chunk = data.get(b.pos..end).ok_or("stored block truncated")?;
                    out.extend_from_slice(chunk);
                    b.pos = end;
                    if out.len() > cap {
                        return Err("inflated size exceeds the declared size".into());
                    }
                }
                1 => {
                    let mut l = [0u8; 288];
                    for (i, v) in l.iter_mut().enumerate() {
                        *v = match i {
                            0..=143 => 8,
                            144..=255 => 9,
                            256..=279 => 7,
                            _ => 8,
                        };
                    }
                    let lit = Huff::new(&l)?;
                    let dist = Huff::new(&[5u8; 30])?;
                    codes(&mut b, &mut out, &lit, &dist, cap)?;
                }
                2 => {
                    let nlen = b.bits(5)? as usize + 257;
                    let ndist = b.bits(5)? as usize + 1;
                    let ncode = b.bits(4)? as usize + 4;
                    if nlen > 286 || ndist > 30 {
                        return Err("bad dynamic block counts".into());
                    }
                    const ORDER: [usize; 19] = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
                    let mut cl = [0u8; 19];
                    for &o in ORDER.iter().take(ncode) {
                        cl[o] = b.bits(3)? as u8;
                    }
                    let clh = Huff::new(&cl)?;
                    let mut lengths = vec![0u8; nlen + ndist];
                    let mut i = 0;
                    while i < nlen + ndist {
                        let sym = clh.decode(&mut b)?;
                        match sym {
                            0..=15 => {
                                lengths[i] = sym as u8;
                                i += 1;
                            }
                            16 => {
                                if i == 0 {
                                    return Err("repeat with no previous length".into());
                                }
                                let prev = lengths[i - 1];
                                let n = 3 + b.bits(2)? as usize;
                                if i + n > lengths.len() {
                                    return Err("too many lengths".into());
                                }
                                for _ in 0..n {
                                    lengths[i] = prev;
                                    i += 1;
                                }
                            }
                            17 | 18 => {
                                let n = if sym == 17 { 3 + b.bits(3)? as usize } else { 11 + b.bits(7)? as usize };
                                if i + n > lengths.len() {
                                    return Err("too many lengths".into());
                                }
                                i += n;
                            }
                            _ => return Err("bad code length symbol".into()),
                        }
                    }
                    if lengths[256] == 0 {
                        return Err("no end-of-block code".into());
                    }
                    let lit = Huff::new(&lengths[..nlen])?;
                    let dist = Huff::new(&lengths[nlen..])?;
                    codes(&mut b, &mut out, &lit, &dist, cap)?;
                }
                _ => return Err("invalid block type".into()),
            }
            if last == 1 {
                return Ok(out);
            }
        }
    }
}

// ── Minimal zip reader ────────────────────────────────────────────────────────

pub struct ZipEntry {
    pub name: String,
    pub method: u16,
    pub compressed_size: u64,
    pub size: u64,
    local_offset: usize,
}

pub struct ZipFile<'a> {
    data: &'a [u8],
    pub entries: Vec<ZipEntry>,
}

fn u16_at(d: &[u8], i: usize) -> Result<u16, String> {
    Ok(u16::from_le_bytes([*d.get(i).ok_or("zip truncated")?, *d.get(i + 1).ok_or("zip truncated")?]))
}
fn u32_at(d: &[u8], i: usize) -> Result<u32, String> {
    Ok(u32::from_le_bytes([
        *d.get(i).ok_or("zip truncated")?,
        *d.get(i + 1).ok_or("zip truncated")?,
        *d.get(i + 2).ok_or("zip truncated")?,
        *d.get(i + 3).ok_or("zip truncated")?,
    ]))
}

impl<'a> ZipFile<'a> {
    pub fn parse(data: &'a [u8]) -> Result<ZipFile<'a>, String> {
        if data.len() < 22 {
            return Err("not a zip file (too small)".into());
        }
        let min = data.len().saturating_sub(22 + 65535);
        let mut eocd = None;
        let mut i = data.len() - 22;
        loop {
            if &data[i..i + 4] == b"PK\x05\x06" {
                eocd = Some(i);
                break;
            }
            if i == min {
                break;
            }
            i -= 1;
        }
        let eocd = eocd.ok_or("not a zip file (no end-of-central-directory record)")?;
        let total = u16_at(data, eocd + 10)? as usize;
        let cd_off = u32_at(data, eocd + 16)? as usize;
        if total == 0xffff || cd_off == 0xffff_ffff {
            return Err("zip64 archives are not supported".into());
        }
        let mut entries = Vec::with_capacity(total);
        let mut p = cd_off;
        for _ in 0..total {
            if data.get(p..p + 4) != Some(b"PK\x01\x02") {
                return Err("bad central directory entry".into());
            }
            let method = u16_at(data, p + 10)?;
            let compressed_size = u32_at(data, p + 20)? as u64;
            let size = u32_at(data, p + 24)? as u64;
            let n = u16_at(data, p + 28)? as usize;
            let e = u16_at(data, p + 30)? as usize;
            let c = u16_at(data, p + 32)? as usize;
            let local_offset = u32_at(data, p + 42)? as usize;
            if compressed_size == 0xffff_ffff || size == 0xffff_ffff || local_offset == 0xffff_ffff {
                return Err("zip64 entries are not supported".into());
            }
            let name = String::from_utf8_lossy(data.get(p + 46..p + 46 + n).ok_or("zip truncated")?).to_string();
            entries.push(ZipEntry { name, method, compressed_size, size, local_offset });
            p += 46 + n + e + c;
        }
        Ok(ZipFile { data, entries })
    }

    pub fn read(&self, entry: &ZipEntry) -> Result<Vec<u8>, String> {
        let p = entry.local_offset;
        if self.data.get(p..p + 4) != Some(b"PK\x03\x04") {
            return Err(format!("bad local header for {}", entry.name));
        }
        let n = u16_at(self.data, p + 26)? as usize;
        let e = u16_at(self.data, p + 28)? as usize;
        let start = p + 30 + n + e;
        let end = start.checked_add(entry.compressed_size as usize).ok_or("overflow")?;
        let raw = self.data.get(start..end).ok_or_else(|| format!("entry data truncated: {}", entry.name))?;
        match entry.method {
            0 => Ok(raw.to_vec()),
            8 => {
                let out = inflate::inflate(raw, entry.size as usize).map_err(|e| format!("{}: {e}", entry.name))?;
                if out.len() as u64 != entry.size {
                    return Err(format!("{}: inflated {} bytes, expected {}", entry.name, out.len(), entry.size));
                }
                Ok(out)
            }
            m => Err(format!("{}: unsupported zip compression method {m}", entry.name)),
        }
    }
}

// ── Versions, the way PCM orders them ─────────────────────────────────────────

/// Dotted version order: each component numerically ("1.10.0" > "1.9.0"), missing
/// components are 0 ("1.0" == "1.0.0"), a non-numeric component counts as 0 and the
/// raw strings break the tie so odd versions still order deterministically.
pub fn compare_versions(a: &str, b: &str) -> Ordering {
    let parse = |s: &str| -> (Vec<u64>, bool) {
        let mut lossy = false;
        let parts = s.trim().split('.').map(|p| p.trim().parse::<u64>().unwrap_or_else(|_| { lossy = true; 0 })).collect();
        (parts, lossy)
    };
    let ((pa, la), (pb, lb)) = (parse(a), parse(b));
    let n = pa.len().max(pb.len());
    for i in 0..n {
        let (x, y) = (pa.get(i).copied().unwrap_or(0), pb.get(i).copied().unwrap_or(0));
        match x.cmp(&y) {
            Ordering::Equal => continue,
            o => return o,
        }
    }
    if la || lb {
        a.trim().cmp(b.trim())
    } else {
        Ordering::Equal
    }
}

/// Order two PackageVersion objects: `version_epoch` first (a higher epoch is always
/// later, as the schema says), then `compare_versions` on `version`.
pub fn version_cmp(a: &Value, b: &Value) -> Ordering {
    let epoch = |v: &Value| v.get("version_epoch").and_then(Value::as_u64).unwrap_or(0);
    let ver = |v: &Value| v.get("version").and_then(Value::as_str).unwrap_or("").to_string();
    epoch(a).cmp(&epoch(b)).then_with(|| compare_versions(&ver(a), &ver(b)))
}

/// PCM's compatibility filter: `kicad_version` (minimum) <= this KiCad and, when present,
/// `kicad_version_max` >= this KiCad, compared on major.minor. An unknown KiCad version
/// (no detected install) accepts everything.
pub fn version_compatible(v: &Value, kicad_version: &str) -> bool {
    let mm = |s: &str| s.split('.').take(2).collect::<Vec<_>>().join(".");
    if kicad_version.trim().is_empty() {
        return true;
    }
    let here = mm(kicad_version);
    if let Some(min) = v.get("kicad_version").and_then(Value::as_str).filter(|s| !s.is_empty()) {
        if compare_versions(&mm(min), &here) == Ordering::Greater {
            return false;
        }
    }
    if let Some(max) = v.get("kicad_version_max").and_then(Value::as_str).filter(|s| !s.is_empty()) {
        if compare_versions(&mm(max), &here) == Ordering::Less {
            return false;
        }
    }
    true
}

/// The version PCM would offer: the highest compatible one that has a download_url.
pub fn latest_version<'a>(versions: &'a [Value], kicad_version: &str) -> Option<&'a Value> {
    versions
        .iter()
        .filter(|v| v.get("download_url").and_then(Value::as_str).map(|u| !u.is_empty()).unwrap_or(false))
        .filter(|v| version_compatible(v, kicad_version))
        .max_by(|a, b| version_cmp(a, b))
}

// ── Repositories (kicad.json pcm.repositories) ────────────────────────────────

/// PCM's repository_id: sha256(url) truncated to 16 hex chars (pcm.cpp SetRepositoryList).
pub fn repository_id(url: &str) -> String {
    sha256_hex(url.as_bytes())[..16].to_string()
}

fn kicad_json_path(ctx: &Ctx) -> Option<PathBuf> {
    ctx.config_dir.as_ref().map(|c| c.join("kicad.json"))
}

fn read_kicad_json(path: &Path) -> Result<Value, String> {
    match std::fs::read_to_string(path) {
        Ok(text) => {
            let v: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", norm(path)))?;
            if v.is_object() {
                Ok(v)
            } else {
                Err(format!("{}: top level is not a JSON object", norm(path)))
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({"meta": {"filename": "kicad", "version": 0}})),
        Err(e) => Err(format!("{}: {e}", norm(path))),
    }
}

/// The `(name, url)` rows KiCad's PCM will use: `pcm.repositories` from kicad.json, or
/// KiCad's built-in default when the key is absent (which is how a fresh KiCad looks).
pub fn registered_repositories(ctx: &Ctx) -> Vec<(String, String)> {
    let doc = kicad_json_path(ctx).and_then(|p| read_kicad_json(&p).ok()).unwrap_or(Value::Null);
    repositories_of(&doc)
}

fn repositories_of(doc: &Value) -> Vec<(String, String)> {
    match doc.get("pcm").and_then(|p| p.get("repositories")).and_then(Value::as_array) {
        Some(rows) => rows
            .iter()
            .filter_map(|r| {
                let url = r.get("url").and_then(Value::as_str)?.to_string();
                let name = r.get("name").and_then(Value::as_str).unwrap_or("").to_string();
                Some((name, url))
            })
            .collect(),
        None => vec![(DEFAULT_REPOSITORY_NAME.to_string(), DEFAULT_REPOSITORY_URL.to_string())],
    }
}

fn repositories_json(rows: &[(String, String)]) -> Value {
    Value::Array(rows.iter().map(|(n, u)| json!({"name": n, "url": u})).collect())
}

fn repositories_report(rows: &[(String, String)]) -> Value {
    Value::Array(rows.iter().map(|(n, u)| json!({"name": n, "url": u, "repositoryId": repository_id(u)})).collect())
}

/// `.adom-bak` beside the file (when it exists), then tmp + rename. Two-space indent,
/// which is how KiCad's own settings writer (nlohmann dump(2)) lays kicad.json out.
fn write_kicad_json(path: &Path, doc: &Value) -> Result<Option<PathBuf>, String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", norm(parent)))?;
    }
    let mut backup = None;
    if path.is_file() {
        let b = path.with_file_name(format!("{}.adom-bak", path.file_name().unwrap_or_default().to_string_lossy()));
        std::fs::copy(path, &b).map_err(|e| format!("cannot back up {}: {e}", norm(path)))?;
        backup = Some(b);
    }
    let mut buf = Vec::new();
    let mut ser = serde_json::Serializer::with_formatter(&mut buf, serde_json::ser::PrettyFormatter::with_indent(b"  "));
    serde::Serialize::serialize(doc, &mut ser).map_err(|e| e.to_string())?;
    buf.push(b'\n');
    let tmp = path.with_file_name(format!("{}.adom-tmp", path.file_name().unwrap_or_default().to_string_lossy()));
    std::fs::write(&tmp, &buf).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| {
        let _ = std::fs::remove_file(&tmp);
        format!("cannot write {}: {e}", norm(path))
    })?;
    Ok(backup)
}

const REPO_RESTART_NOTE: &str = "KiCad reads pcm.repositories at start: a running KiCad keeps its old list until it is closed and launched again, and a KiCad that is running NOW will overwrite this edit when it saves its settings on exit, so close KiCad first when kicadRunning is true or null.";

fn valid_repo_url(u: &str) -> bool {
    (u.starts_with("https://") || u.starts_with("http://") || u.starts_with("file://")) && !u[u.find("://").unwrap_or(0) + 3..].is_empty()
}

/// kicad_pcm_add_repository {url, name?}: register a PCM repository in kicad.json the way
/// the Manage dialog does. The repository.json is fetched to confirm it is one and to
/// take its name when none is given. Idempotent on the URL.
pub fn add_repository(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {
    let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
    let Some(url) = s("url") else {
        return json!({"success": false, "errorCode": "invalid_arg", "error": "url is required",
            "_hint": "url is the repository.json URL, e.g. https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json"});
    };
    if !valid_repo_url(&url) {
        return json!({"success": false, "errorCode": "invalid_arg", "error": format!("not a repository URL: {url}"),
            "_hint": "PCM accepts http(s):// and file:// URLs that point at a repository.json."});
    }
    let Some(path) = kicad_json_path(ctx) else {
        return json!({"success": false, "errorCode": "settings_not_found", "error": "KiCad config directory unknown",
            "_hint": "kicad_readiness first; the bridge needs a detected KiCad to know where kicad.json lives."});
    };
    // Confirm it is a PCM repository (and learn its name), like the dialog does.
    let (fetched, fetched_name, fetch_error) = match fetch(&url, Duration::from_secs(30)) {
        Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
            Ok(v) if v.get("packages").and_then(|p| p.get("url")).is_some() => (true, v.get("name").and_then(Value::as_str).map(String::from), None),
            Ok(_) => (false, None, Some("the URL serves JSON, but not a PCM repository (no packages.url)".to_string())),
            Err(e) => (false, None, Some(format!("the URL does not serve JSON: {e}"))),
        },
        Err(e) => (false, None, Some(e)),
    };
    let name = match s("name").or(fetched_name) {
        Some(n) => n,
        None => {
            return json!({"success": false, "errorCode": "repository_unreachable", "url": url,
                "error": format!("could not read the repository and no name was given: {}", fetch_error.clone().unwrap_or_default()),
                "_hint": "Check the URL serves a repository.json anonymously (curl it), or pass name to register it unverified."});
        }
    };
    let mut doc = match read_kicad_json(&path) {
        Ok(d) => d,
        Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": e}),
    };
    let mut rows = repositories_of(&doc);
    let existing = rows.iter().find(|(_, u)| u == &url).cloned();
    let mut out = json!({
        "success": true, "url": url, "repositoryId": repository_id(&url),
        "file": norm(&path), "verified": fetched,
        "kicadRunning": Value::Null, "restartNeeded": true,
    });
    if let Some(e) = fetch_error {
        out["warning"] = json!(format!("registered unverified: {e}"));
    }
    if let Some((n, _)) = existing {
        out["name"] = json!(n);
        out["added"] = json!(false);
        out["alreadyRegistered"] = json!(true);
        out["repositories"] = repositories_report(&rows);
        out["_hint"] = json!(format!("Already in pcm.repositories as '{n}'; nothing written. {REPO_RESTART_NOTE}"));
        return out;
    }
    rows.push((name.clone(), url.clone()));
    let obj = doc.as_object_mut().unwrap();
    if !obj.get("pcm").map(Value::is_object).unwrap_or(false) {
        obj.insert("pcm".into(), Value::Object(Map::new()));
    }
    obj["pcm"]["repositories"] = repositories_json(&rows);
    match write_kicad_json(&path, &doc) {
        Ok(backup) => {
            out["name"] = json!(name);
            out["added"] = json!(true);
            out["alreadyRegistered"] = json!(false);
            out["backup"] = backup.map(|b| json!(norm(&b))).unwrap_or(Value::Null);
            out["repositories"] = repositories_report(&rows);
            out["_hint"] = json!(format!("Written to kicad.json pcm.repositories (KiCad's own default list was kept in front of it). {REPO_RESTART_NOTE} Then kicad_pcm_install {{\"repositoryUrl\": \"{url}\", \"identifier\": ...}} installs from it without the dialog."));
            out
        }
        Err(e) => json!({"success": false, "errorCode": "settings_write_failed", "error": e,
            "_hint": "The original kicad.json is intact (atomic replace). Check the directory is writable."}),
    }
}

/// kicad_pcm_remove_repository {url}: drop a row from pcm.repositories. Packages
/// installed from it stay installed (PCM keeps them too; they just lose update checks).
pub fn remove_repository(ctx: &Ctx, args: &Value) -> Value {
    let Some(url) = args.get("url").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
        return json!({"success": false, "errorCode": "invalid_arg", "error": "url is required"});
    };
    let Some(path) = kicad_json_path(ctx) else {
        return json!({"success": false, "errorCode": "settings_not_found", "error": "KiCad config directory unknown"});
    };
    let mut doc = match read_kicad_json(&path) {
        Ok(d) => d,
        Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": e}),
    };
    let mut rows = repositories_of(&doc);
    let before = rows.len();
    rows.retain(|(_, u)| u != url);
    if rows.len() == before {
        return json!({"success": true, "url": url, "removed": false, "file": norm(&path), "repositories": repositories_report(&rows),
            "kicadRunning": Value::Null, "restartNeeded": false,
            "_hint": "That URL is not in pcm.repositories; nothing written."});
    }
    let obj = doc.as_object_mut().unwrap();
    if !obj.get("pcm").map(Value::is_object).unwrap_or(false) {
        obj.insert("pcm".into(), Value::Object(Map::new()));
    }
    obj["pcm"]["repositories"] = repositories_json(&rows);
    match write_kicad_json(&path, &doc) {
        Ok(backup) => json!({"success": true, "url": url, "removed": true, "file": norm(&path),
            "backup": backup.map(|b| json!(norm(&b))).unwrap_or(Value::Null),
            "repositories": repositories_report(&rows), "kicadRunning": Value::Null, "restartNeeded": true,
            "_hint": format!("Removed from kicad.json pcm.repositories. Packages installed from it stay installed. {REPO_RESTART_NOTE}")}),
        Err(e) => json!({"success": false, "errorCode": "settings_write_failed", "error": e}),
    }
}

// ── Fetching a repository ─────────────────────────────────────────────────────

pub enum RepoError {
    /// packages.json bytes do not hash to repository.json's packages.sha256.
    Hash,
    Other(String),
}

/// repository.json -> packages.json, sha256-checked the way PCM checks it.
/// Returns (repository document, package array).
pub fn fetch_repository(fetch: Fetch, repo_url: &str, timeout: Duration) -> Result<(Value, Vec<Value>), RepoError> {
    let repo: Value = serde_json::from_slice(&fetch(repo_url, timeout).map_err(RepoError::Other)?).map_err(|e| RepoError::Other(format!("JSONDecodeError: {e}")))?;
    let pkgs_url = repo
        .get("packages")
        .and_then(|p| p.get("url"))
        .and_then(Value::as_str)
        .ok_or_else(|| RepoError::Other("KeyError: 'packages'".into()))?
        .to_string();
    let raw = fetch(&pkgs_url, timeout).map_err(RepoError::Other)?;
    let want_sha = repo.get("packages").and_then(|p| p.get("sha256")).and_then(Value::as_str).unwrap_or("");
    if sha256_hex(&raw) != want_sha {
        return Err(RepoError::Hash);
    }
    let pk: Value = serde_json::from_slice(&raw).map_err(|e| RepoError::Other(format!("JSONDecodeError: {e}")))?;
    let list = pk.get("packages").and_then(Value::as_array).cloned().ok_or_else(|| RepoError::Other("KeyError: 'packages'".into()))?;
    Ok((repo, list))
}

// ── PCM on disk ───────────────────────────────────────────────────────────────

pub struct Paths {
    pub third: PathBuf,
    pub installed: PathBuf,
    pub version: String,
    pub resolved_by: &'static str,
}

/// (3rdparty dir, installed_packages.json, version) or None when KiCad's dirs are unknown.
pub fn paths(ctx: &Ctx) -> Option<Paths> {
    let (third, resolved_by) = ctx.third_party_dir()?;
    let cfg = ctx.config_dir.as_ref()?;
    Some(Paths { third, installed: cfg.join("installed_packages.json"), version: ctx.version.clone(), resolved_by })
}

pub fn read_installed(p: &Path) -> Value {
    if let Ok(text) = std::fs::read_to_string(p) {
        if let Ok(v) = serde_json::from_str::<Value>(&text) {
            if v.is_object() && v.get("packages").map(Value::is_array).unwrap_or(false) {
                return v;
            }
        }
    }
    json!({"packages": []})
}

/// Atomic write, four-space indent like KiCad's own file.
pub fn write_installed(p: &Path, d: &Value) -> Result<(), String> {
    if let Some(parent) = p.parent() {
        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    let mut buf = Vec::new();
    let mut ser = serde_json::Serializer::with_formatter(&mut buf, serde_json::ser::PrettyFormatter::with_indent(b"    "));
    serde::Serialize::serialize(d, &mut ser).map_err(|e| e.to_string())?;
    buf.push(b'\n');
    let tmp = p.with_extension("json.tmp");
    std::fs::write(&tmp, &buf).map_err(|e| e.to_string())?;
    std::fs::rename(&tmp, p).map_err(|e| e.to_string())
}

pub fn pkgdir(identifier: &str) -> String {
    identifier.replace('.', "_")
}

/// Can this identifier name a package folder under 3rdparty? It becomes a path segment that
/// install and uninstall delete: an absolute path or a drive prefix would replace the whole path
/// in Path::join, and a package zip's metadata.json chooses it. KiCad's schema allows letters,
/// digits, '-' and '.'; '_' is kept for packages already installed under it.
pub fn identifier_is_safe(identifier: &str) -> bool {
    !identifier.is_empty() && identifier.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}

fn invalid_identifier(identifier: &str) -> Value {
    json!({"success": false, "errorCode": "invalid_identifier", "identifier": identifier,
        "error": format!("'{identifier}' is not a package identifier: only letters, digits, '.', '-' and '_' are allowed"),
        "_hint": "A PCM identifier looks like inc.adom.wiki.<owner>.<page>. Nothing was deleted or written."})
}

pub struct Extracted {
    pub files: usize,
    pub symbols: Vec<String>,
    pub footprints: Vec<String>,
    pub models: Vec<String>,
    pub package_dir: String,
}

/// PCM's extract(): <folder>/<contents> -> <3rdparty>/<folder>/<pkgid>/<contents>.
/// Zip-slip guarded; entries outside the known folders are ignored (as PCM does).
pub fn extract(zip_bytes: &[u8], third: &Path, identifier: &str) -> Result<Extracted, String> {
    if !identifier_is_safe(identifier) {
        return Err(format!("'{identifier}' is not a package identifier"));
    }
    let zf = ZipFile::parse(zip_bytes)?;
    let total: u64 = zf.entries.iter().map(|e| e.size).sum();
    if total > MAX_ZIP {
        return Err(format!("package unpacks to {total} bytes, over the {MAX_ZIP} cap"));
    }
    let pkgid = pkgdir(identifier);
    let mut out = Extracted { files: 0, symbols: vec![], footprints: vec![], models: vec![], package_dir: pkgid.clone() };
    for entry in &zf.entries {
        let name = entry.name.replace('\\', "/");
        if name.is_empty() || name.ends_with('/') {
            continue;
        }
        let parts: Vec<&str> = name.split('/').filter(|p| !p.is_empty() && *p != ".").collect();
        let drive = name.len() >= 2 && name.as_bytes()[0].is_ascii_alphabetic() && name.as_bytes()[1] == b':';
        if parts.iter().any(|p| *p == "..") || drive || name.starts_with('/') {
            return Err(format!("unsafe zip entry: {}", entry.name));
        }
        if parts.len() < 2 || !FOLDERS.contains(&parts[0]) {
            continue;
        }
        let base = third.join(parts[0]).join(&pkgid);
        let mut target = base.clone();
        for p in &parts[1..] {
            target.push(p);
        }
        if !target.starts_with(&base) {
            return Err(format!("unsafe zip entry: {}", entry.name));
        }
        let bytes = zf.read(entry)?;
        if let Some(parent) = target.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", norm(parent)))?;
        }
        std::fs::write(&target, &bytes).map_err(|e| format!("{}: {e}", norm(&target)))?;
        out.files += 1;
        let last = parts[parts.len() - 1];
        if parts[0] == "symbols" && name.ends_with(".kicad_sym") {
            out.symbols.push(last[..last.len() - 10].to_string());
        } else if parts[0] == "footprints" && parts.len() >= 3 && parts[1].ends_with(".pretty") {
            let lib = parts[1][..parts[1].len() - 7].to_string();
            if !out.footprints.contains(&lib) {
                out.footprints.push(lib);
            }
        } else if parts[0] == "3dmodels" && parts.len() >= 3 && !out.models.iter().any(|m| m == parts[1]) {
            out.models.push(parts[1].to_string());
        }
    }
    Ok(out)
}

/// kicad.json pcm block: (lib_auto_add, lib_prefix). Defaults are KiCad's.
fn pcm_prefs(ctx: &Ctx) -> (bool, String) {
    let v = ctx
        .config_dir
        .as_ref()
        .and_then(|c| std::fs::read_to_string(c.join("kicad.json")).ok())
        .and_then(|s| serde_json::from_str::<Value>(&s).ok())
        .unwrap_or(Value::Null);
    let pcm = v.get("pcm").cloned().unwrap_or(Value::Null);
    let auto_add = pcm.get("lib_auto_add").and_then(Value::as_bool).unwrap_or(true);
    let prefix = pcm.get("lib_prefix").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("PCM_").to_string();
    (auto_add, prefix)
}

fn now_secs() -> u64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

fn ident_of(entry: &Value) -> Option<&str> {
    entry.get("package").and_then(|p| p.get("identifier")).and_then(Value::as_str)
}

/// kicadRunning / restartNeeded for a PCM verb: the process scan answers it where the OS
/// layer has one, and an unseeable KiCad is null with restartNeeded true (wiki #63).
fn running_block() -> (Value, bool) {
    let (running, restart, _, _) = crate::install_kicad::running_report();
    (running, restart)
}

/// The repository URL an installed package came from: its registry row's repository_id
/// matched against the registered repositories (by id, then by name as a fallback).
fn repository_url_for(installed: &Value, ident: &str, repos: &[(String, String)]) -> Option<String> {
    let entry = installed["packages"].as_array()?.iter().find(|e| ident_of(e) == Some(ident))?;
    let id = entry.get("repository_id").and_then(Value::as_str).unwrap_or("");
    let name = entry.get("repository_name").and_then(Value::as_str).unwrap_or("");
    repos
        .iter()
        .find(|(_, u)| !id.is_empty() && repository_id(u) == id)
        .or_else(|| repos.iter().find(|(n, _)| !name.is_empty() && n == name))
        .map(|(_, u)| u.clone())
}

/// kicad_pcm_install {repositoryUrl?, identifier, version?, update?, libPrefix?, force?}.
///
/// `update:true` is PCM's Update button without the dialog: the repository is taken
/// from the installed package's own registry row when repositoryUrl is omitted, the
/// newest compatible version is chosen, an installed version that is already the newest
/// answers upToDate, and otherwise the old files are replaced and installed_packages.json
/// rewritten (previousVersion says what was there). A plain install of a version that
/// differs from the installed one is the same replacement; `force` reinstalls the same one.
/// `kicad_pcm_install {zipPath}` (wiki #132): install a local package zip the same way a
/// repository install does. The zip's metadata.json becomes a one-package repository served
/// from memory, so the extraction, the registry row and every check are the same code.
fn install_from_zip(ctx: &Ctx, args: &Value, zip_path: &str) -> Value {
    let p = file_url_path(zip_path).unwrap_or_else(|| PathBuf::from(zip_path));
    let blob = match std::fs::read(&p) {
        Ok(b) => b,
        Err(e) => return json!({"success": false, "errorCode": "zip_unreadable", "error": format!("cannot read {}: {e}", p.display())}),
    };
    let meta: Value = match ZipFile::parse(&blob).and_then(|z| {
        let entry = z.entries.iter().find(|e| e.name == "metadata.json").ok_or_else(|| "the zip has no metadata.json at its root (not a KiCad PCM package)".to_string())?;
        let raw = z.read(entry)?;
        serde_json::from_slice::<Value>(&raw).map_err(|e| format!("metadata.json: {e}"))
    }) {
        Ok(m) => m,
        Err(e) => return json!({"success": false, "errorCode": "not_a_pcm_package", "error": e,
            "_hint": "A PCM package zip carries metadata.json beside its symbols/, footprints/ and 3dmodels/ folders."}),
    };
    let Some(ident) = meta.get("identifier").and_then(Value::as_str).map(String::from) else {
        return json!({"success": false, "errorCode": "not_a_pcm_package", "error": "metadata.json has no identifier"});
    };
    let zip_url = format!("file:///{}", p.to_string_lossy().replace('\\', "/").trim_start_matches('/'));
    let mut pkg = meta.clone();
    let mut versions = meta.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();
    if versions.is_empty() {
        versions.push(json!({"version": "0.0.0", "status": "stable", "kicad_version": "6.0"}));
    }
    // The zip is one version: the one it says first (PCM writes the packaged version there).
    let mut v = versions[0].clone();
    v["download_url"] = json!(zip_url);
    v["download_sha256"] = json!(sha256_hex(&blob));
    v["download_size"] = json!(blob.len());
    pkg["versions"] = json!([v]);
    let packages = serde_json::to_vec(&json!({"packages": [pkg]})).unwrap_or_default();
    let repo_url = "local-zip://repository.json";
    let pkgs_url = "local-zip://packages.json";
    let repo = serde_json::to_vec(&json!({"name": "Local package zip", "packages": {"url": pkgs_url, "sha256": sha256_hex(&packages)}})).unwrap_or_default();
    let serve = move |url: &str, t: Duration| -> Result<Vec<u8>, String> {
        match url {
            u if u == repo_url => Ok(repo.clone()),
            u if u == pkgs_url => Ok(packages.clone()),
            u => http_fetch(u, t),
        }
    };
    let mut a = args.clone();
    a["identifier"] = json!(ident);
    a["repositoryUrl"] = json!(repo_url);
    a.as_object_mut().map(|o| o.remove("zipPath"));
    let mut out = install(ctx, &a, &serve);
    out["zipPath"] = json!(p.to_string_lossy());
    out["_note"] = json!("Installed from a local zip: KiCad's PCM shows it as installed but cannot check it for updates (no repository URL). Install from the repository instead when it is reachable.");
    out
}

pub fn install(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {
    if let Some(zip) = args.get("zipPath").and_then(Value::as_str).filter(|z| !z.trim().is_empty()) {
        return install_from_zip(ctx, args, zip.trim());
    }
    let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
    let (auto_add, default_prefix) = pcm_prefs(ctx);
    let prefix = s("libPrefix").unwrap_or(default_prefix);
    let update = args.get("update").and_then(Value::as_bool).unwrap_or(false);
    let Some(ident) = s("identifier") else {
        return json!({"success": false, "error": "identifier is required",
            "_hint": "identifier like com.adom.kicad-bridge.sample-lib; repositoryUrl is the repository.json URL (optional for an installed package: its own repository is used)."});
    };
    if !identifier_is_safe(&ident) {
        return invalid_identifier(&ident);
    }
    let want = s("version");
    let Some(paths) = paths(ctx) else {
        return json!({"success": false, "error": "KiCad user/config directories unknown",
            "_hint": "kicad_readiness first; the bridge needs a detected KiCad to know 3rdparty and settings paths."});
    };
    let mut installed = read_installed(&paths.installed);
    let previous: Option<String> = installed["packages"]
        .as_array()
        .and_then(|a| a.iter().find(|e| ident_of(e) == Some(ident.as_str())))
        .and_then(|e| e.get("current_version").and_then(Value::as_str))
        .map(String::from);
    let repo_url = match s("repositoryUrl") {
        Some(u) => u,
        None => {
            if previous.is_none() {
                return json!({"success": false, "errorCode": "not_installed", "error": format!("repositoryUrl is required: {ident} is not installed, so there is no repository to take it from"),
                    "_hint": "repositoryUrl is required for a first install; update:true on an installed package finds its repository itself."});
            }
            match repository_url_for(&installed, &ident, &registered_repositories(ctx)) {
                Some(u) => u,
                None => {
                    return json!({"success": false, "errorCode": "repository_not_registered",
                        "error": format!("{ident} is installed but its repository is not in kicad.json pcm.repositories"),
                        "_hint": "Pass repositoryUrl, or kicad_pcm_add_repository {url} first so KiCad's PCM can see it too."});
                }
            }
        }
    };
    let (repo, packages) = match fetch_repository(fetch, &repo_url, Duration::from_secs(120)) {
        Ok(v) => v,
        Err(RepoError::Hash) => {
            return json!({"success": false, "error": "packages.json hash does not match repository.json",
                "_hint": "The repository is mid-update or corrupted; retry in a minute."})
        }
        Err(RepoError::Other(e)) => return json!({"success": false, "error": format!("could not read repository: {e}")}),
    };
    let Some(pkg) = packages.iter().find(|p| p.get("identifier").and_then(Value::as_str) == Some(ident.as_str())) else {
        let available: Vec<Value> = packages.iter().take(20).map(|p| p.get("identifier").cloned().unwrap_or(Value::Null)).collect();
        return json!({"success": false, "error": format!("package not in repository: {ident}"), "available": available});
    };
    let versions = pkg.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();
    let vsel = match &want {
        Some(w) => match versions.iter().find(|v| v.get("version").and_then(Value::as_str) == Some(w.as_str())) {
            Some(v) => v.clone(),
            None => {
                let available: Vec<Value> = versions.iter().map(|v| v.get("version").cloned().unwrap_or(Value::Null)).collect();
                return json!({"success": false, "error": format!("version {w} not offered"), "available": available});
            }
        },
        None => match latest_version(&versions, &ctx.version) {
            Some(v) => v.clone(),
            None => return json!({"success": false, "error": "package has no downloadable version",
                "_hint": format!("No version with a download_url is compatible with KiCad {}.", ctx.version)}),
        },
    };
    let Some(download_url) = vsel.get("download_url").and_then(Value::as_str).filter(|u| !u.is_empty()).map(String::from) else {
        return json!({"success": false, "error": "package has no downloadable version"});
    };
    let version = vsel.get("version").and_then(Value::as_str).unwrap_or("").to_string();

    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
    let prior_same = previous.as_deref() == Some(version.as_str());
    if update && want.is_none() {
        if let Some(prev) = &previous {
            if compare_versions(&version, prev) != Ordering::Greater && !force {
                return json!({"success": true, "upToDate": true, "identifier": ident, "version": prev, "latestVersion": version,
                    "repositoryUrl": repo_url,
                    "_hint": "Installed version is the newest the repository offers; nothing changed. Pass version to install a specific one, or force:true to reinstall."});
            }
        }
    }
    if prior_same && !force {
        return json!({"success": true, "alreadyInstalled": true, "identifier": ident, "version": version,
            "_hint": "Already installed at this version; pass force:true to reinstall."});
    }

    let ext = match (|| -> Result<Result<Extracted, Value>, String> {
        let blob = fetch(&download_url, Duration::from_secs(600))?;
        let got = sha256_hex(&blob);
        if let Some(want_sha) = vsel.get("download_sha256").and_then(Value::as_str).filter(|s| !s.is_empty()) {
            if got != want_sha {
                return Ok(Err(json!({"success": false, "error": "download sha256 mismatch", "expected": want_sha, "got": got,
                    "_hint": "Refusing to install a package whose bytes do not match the repository's digest."})));
            }
        }
        // replace-on-update: remove the previous extraction first, as PCM does
        for folder in FOLDERS {
            let old = paths.third.join(folder).join(pkgdir(&ident));
            if old.exists() {
                let _ = std::fs::remove_dir_all(&old);
            }
        }
        Ok(Ok(extract(&blob, &paths.third, &ident)?))
    })() {
        Ok(Ok(e)) => e,
        Ok(Err(v)) => return v,
        Err(e) => return json!({"success": false, "error": format!("install failed: {e}")}),
    };

    let mut package: Map<String, Value> = pkg.as_object().cloned().unwrap_or_default();
    package.remove("versions");
    package.insert("versions".into(), json!([vsel]));
    let entry = json!({
        "package": package,
        "current_version": version,
        "repository_id": repository_id(&repo_url),
        "repository_name": repo.get("name").and_then(Value::as_str).unwrap_or(""),
        "install_timestamp": now_secs(),
        "pinned": false,
    });
    let mut list: Vec<Value> = installed["packages"].as_array().cloned().unwrap_or_default();
    list.retain(|e| ident_of(e) != Some(ident.as_str()));
    list.push(entry);
    installed["packages"] = Value::Array(list);
    if let Err(e) = write_installed(&paths.installed, &installed) {
        return json!({"success": false, "error": format!("install failed: could not write installed_packages.json: {e}")});
    }

    let (running, restart) = running_block();
    let nick = |names: &[String]| -> Vec<String> { names.iter().map(|n| format!("{prefix}{n}")).collect() };
    let replaced = previous.as_ref().map(|p| p != &version).unwrap_or(false);
    let mut hint = if replaced {
        format!("Replaced {} with {version} exactly as PCM's Update does (old files removed, registry row rewritten; keep_on_update patterns are not honoured by the bridge). ", previous.clone().unwrap_or_default())
    } else {
        String::from("Installed exactly as PCM would; KiCad's PCM dialog will list it as Installed. ")
    };
    hint.push_str("Library nicknames above are created by KiCad's own PCM scan when the global library table next LOADS, so a KiCad that is already running must be restarted (never done for you) before the libraries appear.");
    if !auto_add {
        hint.push_str(" kicad.json pcm.lib_auto_add is OFF on this box, so KiCad will NOT add the rows itself: register the libraries with kicad_install_library or turn lib_auto_add back on.");
    }
    json!({
        "success": true, "identifier": ident, "version": version,
        "previousVersion": previous, "updated": replaced,
        "repositoryUrl": repo_url,
        "filesInstalled": ext.files,
        "installedTo": norm(&paths.third.join("*").join(&ext.package_dir)),
        "thirdPartyDir": norm(&paths.third), "thirdPartyResolvedBy": paths.resolved_by,
        "symbolLibraries": nick(&ext.symbols),
        "footprintLibraries": nick(&ext.footprints),
        "modelSets": ext.models,
        "libAutoAdd": auto_add, "libPrefix": prefix,
        "kicadRunning": running, "restartNeeded": restart,
        "_hint": hint,
    })
}

/// kicad_pcm_list {checkUpdates?: true}: packages KiCad's PCM considers installed, each
/// with `updateAvailable` from its own repository (the registry row's repository_id
/// matched against kicad.json pcm.repositories, then repository.json -> packages.json,
/// sha256-checked, newest compatible version compared with current_version).
/// checkUpdates:false skips the network and leaves the update fields null.
pub fn list(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {
    let Some(paths) = paths(ctx) else {
        return json!({"success": false, "error": "KiCad config directory unknown"});
    };
    let check = args.get("checkUpdates").and_then(Value::as_bool).unwrap_or(true);
    let repos = registered_repositories(ctx);
    let d = read_installed(&paths.installed);
    let mut cache: HashMap<String, Result<Vec<Value>, String>> = HashMap::new();
    let mut updates = 0usize;
    let out: Vec<Value> = d["packages"]
        .as_array()
        .into_iter()
        .flatten()
        .map(|e| {
            let p = e.get("package").cloned().unwrap_or(Value::Null);
            let id = p.get("identifier").and_then(Value::as_str).unwrap_or("");
            let current = e.get("current_version").and_then(Value::as_str).unwrap_or("");
            let on_disk = identifier_is_safe(id) && FOLDERS.iter().any(|f| paths.third.join(f).join(pkgdir(id)).exists());
            let mut row = json!({"identifier": p.get("identifier"), "name": p.get("name"), "type": p.get("type"),
                   "version": e.get("current_version"), "repository": e.get("repository_name"),
                   "repositoryId": e.get("repository_id"), "pinned": e.get("pinned"), "onDisk": on_disk,
                   "updateAvailable": Value::Null, "latestVersion": Value::Null, "repositoryUrl": Value::Null,
                   "updateCheck": if check { "repository_not_registered" } else { "skipped" }});
            if !check {
                return row;
            }
            let Some(url) = repository_url_for(&d, id, &repos) else { return row };
            row["repositoryUrl"] = json!(url);
            let fetched = cache.entry(url.clone()).or_insert_with(|| match fetch_repository(fetch, &url, Duration::from_secs(20)) {
                Ok((_, list)) => Ok(list),
                Err(RepoError::Hash) => Err("packages.json hash does not match repository.json".into()),
                Err(RepoError::Other(m)) => Err(m),
            });
            match fetched {
                Err(m) => row["updateCheck"] = json!(format!("fetch_failed: {m}")),
                Ok(list) => match list.iter().find(|q| q.get("identifier").and_then(Value::as_str) == Some(id)) {
                    None => row["updateCheck"] = json!("package_not_in_repository"),
                    Some(q) => {
                        let versions = q.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();
                        match latest_version(&versions, &ctx.version) {
                            None => row["updateCheck"] = json!("no_compatible_version"),
                            Some(v) => {
                                let latest = v.get("version").and_then(Value::as_str).unwrap_or("");
                                let pinned = e.get("pinned").and_then(Value::as_bool).unwrap_or(false);
                                let newer = compare_versions(latest, current) == Ordering::Greater;
                                if newer && !pinned {
                                    updates += 1;
                                }
                                row["latestVersion"] = json!(latest);
                                row["updateAvailable"] = json!(newer && !pinned);
                                row["updateCheck"] = json!(if newer && pinned { "pinned" } else { "ok" });
                            }
                        }
                    }
                },
            }
            row
        })
        .collect();
    let hint = if !check {
        "checkUpdates:false: no repository was fetched.".to_string()
    } else if updates > 0 {
        format!("{updates} package(s) have a newer version in their repository: kicad_pcm_install {{\"identifier\": ..., \"update\": true}} applies it (KiCad's own PCM dialog shows the same Update button after a restart).")
    } else {
        "Every installed package is at the newest version its repository offers (updateCheck says why when a check could not run).".to_string()
    };
    json!({"success": true, "count": out.len(), "packages": out, "updatesAvailable": updates,
           "repositories": repositories_report(&repos),
           "thirdPartyDir": norm(&paths.third), "thirdPartyResolvedBy": paths.resolved_by, "kicadVersion": paths.version,
           "_hint": hint})
}

/// kicad_pcm_uninstall {identifier}: remove the 3rdparty directories and the record.
pub fn uninstall(ctx: &Ctx, args: &Value) -> Value {
    let Some(ident) = args.get("identifier").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
        return json!({"success": false, "error": "identifier is required"});
    };
    if !identifier_is_safe(ident) {
        return invalid_identifier(ident);
    }
    let Some(paths) = paths(ctx) else {
        return json!({"success": false, "error": "KiCad user/config directories unknown"});
    };
    let mut removed = Vec::new();
    for folder in FOLDERS {
        let d = paths.third.join(folder).join(pkgdir(ident));
        if d.exists() {
            let _ = std::fs::remove_dir_all(&d);
            removed.push(norm(&d));
        }
    }
    let mut installed = read_installed(&paths.installed);
    let mut list: Vec<Value> = installed["packages"].as_array().cloned().unwrap_or_default();
    let before = list.len();
    list.retain(|e| ident_of(e) != Some(ident));
    let record_removed = before != list.len();
    installed["packages"] = Value::Array(list);
    if let Err(e) = write_installed(&paths.installed, &installed) {
        return json!({"success": false, "error": format!("could not write installed_packages.json: {e}")});
    }
    let (running, restart) = running_block();
    json!({"success": true, "identifier": ident, "removedDirs": removed, "recordRemoved": record_removed,
           "kicadRunning": running, "restartNeeded": restart,
           "_hint": "KiCad drops the PCM_ library rows itself on next load when pcm.lib_auto_remove is on."})
}

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

    #[test]
    fn file_urls_read_local_files() {
        assert_eq!(file_url_path("file:///C:/Users/a/pcm/repository.json").unwrap(), PathBuf::from("C:/Users/a/pcm/repository.json"));
        assert_eq!(file_url_path("file:///home/box/pcm/repository.json").unwrap(), PathBuf::from("/home/box/pcm/repository.json"));
        assert_eq!(file_url_path("file://localhost/home/a%20b/r.json").unwrap(), PathBuf::from("/home/a b/r.json"));
        assert!(file_url_path("https://wiki.adom.inc/x").is_none());
        let dir = std::env::temp_dir().join(format!("pcm-file-url-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let f = dir.join("repository.json");
        std::fs::write(&f, b"{}").unwrap();
        let url = format!("file://{}{}", if cfg!(windows) { "/" } else { "" }, f.to_string_lossy().replace('\\', "/"));
        assert_eq!(http_fetch(&url, Duration::from_secs(1)).unwrap(), b"{}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    // Built with Python zipfile: metadata.json, plugins/__init__.py, symbols/Adom.kicad_sym,
    // footprints/Adom.pretty/R.kicad_mod, 3dmodels/Adom.3dshapes/R.step (deflate), a
    // footprints/ directory entry, colors/theme.json (stored).
    const ZIP: &[u8] = &[80,75,3,4,20,0,0,0,8,0,85,94,43,93,70,221,171,251,38,0,0,0,36,0,0,0,13,0,0,0,109,101,116,97,100,97,116,97,46,106,115,111,110,171,86,202,76,73,205,43,201,76,203,76,45,82,178,82,202,204,75,214,75,76,201,207,213,203,206,76,78,76,209,43,73,45,46,81,170,5,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,175,166,158,248,14,0,0,0,12,0,0,0,19,0,0,0,112,108,117,103,105,110,115,47,95,95,105,110,105,116,95,95,46,112,121,43,40,202,204,43,209,80,207,200,84,215,228,2,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,136,205,60,219,64,0,0,0,16,4,0,0,22,0,0,0,115,121,109,98,111,108,115,47,65,100,111,109,46,107,105,99,97,100,95,115,121,109,211,200,206,76,78,76,137,47,174,204,77,202,207,137,207,201,76,82,208,40,75,45,42,206,204,207,83,48,50,48,50,54,52,52,50,208,84,208,72,79,205,75,45,74,44,201,47,82,40,209,212,228,210,24,213,51,170,103,84,207,176,212,3,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,173,223,251,226,33,0,0,0,31,0,0,0,34,0,0,0,102,111,111,116,112,114,105,110,116,115,47,65,100,111,109,46,112,114,101,116,116,121,47,82,46,107,105,99,97,100,95,109,111,100,211,72,203,207,47,41,40,202,204,43,81,80,10,82,82,208,200,73,172,76,45,82,80,114,211,115,46,85,210,212,228,2,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,79,211,211,10,41,0,0,0,58,0,0,0,29,0,0,0,51,100,109,111,100,101,108,115,47,65,100,111,109,46,51,100,115,104,97,112,101,115,47,82,46,115,116,101,112,243,12,246,215,53,52,48,54,48,214,53,50,180,230,242,112,117,116,113,13,178,118,245,115,9,118,117,182,118,113,12,113,132,177,129,148,174,39,138,90,0,80,75,3,4,20,0,0,0,8,0,85,94,43,93,0,0,0,0,2,0,0,0,0,0,0,0,11,0,0,0,102,111,111,116,112,114,105,110,116,115,47,3,0,80,75,3,4,20,0,0,0,0,0,0,0,33,0,163,169,132,166,21,0,0,0,21,0,0,0,17,0,0,0,99,111,108,111,114,115,47,116,104,101,109,101,46,106,115,111,110,123,34,109,101,116,97,34,58,123,34,110,97,109,101,34,58,34,120,34,125,125,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,70,221,171,251,38,0,0,0,36,0,0,0,13,0,0,0,0,0,0,0,0,0,0,0,128,1,0,0,0,0,109,101,116,97,100,97,116,97,46,106,115,111,110,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,175,166,158,248,14,0,0,0,12,0,0,0,19,0,0,0,0,0,0,0,0,0,0,0,128,1,81,0,0,0,112,108,117,103,105,110,115,47,95,95,105,110,105,116,95,95,46,112,121,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,136,205,60,219,64,0,0,0,16,4,0,0,22,0,0,0,0,0,0,0,0,0,0,0,128,1,144,0,0,0,115,121,109,98,111,108,115,47,65,100,111,109,46,107,105,99,97,100,95,115,121,109,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,173,223,251,226,33,0,0,0,31,0,0,0,34,0,0,0,0,0,0,0,0,0,0,0,128,1,4,1,0,0,102,111,111,116,112,114,105,110,116,115,47,65,100,111,109,46,112,114,101,116,116,121,47,82,46,107,105,99,97,100,95,109,111,100,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,79,211,211,10,41,0,0,0,58,0,0,0,29,0,0,0,0,0,0,0,0,0,0,0,128,1,101,1,0,0,51,100,109,111,100,101,108,115,47,65,100,111,109,46,51,100,115,104,97,112,101,115,47,82,46,115,116,101,112,80,75,1,2,20,3,20,0,0,0,8,0,85,94,43,93,0,0,0,0,2,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,0,16,0,253,65,201,1,0,0,102,111,111,116,112,114,105,110,116,115,47,80,75,1,2,20,3,20,0,0,0,0,0,0,0,33,0,163,169,132,166,21,0,0,0,21,0,0,0,17,0,0,0,0,0,0,0,0,0,0,0,128,1,244,1,0,0,99,111,108,111,114,115,47,116,104,101,109,101,46,106,115,111,110,80,75,5,6,0,0,0,0,7,0,7,0,211,1,0,0,56,2,0,0,0,0];
    const ZIP_SHA: &str = "6b091da4f29425644b6bb432a635430ae43d8ed4eff700a54f053cd3d3aea5bc";
    // zlib.compressobj(9, DEFLATED, -15) of a 2280-byte repetitive text: a dynamic-huffman block.
    const RAW_DYN: &[u8] = &[237,202,215,17,128,32,20,5,209,86,110,5,142,57,244,65,3,68,179,40,138,169,122,173,131,121,159,59,123,88,167,177,249,94,142,16,206,94,11,140,189,49,248,121,221,97,79,237,112,252,123,226,239,3,101,219,8,140,48,97,194,132,9,19,14,11,199,73,154,229,69,89,213,13,23,82,105,67,29,86,127];
    const RAW_STORED: &[u8] = &[1,18,0,237,255,104,101,108,108,111,32,115,116,111,114,101,100,32,98,108,111,99,107];

    #[test]
    fn sha256_vectors() {
        assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
        assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
        assert_eq!(sha256_hex(&[b'a'; 1000]), "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");
        assert_eq!(sha256_hex(ZIP), ZIP_SHA);
    }

    #[test]
    fn inflate_dynamic_and_stored_blocks() {
        let text = "The quick brown fox jumps over the lazy dog. ".repeat(40) + &"0123456789abcdef".repeat(30);
        let out = inflate::inflate(RAW_DYN, text.len()).unwrap();
        assert_eq!(out, text.as_bytes());
        assert_eq!(inflate::inflate(RAW_STORED, 18).unwrap(), b"hello stored block");
        assert!(inflate::inflate(RAW_DYN, 10).is_err(), "output bound enforced");
        assert!(inflate::inflate(&RAW_DYN[..20], text.len()).is_err(), "truncated stream reported");
    }

    #[test]
    fn zip_reader_lists_and_reads_entries() {
        let zf = ZipFile::parse(ZIP).unwrap();
        let names: Vec<&str> = zf.entries.iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, vec!["metadata.json", "plugins/__init__.py", "symbols/Adom.kicad_sym", "footprints/Adom.pretty/R.kicad_mod", "3dmodels/Adom.3dshapes/R.step", "footprints/", "colors/theme.json"]);
        let sym = zf.entries.iter().find(|e| e.name == "symbols/Adom.kicad_sym").unwrap();
        assert_eq!(sym.method, 8);
        assert_eq!(zf.read(sym).unwrap(), "(kicad_symbol_lib (version 20231120) (generator t))\n".repeat(20).as_bytes());
        let stored = zf.entries.iter().find(|e| e.name == "colors/theme.json").unwrap();
        assert_eq!(stored.method, 0);
        assert_eq!(zf.read(stored).unwrap(), br#"{"meta":{"name":"x"}}"#);
        assert!(ZipFile::parse(b"not a zip at all, definitely not").is_err());
    }

    struct Tmp(PathBuf);
    impl Drop for Tmp {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }
    fn tmp(name: &str) -> Tmp {
        let p = std::env::temp_dir().join(format!("kicad-core-pcm-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&p);
        std::fs::create_dir_all(&p).unwrap();
        Tmp(p)
    }
    fn ctx(root: &Path) -> Ctx {
        Ctx { version: "10.0.6".into(), base_dir: None, config_dir: Some(root.join("config")), user_dir: Some(root.join("Documents/KiCad/10.0")), common: Value::Null }
    }
    fn read_json(p: &Path) -> Value {
        serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
    }
    const REPO_URL: &str = "https://wiki.adom.inc/adom/kicad-pcm/repository.json";
    fn repo_table(download_sha: Option<&str>) -> HashMap<String, Vec<u8>> {
        let mut v = json!({"version": "1.0.0", "download_url": "https://example.test/pkg.zip"});
        if let Some(s) = download_sha {
            v["download_sha256"] = json!(s);
        }
        let packages = json!({"packages": [
            {"identifier": "inc.adom.kicad.test", "name": "Adom Test", "type": "library", "versions": [{"version": "0.9.0", "download_url": "https://example.test/old.zip"}, v]},
            {"identifier": "inc.adom.kicad.other", "name": "Other", "type": "plugin", "versions": []}
        ]}).to_string().into_bytes();
        let repo = json!({"name": "Adom PCM", "packages": {"url": "https://example.test/packages.json", "sha256": sha256_hex(&packages)}}).to_string().into_bytes();
        let mut t = HashMap::new();
        t.insert(REPO_URL.to_string(), repo);
        t.insert("https://example.test/packages.json".to_string(), packages);
        t.insert("https://example.test/pkg.zip".to_string(), ZIP.to_vec());
        t
    }
    fn fetcher(table: HashMap<String, Vec<u8>>) -> impl Fn(&str, Duration) -> Result<Vec<u8>, String> {
        move |url: &str, _t: Duration| table.get(url).cloned().ok_or_else(|| format!("URLError: no such url {url}"))
    }

    #[test]
    fn extract_lays_out_like_pcm() {
        let t = tmp("extract");
        let third = t.0.join("3rdparty");
        let e = extract(ZIP, &third, "inc.adom.kicad.test").unwrap();
        assert_eq!(e.package_dir, "inc_adom_kicad_test");
        assert_eq!(e.files, 5, "metadata.json and the directory entry are skipped");
        assert_eq!(e.symbols, vec!["Adom"]);
        assert_eq!(e.footprints, vec!["Adom"]);
        assert_eq!(e.models, vec!["Adom.3dshapes"]);
        assert!(third.join("footprints/inc_adom_kicad_test/Adom.pretty/R.kicad_mod").is_file());
        assert!(third.join("plugins/inc_adom_kicad_test/__init__.py").is_file());
        assert!(third.join("colors/inc_adom_kicad_test/theme.json").is_file());
        assert!(!third.join("metadata.json").exists());
    }

    #[test]
    fn install_from_a_local_zip_like_a_repository() {
        let t = tmp("zipinstall");
        let c = ctx(&t.0);
        let zip = t.0.join("pkg.zip");
        std::fs::write(&zip, ZIP).unwrap();
        let unused = |u: &str, _t: Duration| -> Result<Vec<u8>, String> { Err(format!("no network in this test: {u}")) };
        let r = install(&c, &json!({"zipPath": zip.to_string_lossy()}), &unused);
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["identifier"], json!("inc.adom.kicad.test"));
        assert!(r["_note"].as_str().unwrap().contains("local zip"));
        let reg = read_installed(&t.0.join("config/installed_packages.json"));
        assert!(reg["packages"].as_array().unwrap().iter().any(|e| ident_of(e) == Some("inc.adom.kicad.test")), "{reg}");
        let bad = install(&c, &json!({"zipPath": t.0.join("missing.zip").to_string_lossy()}), &unused);
        assert_eq!(bad["errorCode"], json!("zip_unreadable"));
    }

    #[test]
    fn install_list_uninstall_round_trip() {
        let t = tmp("install");
        let c = ctx(&t.0);
        let f = fetcher(repo_table(Some(ZIP_SHA)));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["version"], json!("1.0.0"));
        assert_eq!(r["filesInstalled"], json!(5));
        assert_eq!(r["symbolLibraries"], json!(["PCM_Adom"]));
        assert_eq!(r["footprintLibraries"], json!(["PCM_Adom"]));
        assert_eq!(r["modelSets"], json!(["Adom.3dshapes"]));
        assert_eq!(r["restartNeeded"], json!(r["kicadRunning"] != json!(false)), "{r}");
        assert_eq!(r["thirdPartyDir"], json!(norm(&t.0.join("Documents/KiCad/10.0/3rdparty"))));
        let reg = read_installed(&t.0.join("config/installed_packages.json"));
        let e = &reg["packages"][0];
        assert_eq!(e["current_version"], json!("1.0.0"));
        assert_eq!(e["repository_id"], json!(&sha256_hex(REPO_URL.as_bytes())[..16]));
        assert_eq!(e["repository_name"], json!("Adom PCM"));
        assert_eq!(e["pinned"], json!(false));
        assert_eq!(e["package"]["versions"].as_array().unwrap().len(), 1, "only the installed version is recorded");
        assert!(e["install_timestamp"].as_u64().unwrap() > 1_700_000_000);
        // second call: already installed unless forced
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
        assert_eq!(r["alreadyInstalled"], json!(true));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test", "force": true, "libPrefix": "X_"}), &f);
        assert_eq!(r["symbolLibraries"], json!(["X_Adom"]));
        // list
        let l = list(&c, &json!({"checkUpdates": false}), &f);
        assert_eq!(l["count"], json!(1));
        assert_eq!(l["packages"][0]["updateCheck"], json!("skipped"));
        assert!(l["packages"][0]["updateAvailable"].is_null());
        assert_eq!(l["packages"][0]["identifier"], json!("inc.adom.kicad.test"));
        assert_eq!(l["packages"][0]["onDisk"], json!(true));
        assert_eq!(l["kicadVersion"], json!("10.0.6"));
        // uninstall
        let u = uninstall(&c, &json!({"identifier": "inc.adom.kicad.test"}));
        assert_eq!(u["recordRemoved"], json!(true));
        assert_eq!(u["removedDirs"].as_array().unwrap().len(), 5);
        assert!(!t.0.join("Documents/KiCad/10.0/3rdparty/symbols/inc_adom_kicad_test").exists());
        assert_eq!(list(&c, &json!({}), &f)["count"], json!(0));
        let u = uninstall(&c, &json!({"identifier": "inc.adom.kicad.test"}));
        assert_eq!(u["recordRemoved"], json!(false));
    }

    /// A package zip holding only metadata.json, with the identifier given.
    fn zip_with_identifier(identifier: &str) -> Vec<u8> {
        let name = b"metadata.json";
        let body = json!({"identifier": identifier, "name": "x", "type": "library",
            "versions": [{"version": "1.0.0", "status": "stable", "kicad_version": "9.0"}]}).to_string().into_bytes();
        let crc = {
            let mut c = !0u32;
            for b in &body {
                c ^= *b as u32;
                for _ in 0..8 {
                    c = if c & 1 == 1 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
                }
            }
            !c
        };
        let size = (body.len() as u32).to_le_bytes();
        let header = |sig: &[u8], v: &mut Vec<u8>, version_made: bool| {
            v.extend_from_slice(sig);
            if version_made {
                v.extend_from_slice(&20u16.to_le_bytes());
            }
            v.extend_from_slice(&20u16.to_le_bytes());
            v.extend_from_slice(&[0; 8]); // flags, stored, time, date
            v.extend_from_slice(&crc.to_le_bytes());
            v.extend_from_slice(&size);
            v.extend_from_slice(&size);
            v.extend_from_slice(&(name.len() as u16).to_le_bytes());
            v.extend_from_slice(&[0; 2]);
        };
        let mut z = Vec::new();
        header(b"PK\x03\x04", &mut z, false);
        z.extend_from_slice(name);
        z.extend_from_slice(&body);
        let cd = z.len() as u32;
        header(b"PK\x01\x02", &mut z, true);
        z.extend_from_slice(&[0; 10]); // comment, disk, attributes
        z.extend_from_slice(&0u32.to_le_bytes()); // local header offset
        z.extend_from_slice(name);
        let cd_size = z.len() as u32 - cd;
        z.extend_from_slice(b"PK\x05\x06");
        z.extend_from_slice(&[0; 4]);
        z.extend_from_slice(&1u16.to_le_bytes());
        z.extend_from_slice(&1u16.to_le_bytes());
        z.extend_from_slice(&cd_size.to_le_bytes());
        z.extend_from_slice(&cd.to_le_bytes());
        z.extend_from_slice(&[0; 2]);
        z
    }

    #[test]
    fn a_path_as_identifier_deletes_nothing() {
        let t = tmp("identpath");
        let c = ctx(&t.0);
        let victim = t.0.join("victim");
        std::fs::create_dir_all(&victim).unwrap();
        std::fs::write(victim.join("keep.txt"), "keep").unwrap();
        let abs = victim.to_string_lossy().to_string();
        let unused = |u: &str, _t: Duration| -> Result<Vec<u8>, String> { Err(format!("no network in this test: {u}")) };

        // the identifier a package zip chooses for itself
        let zip = t.0.join("evil.zip");
        std::fs::write(&zip, zip_with_identifier(&abs)).unwrap();
        let r = install(&c, &json!({"zipPath": zip.to_string_lossy()}), &unused);
        assert_eq!(r["errorCode"], json!("invalid_identifier"), "{r}");

        // the same through the identifier argument
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": abs}), &unused);
        assert_eq!(r["errorCode"], json!("invalid_identifier"), "{r}");
        let r = uninstall(&c, &json!({"identifier": abs}));
        assert_eq!(r["errorCode"], json!("invalid_identifier"), "{r}");

        assert_eq!(std::fs::read_to_string(victim.join("keep.txt")).unwrap(), "keep", "the folder must survive");
        assert!(extract(ZIP, &t.0, &abs).is_err());
        assert!(!t.0.join("config/installed_packages.json").exists(), "nothing recorded");
    }

    #[test]
    fn identifier_rule() {
        for ok in ["inc.adom.wiki.adom.test-board", "com.adom.kicad-bridge.sample-lib", "with_underscore.lib", "A1"] {
            assert!(identifier_is_safe(ok), "{ok}");
        }
        for bad in ["", "/etc", "/Users/me/Documents", r"C:\Users\me", "C:foo", "a/b", r"a\b", "../up", "a b", "a\0b", "\u{e9}t\u{e9}"] {
            assert!(!identifier_is_safe(bad), "{bad}");
        }
    }

    #[test]
    fn install_refusals() {
        let t = tmp("refuse");
        let c = ctx(&t.0);
        let f = fetcher(repo_table(Some("00")));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
        assert_eq!(r["error"], json!("download sha256 mismatch"));
        assert_eq!(r["got"], json!(ZIP_SHA));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.nope"}), &f);
        assert!(r["error"].as_str().unwrap().starts_with("package not in repository"));
        assert_eq!(r["available"], json!(["inc.adom.kicad.test", "inc.adom.kicad.other"]));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test", "version": "3.0"}), &f);
        assert_eq!(r["error"], json!("version 3.0 not offered"));
        assert_eq!(r["available"], json!(["0.9.0", "1.0.0"]));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.other"}), &f);
        assert_eq!(r["error"], json!("package has no downloadable version"));
        let r = install(&c, &json!({"identifier": "x"}), &f);
        assert!(r["error"].as_str().unwrap().contains("required"));
        // packages.json digest mismatch
        let mut table = repo_table(None);
        table.insert("https://example.test/packages.json".into(), b"{\"packages\":[]}".to_vec());
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &fetcher(table));
        assert!(r["error"].as_str().unwrap().contains("hash does not match"));
        // unreachable repository
        let r = install(&c, &json!({"repositoryUrl": "https://example.test/none.json", "identifier": "x"}), &fetcher(HashMap::new()));
        assert!(r["error"].as_str().unwrap().starts_with("could not read repository: URLError"));
        // no KiCad dirs known
        let r = install(&Ctx::default(), &json!({"repositoryUrl": REPO_URL, "identifier": "x"}), &f);
        assert!(r["error"].as_str().unwrap().contains("directories unknown"));
        assert_eq!(list(&Ctx::default(), &json!({}), &f)["success"], json!(false));
    }

    #[test]
    fn kicad_json_prefs_and_common_override() {
        let t = tmp("prefs");
        let mut c = ctx(&t.0);
        std::fs::create_dir_all(t.0.join("config")).unwrap();
        std::fs::write(t.0.join("config/kicad.json"), r#"{"pcm": {"lib_auto_add": false, "lib_prefix": "ADOM_"}}"#).unwrap();
        c.common = json!({"environment": {"vars": {"KICAD10_3RD_PARTY": norm(&t.0.join("custom3rd"))}}});
        let f = fetcher(repo_table(None));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f);
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["libPrefix"], json!("ADOM_"));
        assert_eq!(r["libAutoAdd"], json!(false));
        assert_eq!(r["symbolLibraries"], json!(["ADOM_Adom"]));
        assert_eq!(r["thirdPartyResolvedBy"], json!("kicad_common.json override"));
        assert!(t.0.join("custom3rd/symbols/inc_adom_kicad_test/Adom.kicad_sym").is_file());
        assert!(r["_hint"].as_str().unwrap().contains("lib_auto_add is OFF"));
    }

    #[test]
    fn installed_registry_is_tolerant_and_pretty() {
        let t = tmp("registry");
        let p = t.0.join("installed_packages.json");
        assert_eq!(read_installed(&p), json!({"packages": []}));
        std::fs::write(&p, "garbage").unwrap();
        assert_eq!(read_installed(&p), json!({"packages": []}));
        write_installed(&p, &json!({"packages": [{"current_version": "1"}]})).unwrap();
        let text = std::fs::read_to_string(&p).unwrap();
        assert!(text.starts_with("{\n    \"packages\""), "{text}");
        assert!(text.ends_with("}\n"));
        assert_eq!(read_installed(&p)["packages"][0]["current_version"], json!("1"));
    }

    #[test]
    fn version_order_matches_pcm() {
        use std::cmp::Ordering::*;
        assert_eq!(compare_versions("1.0.0", "1.0"), Equal);
        assert_eq!(compare_versions("1.1.0", "1.0.0"), Greater);
        assert_eq!(compare_versions("1.10.0", "1.9.0"), Greater, "numeric, not lexical");
        assert_eq!(compare_versions("2", "1.99.99"), Greater);
        assert_eq!(compare_versions("0.9", "1.0.0"), Less);
        assert_eq!(compare_versions("1.0.0-rc1", "1.0.0"), Greater, "non-numeric tail ties on numbers, then the string decides");
        // epoch wins over the dotted number
        let a = json!({"version": "1.0.0", "version_epoch": 1});
        let b = json!({"version": "9.9.9"});
        assert_eq!(version_cmp(&a, &b), Greater);
        assert_eq!(version_cmp(&json!({"version": "1.2"}), &json!({"version": "1.10"})), Less);
        // compatibility on major.minor
        let v = json!({"version": "1", "kicad_version": "10.0", "download_url": "u"});
        assert!(version_compatible(&v, "10.0.5"));
        assert!(!version_compatible(&v, "9.0.4"));
        assert!(version_compatible(&v, ""), "unknown KiCad accepts everything");
        let capped = json!({"version": "1", "kicad_version": "8.0", "kicad_version_max": "9.0", "download_url": "u"});
        assert!(version_compatible(&capped, "9.0.2"));
        assert!(!version_compatible(&capped, "10.0.5"));
        // latest: highest compatible with a download_url
        let versions = vec![
            json!({"version": "1.0.0", "kicad_version": "10.0", "download_url": "a"}),
            json!({"version": "1.2.0", "kicad_version": "11.0", "download_url": "c"}),
            json!({"version": "1.1.0", "kicad_version": "10.0", "download_url": "b"}),
            json!({"version": "1.3.0", "kicad_version": "10.0"}),
        ];
        assert_eq!(latest_version(&versions, "10.0.5").unwrap()["version"], json!("1.1.0"));
        assert_eq!(latest_version(&versions, "11.0.0").unwrap()["version"], json!("1.2.0"));
        assert!(latest_version(&versions[3..], "10.0.5").is_none(), "no download_url, nothing to offer");
        assert_eq!(repository_id("https://repository.kicad.org/repository.json").len(), 16);
    }

    #[test]
    fn repositories_edit_round_trip() {
        let t = tmp("repos");
        let c = ctx(&t.0);
        let cfg = t.0.join("config");
        let f = fetcher(repo_table(Some(ZIP_SHA)));
        // Nothing registered yet: KiCad's built-in default is what PCM would use.
        assert_eq!(registered_repositories(&c), vec![(DEFAULT_REPOSITORY_NAME.to_string(), DEFAULT_REPOSITORY_URL.to_string())]);
        // Validation.
        assert_eq!(add_repository(&c, &json!({}), &f)["errorCode"], json!("invalid_arg"));
        assert_eq!(add_repository(&c, &json!({"url": "ftp://x/y.json"}), &f)["errorCode"], json!("invalid_arg"));
        let r = add_repository(&c, &json!({"url": "https://example.test/none.json"}), &f);
        assert_eq!(r["errorCode"], json!("repository_unreachable"));
        assert!(!cfg.join("kicad.json").exists(), "a refused add writes nothing");
        // Unverified add with an explicit name still lands, with a warning.
        let r = add_repository(&c, &json!({"url": "https://example.test/none.json", "name": "Blind"}), &f);
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["verified"], json!(false));
        assert!(r["warning"].as_str().unwrap().contains("unverified"));
        assert!(r["backup"].is_null(), "no kicad.json existed, so nothing to back up");
        // First add on a fresh kicad.json: the official row is kept in front.
        let r = add_repository(&c, &json!({"url": REPO_URL}), &f);
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["added"], json!(true));
        assert_eq!(r["verified"], json!(true));
        assert_eq!(r["name"], json!("Adom PCM"), "name taken from the fetched repository.json");
        assert_eq!(r["repositoryId"], json!(repository_id(REPO_URL)));
        assert_eq!(r["restartNeeded"], json!(true));
        assert!(r["kicadRunning"].is_null());
        let bak = cfg.join("kicad.json.adom-bak");
        assert!(bak.exists());
        assert_eq!(r["backup"], json!(norm(&bak)));
        let doc = read_json(&cfg.join("kicad.json"));
        assert_eq!(doc["pcm"]["repositories"], json!([
            {"name": DEFAULT_REPOSITORY_NAME, "url": DEFAULT_REPOSITORY_URL},
            {"name": "Blind", "url": "https://example.test/none.json"},
            {"name": "Adom PCM", "url": REPO_URL},
        ]));
        assert_eq!(doc["meta"]["filename"], json!("kicad"));
        let text = std::fs::read_to_string(cfg.join("kicad.json")).unwrap();
        assert!(text.starts_with("{\n  \""), "two-space indent like KiCad: {text}");
        assert!(!cfg.join("kicad.json.adom-tmp").exists());
        // Idempotent: same URL again writes nothing (backup untouched).
        let before = std::fs::read_to_string(&bak).unwrap();
        let r = add_repository(&c, &json!({"url": REPO_URL, "name": "Renamed"}), &f);
        assert_eq!(r["added"], json!(false));
        assert_eq!(r["alreadyRegistered"], json!(true));
        assert_eq!(r["name"], json!("Adom PCM"));
        assert_eq!(std::fs::read_to_string(&bak).unwrap(), before);
        assert_eq!(registered_repositories(&c).len(), 3);
        // Other keys in kicad.json survive the edit.
        let mut doc = read_json(&cfg.join("kicad.json"));
        doc["system"] = json!({"check_for_kicad_updates": true});
        doc["pcm"]["lib_prefix"] = json!("PCM_");
        std::fs::write(cfg.join("kicad.json"), doc.to_string()).unwrap();
        let r = remove_repository(&c, &json!({"url": "https://example.test/none.json"}));
        assert_eq!(r["removed"], json!(true), "{r}");
        let doc = read_json(&cfg.join("kicad.json"));
        assert_eq!(doc["system"]["check_for_kicad_updates"], json!(true));
        assert_eq!(doc["pcm"]["lib_prefix"], json!("PCM_"));
        assert_eq!(doc["pcm"]["repositories"].as_array().unwrap().len(), 2);
        let r = remove_repository(&c, &json!({"url": "https://example.test/none.json"}));
        assert_eq!(r["removed"], json!(false));
        assert_eq!(remove_repository(&c, &json!({}))["errorCode"], json!("invalid_arg"));
        assert_eq!(add_repository(&Ctx::default(), &json!({"url": REPO_URL}), &f)["errorCode"], json!("settings_not_found"));
        // A corrupt kicad.json is refused, not clobbered.
        std::fs::write(cfg.join("kicad.json"), "{ nope").unwrap();
        assert_eq!(add_repository(&c, &json!({"url": "https://example.test/other.json", "name": "x"}), &f)["errorCode"], json!("settings_unreadable"));
        assert_eq!(std::fs::read_to_string(cfg.join("kicad.json")).unwrap(), "{ nope");
    }

    fn repo_table_v(versions: Vec<Value>) -> HashMap<String, Vec<u8>> {
        let packages = json!({"packages": [
            {"identifier": "inc.adom.kicad.test", "name": "Adom Test", "type": "library", "versions": versions}
        ]}).to_string().into_bytes();
        let repo = json!({"name": "Adom PCM", "packages": {"url": "https://example.test/packages.json", "sha256": sha256_hex(&packages)}}).to_string().into_bytes();
        let mut t = HashMap::new();
        t.insert(REPO_URL.to_string(), repo);
        t.insert("https://example.test/packages.json".to_string(), packages);
        t.insert("https://example.test/pkg.zip".to_string(), ZIP.to_vec());
        t
    }

    #[test]
    fn list_reports_updates_and_update_installs() {
        let t = tmp("update");
        let c = ctx(&t.0);
        let v100 = json!({"version": "1.0.0", "status": "stable", "kicad_version": "10.0", "download_url": "https://example.test/pkg.zip", "download_sha256": ZIP_SHA});
        let mut v110 = v100.clone();
        v110["version"] = json!("1.1.0");
        let mut v200 = v100.clone();
        v200["version"] = json!("2.0.0");
        v200["kicad_version"] = json!("11.0");

        // Repository offers 1.0.0 only; register it and install.
        let f1 = fetcher(repo_table_v(vec![v100.clone()]));
        assert_eq!(add_repository(&c, &json!({"url": REPO_URL}), &f1)["added"], json!(true));
        let r = install(&c, &json!({"repositoryUrl": REPO_URL, "identifier": "inc.adom.kicad.test"}), &f1);
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["version"], json!("1.0.0"));
        assert!(r["previousVersion"].is_null());
        assert_eq!(r["updated"], json!(false));
        let l = list(&c, &json!({}), &f1);
        assert_eq!(l["updatesAvailable"], json!(0));
        assert_eq!(l["packages"][0]["updateAvailable"], json!(false));
        assert_eq!(l["packages"][0]["latestVersion"], json!("1.0.0"));
        assert_eq!(l["packages"][0]["updateCheck"], json!("ok"));
        assert_eq!(l["packages"][0]["repositoryUrl"], json!(REPO_URL));
        assert_eq!(l["repositories"].as_array().unwrap().len(), 2);
        // update:true with nothing newer: upToDate, nothing touched.
        let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true}), &f1);
        assert_eq!(r["upToDate"], json!(true), "{r}");
        assert_eq!(r["version"], json!("1.0.0"));

        // The repository bumps to 1.1.0 (and lists a 2.0.0 this KiCad cannot take).
        let f2 = fetcher(repo_table_v(vec![v100.clone(), v110.clone(), v200.clone()]));
        let l = list(&c, &json!({}), &f2);
        assert_eq!(l["updatesAvailable"], json!(1), "{l}");
        assert_eq!(l["packages"][0]["updateAvailable"], json!(true));
        assert_eq!(l["packages"][0]["latestVersion"], json!("1.1.0"), "2.0.0 needs KiCad 11, so it is not offered");
        assert!(l["_hint"].as_str().unwrap().contains("update"));
        // Headless Update button: repositoryUrl omitted, resolved from the registry row.
        let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true}), &f2);
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["version"], json!("1.1.0"));
        assert_eq!(r["previousVersion"], json!("1.0.0"));
        assert_eq!(r["updated"], json!(true));
        assert_eq!(r["repositoryUrl"], json!(REPO_URL));
        assert!(r["_hint"].as_str().unwrap().contains("Replaced 1.0.0 with 1.1.0"));
        let reg = read_installed(&t.0.join("config/installed_packages.json"));
        assert_eq!(reg["packages"].as_array().unwrap().len(), 1, "one row, replaced");
        assert_eq!(reg["packages"][0]["current_version"], json!("1.1.0"));
        assert_eq!(reg["packages"][0]["package"]["versions"][0]["version"], json!("1.1.0"));
        let l = list(&c, &json!({}), &f2);
        assert_eq!(l["updatesAvailable"], json!(0));
        assert_eq!(l["packages"][0]["updateAvailable"], json!(false));
        // A pinned package is never offered an update, as PCM does.
        let mut reg = read_installed(&t.0.join("config/installed_packages.json"));
        reg["packages"][0]["current_version"] = json!("1.0.0");
        reg["packages"][0]["pinned"] = json!(true);
        write_installed(&t.0.join("config/installed_packages.json"), &reg).unwrap();
        let l = list(&c, &json!({}), &f2);
        assert_eq!(l["packages"][0]["updateAvailable"], json!(false));
        assert_eq!(l["packages"][0]["updateCheck"], json!("pinned"));
        assert_eq!(l["updatesAvailable"], json!(0));
        // Unreachable repository: the row says so instead of failing the verb.
        let l = list(&c, &json!({}), &fetcher(HashMap::new()));
        assert_eq!(l["success"], json!(true));
        assert!(l["packages"][0]["updateCheck"].as_str().unwrap().starts_with("fetch_failed"));
        // Repository removed from kicad.json: update:true without repositoryUrl cannot resolve it.
        remove_repository(&c, &json!({"url": REPO_URL}));
        let l = list(&c, &json!({}), &f2);
        assert_eq!(l["packages"][0]["updateCheck"], json!("repository_not_registered"));
        let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true}), &f2);
        assert_eq!(r["errorCode"], json!("repository_not_registered"));
        // ...but an explicit repositoryUrl still works, and a not-installed package needs one.
        let r = install(&c, &json!({"identifier": "inc.adom.kicad.test", "update": true, "repositoryUrl": REPO_URL, "force": true}), &f2);
        assert_eq!(r["version"], json!("1.1.0"), "{r}");
        let r = install(&c, &json!({"identifier": "inc.adom.kicad.other", "update": true}), &f2);
        assert_eq!(r["errorCode"], json!("not_installed"));
    }
}