//! Figure libraries (plan D6): folders of `.fig` files the user places in a
//! drawing, as xfig's library mode does.
//!
//! A library is a folder with `.fig` files; folders inside it are libraries
//! of their own, named `Outer / Inner`. RustFig bundles xfig's libraries
//! (`libraries/` in the source tree, next to the program when installed, or
//! the folders in `RUSTFIG_LIBRARIES`) and reads the user's own from the
//! `library_dir` preference. The web build has no file system: it reads the
//! bundle from the served `libraries/` folder through an index ([`web`]).
//
// Ported from xfig src/w_library.c and src/e_placelib.c (xfig licence, see LICENSE-xfig).

use crate::tools::{Button, Key, ToolEvent, ToolKind, ToolState};
use crate::{Engine, EngineError, UiRequest};
use rustfig_geom::{Affine, Point, Rect};
use rustfig_model::{Compound, ObjectKind};
use rustfig_model::{Document, Object, ObjectId};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};

/// The bundled libraries in the browser (C2).
#[path = "library_web.rs"]
pub mod web;

/// Most libraries listed.
pub const MAX_LIBRARIES: usize = 5_000;
/// Most objects listed in one library.
pub const MAX_OBJECTS: usize = 20_000;
/// Deepest folder nesting scanned.
#[cfg(not(target_arch = "wasm32"))]
const MAX_DEPTH: usize = 8;
/// Largest library figure read.
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Most characters of an object name.
const MAX_NAME: usize = 120;
/// The name xfig gives the figures lying in the top folder itself.
pub const TOP_NAME: &str = "(Toplevel Directory)";

/// One library: a folder with figures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Library {
    /// `Electronic / Schematic / Classic`; unique in the list.
    pub name: String,
    pub path: PathBuf,
    /// From the user's folder, not bundled with RustFig.
    pub user: bool,
    /// How many figures it holds.
    pub objects: usize,
}

/// A change made to a library object in hand before it is placed (xfig's
/// keys r, l, h, v, s, S while placing).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LibOp {
    RotateCw,
    RotateCcw,
    FlipH,
    FlipV,
    Smaller,
    Larger,
}

impl LibOp {
    pub fn from_name(s: &str) -> Option<LibOp> {
        Some(match s {
            "rotate_cw" => LibOp::RotateCw,
            "rotate_ccw" => LibOp::RotateCcw,
            "flip_h" => LibOp::FlipH,
            "flip_v" => LibOp::FlipV,
            "smaller" => LibOp::Smaller,
            "larger" => LibOp::Larger,
            _ => return None,
        })
    }

    pub fn name(self) -> &'static str {
        match self {
            LibOp::RotateCw => "rotate_cw",
            LibOp::RotateCcw => "rotate_ccw",
            LibOp::FlipH => "flip_h",
            LibOp::FlipV => "flip_v",
            LibOp::Smaller => "smaller",
            LibOp::Larger => "larger",
        }
    }

    /// xfig's key for it (`transform_lib_obj`).
    pub fn from_key(c: char) -> Option<LibOp> {
        Some(match c {
            'r' => LibOp::RotateCw,
            'l' => LibOp::RotateCcw,
            'h' => LibOp::FlipH,
            'v' => LibOp::FlipV,
            's' => LibOp::Smaller,
            'S' => LibOp::Larger,
            _ => return None,
        })
    }

    fn affine(self) -> Affine {
        match self {
            LibOp::RotateCw => Affine::rotate(-std::f64::consts::FRAC_PI_2),
            LibOp::RotateCcw => Affine::rotate(std::f64::consts::FRAC_PI_2),
            LibOp::FlipH => Affine::scale(-1.0, 1.0),
            LibOp::FlipV => Affine::scale(1.0, -1.0),
            LibOp::Smaller => Affine::scale(0.9, 0.9),
            LibOp::Larger => Affine::scale(1.1, 1.1),
        }
    }
}

/// A library object read from its file, ready to place.
#[derive(Debug, Clone, PartialEq)]
pub struct LibObject {
    /// One compound holding the figure, where the file has it.
    pub object: Object,
    /// Its top-left corner in the file.
    pub nw: Point,
}

/// Where the packages put the libraries, relative to the program's folder:
/// next to it (Windows zip, Linux tarball), `../Resources/libraries` (macOS
/// app bundle), `../share/rustfig/libraries` (.deb, `install.sh`, Homebrew),
/// and `../../libraries` (`cargo run`: the program is in target/<profile>/).
#[cfg(not(target_arch = "wasm32"))]
pub fn beside_program(dir: &Path) -> [PathBuf; 4] {
    [
        dir.join("libraries"),
        dir.join("../Resources/libraries"),
        dir.join("../share/rustfig/libraries"),
        dir.join("../../libraries"),
    ]
}

/// The folders bundled libraries are looked for in, most specific first:
/// `RUSTFIG_LIBRARIES` (folders separated as `PATH` is), the places the
/// packages install them ([`beside_program`]), and the source tree's own.
#[cfg(not(target_arch = "wasm32"))]
pub fn bundled_roots() -> Vec<PathBuf> {
    let mut v: Vec<PathBuf> = Vec::new();
    if let Some(p) = std::env::var_os("RUSTFIG_LIBRARIES") {
        v.extend(std::env::split_paths(&p).filter(|p| !p.as_os_str().is_empty()));
    }
    if let Some(dir) = std::env::current_exe()
        .ok()
        .and_then(|e| e.parent().map(Path::to_path_buf))
    {
        v.extend(beside_program(&dir));
    }
    // The source tree this build came from (development builds and tests).
    v.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../libraries"));
    let mut out: Vec<PathBuf> = Vec::new();
    for p in v {
        if p.is_dir() && !out.iter().any(|o| same_dir(o, &p)) {
            out.push(p);
        }
    }
    // The first folder that exists is the bundle; the others would only repeat it
    // unless `RUSTFIG_LIBRARIES` named several on purpose.
    if std::env::var_os("RUSTFIG_LIBRARIES").is_none() {
        out.truncate(1);
    }
    out
}

/// The browser: the bundle is the `libraries/` folder next to the page; its
/// figures come from the index and the fetch cache of [`web`].
#[cfg(target_arch = "wasm32")]
pub fn bundled_roots() -> Vec<PathBuf> {
    vec![PathBuf::from(web::ROOT)]
}

#[cfg(not(target_arch = "wasm32"))]
fn same_dir(a: &Path, b: &Path) -> bool {
    match (a.canonicalize(), b.canonicalize()) {
        (Ok(x), Ok(y)) => x == y,
        _ => a == b,
    }
}

/// The user's library folder from the `library_dir` preference (`~` is the
/// home folder), if set and not empty.
pub fn user_root(pref: &str) -> Option<PathBuf> {
    let s = pref.trim();
    if s.is_empty() {
        return None;
    }
    if let Some(rest) = s.strip_prefix('~') {
        let home = home_dir()?;
        let rest = rest.trim_start_matches(['/', '\\']);
        return Some(if rest.is_empty() { home } else { home.join(rest) });
    }
    Some(PathBuf::from(s))
}

#[cfg(not(target_arch = "wasm32"))]
fn home_dir() -> Option<PathBuf> {
    std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from)
}

#[cfg(target_arch = "wasm32")]
fn home_dir() -> Option<PathBuf> {
    None
}

/// One folder libraries are read from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LibRoot {
    pub path: PathBuf,
    /// The user's folder, not the bundled one.
    pub user: bool,
    /// From a library list file: the name of the library this folder is
    /// (its subfolders are `name / sub`). Without a name the folder's own
    /// subfolders are the top-level libraries.
    pub name: Option<String>,
}

/// One line of a library list file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListEntry {
    pub name: String,
    pub path: PathBuf,
}

/// The most bytes read from a library list file.
pub const MAX_LIST_BYTES: u64 = 256 * 1024;
/// The most entries taken from a library list file.
pub const MAX_LIST_ENTRIES: usize = 500;
const MAX_LIST_LINE: usize = 4096;

/// Parse a library list file (xfig's `MakeLibrary`: a `library_dir` that is
/// a file names the library folders, one per line as `PATH [NAME]`; a line
/// starting with `#` is a comment). Without NAME the last folder of PATH
/// names the library. Beyond xfig: blank lines are skipped, `~` is the home
/// folder, a relative PATH is taken from `base` (the list file's folder),
/// and overlong lines, names with control characters, and entries past
/// `MAX_LIST_ENTRIES` are dropped.
pub fn parse_list(text: &str, base: &Path) -> Vec<ListEntry> {
    let mut out: Vec<ListEntry> = Vec::new();
    for line in text.lines() {
        if out.len() >= MAX_LIST_ENTRIES {
            break;
        }
        if line.starts_with('#') || line.len() > MAX_LIST_LINE || line.chars().any(|c| c.is_control() && c != '\t') {
            continue;
        }
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let (path_text, name_text) = match line.find(char::is_whitespace) {
            Some(i) => (line.get(..i).unwrap_or(line), line.get(i..).unwrap_or("").trim()),
            None => (line, ""),
        };
        let Some(path) = user_root(path_text) else { continue };
        let path = if path.is_absolute() { path } else { base.join(path) };
        let name = if name_text.is_empty() {
            path_text
                .trim_end_matches(['/', '\\'])
                .rsplit(['/', '\\'])
                .next()
                .unwrap_or("")
                .to_string()
        } else {
            name_text.to_string()
        };
        if name.is_empty() || name.chars().count() > MAX_NAME || name == "." || name == ".." || name == TOP_NAME {
            continue;
        }
        out.push(ListEntry { name, path });
    }
    out
}

/// What the preference `library_dir` names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
    /// A folder whose subfolders are the libraries.
    Folder(PathBuf),
    /// A list file and the folders it names.
    List(PathBuf, Vec<ListEntry>),
    /// Nothing there (or unreadable).
    Missing(PathBuf),
}

/// Look at `library_dir`: a folder, a list file, or nothing.
pub fn source(pref: &str) -> Option<Source> {
    let path = user_root(pref)?;
    let Ok(meta) = std::fs::metadata(&path) else {
        return Some(Source::Missing(path));
    };
    if meta.is_dir() {
        return Some(Source::Folder(path));
    }
    if meta.len() > MAX_LIST_BYTES {
        return Some(Source::Missing(path));
    }
    // Lossy: a stray non-UTF-8 byte only spoils its own line, as in the C reader.
    let Ok(bytes) = std::fs::read(&path) else {
        return Some(Source::Missing(path));
    };
    let text = String::from_utf8_lossy(&bytes);
    let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
    let entries = parse_list(&text, &base);
    Some(Source::List(path, entries))
}

/// The folders to scan for the user's libraries (`library_dir`).
pub fn user_roots(pref: &str) -> Vec<LibRoot> {
    match source(pref) {
        None | Some(Source::Missing(_)) => Vec::new(),
        Some(Source::Folder(p)) => vec![LibRoot {
            path: p,
            user: true,
            name: None,
        }],
        Some(Source::List(_, entries)) => entries
            .into_iter()
            .map(|e| LibRoot {
                path: e.path,
                user: true,
                name: Some(e.name),
            })
            .collect(),
    }
}

/// Every library under the folders.
pub fn scan(roots: &[LibRoot]) -> Vec<Library> {
    scan_cancellable(roots, &AtomicBool::new(false))
}

/// `scan`, stopping early (with what was found so far) once `cancel` is
/// set: xfig's Stop button while a big library loads.
pub fn scan_cancellable(roots: &[LibRoot], cancel: &AtomicBool) -> Vec<Library> {
    let mut out: Vec<Library> = Vec::new();
    for LibRoot { path: root, user, name } in roots {
        if cancel.load(Ordering::Relaxed) {
            break;
        }
        let mut found = Vec::new();
        match name {
            Some(n) => scan_dir(root, n, *user, false, 0, cancel, &mut found),
            None => scan_dir(root, "", *user, true, 0, cancel, &mut found),
        }
        found.sort_by(|a, b| {
            a.name
                .to_lowercase()
                .cmp(&b.name.to_lowercase())
                .then(a.name.cmp(&b.name))
        });
        for mut l in found {
            if out.iter().any(|o| o.name == l.name) {
                l.name = format!("{} ({})", l.name, if *user { "user" } else { "bundled" });
            }
            if out.len() >= MAX_LIBRARIES {
                return out;
            }
            out.push(l);
        }
    }
    out
}

#[cfg(not(target_arch = "wasm32"))]
fn scan_dir(dir: &Path, name: &str, user: bool, top: bool, depth: usize, cancel: &AtomicBool, out: &mut Vec<Library>) {
    if depth > MAX_DEPTH || cancel.load(Ordering::Relaxed) || out.len() >= MAX_LIBRARIES {
        return;
    }
    let Ok(rd) = std::fs::read_dir(dir) else { return };
    let mut figs = 0;
    let mut subs = Vec::new();
    for e in rd.flatten() {
        let fname = e.file_name().to_string_lossy().into_owned();
        let path = e.path();
        if fname.starts_with('.') {
            continue;
        }
        if path.is_dir() {
            subs.push((fname, path));
        } else if is_fig_name(&fname) {
            figs += 1;
        }
    }
    if figs > 0 {
        let lname = if top { TOP_NAME.to_string() } else { name.to_string() };
        out.push(Library {
            name: lname,
            path: dir.to_path_buf(),
            user,
            objects: figs,
        });
    }
    for (fname, path) in subs {
        let sub = if top { fname } else { format!("{name} / {fname}") };
        scan_dir(&path, &sub, user, false, depth + 1, cancel, out);
    }
}

#[cfg(target_arch = "wasm32")]
fn scan_dir(
    dir: &Path,
    _name: &str,
    user: bool,
    _top: bool,
    _depth: usize,
    _cancel: &AtomicBool,
    out: &mut Vec<Library>,
) {
    if !user && dir == Path::new(web::ROOT) {
        out.extend(web::libraries());
    }
}

#[cfg(not(target_arch = "wasm32"))]
fn is_fig_name(n: &str) -> bool {
    n.len() > 4 && n.to_ascii_lowercase().ends_with(".fig")
}

/// The names of the figures in a library folder, sorted (without `.fig`).
#[cfg(not(target_arch = "wasm32"))]
pub fn object_names(dir: &Path) -> Vec<String> {
    let Ok(rd) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut v: Vec<String> = rd
        .flatten()
        .filter(|e| e.path().is_file())
        .filter_map(|e| {
            let n = e.file_name().to_string_lossy().into_owned();
            (is_fig_name(&n) && !n.starts_with('.')).then(|| n[..n.len() - 4].to_string())
        })
        .collect();
    v.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then(a.cmp(b)));
    v.truncate(MAX_OBJECTS);
    v
}

#[cfg(target_arch = "wasm32")]
pub fn object_names(dir: &Path) -> Vec<String> {
    web::figure_names(dir)
}

/// Whether `name` can be a figure's file name without the extension: no
/// folders, nothing hidden.
pub fn valid_name(name: &str) -> bool {
    !name.is_empty()
        && name.chars().count() <= MAX_NAME
        && !name.starts_with('.')
        && !name
            .chars()
            .any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
}

/// The comment lines at the top of a library figure (what xfig shows under
/// "Object comments"), read without parsing the whole file.
#[cfg(not(target_arch = "wasm32"))]
pub fn object_comments(dir: &Path, name: &str) -> Vec<String> {
    use std::io::{BufRead, BufReader};
    if !valid_name(name) {
        return Vec::new();
    }
    let Ok(f) = std::fs::File::open(dir.join(format!("{name}.fig"))) else {
        return Vec::new();
    };
    let lines: Vec<String> = BufReader::new(f).lines().map_while(Result::ok).take(200).collect();
    comments_of_lines(lines)
}

/// The browser: from the figure's fetched bytes (none before it is fetched).
#[cfg(target_arch = "wasm32")]
pub fn object_comments(dir: &Path, name: &str) -> Vec<String> {
    match web::cached(dir, name) {
        Some(b) => comments_of_bytes(&b),
        None => Vec::new(),
    }
}

/// The comment lines at the top of a figure's bytes.
pub fn comments_of_bytes(bytes: &[u8]) -> Vec<String> {
    let text = String::from_utf8_lossy(bytes.get(..bytes.len().min(64 * 1024)).unwrap_or_default());
    comments_of_lines(text.lines().take(200).map(str::to_string).collect())
}

fn comments_of_lines(lines: Vec<String>) -> Vec<String> {
    let mut out = Vec::new();
    // The `#FIG` line (and `#encoding`), then seven header lines (orientation,
    // justification, units, paper size, magnification, pages, transparent
    // colour); the figure's comment lines follow.
    // When the figure has none, the comments of its first object (after the
    // resolution line, e.g. a library part's compound) are shown instead.
    let first = lines.iter().take_while(|l| l.starts_with('#')).count();
    let mut past_resolution = false;
    for l in lines.into_iter().skip(first + 7) {
        let Some(c) = l.strip_prefix('#') else {
            if out.is_empty() && !past_resolution && is_resolution_line(&l) {
                past_resolution = true;
                continue;
            }
            break;
        };
        out.push(c.strip_prefix(' ').unwrap_or(c).to_string());
        if out.len() >= 100 {
            break;
        }
    }
    out
}

/// The Fig 3.2 resolution line: "1200 2" (resolution and coordinate system).
fn is_resolution_line(l: &str) -> bool {
    let mut it = l.split_whitespace();
    matches!((it.next(), it.next(), it.next()), (Some(a), Some(b), None) if a.parse::<u32>().is_ok() && b.parse::<u8>().is_ok())
}

/// Read a library figure as one drawing whose only object is one compound:
/// the file's compound where it holds nothing else, else all of its objects
/// in a new one; the figure's comments become the compound's. Pictures it
/// names are loaded from the library folder. The user colours stay the
/// file's own.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_figure(dir: &Path, name: &str) -> Result<Document, String> {
    if !valid_name(name) {
        return Err(format!("bad object name {name:?}"));
    }
    let path = dir.join(format!("{name}.fig"));
    let meta = std::fs::metadata(&path).map_err(|e| format!("{}: {e}", path.display()))?;
    if meta.len() > MAX_FILE_BYTES {
        return Err(format!("{}: too large", path.display()));
    }
    let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
    let mut doc = figure_from_bytes(&bytes, name, Some(dir))?;
    absolutize_pictures(&mut doc.objects, dir);
    Ok(doc)
}

/// A library figure's bytes as [`read_figure`]'s one-compound drawing
/// (pictures it names are loaded from `dir` where there is one).
pub fn figure_from_bytes(bytes: &[u8], name: &str, dir: Option<&Path>) -> Result<Document, String> {
    let (mut doc, _) = rustfig_format::read_any_messages(bytes).map_err(|e| format!("{name}.fig: {e}"))?;
    // A picture that fails to load draws as its empty frame, as in xfig.
    let _ = crate::import::load_all_pictures(&mut doc, dir);
    let comments = std::mem::take(&mut doc.comments);
    let objects: Vec<Object> = std::mem::take(&mut doc.objects)
        .into_iter()
        .map(std::sync::Arc::unwrap_or_clone)
        .collect();
    if objects.is_empty() {
        return Err(format!("{name}.fig: empty library file"));
    }
    let mut one = match <[Object; 1]>::try_from(objects) {
        Ok([o]) if matches!(o.kind, ObjectKind::Compound(_)) => o,
        Ok([o]) => Object::new(ObjectKind::Compound(Compound { objects: vec![o] })),
        Err(objects) => Object::new(ObjectKind::Compound(Compound { objects })),
    };
    if !comments.is_empty() {
        one.comments = comments;
    }
    doc.objects = vec![std::sync::Arc::new(one)];
    Ok(doc)
}

/// The browser: from the fetch cache; a figure not fetched yet is an error
/// that says so (the panel fetches before it asks).
#[cfg(target_arch = "wasm32")]
pub fn read_figure(dir: &Path, name: &str) -> Result<Document, String> {
    if !valid_name(name) {
        return Err(format!("bad object name {name:?}"));
    }
    let bytes = web::cached(dir, name).ok_or_else(|| format!("{name}.fig is still loading, try again"))?;
    figure_from_bytes(&bytes, name, None)
}

/// Picture names in library figures are relative to the library folder;
/// placed in a drawing they must keep pointing at the same files (xfig's
/// `REMAP_IMAGES`).
#[cfg(not(target_arch = "wasm32"))]
fn absolutize_pictures(objs: &mut [std::sync::Arc<Object>], dir: &Path) {
    fn walk(o: &mut Object, dir: &Path) {
        match &mut o.kind {
            ObjectKind::Compound(c) => c.objects.iter_mut().for_each(|o| walk(o, dir)),
            ObjectKind::Polyline(p) => {
                if let Some(pic) = &mut p.picture {
                    if !pic.file.is_empty() && !Path::new(&pic.file).is_absolute() {
                        pic.file = dir.join(&pic.file).to_string_lossy().into_owned();
                    }
                }
            }
            _ => {}
        }
    }
    for o in objs {
        walk(std::sync::Arc::make_mut(o), dir);
    }
}

/// The compound with `ops` applied about its centre (xfig applies them about
/// the pointer, which is the same up to where the result is placed).
pub fn with_ops(obj: &Object, ops: &[LibOp]) -> Object {
    let mut o = obj.clone();
    let bb = obj.geometry_bbox();
    if bb.is_empty() {
        return o;
    }
    let c = bb.center();
    for op in ops {
        o.transform(
            &Affine::about(op.affine(), c),
            *op == LibOp::Smaller || *op == LibOp::Larger,
        );
    }
    o
}

/// Top-left corner of an object's geometry.
pub fn nw_of(o: &Object) -> Point {
    let b: Rect = o.geometry_bbox();
    if b.is_empty() {
        Point::ZERO
    } else {
        b.min
    }
}

impl Engine {
    /// The folders libraries are read from: the bundled ones, then the
    /// user's (`library_dir`).
    pub fn library_roots(&self) -> Vec<LibRoot> {
        let mut v: Vec<LibRoot> = bundled_roots()
            .into_iter()
            .map(|path| LibRoot {
                path,
                user: false,
                name: None,
            })
            .collect();
        let pref = self
            .prefs
            .get("library_dir")
            .and_then(|v| v.as_str().map(str::to_string))
            .unwrap_or_else(|| "~/xfiglib".to_string());
        v.extend(user_roots(&pref));
        v
    }

    /// All libraries, bundled first.
    pub fn libraries(&self) -> Vec<Library> {
        scan(&self.library_roots())
    }

    /// The library called `name`.
    pub fn find_library(&self, name: &str) -> Result<Library, EngineError> {
        self.libraries()
            .into_iter()
            .find(|l| l.name == name)
            .ok_or_else(|| EngineError::bad_params(format!("no library {name:?} (see library.list)")))
    }

    /// Read a library object for placing, its user colours mapped into the
    /// drawing (as xfig's merge does).
    pub fn library_object(&mut self, library: &str, name: &str) -> Result<LibObject, EngineError> {
        let lib = self.find_library(library)?;
        self.library_object_in(&lib, name)
    }

    /// [`Engine::library_object`] in a library already found.
    pub fn library_object_in(&mut self, lib: &Library, name: &str) -> Result<LibObject, EngineError> {
        let doc = read_figure(&lib.path, name).map_err(EngineError::bad_params)?;
        let mut objs = crate::cmd::file::mapped_objects(self, doc);
        let object = objs
            .pop()
            .ok_or_else(|| EngineError::bad_params(format!("{name}.fig holds no objects")))?;
        let nw = nw_of(&object);
        Ok(LibObject { object, nw })
    }

    /// Library mode: the object follows the pointer (xfig's `put_selected`).
    pub fn begin_library(&mut self, library: &str, name: &str, ops: Vec<LibOp>) -> Result<(), EngineError> {
        let lo = self.library_object(library, name)?;
        let hand = with_ops(&lo.object, &ops);
        let nw = nw_of(&hand);
        let v = self.view.visible();
        let cursor = if v.is_empty() {
            nw
        } else {
            Point::new(v.min.x + 600.0, v.min.y + 600.0)
        };
        // xfig turns the current mode off: the library has its own.
        self.set_tool(ToolKind::None);
        self.tool_state = ToolState::Library {
            library: library.to_string(),
            name: name.to_string(),
            orig: Box::new(lo.object),
            ops,
            hand: Box::new(hand),
            nw,
            cursor,
            draw_box: false,
        };
        self.set_status(format!("Placing library object {name}.fig"));
        Ok(())
    }

    /// The library object in hand: (library, name, ops), if any.
    pub fn library_in_hand(&self) -> Option<(&str, &str, &[LibOp])> {
        match &self.tool_state {
            ToolState::Library { library, name, ops, .. } => Some((library, name, ops)),
            _ => None,
        }
    }

    /// Apply a change to the library object in hand (xfig's keys).
    pub fn library_transform(&mut self, op: LibOp) -> Result<(), EngineError> {
        let ToolState::Library {
            orig, ops, hand, nw, ..
        } = &mut self.tool_state
        else {
            return Err(EngineError::bad_params("no library object in hand"));
        };
        ops.push(op);
        // Keep the list short: four turns are none, two flips are none.
        if ops.len() > 64 {
            ops.remove(0);
        }
        **hand = with_ops(orig, ops);
        *nw = nw_of(hand);
        Ok(())
    }

    pub(crate) fn library_event(&mut self, ev: ToolEvent) -> Result<(), EngineError> {
        let ToolState::Library { library, name, ops, .. } = &self.tool_state else {
            return Ok(());
        };
        let (library, name) = (library.clone(), name.clone());
        let op_names: Vec<&str> = ops.iter().map(|o| o.name()).collect();
        let op_names = json!(op_names);
        match ev {
            ToolEvent::Move { pos } if pos.is_finite() => {
                let p = self.snap(pos);
                if let ToolState::Library { cursor, .. } = &mut self.tool_state {
                    *cursor = p;
                }
            }
            ToolEvent::Press { pos, button, shift } if pos.is_finite() => match (button, shift) {
                (Button::Left, shift) => {
                    let p = self.snap(pos);
                    let r = self.execute(
                        "library.place",
                        &json!({"library": library, "name": name, "ops": op_names, "x": p.x, "y": p.y}),
                    )?;
                    if shift {
                        // xfig: edit the object just placed.
                        if let Some(id) = r.get("id").and_then(Value::as_u64) {
                            self.request(UiRequest::EditObject { id: ObjectId(id) });
                        }
                    }
                }
                (Button::Middle, false) => {
                    // "new object": back to the library panel.
                    self.tool_state = ToolState::Idle;
                    self.request(UiRequest::OpenLibrary);
                }
                (Button::Middle, true) => {
                    if let ToolState::Library { draw_box, .. } = &mut self.tool_state {
                        *draw_box = !*draw_box;
                    }
                }
                (Button::Right, true) => {
                    self.execute(
                        "library.place",
                        &json!({"library": library, "name": name, "ops": op_names, "orig": true}),
                    )?;
                }
                (Button::Right, false) => {
                    self.tool_state = ToolState::Idle;
                    self.set_status("Library mode cancelled.");
                }
            },
            ToolEvent::Key(Key::Escape) => {
                self.tool_state = ToolState::Idle;
                self.set_status("Library mode cancelled.");
            }
            ToolEvent::Text(t) => {
                for c in t.chars().take(16) {
                    if let Some(op) = LibOp::from_key(c) {
                        self.library_transform(op)?;
                    }
                }
            }
            _ => {}
        }
        Ok(())
    }
}

/// Whether `text` holds every word of `query` (case-insensitive), as the font
/// chooser's search matches.
pub fn matches(text: &str, query: &str) -> bool {
    let text = text.to_lowercase();
    query.split_whitespace().all(|w| text.contains(&w.to_lowercase()))
}

/// Most hits `library.search` returns.
pub const MAX_HITS: usize = 1_000;
/// Longest query used.
const MAX_QUERY: usize = 200;

/// One search result: a figure and its library.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
    pub library: String,
    pub name: String,
    pub user: bool,
    /// Found by its comments, not its name.
    pub by_comment: bool,
}

/// Comments read so far, by file: (modification time, comment text). A
/// figure is read again when its file changed.
#[cfg(not(target_arch = "wasm32"))]
type CommentCache = std::collections::HashMap<PathBuf, (Option<std::time::SystemTime>, String)>;

#[cfg(not(target_arch = "wasm32"))]
fn comment_cache() -> &'static std::sync::Mutex<CommentCache> {
    static C: std::sync::OnceLock<std::sync::Mutex<CommentCache>> = std::sync::OnceLock::new();
    C.get_or_init(Default::default)
}

/// Search the figures of all libraries for `query` (every word, case
/// insensitive): by name first, then (`comments`) by the comment lines at the
/// top of the file. The names come from the folders each time (a few
/// milliseconds for xfig's 2,540 figures); comments are read on the first
/// search that needs them and kept until the file changes. At most `limit`
/// hits (capped at [`MAX_HITS`] + 1); an empty query finds nothing.
pub fn search(libs: &[Library], query: &str, limit: usize, comments: bool) -> Vec<Hit> {
    let query: String = query.chars().take(MAX_QUERY).collect();
    let limit = limit.min(MAX_HITS + 1);
    let mut out: Vec<Hit> = Vec::new();
    if query.trim().is_empty() || limit == 0 {
        return out;
    }
    let mut rest: Vec<(&Library, String)> = Vec::new();
    'libs: for l in libs {
        for n in object_names(&l.path) {
            if matches(&n, &query) {
                if out.len() >= limit {
                    break 'libs;
                }
                out.push(Hit {
                    library: l.name.clone(),
                    name: n,
                    user: l.user,
                    by_comment: false,
                });
            } else if comments {
                rest.push((l, n));
            }
        }
    }
    #[cfg(not(target_arch = "wasm32"))]
    {
        let mut cache = comment_cache().lock().ok();
        for (l, n) in rest {
            if out.len() >= limit {
                break;
            }
            let path = l.path.join(format!("{n}.fig"));
            let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
            let cached = cache
                .as_ref()
                .and_then(|c| c.get(&path))
                .filter(|(t, _)| *t == mtime)
                .map(|(_, s)| s.clone());
            let text = match cached {
                Some(s) => s,
                None => {
                    let s = object_comments(&l.path, &n).join("\n");
                    if let Some(c) = cache.as_mut() {
                        if c.len() > 100_000 {
                            c.clear();
                        }
                        c.insert(path, (mtime, s.clone()));
                    }
                    s
                }
            };
            if !text.is_empty() && matches(&text, &query) {
                out.push(Hit {
                    library: l.name.clone(),
                    name: n,
                    user: l.user,
                    by_comment: true,
                });
            }
        }
    }
    #[cfg(target_arch = "wasm32")]
    let _ = rest;
    out
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
    use super::*;

    /// Each package's layout puts the libraries where the program looks
    /// (`cargo xtask package`, the Homebrew formula).
    #[test]
    fn packaged_libraries_are_found_beside_the_program() {
        let base = std::env::temp_dir().join(format!("rustfig-libroots-{}", std::process::id()));
        for (program_dir, libs) in [
            ("zip", "zip/libraries"),
            ("RustFig.app/Contents/MacOS", "RustFig.app/Contents/Resources/libraries"),
            ("usr/bin", "usr/share/rustfig/libraries"),
        ] {
            let (dir, lib) = (base.join(program_dir), base.join(libs));
            std::fs::create_dir_all(&dir).unwrap();
            std::fs::create_dir_all(&lib).unwrap();
            let found = beside_program(&dir).into_iter().find(|p| p.is_dir()).unwrap();
            assert!(same_dir(&found, &lib), "{program_dir}: {}", found.display());
        }
        let _ = std::fs::remove_dir_all(&base);
    }
}
