pub mod hierarchy;
pub mod node;

use std::path::Path;
use std::sync::Arc;

use crate::storage::{DocumentStorage, EmbeddingEngine, Model};
use serde_json::Value as JsonValue;
use uuid::Uuid;

use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::{BOOKS_DIR, ProjectLayout};
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind as NK};

pub use node::NodeKind;

/// Canonical ordering of the project-managed books. The tag is the
/// `system_tag` we write into metadata; the second element is the
/// human-facing default title used when the book is freshly created.
/// Display order matches array order (Notes first, Help last).
///
/// The `Scripts` system book is the default home for Bund script
/// nodes (`NodeKind::Script`) that aren't logically tied to a
/// particular user Book — global hooks, lint rules, AI templates.
/// Scripts can also live inside any user Book if they belong to
/// that book's workflow; nothing forces them under `Scripts`.
///
/// The `Facts` system book (1.2.21+) holds a world's invariants —
/// climate, geography, seasons, distances, chronology — the ground
/// truth every chapter must stay consistent with.  Free-form prose
/// like `Notes`/`Research`; the AI's `Facts` scope and the
/// `Ctrl+B Shift+X` fact-check chord treat it as the authoritative
/// reference.
pub const SYSTEM_BOOKS: &[(&str, &str)] = &[
    ("notes", "Notes"),
    ("research", "Research"),
    // SOURCES-1 (1.4.5+) — bibliography entries (HJSON paragraphs), beside the
    // other reference material. ensure_system_books bumps existing books down on
    // first open after upgrade.
    ("sources", "Sources"),
    // TERMS-1 (1.4.8+) — terminology governance: canonical terms + banned
    // synonyms (HJSON paragraphs), beside the other reference material.
    ("glossary", "Glossary"),
    // REUSE-1 (1.4.9+) — reusable content blocks: Typst paragraphs referenced
    // elsewhere via `#include`, copied to a `snippets/` sidecar at assembly.
    ("snippets", "Snippets"),
    ("facts", "Facts"),
    ("prompts", "Prompts"),
    ("places", "Places"),
    ("characters", "Characters"),
    ("artefacts", "Artefacts"),
    ("world", "World"),
    ("threads", "Threads"),
    ("planning", "Planning"),
    ("language", "Language"),
    ("submissions", "Submissions"),
    ("typst", "Typst"),
    ("scripts", "Scripts"),
    ("help", "Help"),
    ("intent", "Intent"),
    // MYTH-1 (1.4.19+) — the Mythology book: declared symbols / motifs /
    // archetypes (HJSON paragraphs tagged `para:myth-*`), read by the myth scan.
    ("mythology", "Mythology"),
];

pub const SYSTEM_TAG_NOTES: &str = "notes";
/// MYTH-1 (1.4.19+) — the Mythology book holding declared symbols/motifs/archetypes.
pub const SYSTEM_TAG_MYTHOLOGY: &str = "mythology";
/// SOURCES-1 (1.4.5+) — the bibliography book: citation entries as HJSON
/// paragraphs, compiled to `sources.bib` at assembly. See
/// `Documentation/PROPOSALS/SOURCES-1_PLAN.md`. (Consumed from S-P1.)
#[allow(dead_code)]
pub const SYSTEM_TAG_SOURCES: &str = "sources";
/// TERMS-1 (1.4.8+) — the Glossary book: canonical terms + banned synonyms as
/// HJSON paragraphs, flagged in prose by the editor overlay + `terms check`.
#[allow(dead_code)]
pub const SYSTEM_TAG_GLOSSARY: &str = "glossary";
/// REUSE-1 (1.4.9+) — the Snippets book: reusable Typst paragraphs copied to a
/// `snippets/` sidecar at assembly, referenced from prose via `#include`.
#[allow(dead_code)]
pub const SYSTEM_TAG_SNIPPETS: &str = "snippets";
/// 1.2.21+ — top-level container for a world's invariants
/// (climate, geography, seasons, distances, chronology).
/// Free-form prose like Notes; the AI `Facts` scope loads
/// every paragraph here as ground truth, and the
/// `Ctrl+B Shift+X` fact-check chord grounds against it.
pub const SYSTEM_TAG_FACTS: &str = "facts";
pub const SYSTEM_TAG_PROMPTS: &str = "prompts";
pub const SYSTEM_TAG_PLACES: &str = "places";
pub const SYSTEM_TAG_CHARACTERS: &str = "characters";
pub const SYSTEM_TAG_ARTEFACTS: &str = "artefacts";
/// WORLD-4 — the world-simulation book (the 10th system book, after Artefacts).
/// The `inkhaven realworld` compiler materializes its deterministic layer
/// outputs here as structured paragraphs (astronomy / geology / climate /
/// hydrology / demographics / magic ledger). Compiler-owned: re-compiling
/// regenerates the structured leaves; author prose lives alongside.
pub const SYSTEM_TAG_WORLD: &str = "world";
/// 1.2.14+ — top-level container for narrative
/// plot threads.  Each thread is an HJSON-fronted
/// paragraph capturing one named arc (the
/// inheritance subplot, the redemption arc, the
/// secret-society reveal) with status / weight /
/// arc-shape / character-and-place links / tension
/// level.  Drives the thread weave view (`Ctrl+V
/// Shift+H` → picker → `w` weave), the AI thread
/// audit (`Ctrl+V Shift+A`), and the rebindable
/// CLI surface (`inkhaven thread add` / `list`).
/// See `Documentation/PROPOSALS/1.2.14_PLAN.md`.
pub const SYSTEM_TAG_THREADS: &str = "threads";
/// 1.2.13+ — top-level container for invented-
/// language books (Quenya / Drow / Klingon / …).
/// Per-language children are `NodeKind::Book` nodes
/// scaffolded with `Meta / Dictionary / Grammar /
/// Phonology / Sample texts` chapters by the
/// `inkhaven language init <name>` CLI.  Dictionary-
/// entry paragraphs live under alphabet
/// subchapters whose names come from the language's
/// `Meta/overview.alphabet` HJSON field — author-
/// defined so non-Latin orthographies (Hebrew,
/// Arabic, Asian scripts) can use logical
/// groupings instead of per-letter sections.
/// See `Documentation/PROPOSALS/LANGUAGE_BOOK.md`.
/// 1.3.2+ PLANNING-1 — top-level container for story
/// structure: beats / acts / turning points of a chosen
/// framework (three-act / Save the Cat / …) as HJSON-fronted
/// paragraphs (`crate::planning`).  The Planning Board's data
/// home; the `inkhaven plan` CLI + the structure-outline view
/// read it.  Orthogonal to Threads (arc payoff) + Timeline (when).
pub const SYSTEM_TAG_PLANNING: &str = "planning";
pub const SYSTEM_TAG_LANGUAGES: &str = "language";
/// 1.3.1+ SUBMISSION-1 — top-level container for the
/// submission package: generated query-letter / synopsis /
/// comp-title / logline drafts as editable prose paragraphs.
/// Paired with the `.inkhaven/submissions.json` tracker
/// sidecar (`crate::submissions`) + the `inkhaven submissions`
/// CLI; a record's `draft_ref` points at a paragraph slug here.
// Consumed by P3 (the generators write drafts into this book) + the P2.2
// TUI; the P2.1 tracker only needs the SYSTEM_BOOKS entry to seed it.
#[allow(dead_code)]
pub const SYSTEM_TAG_SUBMISSIONS: &str = "submissions";
pub const SYSTEM_TAG_TYPST: &str = "typst";
pub const SYSTEM_TAG_SCRIPTS: &str = "scripts";
pub const SYSTEM_TAG_HELP: &str = "help";
/// 1.2.6+ — system tag stamped onto the auto-created
/// Timeline chapter inside each user book that has events.
/// Lookups by tag find the chapter regardless of user
/// renames (rename keeps the tag).
pub const SYSTEM_TAG_BOOK_TIMELINE: &str = "book_timeline";

/// Where a newly-created node lands among its parent's existing children.
#[derive(Debug, Clone, Copy)]
pub enum InsertPosition {
    /// Append after the last existing sibling (typical "add at end" behavior).
    End,
    /// Insert immediately after the given sibling; all siblings with order >
    /// the anchor's get bumped by +1 and have their fs entries renamed.
    After(Uuid),
    /// Insert immediately BEFORE the given sibling; the anchor and every
    /// sibling at or after its order get bumped by +1, and the new node
    /// takes the anchor's old order. Mirror of `After`.
    Before(Uuid),
}

/// A versioned snapshot of a paragraph's body at a point in time. Stored as a
/// separate bdslib document with `kind: "snapshot"` so it doesn't pollute the
/// hierarchy listing or vector search.
#[derive(Debug, Clone)]
pub struct Snapshot {
    pub id: Uuid,
    #[allow(dead_code)]
    pub parent_id: Uuid,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub word_count: u64,
    pub preview: String,
    /// 1.2.6+ — user-supplied annotation captured at snapshot
    /// time. Empty when the user pressed Enter on the prompt
    /// without typing anything (or skipped the prompt entirely
    /// via the legacy `create_snapshot` path). Surfaced by the
    /// F6 snapshot picker so the user sees what each version
    /// was *about*.
    pub annotation: String,
}

/// Thin wrapper over bdslib's DocumentStorage with the project's chosen
/// embedding engine attached.
#[derive(Clone)]
pub struct Store {
    inner: DocumentStorage,
    layout: Arc<ProjectLayout>,
}

impl Store {
    pub fn open(layout: ProjectLayout, cfg: &Config) -> Result<Self> {
        let root = layout
            .store_root()
            .to_str()
            .ok_or_else(|| Error::Store("project root path is not valid UTF-8".into()))?;

        // 1.2.18+ I.1.3 — env-gated phase timing for the
        // performance pass.  Set INKHAVEN_PERF_TRACE=1 to
        // emit per-phase millis on stderr.  Harmless +
        // permanent — the `Instant` calls are ~nanoseconds
        // when the env flag is unset (the format! +
        // eprintln! are guarded).
        let perf = perf_trace_enabled();
        let t0 = std::time::Instant::now();

        let engine = build_embedding_engine(&cfg.embeddings.model)?;
        perf_mark(perf, "store.open.embedding_engine", t0.elapsed());

        let t1 = std::time::Instant::now();
        // Clamp to ≥2 so a background job can always get a connection while the
        // main thread holds one (1.3.12 DEEP-1 invariant).
        let pool_size = cfg.embeddings.pool_size.max(2);
        let inner = DocumentStorage::with_embedding(root, engine, pool_size).map_err(|e| {
            Error::Store(format!(
                "couldn't open the document store at {} — {}.\n\
                 Another inkhaven process may be using the project, or the database \
                 may be corrupt. If you have a backup, restore it; otherwise \
                 `inkhaven init` a fresh project and re-add your work.",
                layout.root.display(),
                e
            ))
        })?;

        perf_mark(perf, "store.open.duckdb_open", t1.elapsed());

        let store = Self {
            inner,
            layout: Arc::new(layout),
        };
        let t2 = std::time::Instant::now();
        store.ensure_system_books(cfg)?;
        perf_mark(perf, "store.open.ensure_system_books", t2.elapsed());
        store.ensure_artefacts_directory(cfg)?;
        // Arm the scripting layer for every path that opens a
        // project — TUI, `inkhaven bund`, `inkhaven add`,
        // `inkhaven reindex`, etc. Idempotent in practice
        // (single-project-per-process).
        crate::scripting::configure(cfg.scripting.clone(), store.clone(), cfg.clone());
        Ok(store)
    }

    /// Create the per-project artefacts root if it doesn't exist yet.
    /// Each user book gets its own subdirectory under here for PDFs /
    /// build intermediates / etc. With the empty-string default the
    /// directory lives in the OS per-user cache (see
    /// `resolve_artefacts_dir`).
    fn ensure_artefacts_directory(&self, cfg: &Config) -> Result<()> {
        let abs = self.resolve_artefacts_dir(cfg);
        std::fs::create_dir_all(&abs).map_err(Error::Io)?;
        Ok(())
    }

    /// Resolve `cfg.artefacts_directory` to an absolute path. Precedence:
    ///   1. **Empty string** — use the OS-appropriate per-user cache
    ///      directory: `<cache_dir>/inkhaven/artefacts/<project-basename>`.
    ///      This is the default behaviour because build artefacts are
    ///      ephemeral and don't belong inside the project tree.
    ///   2. **Absolute path** — used verbatim.
    ///   3. **Relative path** — joined to the project root (legacy
    ///      behaviour; useful if the user explicitly wants artefacts
    ///      tracked alongside the manuscript).
    pub fn resolve_artefacts_dir(&self, cfg: &Config) -> std::path::PathBuf {
        let raw = cfg.artefacts_directory.trim();
        if raw.is_empty() {
            return default_user_artefacts_dir(&self.layout.root);
        }
        let p = std::path::PathBuf::from(raw);
        // 1.2.15+ Phase S.6 (H4) — reject `..`
        // traversal in relative `artefacts_directory`
        // values.  Absolute paths still honoured per
        // documented intent (Case 2 above); the
        // S.6.H1 project-trust prompt is the gate
        // for "is this absolute path safe to use".
        match crate::path_safety::resolve_within_or_absolute(&self.layout.root, &p) {
            Ok(resolved) => resolved,
            Err(e) => {
                tracing::warn!(
                    target: "inkhaven::security",
                    "artefacts_directory `{raw}` rejected ({e}); falling back to default per-user dir",
                );
                default_user_artefacts_dir(&self.layout.root)
            }
        }
    }


    /// Provision the side-effects of creating a user book at the root
    /// level: an artefacts subdirectory matching the book's slug, and a
    /// Typst-book chapter (same display name as the new book) carrying
    /// three starter paragraphs — `index.typ`, `settings.typ`,
    /// `globals.typ`. `index.typ` is seeded with `import` statements
    /// pulling in the other two so a fresh book's Typst skeleton is
    /// ready to render.
    ///
    /// Idempotent: re-calling with the same book is a no-op for any
    /// piece that's already in place.
    ///
    /// Called from both the TUI's `commit_add` (when creating a Book at
    /// root via the Tree pane) and the CLI's `add` subcommand.
    /// 1.2.6+ — find (or lazily create) the `Timeline`
    /// chapter inside `book_id`. Stamped with
    /// `system_tag: "book_timeline"` so subsequent lookups
    /// survive user renames. Returns the chapter's node id.
    /// Idempotent: a second call is a hierarchy lookup with
    /// no writes.
    pub fn ensure_timeline_chapter(
        &self,
        cfg: &Config,
        book_id: Uuid,
    ) -> Result<Uuid> {
        let hierarchy = crate::store::hierarchy::Hierarchy::load(self)?;
        let book = hierarchy
            .get(book_id)
            .cloned()
            .ok_or_else(|| Error::Store(format!(
                "ensure_timeline_chapter: book {book_id} missing"
            )))?;
        if book.kind != NK::Book {
            return Err(Error::Store(format!(
                "ensure_timeline_chapter: `{}` is not a Book", book.title
            )));
        }
        if let Some(existing) = hierarchy.iter().find(|n| {
            n.parent_id == Some(book_id)
                && n.system_tag.as_deref() == Some(SYSTEM_TAG_BOOK_TIMELINE)
        }) {
            return Ok(existing.id);
        }
        let mut created = self.create_node(
            cfg,
            &hierarchy,
            NK::Chapter,
            "Timeline",
            Some(&book),
            None,
            InsertPosition::End,
        )?;
        created.system_tag = Some(SYSTEM_TAG_BOOK_TIMELINE.to_owned());
        created.modified_at = chrono::Utc::now();
        self.inner
            .update_metadata(created.id, created.to_json())
            .map_err(|e| Error::Store(format!(
                "ensure_timeline_chapter: stamp system_tag: {e}"
            )))?;
        self.sync()?;
        Ok(created.id)
    }

    pub fn provision_user_book(
        &self,
        cfg: &Config,
        book_node: &Node,
    ) -> Result<()> {
        // Only root-level Books trigger provisioning.
        if book_node.kind != NK::Book || book_node.parent_id.is_some() {
            return Ok(());
        }
        // Skip system books — they shouldn't get artefact subdirs or a
        // self-referential Typst entry.
        if book_node.system_tag.is_some() {
            return Ok(());
        }

        // (a) Artefacts subdirectory under the resolved root
        // (per-user cache by default — see `resolve_artefacts_dir`).
        let sub = self.resolve_artefacts_dir(cfg).join(&book_node.slug);
        std::fs::create_dir_all(&sub).map_err(Error::Io)?;

        // (b) Typst-book chapter + three starter paragraphs.
        self.ensure_typst_skeleton(cfg, &book_node.title)?;
        // (c) Sources chapter + a placeholder citation entry (SOURCES-1).
        self.ensure_sources_chapter(cfg, &book_node.title)?;
        Ok(())
    }

    /// Ensure a chapter named after `book_title` exists inside the **Sources**
    /// system book, seeded — on first creation only — with one placeholder
    /// citation paragraph carrying the HJSON authoring template. Mirrors
    /// `ensure_typst_skeleton`: chapter-per-book, idempotent, safe to call
    /// repeatedly. The placeholder is seeded ONLY when the chapter is freshly
    /// created, so deleting it won't make it reappear on the next open.
    fn ensure_sources_chapter(&self, cfg: &Config, book_title: &str) -> Result<()> {
        let hierarchy = Hierarchy::load(self)?;
        let Some(sources_book) = hierarchy
            .iter()
            .find(|n| n.kind == NK::Book
                && n.system_tag.as_deref() == Some(SYSTEM_TAG_SOURCES))
            .cloned()
        else {
            // Sources book missing — ensure_system_books seeds it on every
            // open, so this only happens on an unusual hierarchy. Bail cleanly.
            return Ok(());
        };

        // An existing chapter for this book → nothing to do. Never re-seed.
        if hierarchy.iter().any(|n| n.kind == NK::Chapter
            && n.parent_id == Some(sources_book.id)
            && n.title == book_title)
        {
            return Ok(());
        }

        let chapter = self.create_node(
            cfg,
            &hierarchy,
            NK::Chapter,
            book_title,
            Some(&sources_book),
            None,
            InsertPosition::End,
        )?;

        // One placeholder citation entry so the author sees the schema. The
        // `.typ` file already exists from create_node's paragraph branch;
        // overwrite it with the HJSON template and flip content_type so the
        // editor highlights it as HJSON.
        let h = Hierarchy::load(self)?;
        let mut created = self.create_node(
            cfg,
            &h,
            NK::Paragraph,
            "example",
            Some(&chapter),
            None,
            InsertPosition::End,
        )?;
        created.content_type = Some("hjson".to_string());
        let body = crate::sources::ENTRY_TEMPLATE.as_bytes();
        if let Some(rel) = &created.file {
            let abs = self.layout.root.join(rel);
            crate::io_atomic::write(&abs, body).map_err(Error::Io)?;
        }
        // Persists content_type via to_json() + writes content to bdslib.
        self.update_paragraph_content(&mut created, body)?;
        Ok(())
    }

    /// Ensure a chapter named after `book_title` exists inside the
    /// Typst system book, and that it contains paragraphs `index.typ`,
    /// `settings.typ`, `globals.typ`. Each part is created only if
    /// missing — safe to call repeatedly.
    fn ensure_typst_skeleton(&self, cfg: &Config, book_title: &str) -> Result<()> {
        let hierarchy = Hierarchy::load(self)?;
        let Some(typst_book) = hierarchy
            .iter()
            .find(|n| n.kind == NK::Book && n.system_tag.as_deref() == Some(SYSTEM_TAG_TYPST))
            .cloned()
        else {
            // Typst system book is missing — shouldn't happen because
            // ensure_system_books seeds it on every open, but bail
            // cleanly rather than panic if the hierarchy is unusual.
            return Ok(());
        };

        // Find or create the chapter matching the book's title.
        let chapter = match hierarchy
            .iter()
            .find(|n| n.kind == NK::Chapter
                && n.parent_id == Some(typst_book.id)
                && n.title == book_title)
            .cloned()
        {
            Some(n) => n,
            None => self.create_node(
                cfg,
                &hierarchy,
                NK::Chapter,
                book_title,
                Some(&typst_book),
                None,
                InsertPosition::End,
            )?,
        };

        // Each paragraph needs a deterministic title and a starter
        // body. globals.typ ships the four wrap_* functions used by the
        // Book assembly procedure (Ctrl+B A) — pulled from
        // `cfg.typst_templates` so the user can pre-customise them in
        // HJSON.
        let globals_body = cfg.typst_templates.globals_typ_body();
        let seeds: [(&str, String); 3] = [
            (
                "index.typ",
                "= index.typ\n\n#import \"globals.typ\": *\n#import \"settings.typ\": *\n"
                    .into(),
            ),
            (
                "settings.typ",
                "= settings.typ\n\n// Document-wide #set / #show rules go here.\n".into(),
            ),
            ("globals.typ", globals_body),
        ];
        // Reload hierarchy after each create so subsequent lookups see
        // freshly-added siblings.
        for (title, body) in &seeds {
            let h = Hierarchy::load(self)?;
            let already = h.iter().any(|n| {
                n.kind == NK::Paragraph
                    && n.parent_id == Some(chapter.id)
                    && n.title == *title
            });
            if already {
                continue;
            }
            let mut created = self.create_node(
                cfg,
                &h,
                NK::Paragraph,
                title,
                Some(&chapter),
                None,
                InsertPosition::End,
            )?;
            // Overwrite the auto-generated `= Title\n\n` body with the
            // seeded content. The fs file already exists from
            // `create_node`'s paragraph branch.
            if let Some(rel) = &created.file {
                let abs = self.layout.root.join(rel);
                crate::io_atomic::write(&abs, body.as_bytes()).map_err(Error::Io)?;
                self.update_paragraph_content(&mut created, body.as_bytes())?;
            }
        }
        Ok(())
    }

    /// Six books that every project keeps at the root, in this order:
    /// Notes, Research, Prompts, Places, Characters, Help. They are created
    /// on first open (or on upgrade from a pre-feature project), tagged with
    /// `system_tag`, and marked `protected=true`. Help is additionally read-
    /// only — enforced by the editor layer, not the store, so the underlying
    /// content can still be authored by tooling or future bundled-help logic.
    fn ensure_system_books(&self, cfg: &Config) -> Result<()> {
        let hierarchy = Hierarchy::load(self)?;
        let mut existing_by_tag: std::collections::HashMap<String, Node> =
            std::collections::HashMap::new();
        for node in hierarchy.iter() {
            if node.kind == NK::Book {
                if let Some(tag) = node.system_tag.as_deref() {
                    existing_by_tag.insert(tag.to_string(), node.clone());
                }
            }
        }

        for (idx, (tag, title)) in SYSTEM_BOOKS.iter().enumerate() {
            let target_order = idx as u32;
            match existing_by_tag.get(*tag).cloned() {
                Some(mut node) => {
                    // System book already exists. Only re-stamp the flags
                    // that should never go missing (protected). Leave its
                    // `order` alone — user-added books may have shifted it
                    // away from the canonical position and we must not undo
                    // those shifts on every project open.
                    if !node.protected {
                        node.protected = true;
                        self.inner
                            .update_metadata(node.id, node.to_json())
                            .map_err(|e| {
                                Error::Store(format!("update_metadata: {e}"))
                            })?;
                    }
                }
                None => {
                    // First-time creation: the canonical order matches the
                    // SYSTEM_BOOKS array index. Reload before each create so
                    // subsequent slugs see prior creates and don't collide.
                    let h = Hierarchy::load(self)?;

                    // Make room for the new system book at its
                    // canonical slot: bump every existing root book
                    // (system or user) with `order >= target_order` up
                    // by 1. Without this, inserting `Artefacts` at
                    // slot 5 into a project that already has `Typst`
                    // at order 5 would either collide on order (sort
                    // tie-broken by slug — usually wrong) or land out
                    // of the intended sequence. A single open-time
                    // pass is enough; subsequent opens are no-ops
                    // since the book then exists.
                    for n in h.children_of(None) {
                        if n.order >= target_order {
                            let mut bumped = n.clone();
                            bumped.order += 1;
                            self.inner
                                .update_metadata(bumped.id, bumped.to_json())
                                .map_err(|e| {
                                    Error::Store(format!("update_metadata (bump): {e}"))
                                })?;
                        }
                    }
                    let h = Hierarchy::load(self)?;

                    let mut node = self.create_node(
                        cfg,
                        &h,
                        NK::Book,
                        title,
                        None,
                        None,
                        InsertPosition::End,
                    )?;
                    node.order = target_order;
                    node.protected = true;
                    node.system_tag = Some(tag.to_string());
                    self.inner
                        .update_metadata(node.id, node.to_json())
                        .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
                }
            }
        }
        // Heal-pass: if two system books ended up sharing the same
        // `order` (older seeder versions used to set the new book's
        // canonical idx without bumping the existing books), the
        // lexicographic tie-breaker chooses the wrong visual order.
        // Re-stamp the colliding pair to their canonical positions
        // and bump anything else above them. This is conservative —
        // it only fires when there's a clear collision, so a user
        // who deliberately reordered system books isn't surprised.
        let healed = Hierarchy::load(self)?;
        let mut by_order: std::collections::HashMap<u32, Vec<Node>> =
            std::collections::HashMap::new();
        for n in healed.children_of(None) {
            if n.system_tag.is_some() {
                by_order.entry(n.order).or_default().push(n.clone());
            }
        }
        let any_collision = by_order.values().any(|v| v.len() > 1);
        if any_collision {
            for (idx, (tag, _title)) in SYSTEM_BOOKS.iter().enumerate() {
                let target = idx as u32;
                if let Some(node) = healed.iter().find(|n| {
                    n.kind == NK::Book && n.system_tag.as_deref() == Some(*tag)
                }) {
                    if node.order != target {
                        let mut updated = node.clone();
                        updated.order = target;
                        self.inner
                            .update_metadata(updated.id, updated.to_json())
                            .map_err(|e| {
                                Error::Store(format!("update_metadata (heal): {e}"))
                            })?;
                    }
                }
            }
        }

        self.sync()?;
        Ok(())
    }

    pub fn raw(&self) -> &DocumentStorage {
        &self.inner
    }

    /// Project root path (the directory the user passed via --project).
    /// Read-only accessor — callers should not mutate the layout.
    pub fn project_root(&self) -> &std::path::Path {
        &self.layout.root
    }

    pub fn sync(&self) -> Result<()> {
        self.inner.sync().map_err(|e| Error::Store(e.to_string()))
    }

    /// Drain DuckDB's WAL into the main `.db` files. Used by the
    /// background sync tick and the TUI shutdown path; per-save
    /// callers don't need this because every commit is already
    /// fsync-durable.
    pub fn checkpoint(&self) -> Result<()> {
        self.inner
            .checkpoint()
            .map_err(|e| Error::Store(e.to_string()))
    }

    /// 1.2.16+ Phase P.4 — DuckDB integrity check.
    /// Returns `(meta_status, blobs_status)`;
    /// expected `("ok", "ok")` on healthy.  Any
    /// other value signals corruption.
    pub fn integrity_check(&self) -> Result<(String, String)> {
        self.inner
            .integrity_check()
            .map_err(|e| Error::Store(e.to_string()))
    }

    /// 1.2.16+ Phase P.4 — paragraph row count.
    pub fn row_count(&self) -> Result<usize> {
        self.inner
            .row_count()
            .map_err(|e| Error::Store(e.to_string()))
    }

    /// 1.2.16+ Phase P.4 — total HNSW vector
    /// count (the store keeps two vectors per
    /// document).
    pub fn vector_count(&self) -> Result<usize> {
        self.inner
            .vector_count()
            .map_err(|e| Error::Store(e.to_string()))
    }

    /// Add a hierarchy node to bdslib. The metadata is serialized; the content
    /// bytes are indexed for vector search. Returns the bdslib-assigned UUIDv7
    /// after we copy it back onto the Node.
    pub fn put_node(&self, node: &mut Node, content: &[u8]) -> Result<()> {
        let id = self
            .inner
            .add_document(node.to_json(), content)
            .map_err(|e| Error::Store(format!("add_document: {e}")))?;
        node.id = id;
        // Re-write metadata with the now-final id available to consumers.
        self.inner
            .update_metadata(id, node.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        // Fire hook.on_create ( uuid kind -- ). Errors are logged
        // and swallowed inside `hooks::fire` — a misbehaving hook
        // never aborts the create.
        fire_hook(
            "hook.on_create",
            vec![
                bund_string(&id.to_string()),
                bund_string(node.kind.as_str()),
            ],
        );
        Ok(())
    }

    pub fn search_text(&self, query: &str, limit: usize) -> Result<Vec<JsonValue>> {
        self.inner
            .search_document_text(query, limit)
            .map_err(|e| Error::Store(format!("search_document_text: {e}")))
    }

    /// Embed arbitrary texts via the store's loaded embedding engine — for
    /// on-demand semantic comparison (e.g. conlang near-synonym detection).
    pub fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
        self.inner
            .embed_batch(texts)
            .map_err(|e| Error::Store(format!("embed_batch: {e}")))
    }

    pub fn get_content(&self, id: Uuid) -> Result<Option<Vec<u8>>> {
        self.inner
            .get_content(id)
            .map_err(|e| Error::Store(format!("get_content: {e}")))
    }

    /// Create a hierarchy node end-to-end: validate placement, derive a unique
    /// slug, compute the filesystem path, write a .typ template (paragraphs) or
    /// create a directory (branches), insert into bdslib, sync. Returns the
    /// fully-populated `Node`. Callers should reload `Hierarchy` afterward.
    ///
    /// `position` controls where among existing siblings the new node lands:
    ///   * `InsertPosition::End` — appended after the last sibling (default).
    ///   * `InsertPosition::After(uuid)` — placed immediately after the named
    ///     sibling; all later siblings get their `order` bumped by +1 and
    ///     their filesystem entries renamed.
    pub fn create_node(
        &self,
        cfg: &Config,
        hierarchy: &Hierarchy,
        kind: NodeKind,
        title: &str,
        parent: Option<&Node>,
        slug_override: Option<&str>,
        position: InsertPosition,
    ) -> Result<Node> {
        hierarchy.validate_placement(cfg, parent, kind)?;

        let slug_seed = slug_override.unwrap_or(title);
        let mut slug = slug::slugify(slug_seed);
        if slug.is_empty() {
            return Err(Error::Store(format!(
                "could not derive a slug from `{slug_seed}`; pass --slug"
            )));
        }

        let parent_id = parent.map(|p| p.id);
        let siblings = hierarchy.children_of(parent_id);
        if siblings.iter().any(|n| n.slug == slug) {
            let base = slug.clone();
            let mut n = 2;
            while siblings.iter().any(|s| s.slug == slug) {
                slug = format!("{base}-{n}");
                n += 1;
            }
        }

        // Decide the new node's `order`. For After(anchor), shift every
        // sibling strictly after the anchor by +1 (highest order first so
        // filesystem renames never collide).
        let order = match position {
            InsertPosition::End => hierarchy.next_order(parent_id),
            InsertPosition::After(anchor_id) => {
                let Some(anchor) = hierarchy.get(anchor_id) else {
                    return Err(Error::Store(format!("insert-after: missing anchor {anchor_id}")));
                };
                if anchor.parent_id != parent_id {
                    return Err(Error::Store(
                        "insert-after: anchor does not share the requested parent".into(),
                    ));
                }
                let anchor_order = anchor.order;
                let mut to_shift: Vec<(Uuid, u32)> = hierarchy
                    .children_of(parent_id)
                    .into_iter()
                    .filter(|n| n.order > anchor_order && n.id != anchor_id)
                    .map(|n| (n.id, n.order))
                    .collect();
                to_shift.sort_by_key(|(_, ord)| std::cmp::Reverse(*ord));
                for (id, old_order) in to_shift {
                    self.shift_sibling_order(hierarchy, id, old_order + 1)?;
                }
                anchor_order + 1
            }
            InsertPosition::Before(anchor_id) => {
                let Some(anchor) = hierarchy.get(anchor_id) else {
                    return Err(Error::Store(format!(
                        "insert-before: missing anchor {anchor_id}"
                    )));
                };
                if anchor.parent_id != parent_id {
                    return Err(Error::Store(
                        "insert-before: anchor does not share the requested parent".into(),
                    ));
                }
                let anchor_order = anchor.order;
                // Shift the anchor and everything after it up by 1, highest
                // first so renames never collide. The new node then takes
                // the anchor's old order.
                let mut to_shift: Vec<(Uuid, u32)> = hierarchy
                    .children_of(parent_id)
                    .into_iter()
                    .filter(|n| n.order >= anchor_order)
                    .map(|n| (n.id, n.order))
                    .collect();
                to_shift.sort_by_key(|(_, ord)| std::cmp::Reverse(*ord));
                for (id, old_order) in to_shift {
                    self.shift_sibling_order(hierarchy, id, old_order + 1)?;
                }
                anchor_order
            }
        };

        let path_chain: Vec<String> = match parent {
            None => Vec::new(),
            Some(p) => {
                let mut chain: Vec<String> = hierarchy
                    .ancestors(p)
                    .into_iter()
                    .map(|a| a.slug.clone())
                    .collect();
                chain.push(p.slug.clone());
                chain
            }
        };

        let mut node = Node {
            id: Uuid::nil(),
            kind,
            title: title.to_string(),
            slug,
            path: path_chain,
            parent_id,
            order,
            file: None,
            word_count: 0,
            modified_at: chrono::Utc::now(),
            protected: false,
            system_tag: None,
            image_ext: None,
            image_caption: None,
            image_alt: None,
            content_type: None,
            status: None,
            target_words: None,
            target_hit_at_status: None,
            linked_paragraphs: Vec::new(),
            bookmark: false,
            tags: Vec::new(),
            ai_memory: Vec::new(),
            event: None,
        };

        let rel_path = match parent {
            None => std::path::PathBuf::from(BOOKS_DIR).join(node.fs_name()),
            Some(p) => hierarchy.fs_path(p, &self.layout).join(node.fs_name()),
        };
        let abs_path = self.layout.root.join(&rel_path);

        // 1.2.8+ — paragraphs under the Help book default to
        // markdown content_type.  Doc content is mostly MD,
        // so this turns on the markdown syntax highlighter
        // for new Help paragraphs and gives them a `# Title`
        // template instead of typst's `= Title`.  Detection
        // is by walking the parent chain looking for a node
        // tagged with the system `help` tag (the well-known
        // root for the Help book — survives renames because
        // the tag is sticky).  Limited to the Help book ON
        // PURPOSE: every other book stays on the typst
        // default.
        let in_help_book = parent
            .map(|p| {
                let mut cur: Option<&Node> = Some(p);
                while let Some(n) = cur {
                    if n.system_tag.as_deref() == Some(SYSTEM_TAG_HELP) {
                        return true;
                    }
                    cur = n.parent_id.and_then(|id| hierarchy.get(id));
                }
                false
            })
            .unwrap_or(false);

        let content: Vec<u8> = match kind {
            NK::Paragraph => {
                if let Some(parent_dir) = abs_path.parent() {
                    std::fs::create_dir_all(parent_dir)?;
                }
                let template = if in_help_book {
                    node.content_type = Some("markdown".to_string());
                    format!("# {}\n\n", node.title)
                } else {
                    format!("= {}\n\n", node.title)
                };
                crate::io_atomic::write(&abs_path, template.as_bytes())?;
                node.file = Some(rel_path.to_string_lossy().into_owned());
                node.word_count = template.split_whitespace().count() as u64;
                template.into_bytes()
            }
            NK::Script => {
                if let Some(parent_dir) = abs_path.parent() {
                    std::fs::create_dir_all(parent_dir)?;
                }
                let template = format!(
                    "// {}\n// Bund script — evaluated into the Adam VM at\n\
                     // project open. Register hooks via:\n\
                     //   \"hook.on_save\" {{ drop \"saved\" println }} register\n\n",
                    node.title
                );
                crate::io_atomic::write(&abs_path, template.as_bytes())?;
                node.file = Some(rel_path.to_string_lossy().into_owned());
                node.word_count = template.split_whitespace().count() as u64;
                // Drive the Bund syntax highlighter via the same
                // content_type channel the HJSON path uses.
                node.content_type = Some("bund".to_string());
                template.into_bytes()
            }
            _ => {
                std::fs::create_dir_all(&abs_path)?;
                node.title.clone().into_bytes()
            }
        };

        self.put_node(&mut node, &content)?;
        self.sync()?;
        Ok(node)
    }

    /// Change a single node's `order` by renaming its filesystem entry and
    /// updating bdslib metadata (plus descendant `file` paths for branches).
    /// Errors if a sibling already occupies the target slot — callers are
    /// expected to process shifts highest-order-first to avoid this.
    fn shift_sibling_order(
        &self,
        hierarchy: &Hierarchy,
        node_id: Uuid,
        new_order: u32,
    ) -> Result<()> {
        let node = hierarchy
            .get(node_id)
            .cloned()
            .ok_or_else(|| Error::Store(format!("shift_sibling_order: missing {node_id}")))?;
        if node.order == new_order {
            return Ok(());
        }
        let old_rel = hierarchy.fs_path(&node, &self.layout);
        let old_abs = self.layout.root.join(&old_rel);

        let mut new_node = node.clone();
        new_node.order = new_order;
        let new_name = new_node.fs_name();
        let parent_dir = old_abs
            .parent()
            .ok_or_else(|| Error::Store("filesystem entry has no parent directory".into()))?;
        let new_abs = parent_dir.join(&new_name);

        // Books carry no `NN-` order prefix in their filesystem name, so a
        // reorder produces an identical path. Skip the rename in that case;
        // only metadata changes. (The `new_abs.exists()` guard would
        // otherwise reject the no-op as a collision.)
        let needs_rename = old_abs != new_abs;
        if needs_rename {
            if new_abs.exists() {
                return Err(Error::Store(format!(
                    "shift_sibling_order: target `{}` already exists",
                    new_abs.display()
                )));
            }
            std::fs::rename(&old_abs, &new_abs)?;
        }

        let new_rel = new_abs
            .strip_prefix(&self.layout.root)
            .unwrap_or(&new_abs)
            .to_string_lossy()
            .into_owned();
        if new_node.file.is_some() {
            new_node.file = Some(new_rel.clone());
        }
        self.inner
            .update_metadata(new_node.id, new_node.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        if node.kind != NK::Paragraph {
            self.rewrite_descendant_files(hierarchy, &node, &old_rel, &new_rel)?;
        }
        Ok(())
    }

    /// Change a node's displayed `title` without touching the filesystem.
    /// Slug, order, parent, file path all stay the same — only the
    /// `metadata.title` JSON field is updated in bdslib. Re-embeds so the
    /// new title participates in semantic search.
    pub fn rename_node(&self, hierarchy: &Hierarchy, node_id: Uuid, new_title: &str) -> Result<()> {
        let mut node = hierarchy
            .get(node_id)
            .cloned()
            .ok_or_else(|| Error::Store(format!("rename_node: missing {node_id}")))?;
        let trimmed = new_title.trim();
        if trimmed.is_empty() {
            return Err(Error::Store("rename: title cannot be empty".into()));
        }
        node.title = trimmed.to_string();
        node.modified_at = chrono::Utc::now();

        // 1.2.4+: re-derive the slug for paragraph nodes so the
        // on-disk filename tracks the title. The slug is stable
        // for branches (renaming a chapter directory would also
        // need to rewrite every descendant's `file` field — out
        // of scope for this change; branch rename keeps the
        // original folder name).
        if matches!(node.kind, NodeKind::Paragraph) {
            let new_slug_base = slug::slugify(trimmed);
            if !new_slug_base.is_empty() && new_slug_base != node.slug {
                let mut new_slug = new_slug_base.clone();
                let mut n = 2;
                let siblings = hierarchy.children_of(node.parent_id);
                while siblings
                    .iter()
                    .any(|s| s.id != node.id && s.slug == new_slug)
                {
                    new_slug = format!("{new_slug_base}-{n}");
                    n += 1;
                }
                if new_slug != node.slug {
                    // Compute paths from the *current* slug (old)
                    // and the post-rename slug (new). Both live in
                    // the same parent directory so we only rename
                    // the basename.
                    if let Some(rel_old) = node.file.as_ref().cloned() {
                        let old_abs = self.layout.root.join(&rel_old);
                        // Rebuild the path with the new slug.
                        node.slug = new_slug.clone();
                        let new_name = node.fs_name();
                        let parent_rel = std::path::Path::new(&rel_old)
                            .parent()
                            .map(|p| p.to_path_buf())
                            .unwrap_or_default();
                        let new_rel = parent_rel.join(&new_name);
                        let new_abs = self.layout.root.join(&new_rel);
                        if old_abs != new_abs {
                            if let Err(e) = std::fs::rename(&old_abs, &new_abs) {
                                // Don't surface a hard error here:
                                // the metadata update can still
                                // happen, and `inkhaven reindex`
                                // will pick up the drift on next
                                // launch. Roll back the slug.
                                tracing::warn!(
                                    target: "inkhaven::rename",
                                    "rename {} → {} failed: {e}",
                                    old_abs.display(),
                                    new_abs.display(),
                                );
                                node.slug = rel_old
                                    .rsplit('/')
                                    .next()
                                    .and_then(|n| {
                                        // Reverse-engineer old slug
                                        // from "NN-slug.typ".
                                        n.trim_end_matches(".typ")
                                            .trim_end_matches(".hjson")
                                            .splitn(2, '-')
                                            .nth(1)
                                            .map(|s| s.to_string())
                                    })
                                    .unwrap_or(node.slug);
                            } else {
                                node.file =
                                    Some(new_rel.to_string_lossy().into_owned());
                            }
                        }
                    } else {
                        // No file on disk (shouldn't happen for a
                        // paragraph but stay safe). Just stamp the
                        // new slug.
                        node.slug = new_slug;
                    }
                }
            }
        }

        self.inner
            .update_metadata(node.id, node.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        self.inner
            .reembed_document(node.id)
            .map_err(|e| Error::Store(format!("reembed_document: {e}")))?;
        self.sync()?;
        // Fire hook.on_rename ( uuid new_title -- ).
        fire_hook(
            "hook.on_rename",
            vec![bund_string(&node.id.to_string()), bund_string(trimmed)],
        );
        Ok(())
    }

    /// Swap two sibling nodes in the hierarchy: exchange their `order` fields,
    /// rename the corresponding filesystem entries, and update every
    /// descendant paragraph's `file` path that points through a renamed
    /// branch directory.
    ///
    /// Caller passes both nodes by id; they must share the same `parent_id`.
    /// On error, partial filesystem renames may leave drift — `inkhaven
    /// reindex` will catch it.
    pub fn swap_siblings(
        &self,
        hierarchy: &Hierarchy,
        a_id: Uuid,
        b_id: Uuid,
    ) -> Result<()> {
        if a_id == b_id {
            return Err(Error::Store("cannot swap a node with itself".into()));
        }
        let a = hierarchy
            .get(a_id)
            .ok_or_else(|| Error::Store(format!("node {a_id} missing from hierarchy")))?
            .clone();
        let b = hierarchy
            .get(b_id)
            .ok_or_else(|| Error::Store(format!("node {b_id} missing from hierarchy")))?
            .clone();
        if a.parent_id != b.parent_id {
            return Err(Error::Store("can only swap siblings".into()));
        }

        let a_old_rel = hierarchy.fs_path(&a, &self.layout);
        let b_old_rel = hierarchy.fs_path(&b, &self.layout);
        let a_old_abs = self.layout.root.join(&a_old_rel);
        let b_old_abs = self.layout.root.join(&b_old_rel);

        // Build the new nodes with swapped orders so we can compute new
        // filesystem segment names via `fs_name()`.
        let mut a_new = a.clone();
        a_new.order = b.order;
        let mut b_new = b.clone();
        b_new.order = a.order;

        let a_new_name = a_new.fs_name();
        let b_new_name = b_new.fs_name();

        // Both new names exist in the same parent directory.
        let parent_dir = a_old_abs
            .parent()
            .ok_or_else(|| Error::Store("filesystem entry has no parent directory".into()))?;
        let a_new_abs = parent_dir.join(&a_new_name);
        let b_new_abs = parent_dir.join(&b_new_name);

        // Two-step rename to avoid any chance of clobbering when the slugs
        // happen to collide with the swapped order prefix. Use temp names
        // adjacent to the originals.
        let tmp_a = parent_dir.join(format!(".inkhaven-mv-a-{}", a.id.as_simple()));
        let tmp_b = parent_dir.join(format!(".inkhaven-mv-b-{}", b.id.as_simple()));

        std::fs::rename(&a_old_abs, &tmp_a)?;
        if let Err(e) = std::fs::rename(&b_old_abs, &tmp_b) {
            // Roll back the first rename so we don't leave the project broken.
            let _ = std::fs::rename(&tmp_a, &a_old_abs);
            return Err(Error::Io(e));
        }
        if let Err(e) = std::fs::rename(&tmp_a, &a_new_abs) {
            let _ = std::fs::rename(&tmp_b, &b_old_abs);
            return Err(Error::Io(e));
        }
        std::fs::rename(&tmp_b, &b_new_abs)?;

        // Update file paths on the moved nodes themselves (paragraphs only).
        let a_new_rel = a_new_abs
            .strip_prefix(&self.layout.root)
            .unwrap_or(&a_new_abs)
            .to_string_lossy()
            .into_owned();
        let b_new_rel = b_new_abs
            .strip_prefix(&self.layout.root)
            .unwrap_or(&b_new_abs)
            .to_string_lossy()
            .into_owned();
        if a_new.file.is_some() {
            a_new.file = Some(a_new_rel.clone());
        }
        if b_new.file.is_some() {
            b_new.file = Some(b_new_rel.clone());
        }

        // Persist both nodes' metadata.
        self.inner
            .update_metadata(a_new.id, a_new.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        self.inner
            .update_metadata(b_new.id, b_new.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;

        // For each renamed branch, rewrite descendants' `file` fields so they
        // point at the new directory.
        if a.kind != NK::Paragraph {
            self.rewrite_descendant_files(hierarchy, &a, &a_old_rel, &a_new_rel)?;
        }
        if b.kind != NK::Paragraph {
            self.rewrite_descendant_files(hierarchy, &b, &b_old_rel, &b_new_rel)?;
        }

        self.sync()?;
        Ok(())
    }

    /// OUTLINE-1 (O-P3/O-P4) — relocate a **childless** node (paragraph / image
    /// / script, or an empty branch) to become the last child of
    /// `new_parent_id` (`None` = a top-level book slot). Filesystem-aware:
    /// renames the on-disk entry into the destination parent's directory with a
    /// fresh `NN-` order prefix, uniquifies the slug against the destination's
    /// siblings, and rewrites the node's `parent_id` / `order` / `slug` / `path`
    /// / `file` metadata.
    ///
    /// Restricted to childless nodes on purpose: a branch move would also have
    /// to rewrite every descendant's stored `path` chain (the load-time depth
    /// sort key) and `file`, which is the Tree pane's domain. Promote/demote and
    /// the Outline's cross-parent paragraph *move* are all childless, so this
    /// covers them safely. Callers reload `Hierarchy` afterward.
    pub fn move_node_to_parent(
        &self,
        cfg: &Config,
        hierarchy: &Hierarchy,
        node_id: Uuid,
        new_parent_id: Option<Uuid>,
    ) -> Result<()> {
        let node = hierarchy
            .get(node_id)
            .cloned()
            .ok_or_else(|| Error::Store(format!("move: missing node {node_id}")))?;
        if hierarchy.has_children(node_id) {
            return Err(Error::Store(
                "move: node has children — restructure branches in the Tree pane".into(),
            ));
        }
        if node.parent_id == new_parent_id {
            return Ok(()); // already there — reorder, not reparent
        }
        let new_parent = match new_parent_id {
            Some(id) => Some(
                hierarchy
                    .get(id)
                    .cloned()
                    .ok_or_else(|| Error::Store(format!("move: missing parent {id}")))?,
            ),
            None => None,
        };
        hierarchy.validate_placement(cfg, new_parent.as_ref(), node.kind)?;

        // Uniquify the slug against the destination's existing children.
        let dest_siblings = hierarchy.children_of(new_parent_id);
        let mut slug = node.slug.clone();
        if dest_siblings.iter().any(|s| s.slug == slug) {
            let base = slug.clone();
            let mut n = 2;
            while dest_siblings.iter().any(|s| s.slug == slug) {
                slug = format!("{base}-{n}");
                n += 1;
            }
        }

        let old_rel = hierarchy.fs_path(&node, &self.layout);
        let old_abs = self.layout.root.join(&old_rel);

        let mut new_node = node.clone();
        new_node.parent_id = new_parent_id;
        new_node.slug = slug;
        new_node.order = hierarchy.next_order(new_parent_id);
        new_node.path = match new_parent.as_ref() {
            None => Vec::new(),
            Some(p) => {
                let mut chain: Vec<String> = hierarchy
                    .ancestors(p)
                    .into_iter()
                    .map(|a| a.slug.clone())
                    .collect();
                chain.push(p.slug.clone());
                chain
            }
        };
        new_node.modified_at = chrono::Utc::now();

        let dest_dir_rel = match new_parent.as_ref() {
            None => std::path::PathBuf::from(BOOKS_DIR),
            Some(p) => hierarchy.fs_path(p, &self.layout),
        };
        let new_rel = dest_dir_rel.join(new_node.fs_name());
        let new_abs = self.layout.root.join(&new_rel);
        if new_abs.exists() {
            return Err(Error::Store(format!(
                "move: target `{}` already exists",
                new_abs.display()
            )));
        }
        if let Some(parent_dir) = new_abs.parent() {
            std::fs::create_dir_all(parent_dir)?;
        }
        std::fs::rename(&old_abs, &new_abs)?;

        let new_rel_str = new_rel
            .strip_prefix(&self.layout.root)
            .unwrap_or(&new_rel)
            .to_string_lossy()
            .into_owned();
        if new_node.file.is_some() {
            new_node.file = Some(new_rel_str);
        }
        self.inner
            .update_metadata(new_node.id, new_node.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        self.sync()?;
        Ok(())
    }

    /// OUTLINE-1 (O-P4/O-P6) — duplicate paragraph `src_id` as a new paragraph
    /// (fresh uuid) appended to `new_parent_id`, carrying the prose metadata
    /// (tags / status / target / content-type / outgoing links) but NOT the
    /// timeline event — a copy must never mint a duplicate event. Body is read
    /// from the source and written to the new on-disk file. Returns the new id.
    /// Shared by the Outline/Tree `y`+`f` clipboard and the `inkhaven paragraph
    /// copy` CLI. Caller reloads `Hierarchy` afterward.
    pub fn copy_paragraph_to_parent(
        &self,
        cfg: &Config,
        hierarchy: &Hierarchy,
        src_id: Uuid,
        new_parent_id: Option<Uuid>,
    ) -> Result<Uuid> {
        let src = hierarchy
            .get(src_id)
            .cloned()
            .ok_or_else(|| Error::Store(format!("copy: missing source {src_id}")))?;
        if src.kind != NK::Paragraph {
            return Err(Error::Store("copy: only paragraphs can be copied".into()));
        }
        let content = self.get_content(src_id)?.unwrap_or_default();
        let parent_node = match new_parent_id {
            Some(id) => Some(
                hierarchy
                    .get(id)
                    .cloned()
                    .ok_or_else(|| Error::Store(format!("copy: missing parent {id}")))?,
            ),
            None => None,
        };
        let created = self.create_node(
            cfg,
            hierarchy,
            NK::Paragraph,
            &src.title,
            parent_node.as_ref(),
            None,
            InsertPosition::End,
        )?;
        if let Some(rel) = created.file.as_ref() {
            let abs = self.layout.root.join(rel);
            crate::io_atomic::write(&abs, &content)?;
        }
        let mut updated = created.clone();
        updated.tags = src.tags.clone();
        updated.status = src.status.clone();
        updated.target_words = src.target_words;
        updated.content_type = src.content_type.clone();
        updated.linked_paragraphs = src.linked_paragraphs.clone();
        updated.word_count =
            String::from_utf8_lossy(&content).split_whitespace().count() as u64;
        updated.modified_at = chrono::Utc::now();
        self.inner
            .update_metadata(updated.id, updated.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        self.sync()?;
        Ok(updated.id)
    }

    /// Walk descendants of `moved` and rewrite each paragraph's `file` field
    /// so the prefix that used to be `old_rel` becomes `new_rel`.
    fn rewrite_descendant_files(
        &self,
        hierarchy: &Hierarchy,
        moved: &Node,
        old_rel: &std::path::Path,
        new_rel: &str,
    ) -> Result<()> {
        let old_prefix = old_rel.to_string_lossy().into_owned();
        for descendant_id in hierarchy.collect_subtree(moved.id) {
            if descendant_id == moved.id {
                continue;
            }
            let Some(descendant) = hierarchy.get(descendant_id) else {
                continue;
            };
            if descendant.kind != NK::Paragraph {
                continue;
            }
            let Some(old_file) = descendant.file.as_ref() else {
                continue;
            };
            if let Some(rest) = old_file.strip_prefix(&old_prefix) {
                let new_file = format!("{new_rel}{rest}");
                let mut updated = descendant.clone();
                updated.file = Some(new_file);
                self.inner
                    .update_metadata(updated.id, updated.to_json())
                    .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
            }
        }
        Ok(())
    }

    // -------- snapshots ---------------------------------------------------

    /// Create a versioned snapshot of `parent`'s current content. Stored as a
    /// bdslib document with `kind:"snapshot"` and a `parent_id` back-reference.
    /// Snapshots are added via `add_document_no_embed` so they don't appear in
    /// vector search results — only the live paragraph version does.
    pub fn create_snapshot(&self, parent: &Node, content: &[u8]) -> Result<Uuid> {
        self.create_snapshot_annotated(parent, content, "")
    }

    /// 1.2.6+ — `create_snapshot` plus a user-supplied
    /// `annotation` ("first complete draft", "before the
    /// lighthouse rewrite") stamped into the snapshot's
    /// metadata. Empty annotation = same shape as the legacy
    /// `create_snapshot` path.
    pub fn create_snapshot_annotated(
        &self,
        parent: &Node,
        content: &[u8],
        annotation: &str,
    ) -> Result<Uuid> {
        let preview = first_prose_line(content);
        let word_count = std::str::from_utf8(content)
            .map(|s| s.split_whitespace().count() as u64)
            .unwrap_or(0);
        let meta = serde_json::json!({
            "kind": "snapshot",
            "parent_id": parent.id.to_string(),
            "parent_title": parent.title,
            "created_at": chrono::Utc::now().to_rfc3339(),
            "word_count": word_count,
            "preview": preview,
            "annotation": annotation,
        });
        let id = self
            .inner
            .add_document_no_embed(meta, content)
            .map_err(|e| Error::Store(format!("create_snapshot: {e}")))?;
        self.sync()?;
        // Fire hook.on_snapshot ( parent_uuid snapshot_uuid -- ).
        fire_hook(
            "hook.on_snapshot",
            vec![
                bund_string(&parent.id.to_string()),
                bund_string(&id.to_string()),
            ],
        );
        Ok(id)
    }

    /// Every snapshot whose `parent_id` matches the given paragraph,
    /// returned newest first.
    pub fn list_snapshots(&self, parent_id: Uuid) -> Result<Vec<Snapshot>> {
        let pid = parent_id.to_string();
        let raw = self
            .inner
            .list_metadata()
            .map_err(|e| Error::Store(format!("list_metadata: {e}")))?;
        let mut out: Vec<Snapshot> = raw
            .into_iter()
            .filter_map(|(id, meta)| {
                let kind = meta.get("kind").and_then(|v| v.as_str())?;
                if kind != "snapshot" {
                    return None;
                }
                let pid_in = meta.get("parent_id").and_then(|v| v.as_str())?;
                if pid_in != pid {
                    return None;
                }
                let created_at = meta
                    .get("created_at")
                    .and_then(|v| v.as_str())
                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                    .map(|dt| dt.with_timezone(&chrono::Utc))
                    .unwrap_or_else(chrono::Utc::now);
                let word_count = meta.get("word_count").and_then(|v| v.as_u64()).unwrap_or(0);
                let preview = meta
                    .get("preview")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let annotation = meta
                    .get("annotation")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                Some(Snapshot {
                    id,
                    parent_id,
                    created_at,
                    word_count,
                    preview,
                    annotation,
                })
            })
            .collect();
        out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
        Ok(out)
    }

    pub fn snapshot_content(&self, snapshot_id: Uuid) -> Result<Option<Vec<u8>>> {
        self.inner
            .get_content(snapshot_id)
            .map_err(|e| Error::Store(format!("snapshot_content: {e}")))
    }

    /// Every snapshot in the project, paired with its parent
    /// paragraph's title (captured in the metadata at snapshot time,
    /// so this needs no hierarchy walk), newest first. Powers the
    /// project-wide snapshot browser (Ctrl+F6) — the counterpart to
    /// `list_snapshots`, which is scoped to one paragraph.
    pub fn list_all_snapshots(&self) -> Result<Vec<(String, Snapshot)>> {
        let raw = self
            .inner
            .list_metadata()
            .map_err(|e| Error::Store(format!("list_metadata: {e}")))?;
        let mut out: Vec<(String, Snapshot)> = raw
            .into_iter()
            .filter_map(|(id, meta)| {
                let kind = meta.get("kind").and_then(|v| v.as_str())?;
                if kind != "snapshot" {
                    return None;
                }
                let parent_id = meta
                    .get("parent_id")
                    .and_then(|v| v.as_str())
                    .and_then(|s| Uuid::parse_str(s).ok())?;
                let parent_title = meta
                    .get("parent_title")
                    .and_then(|v| v.as_str())
                    .unwrap_or("(untitled)")
                    .to_string();
                let created_at = meta
                    .get("created_at")
                    .and_then(|v| v.as_str())
                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                    .map(|dt| dt.with_timezone(&chrono::Utc))
                    .unwrap_or_else(chrono::Utc::now);
                let word_count = meta.get("word_count").and_then(|v| v.as_u64()).unwrap_or(0);
                let preview = meta
                    .get("preview")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let annotation = meta
                    .get("annotation")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                Some((
                    parent_title,
                    Snapshot {
                        id,
                        parent_id,
                        created_at,
                        word_count,
                        preview,
                        annotation,
                    },
                ))
            })
            .collect();
        out.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at));
        Ok(out)
    }

    /// Fetch the raw bytes of an Image node from bdslib. The on-disk
    /// copy under `books/<...>` is the working copy; bdslib is the
    /// source of truth (so a hand-edit on disk isn't re-ingested by
    /// `Book assembly` — re-importing the file via F3 is the way).
    pub fn image_bytes(&self, image_id: Uuid) -> Result<Option<Vec<u8>>> {
        self.inner
            .get_content(image_id)
            .map_err(|e| Error::Store(format!("image_bytes: {e}")))
    }

    /// Create an Image node in bdslib + on disk. `title` becomes the
    /// node's display title; the slug is derived from it. `ext` is
    /// the canonical file extension (`png`, `jpg`, …) without a dot.
    /// `bytes` is the image content — written to disk verbatim and
    /// also stored in bdslib via `add_document_no_embed` so backup /
    /// restore round-trip it.
    pub fn create_image_node(
        &self,
        cfg: &Config,
        hierarchy: &Hierarchy,
        title: &str,
        ext: &str,
        bytes: &[u8],
        parent: Option<&Node>,
        position: InsertPosition,
    ) -> Result<Node> {
        // Build the node skeleton via the existing branch path —
        // create_node handles slug uniqueness, ordering, and
        // metadata persistence. We then override the kind-specific
        // fields and update.
        let mut node = self.create_node(
            cfg,
            hierarchy,
            NK::Image,
            title,
            parent,
            None,
            position,
        )?;
        node.image_ext = Some(ext.to_lowercase());
        // The placeholder body create_node wrote (a paragraph-style
        // `= Title` markdown stub) isn't useful for an image. Replace
        // both bdslib content and on-disk file with the bytes.
        let abs = self.layout.root.join(
            node.file.as_deref().unwrap_or(""),
        );
        crate::io_atomic::write(&abs, bytes).map_err(Error::Io)?;
        // Rename the on-disk file from `NN-slug.typ` (what create_node
        // wrote) to `NN-slug.<ext>` so the on-disk extension matches.
        let abs_typ = abs.clone();
        let abs_image =
            self.layout.root.join(
                std::path::PathBuf::from(node.file.clone().unwrap_or_default())
                    .with_extension(&node.image_ext.clone().unwrap_or_default()),
            );
        if abs_typ != abs_image && abs_typ.exists() {
            let _ = std::fs::rename(&abs_typ, &abs_image);
        }
        // Update node.file to reflect the new extension.
        if let Some(rel) = node.file.as_ref() {
            let rel_image =
                std::path::PathBuf::from(rel).with_extension(&node.image_ext.clone().unwrap_or_default());
            node.file = Some(rel_image.to_string_lossy().into_owned());
        }
        self.inner
            .update_content(node.id, bytes)
            .map_err(|e| Error::Store(format!("update_content (image): {e}")))?;
        self.inner
            .update_metadata(node.id, node.to_json())
            .map_err(|e| Error::Store(format!("update_metadata (image): {e}")))?;
        self.sync()?;
        Ok(node)
    }

    /// Delete a single snapshot by id. Snapshots have no on-disk file
    /// (they live entirely in bdslib as `add_document_no_embed`), so
    /// this is just a bdslib delete + a sync to flush the change.
    pub fn delete_snapshot(&self, snapshot_id: Uuid) -> Result<()> {
        self.inner
            .delete_document(snapshot_id)
            .map_err(|e| Error::Store(format!("delete_snapshot {snapshot_id}: {e}")))?;
        self.sync()?;
        Ok(())
    }

    /// Delete the on-disk subtree at `fs_rel` (relative to project root) and
    /// remove every UUID in `ids` from bdslib. Errors from individual bdslib
    /// deletes are logged but don't abort the loop — orphans get caught by
    /// `inkhaven reindex`.
    pub fn delete_subtree(&self, fs_rel: &Path, ids: &[Uuid]) -> Result<()> {
        let abs = self.layout.root.join(fs_rel);
        if abs.is_dir() {
            std::fs::remove_dir_all(&abs)?;
        } else if abs.is_file() {
            std::fs::remove_file(&abs)?;
        }
        for id in ids {
            if let Err(e) = self.inner.delete_document(*id) {
                tracing::warn!(uuid = %id, "delete_document failed: {e}");
            }
        }
        self.sync()?;
        // 1.2.6+ — scrub paragraph-link references to the deleted
        // nodes. Without this, every other paragraph's
        // `linked_paragraphs: Vec<Uuid>` keeps the dead UUIDs
        // and the Ctrl+V L picker silently filters them at view
        // time. Walk the post-delete hierarchy, prune, persist
        // any node that changed. Errors are logged but don't
        // abort the delete itself.
        let deleted: std::collections::HashSet<Uuid> = ids.iter().copied().collect();
        let scrubbed = self.scrub_linked_paragraphs(&deleted);
        if scrubbed > 0 {
            tracing::info!(
                target: "inkhaven::delete",
                "delete_subtree: scrubbed paragraph links from {scrubbed} other paragraph(s)",
            );
        }
        // Fire hook.on_delete ( uuid -- ) once per deleted id, in
        // the same order the store walks them. Best-effort: hook
        // failures are logged inside `hooks::fire`, never abort.
        for id in ids {
            fire_hook("hook.on_delete", vec![bund_string(&id.to_string())]);
        }
        Ok(())
    }

    /// Walk every remaining node and remove any UUID in
    /// `deleted` from its `linked_paragraphs` field. Returns the
    /// number of nodes touched. Used by `delete_subtree` (1.2.6+)
    /// to keep paragraph-link metadata in sync with reality.
    fn scrub_linked_paragraphs(
        &self,
        deleted: &std::collections::HashSet<Uuid>,
    ) -> usize {
        // Re-load hierarchy after the delete so we walk what's
        // actually still on disk + in the store.
        let hierarchy = match crate::store::hierarchy::Hierarchy::load(self) {
            Ok(h) => h,
            Err(e) => {
                tracing::warn!(
                    target: "inkhaven::delete",
                    "scrub: hierarchy reload failed: {e}",
                );
                return 0;
            }
        };
        let mut touched = 0usize;
        for (n, _) in hierarchy.flatten() {
            // 1.2.6+ — also scrub event-side links (characters
            // / places). Either source of dirt counts as
            // "needs rewrite".
            let para_hit = n
                .linked_paragraphs
                .iter()
                .any(|id| deleted.contains(id));
            let event_hit = n.event.as_ref().is_some_and(|e| {
                e.characters.iter().any(|id| deleted.contains(id))
                    || e.places.iter().any(|id| deleted.contains(id))
            });
            if !para_hit && !event_hit {
                continue;
            }
            let mut updated = n.clone();
            if para_hit {
                updated.linked_paragraphs.retain(|id| !deleted.contains(id));
            }
            if event_hit {
                if let Some(ev) = updated.event.as_mut() {
                    ev.characters.retain(|id| !deleted.contains(id));
                    ev.places.retain(|id| !deleted.contains(id));
                }
            }
            updated.modified_at = chrono::Utc::now();
            // Re-evaluate orphan state when this event lost
            // its last cross-link.
            reconcile_event_orphan_tag(&mut updated);
            if let Err(e) = self.inner.update_metadata(updated.id, updated.to_json()) {
                tracing::warn!(
                    target: "inkhaven::delete",
                    uuid = %updated.id,
                    "scrub: update_metadata failed: {e}",
                );
                continue;
            }
            touched += 1;
        }
        touched
    }

    /// Convert a text-leaf node between its three flavours:
    ///
    ///   Paragraph(typst)  ←→  Paragraph(hjson)  ←→  Script(bund)
    ///
    /// The conversion renames the file on disk to match the new
    /// extension (`.typ` / `.hjson` / `.bund`), stamps the new
    /// `kind` and `content_type` into the metadata, and persists
    /// via `update_metadata`. Body contents are NOT translated —
    /// switching `.typ` → `.bund` just changes the kind label;
    /// the writer is responsible for the body making sense in
    /// the new flavour.
    ///
    /// `new_kind` must be either `Paragraph` or `Script`;
    /// `new_content_type` is `None` for plain typst, `Some("hjson")`
    /// for HJSON, `Some("bund")` for Bund scripts. Other
    /// combinations are rejected.
    pub fn convert_leaf(
        &self,
        hierarchy: &Hierarchy,
        node_id: Uuid,
        new_kind: NodeKind,
        new_content_type: Option<&str>,
    ) -> Result<Node> {
        let node = hierarchy
            .get(node_id)
            .cloned()
            .ok_or_else(|| Error::Store(format!("convert_leaf: missing {node_id}")))?;
        if !matches!(node.kind, NodeKind::Paragraph | NodeKind::Script) {
            return Err(Error::Store(format!(
                "convert_leaf: can't convert a {} (only paragraph / script)",
                node.kind.as_str()
            )));
        }
        if !matches!(new_kind, NodeKind::Paragraph | NodeKind::Script) {
            return Err(Error::Store(format!(
                "convert_leaf: new kind {} is not a text leaf",
                new_kind.as_str()
            )));
        }
        // Validate content_type vs new kind.
        match (new_kind, new_content_type) {
            (NodeKind::Paragraph, None | Some("typst") | Some("hjson") | Some("jinja")) => {}
            (NodeKind::Script, Some("bund")) => {}
            (k, ct) => {
                return Err(Error::Store(format!(
                    "convert_leaf: content_type {ct:?} not valid for {}",
                    k.as_str()
                )));
            }
        }

        let Some(old_rel) = node.file.clone() else {
            return Err(Error::Store(
                "convert_leaf: node has no file on disk".into(),
            ));
        };
        let old_abs = self.layout.root.join(&old_rel);

        let mut new_node = node.clone();
        new_node.kind = new_kind;
        new_node.content_type = new_content_type.map(str::to_string);
        // "typst" as an explicit content_type is redundant — None
        // is the canonical default. Keep persistence terse.
        if new_node.content_type.as_deref() == Some("typst") {
            new_node.content_type = None;
        }
        new_node.modified_at = chrono::Utc::now();

        let new_name = new_node.fs_name();
        let parent_dir = old_abs
            .parent()
            .ok_or_else(|| Error::Store("convert_leaf: no parent directory".into()))?;
        let new_abs = parent_dir.join(&new_name);

        if new_abs != old_abs {
            if new_abs.exists() {
                return Err(Error::Store(format!(
                    "convert_leaf: target `{}` already exists",
                    new_abs.display()
                )));
            }
            std::fs::rename(&old_abs, &new_abs)?;
            let new_rel = new_abs
                .strip_prefix(&self.layout.root)
                .unwrap_or(&new_abs)
                .to_string_lossy()
                .into_owned();
            new_node.file = Some(new_rel);
        }

        self.inner
            .update_metadata(new_node.id, new_node.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        self.sync()?;
        Ok(new_node)
    }

    /// Update a paragraph's stored content + metadata and re-embed both
    /// vectors from the attached embedding engine. `node` is mutated to
    /// reflect the new word_count and modified_at.
    pub fn update_paragraph_content(&self, node: &mut Node, content: &[u8]) -> Result<()> {
        let id = node.id;
        node.word_count = std::str::from_utf8(content)
            .map(|s| s.split_whitespace().count() as u64)
            .unwrap_or(0);
        node.modified_at = chrono::Utc::now();

        self.inner
            .update_content(id, content)
            .map_err(|e| Error::Store(format!("update_content: {e}")))?;
        self.inner
            .update_metadata(id, node.to_json())
            .map_err(|e| Error::Store(format!("update_metadata: {e}")))?;
        self.inner
            .reembed_document(id)
            .map_err(|e| Error::Store(format!("reembed_document: {e}")))?;
        // Fire hook.on_save ( uuid -- ).
        fire_hook("hook.on_save", vec![bund_string(&id.to_string())]);
        Ok(())
    }
}

// ── Bund hook helpers ─────────────────────────────────────────────────

/// Forward a hook fire request to the scripting layer. Behind a
/// helper so the call sites in this module stay one-liners.
fn fire_hook(name: &str, args: Vec<rust_dynamic::value::Value>) {
    crate::scripting::hooks::fire(name, args);
}

/// Build a Bund STRING value. Used to push `Uuid`, `&str`, etc.
/// onto the workbench in hook fire calls.
fn bund_string(s: &str) -> rust_dynamic::value::Value {
    rust_dynamic::value::Value::from_string(s)
}

/// 1.2.6+ — keep the `orphan` tag in sync with the event's
/// actual link state. Called by every mutation that touches
/// an event (add, link change, scrub-on-delete). Idempotent;
/// no-op when the node isn't an event.
///
/// Fires `hook.on_event_orphaned ( uuid -- )` on the
/// transition `linked → orphan`. The opposite transition
/// (orphan → linked) doesn't fire — link-add paths fire
/// `hook.on_event_added` or a future
/// `hook.on_event_linked`.
pub(crate) fn reconcile_event_orphan_tag(node: &mut Node) {
    let Some(ev) = node.event.as_ref() else {
        return;
    };
    let is_orphan = ev.is_orphan(&node.linked_paragraphs);
    let pos = node.tags.iter().position(|t| t.eq_ignore_ascii_case("orphan"));
    match (is_orphan, pos) {
        (true, None) => {
            node.tags.push("orphan".to_owned());
            fire_hook(
                "hook.on_event_orphaned",
                vec![bund_string(&node.id.to_string())],
            );
        }
        (false, Some(i)) => {
            node.tags.remove(i);
        }
        _ => {}
    }
}

fn first_prose_line(content: &[u8]) -> String {
    let s = std::str::from_utf8(content).unwrap_or("");
    for line in s.lines() {
        let t = line.trim();
        if t.is_empty() || t.starts_with('=') || t.starts_with("//") {
            continue;
        }
        let chars: Vec<char> = t.chars().collect();
        if chars.len() > 80 {
            let mut o: String = chars.iter().take(79).collect();
            o.push('…');
            return o;
        }
        return t.to_string();
    }
    String::new()
}

/// 1.2.18+ I.1.3 — true when `INKHAVEN_PERF_TRACE` is set
/// to a non-empty, non-"0" value.  Drives the env-gated
/// phase timers in the project-load path.
pub(crate) fn perf_trace_enabled() -> bool {
    std::env::var("INKHAVEN_PERF_TRACE")
        .map(|v| !v.is_empty() && v != "0")
        .unwrap_or(false)
}

/// Emit a single phase-timing line to stderr when `on`.
/// Format: `[perf] <label> <millis>ms` — line-oriented +
/// grep-friendly for the I.1.3 hotspot write-up.
pub(crate) fn perf_mark(on: bool, label: &str, elapsed: std::time::Duration) {
    if on {
        eprintln!("[perf] {label} {:.2}ms", elapsed.as_secs_f64() * 1000.0);
    }
}

fn build_embedding_engine(model_name: &str) -> Result<EmbeddingEngine> {
    let model = match model_name {
        "MultilingualE5Small" => Model::MultilingualE5Small,
        "MultilingualE5Base" => Model::MultilingualE5Base,
        "MultilingualE5Large" => Model::MultilingualE5Large,
        "BGEM3" => Model::BGEM3,
        "BGESmallENV15" => Model::BGESmallENV15,
        "BGEBaseENV15" => Model::BGEBaseENV15,
        "BGELargeENV15" => Model::BGELargeENV15,
        other => {
            return Err(Error::Config(format!(
                "unknown embedding model `{other}`; see fastembed::EmbeddingModel for options"
            )));
        }
    };

    EmbeddingEngine::new(model, embedding_cache_dir())
        .map_err(|e| Error::Store(e.to_string()))
}

/// Per-user cache directory for fastembed model files. Returning None falls
/// back to fastembed's default (typically `.fastembed_cache/` in the current
/// working directory, which we'd rather not pollute).
fn embedding_cache_dir() -> Option<std::path::PathBuf> {
    let dirs = directories::ProjectDirs::from("dev", "inkhaven", "inkhaven")?;
    let path = dirs.cache_dir().join("embeddings");
    let _ = std::fs::create_dir_all(&path);
    Some(path)
}

/// Default backup directory for a given project. Lives **next to** the
/// project (sibling directory in the same parent), in a shared
/// `inkhaven-backups/` folder with a `<project-basename>` subfolder so
/// multiple projects in the same parent don't collide.
///
/// Why sibling-of-project rather than an OS cache directory: backups
/// are user-facing artefacts that need to be obvious when listing files.
/// Hiding them in `~/Library/Caches/...` makes them hard to find and
/// hard to copy to external storage.
///
/// Layout for a project at `~/Books/my-novel/`:
/// ```text
/// ~/Books/
/// ├── inkhaven-backups/
/// │   └── my-novel/         ← snapshots land here
/// └── my-novel/
/// ```
pub fn default_user_backup_dir(project_root: &std::path::Path) -> std::path::PathBuf {
    let project_id = project_basename(project_root);
    project_root
        .parent()
        .unwrap_or(std::path::Path::new("."))
        .join("inkhaven-backups")
        .join(project_id)
}

/// Default artefacts directory — same sibling-of-project pattern as
/// `default_user_backup_dir`. PDFs, build intermediates, and other
/// per-book outputs land under `<parent>/inkhaven-artefacts/<project-basename>/<book-slug>/`.
pub fn default_user_artefacts_dir(project_root: &std::path::Path) -> std::path::PathBuf {
    let project_id = project_basename(project_root);
    project_root
        .parent()
        .unwrap_or(std::path::Path::new("."))
        .join("inkhaven-artefacts")
        .join(project_id)
}

/// Project identifier for the default-path resolvers. The bare filename
/// of the project root works in practice; collisions between same-named
/// projects in different parents are surfaced quickly by the user and
/// can be resolved by setting an explicit `backup.out_dir` /
/// `artefacts_directory` in their HJSON.
fn project_basename(project_root: &std::path::Path) -> String {
    project_root
        .file_name()
        .and_then(|s| s.to_str())
        .map(|s| s.to_string())
        .unwrap_or_else(|| "default".into())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::store::node::EventData;

    #[test]
    fn world_system_book_is_registered_after_artefacts() {
        // WORLD-4 — the World book is the 10th system book, seeded right after
        // Artefacts (so the worldbuilding cluster Places/Characters/Artefacts/
        // World sits together).
        let tags: Vec<&str> = SYSTEM_BOOKS.iter().map(|(t, _)| *t).collect();
        let artefacts = tags.iter().position(|t| *t == SYSTEM_TAG_ARTEFACTS).unwrap();
        let world = tags.iter().position(|t| *t == SYSTEM_TAG_WORLD).unwrap();
        assert_eq!(world, artefacts + 1, "World must immediately follow Artefacts");
        // The const and the array entry agree, and the title is "World".
        assert_eq!(SYSTEM_BOOKS[world], (SYSTEM_TAG_WORLD, "World"));
    }

    fn make_event_node() -> Node {
        Node {
            id: Uuid::nil(),
            kind: NK::Paragraph,
            title: "Storm".into(),
            slug: "storm".into(),
            path: Vec::new(),
            parent_id: None,
            order: 0,
            file: None,
            word_count: 0,
            modified_at: chrono::Utc::now(),
            protected: false,
            system_tag: None,
            image_ext: None,
            image_caption: None,
            image_alt: None,
            content_type: None,
            status: None,
            target_words: None,
            target_hit_at_status: None,
            linked_paragraphs: Vec::new(),
            bookmark: false,
            tags: Vec::new(),
            ai_memory: Vec::new(),
            event: Some(EventData {
                start_ticks: 0,
                end_ticks: None,
                precision: crate::timeline::Precision::Day,
                characters: Vec::new(),
                places: Vec::new(),
                track: None,
            }),
        }
    }

    #[test]
    fn orphan_tag_added_when_all_links_empty() {
        let mut n = make_event_node();
        reconcile_event_orphan_tag(&mut n);
        assert!(n.tags.iter().any(|t| t == "orphan"));
    }

    #[test]
    fn orphan_tag_removed_when_link_added() {
        let mut n = make_event_node();
        n.tags.push("orphan".into());
        n.linked_paragraphs.push(Uuid::new_v4());
        reconcile_event_orphan_tag(&mut n);
        assert!(!n.tags.iter().any(|t| t == "orphan"));
    }

    #[test]
    fn orphan_tag_noop_on_non_event_node() {
        let mut n = make_event_node();
        n.event = None;
        // Even when tags include "orphan", non-event nodes
        // are left alone.
        n.tags.push("orphan".into());
        reconcile_event_orphan_tag(&mut n);
        assert!(n.tags.iter().any(|t| t == "orphan"));
    }

    #[test]
    fn orphan_tag_kept_when_event_links_present_but_paragraphs_empty() {
        // Either link kind keeps the event un-orphaned.
        let mut n = make_event_node();
        if let Some(ev) = n.event.as_mut() {
            ev.characters.push(Uuid::new_v4());
        }
        reconcile_event_orphan_tag(&mut n);
        assert!(!n.tags.iter().any(|t| t == "orphan"));
    }
}
