//! Read-only `ink.*` Bund stdlib. These are the words that let a
//! script see the project — `Store` access, basically — without
//! letting it mutate anything. Write-side words land later under
//! the policy sandbox (P3+).
//!
//! All `register_inline` failures bubble back as `anyhow::Error` so
//! the caller (init_adam) can fail the whole VM construction. In
//! practice the registry only fails on duplicate names.

use anyhow::{anyhow, Result};
use rust_dynamic::value::Value;
use rust_multistackvm::multistackvm::VM;
use std::collections::HashMap;

use super::helpers::{
    active_store, pull, push, require_depth, value_to_i64, value_to_string, value_to_uuid,
};
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};

/// Register every `ink.*` word (read + write + db).
pub fn register(vm: &mut VM) -> Result<()> {
    // ── Read-only (store_read) ────────────────────────────────
    vm.register_inline("ink.node.list".to_string(), ink_node_list)
        .map_err(|e| anyhow!("register ink.node.list: {e}"))?;
    vm.register_inline("ink.node.get".to_string(), ink_node_get)
        .map_err(|e| anyhow!("register ink.node.get: {e}"))?;
    vm.register_inline("ink.node.children".to_string(), ink_node_children)
        .map_err(|e| anyhow!("register ink.node.children: {e}"))?;
    vm.register_inline("ink.paragraph.text".to_string(), ink_paragraph_text)
        .map_err(|e| anyhow!("register ink.paragraph.text: {e}"))?;
    vm.register_inline("ink.search.text".to_string(), ink_search_text)
        .map_err(|e| anyhow!("register ink.search.text: {e}"))?;
    vm.register_inline("ink.snapshot.list".to_string(), ink_snapshot_list)
        .map_err(|e| anyhow!("register ink.snapshot.list: {e}"))?;
    vm.register_inline("ink.path.to_uuid".to_string(), ink_path_to_uuid)
        .map_err(|e| anyhow!("register ink.path.to_uuid: {e}"))?;

    // ── Tree mutation (store_write) ───────────────────────────
    vm.register_inline("ink.tree.add".to_string(), ink_tree_add)
        .map_err(|e| anyhow!("register ink.tree.add: {e}"))?;
    vm.register_inline("ink.tree.delete".to_string(), ink_tree_delete)
        .map_err(|e| anyhow!("register ink.tree.delete: {e}"))?;
    vm.register_inline("ink.tree.rename".to_string(), ink_tree_rename)
        .map_err(|e| anyhow!("register ink.tree.rename: {e}"))?;
    vm.register_inline("ink.tree.move_up".to_string(), ink_tree_move_up)
        .map_err(|e| anyhow!("register ink.tree.move_up: {e}"))?;
    vm.register_inline("ink.tree.move_down".to_string(), ink_tree_move_down)
        .map_err(|e| anyhow!("register ink.tree.move_down: {e}"))?;
    // OUTLINE-1 — outline read + cross-parent paragraph copy/move.
    vm.register_inline("ink.outline.print".to_string(), ink_outline_print)
        .map_err(|e| anyhow!("register ink.outline.print: {e}"))?;
    vm.register_inline(
        "ink.outline.paragraph_copy".to_string(),
        ink_outline_paragraph_copy,
    )
    .map_err(|e| anyhow!("register ink.outline.paragraph_copy: {e}"))?;
    vm.register_inline(
        "ink.outline.paragraph_move".to_string(),
        ink_outline_paragraph_move,
    )
    .map_err(|e| anyhow!("register ink.outline.paragraph_move: {e}"))?;
    vm.register_inline("ink.tree.morph".to_string(), ink_tree_morph)
        .map_err(|e| anyhow!("register ink.tree.morph: {e}"))?;

    // ── Paragraph mutation (store_write) ──────────────────────
    vm.register_inline("ink.paragraph.set_status".to_string(), ink_paragraph_set_status)
        .map_err(|e| anyhow!("register ink.paragraph.set_status: {e}"))?;
    vm.register_inline("ink.paragraph.set_target".to_string(), ink_paragraph_set_target)
        .map_err(|e| anyhow!("register ink.paragraph.set_target: {e}"))?;
    vm.register_inline("ink.paragraph.target".to_string(), ink_paragraph_target)
        .map_err(|e| anyhow!("register ink.paragraph.target: {e}"))?;
    vm.register_inline("ink.paragraph.save".to_string(), ink_paragraph_save)
        .map_err(|e| anyhow!("register ink.paragraph.save: {e}"))?;

    // ── Events (1.2.6+ — Phase 4 of the timeline feature).
    //   Reads under store_read; writes under store_write.
    vm.register_inline("ink.event.list".to_string(), ink_event_list)
        .map_err(|e| anyhow!("register ink.event.list: {e}"))?;
    vm.register_inline("ink.event.list_orphans".to_string(), ink_event_list_orphans)
        .map_err(|e| anyhow!("register ink.event.list_orphans: {e}"))?;
    vm.register_inline("ink.event.add".to_string(), ink_event_add)
        .map_err(|e| anyhow!("register ink.event.add: {e}"))?;
    vm.register_inline("ink.event.set_end".to_string(), ink_event_set_end)
        .map_err(|e| anyhow!("register ink.event.set_end: {e}"))?;
    vm.register_inline("ink.event.set_precision".to_string(), ink_event_set_precision)
        .map_err(|e| anyhow!("register ink.event.set_precision: {e}"))?;
    vm.register_inline("ink.event.set_track".to_string(), ink_event_set_track)
        .map_err(|e| anyhow!("register ink.event.set_track: {e}"))?;
    vm.register_inline("ink.event.link_paragraph".to_string(), ink_event_link_paragraph)
        .map_err(|e| anyhow!("register ink.event.link_paragraph: {e}"))?;

    // ── Threads (1.2.14+; read words land in
    //   1.2.16+ Phase I.4.a).  More thread
    //   mutations follow in I.4.b.
    vm.register_inline("ink.thread.list".to_string(), ink_thread_list)
        .map_err(|e| anyhow!("register ink.thread.list: {e}"))?;

    // ── Tags (1.2.6+) — read words under store_read,
    //   add/remove under store_write.
    vm.register_inline("ink.tag.list".to_string(), ink_tag_list)
        .map_err(|e| anyhow!("register ink.tag.list: {e}"))?;
    vm.register_inline("ink.tag.list_for".to_string(), ink_tag_list_for)
        .map_err(|e| anyhow!("register ink.tag.list_for: {e}"))?;
    vm.register_inline("ink.tag.search".to_string(), ink_tag_search)
        .map_err(|e| anyhow!("register ink.tag.search: {e}"))?;
    vm.register_inline("ink.tag.add".to_string(), ink_tag_add)
        .map_err(|e| anyhow!("register ink.tag.add: {e}"))?;
    vm.register_inline("ink.tag.remove".to_string(), ink_tag_remove)
        .map_err(|e| anyhow!("register ink.tag.remove: {e}"))?;

    // ── DB management (store_write) ───────────────────────────
    vm.register_inline("ink.db.sync".to_string(), ink_db_sync)
        .map_err(|e| anyhow!("register ink.db.sync: {e}"))?;
    vm.register_inline("ink.db.checkpoint".to_string(), ink_db_checkpoint)
        .map_err(|e| anyhow!("register ink.db.checkpoint: {e}"))?;
    vm.register_inline("ink.db.reindex".to_string(), ink_db_reindex)
        .map_err(|e| anyhow!("register ink.db.reindex: {e}"))?;

    Ok(())
}

// VMInlineFn signature: `fn(&mut VM) -> Result<&mut VM, easy_error::Error>`.
// Bund expects easy_error; we use anyhow internally and convert at the
// boundary via `to_bund_err`.
type BundError = easy_error::Error;
type BundResult<'a> = std::result::Result<&'a mut VM, BundError>;

fn to_bund_err(e: anyhow::Error) -> BundError {
    easy_error::err_msg(e.to_string())
}

// ── ink.node.list ────────────────────────────────────────────────────
// Stack: ( -- list )
// Pushes a list of hashes, one per node in the project. Each hash
// has keys: id, kind, title, slug, parent_id (or empty), order,
// status (or empty), content_type (or empty).

fn ink_node_list(vm: &mut VM) -> BundResult<'_> {
    do_ink_node_list(vm).map_err(to_bund_err)
}

fn do_ink_node_list(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.node.list";
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy load: {e}"))?;
    let items: Vec<Value> = hierarchy.iter().map(node_summary_dict).collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── ink.node.get ─────────────────────────────────────────────────────
// Stack: ( uuid -- hash | NODATA )

fn ink_node_get(vm: &mut VM) -> BundResult<'_> {
    do_ink_node_get(vm).map_err(to_bund_err)
}

fn do_ink_node_get(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.node.get";
    require_depth(vm, 1, tag)?;
    let id = value_to_uuid(pull(vm, tag)?, tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy load: {e}"))?;
    let out = match hierarchy.get(id) {
        Some(node) => node_full_dict(node),
        None => Value::nodata(),
    };
    push(vm, out);
    Ok(vm)
}

// ── ink.node.children ────────────────────────────────────────────────
// Stack: ( uuid_or_empty -- list )
// Empty string = root (top-level books).

fn ink_node_children(vm: &mut VM) -> BundResult<'_> {
    do_ink_node_children(vm).map_err(to_bund_err)
}

fn do_ink_node_children(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.node.children";
    require_depth(vm, 1, tag)?;
    let arg = pull(vm, tag)?;
    let s = value_to_string(arg, "parent", tag)?;
    let parent_id = if s.is_empty() {
        None
    } else {
        Some(uuid::Uuid::parse_str(&s).map_err(|e| anyhow!("{tag} UUID parse failed: {e}"))?)
    };
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy load: {e}"))?;
    let items: Vec<Value> = hierarchy
        .children_of(parent_id)
        .into_iter()
        .map(node_summary_dict)
        .collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── ink.paragraph.text ───────────────────────────────────────────────
// Stack: ( uuid -- string | NODATA )

fn ink_paragraph_text(vm: &mut VM) -> BundResult<'_> {
    do_ink_paragraph_text(vm).map_err(to_bund_err)
}

fn do_ink_paragraph_text(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.paragraph.text";
    require_depth(vm, 1, tag)?;
    let id = value_to_uuid(pull(vm, tag)?, tag)?;
    let store = active_store(tag)?;
    let out = match store
        .get_content(id)
        .map_err(|e| anyhow!("{tag} get_content: {e}"))?
    {
        Some(bytes) => Value::from_string(String::from_utf8_lossy(&bytes).into_owned()),
        None => Value::nodata(),
    };
    push(vm, out);
    Ok(vm)
}

// ── ink.search.text ──────────────────────────────────────────────────
// Stack: ( query limit -- list )
// limit must be a positive int. Returns a list of hit hashes with
// id, title, score, document, kind.

fn ink_search_text(vm: &mut VM) -> BundResult<'_> {
    do_ink_search_text(vm).map_err(to_bund_err)
}

fn do_ink_search_text(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.search.text";
    require_depth(vm, 2, tag)?;
    let limit_v = pull(vm, tag)?;
    let query_v = pull(vm, tag)?;
    let query = value_to_string(query_v, "query", tag)?;
    let limit = value_to_i64(limit_v, "limit", tag)?.max(0) as usize;
    let store = active_store(tag)?;
    let hits = store
        .search_text(&query, limit)
        .map_err(|e| anyhow!("{tag} search_text: {e}"))?;
    let items: Vec<Value> = hits.into_iter().map(search_hit_dict).collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── ink.snapshot.list ────────────────────────────────────────────────
// Stack: ( paragraph_uuid -- list )

fn ink_snapshot_list(vm: &mut VM) -> BundResult<'_> {
    do_ink_snapshot_list(vm).map_err(to_bund_err)
}

fn do_ink_snapshot_list(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.snapshot.list";
    require_depth(vm, 1, tag)?;
    let id = value_to_uuid(pull(vm, tag)?, tag)?;
    let store = active_store(tag)?;
    let snaps = store
        .list_snapshots(id)
        .map_err(|e| anyhow!("{tag} list_snapshots: {e}"))?;
    let items: Vec<Value> = snaps
        .into_iter()
        .map(|s| {
            let mut h: HashMap<String, Value> = HashMap::new();
            h.insert("id".into(), Value::from_string(s.id.to_string()));
            h.insert(
                "created_at".into(),
                Value::from_string(s.created_at.to_rfc3339()),
            );
            h.insert("word_count".into(), Value::from_int(s.word_count as i64));
            h.insert("preview".into(), Value::from_string(s.preview));
            Value::from_dict(h)
        })
        .collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── value builders ───────────────────────────────────────────────────

fn node_summary_dict(n: &Node) -> Value {
    let mut h: HashMap<String, Value> = HashMap::new();
    h.insert("id".into(), Value::from_string(n.id.to_string()));
    h.insert("kind".into(), Value::from_string(n.kind.as_str()));
    h.insert("title".into(), Value::from_string(&n.title));
    h.insert("slug".into(), Value::from_string(&n.slug));
    Value::from_dict(h)
}

fn node_full_dict(n: &Node) -> Value {
    let mut h: HashMap<String, Value> = HashMap::new();
    h.insert("id".into(), Value::from_string(n.id.to_string()));
    h.insert("kind".into(), Value::from_string(n.kind.as_str()));
    h.insert("title".into(), Value::from_string(&n.title));
    h.insert("slug".into(), Value::from_string(&n.slug));
    h.insert("order".into(), Value::from_int(n.order as i64));
    h.insert(
        "word_count".into(),
        Value::from_int(n.word_count as i64),
    );
    h.insert(
        "modified_at".into(),
        Value::from_string(n.modified_at.to_rfc3339()),
    );
    h.insert(
        "parent_id".into(),
        match n.parent_id {
            Some(p) => Value::from_string(p.to_string()),
            None => Value::nodata(),
        },
    );
    h.insert(
        "system_tag".into(),
        match &n.system_tag {
            Some(s) => Value::from_string(s),
            None => Value::nodata(),
        },
    );
    h.insert(
        "status".into(),
        match &n.status {
            Some(s) => Value::from_string(s),
            None => Value::nodata(),
        },
    );
    h.insert(
        "content_type".into(),
        match &n.content_type {
            Some(s) => Value::from_string(s),
            None => Value::nodata(),
        },
    );
    Value::from_dict(h)
}

fn search_hit_dict(hit: serde_json::Value) -> Value {
    let mut h: HashMap<String, Value> = HashMap::new();
    if let Some(id) = hit.get("id").and_then(|v| v.as_str()) {
        h.insert("id".into(), Value::from_string(id));
    }
    if let Some(score) = hit.get("score").and_then(|v| v.as_f64()) {
        h.insert("score".into(), Value::from_float(score));
    }
    if let Some(meta) = hit.get("metadata") {
        if let Some(title) = meta.get("title").and_then(|v| v.as_str()) {
            h.insert("title".into(), Value::from_string(title));
        }
        if let Some(kind) = meta.get("kind").and_then(|v| v.as_str()) {
            h.insert("kind".into(), Value::from_string(kind));
        }
    }
    if let Some(doc) = hit.get("document").and_then(|v| v.as_str()) {
        h.insert("document".into(), Value::from_string(doc));
    }
    Value::from_dict(h)
}

// ─────────────────────────────────────────────────────────────────────
// Phase A — write-side ink.* words
// ─────────────────────────────────────────────────────────────────────

use crate::scripting::stdlib::helpers::{active_config, resolve_path};
use crate::store::InsertPosition;

// ── ink.path.to_uuid ─────────────────────────────────────────────────
// Stack: ( slug_path -- uuid_string | NODATA )

fn ink_path_to_uuid(vm: &mut VM) -> BundResult<'_> {
    do_ink_path_to_uuid(vm).map_err(to_bund_err)
}

fn do_ink_path_to_uuid(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.path.to_uuid";
    require_depth(vm, 1, tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let out = match resolve_path(&hierarchy, &path, tag)? {
        Some(id) => Value::from_string(id.to_string()),
        None => Value::nodata(),
    };
    push(vm, out);
    Ok(vm)
}

// ── ink.tree.add ─────────────────────────────────────────────────────
// Stack: ( parent_path kind title -- uuid_string )
// kind ∈ { "book", "chapter", "subchapter", "paragraph", "hjson",
//         "script", "bund" }. "hjson" → Paragraph with content_type
// patched after create; "bund" is an alias for "script".

fn ink_tree_add(vm: &mut VM) -> BundResult<'_> {
    do_ink_tree_add(vm).map_err(to_bund_err)
}

fn do_ink_tree_add(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tree.add";
    require_depth(vm, 3, tag)?;
    let title = value_to_string(pull(vm, tag)?, "title", tag)?;
    let kind_str = value_to_string(pull(vm, tag)?, "kind", tag)?;
    let parent_path = value_to_string(pull(vm, tag)?, "parent_path", tag)?;
    let (kind, post_morph_ct) = match kind_str.as_str() {
        "book" => (NodeKind::Book, None),
        "chapter" => (NodeKind::Chapter, None),
        "subchapter" => (NodeKind::Subchapter, None),
        "paragraph" => (NodeKind::Paragraph, None),
        "hjson" => (NodeKind::Paragraph, Some("hjson")),
        "script" | "bund" => (NodeKind::Script, None),
        other => return Err(anyhow!("{tag}: unknown kind `{other}`")),
    };

    let store = active_store(tag)?;
    let cfg = active_config(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let parent_id = resolve_path(&hierarchy, &parent_path, tag)?;
    let parent_node: Option<Node> = parent_id.and_then(|id| hierarchy.get(id).cloned());
    let created = store
        .create_node(
            cfg,
            &hierarchy,
            kind,
            &title,
            parent_node.as_ref(),
            None,
            InsertPosition::End,
        )
        .map_err(|e| anyhow!("{tag} create: {e}"))?;

    // For "hjson", we created a Paragraph then need to flip its
    // content_type. `Store::convert_leaf` does both the metadata
    // stamp AND the file rename.
    let final_id = if let Some(ct) = post_morph_ct {
        let h2 = Hierarchy::load(store).map_err(|e| anyhow!("{tag} reload: {e}"))?;
        let morphed = store
            .convert_leaf(&h2, created.id, NodeKind::Paragraph, Some(ct))
            .map_err(|e| anyhow!("{tag} morph: {e}"))?;
        morphed.id
    } else {
        created.id
    };

    push(vm, Value::from_string(final_id.to_string()));
    Ok(vm)
}

// ── ink.tree.delete ──────────────────────────────────────────────────
// Stack: ( path -- )
// Deletes the node + its entire subtree. Fires `hook.on_delete`
// once per removed id.

fn ink_tree_delete(vm: &mut VM) -> BundResult<'_> {
    do_ink_tree_delete(vm).map_err(to_bund_err)
}

fn do_ink_tree_delete(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tree.delete";
    require_depth(vm, 1, tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: cannot delete root"))?;
    let node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node {node_id} vanished"))?;
    let ids: Vec<uuid::Uuid> = hierarchy.collect_subtree(node.id).into_iter().collect();
    // The `layout` arg to `Hierarchy::fs_path` is currently
    // ignored by the implementation; pass a layout reconstructed
    // from project root.
    let layout = crate::project::ProjectLayout::new(store.project_root());
    let fs_rel = hierarchy.fs_path(&node, &layout);
    store
        .delete_subtree(&fs_rel, &ids)
        .map_err(|e| anyhow!("{tag} delete: {e}"))?;
    Ok(vm)
}

// ── ink.tree.rename ──────────────────────────────────────────────────
// Stack: ( path new_title -- )

fn ink_tree_rename(vm: &mut VM) -> BundResult<'_> {
    do_ink_tree_rename(vm).map_err(to_bund_err)
}

fn do_ink_tree_rename(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tree.rename";
    require_depth(vm, 2, tag)?;
    let new_title = value_to_string(pull(vm, tag)?, "new_title", tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: cannot rename root"))?;
    store
        .rename_node(&hierarchy, node_id, &new_title)
        .map_err(|e| anyhow!("{tag}: {e}"))?;
    Ok(vm)
}

// ── ink.tree.move_up / .move_down ───────────────────────────────────
// Stack: ( path -- )
// Swap with the previous / next sibling. No-op when at the edge.

fn ink_tree_move_up(vm: &mut VM) -> BundResult<'_> {
    do_ink_tree_move(vm, MoveDir::Up).map_err(to_bund_err)
}

fn ink_tree_move_down(vm: &mut VM) -> BundResult<'_> {
    do_ink_tree_move(vm, MoveDir::Down).map_err(to_bund_err)
}

#[derive(Clone, Copy)]
enum MoveDir {
    Up,
    Down,
}

fn do_ink_tree_move(vm: &mut VM, dir: MoveDir) -> Result<&mut VM> {
    let tag = match dir {
        MoveDir::Up => "ink.tree.move_up",
        MoveDir::Down => "ink.tree.move_down",
    };
    require_depth(vm, 1, tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: cannot move root"))?;
    let node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    let siblings = hierarchy.children_of(node.parent_id);
    let pos = siblings
        .iter()
        .position(|n| n.id == node.id)
        .ok_or_else(|| anyhow!("{tag}: node not among siblings"))?;
    let neighbour_idx = match dir {
        MoveDir::Up => pos.checked_sub(1),
        MoveDir::Down => {
            if pos + 1 < siblings.len() {
                Some(pos + 1)
            } else {
                None
            }
        }
    };
    let Some(idx) = neighbour_idx else {
        // edge — silently no-op (matches the TUI's move_current
        // behaviour at the boundary).
        return Ok(vm);
    };
    let neighbour_id = siblings[idx].id;
    store
        .swap_siblings(&hierarchy, node.id, neighbour_id)
        .map_err(|e| anyhow!("{tag}: {e}"))?;
    Ok(vm)
}

// ── ink.outline.print ────────────────────────────────────────────────
// Stack: ( -- text )
// Push the manuscript outline as an indented text tree (same render as the
// `inkhaven outline` CLI). store_read.

fn ink_outline_print(vm: &mut VM) -> BundResult<'_> {
    do_ink_outline_print(vm).map_err(to_bund_err)
}

fn do_ink_outline_print(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.outline.print";
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let text = crate::cli::outline::render(&hierarchy, None);
    push(vm, Value::from_string(text));
    Ok(vm)
}

// ── ink.outline.paragraph_copy / paragraph_move ──────────────────────
// Stack: ( src_path dest_path -- uuid )  for copy (uuid of the new paragraph)
//        ( src_path dest_path -- )       for move
// Cross-parent paragraph relocation by slug path; the paragraph lands as the
// last child of dest's effective parent (INTO a branch, alongside a
// paragraph). store_write. Shared store primitives back both these and the
// `inkhaven paragraph copy|move` CLI.

fn ink_outline_paragraph_copy(vm: &mut VM) -> BundResult<'_> {
    do_ink_outline_paragraph(vm, true).map_err(to_bund_err)
}

fn ink_outline_paragraph_move(vm: &mut VM) -> BundResult<'_> {
    do_ink_outline_paragraph(vm, false).map_err(to_bund_err)
}

fn do_ink_outline_paragraph(vm: &mut VM, is_copy: bool) -> Result<&mut VM> {
    let tag = if is_copy {
        "ink.outline.paragraph_copy"
    } else {
        "ink.outline.paragraph_move"
    };
    require_depth(vm, 2, tag)?;
    // Pushed src then dest, so dest pops first.
    let dest_path = value_to_string(pull(vm, tag)?, "dest_path", tag)?;
    let src_path = value_to_string(pull(vm, tag)?, "src_path", tag)?;
    let store = active_store(tag)?;
    let cfg = active_config(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;

    let src_id = resolve_path(&hierarchy, &src_path, tag)?
        .ok_or_else(|| anyhow!("{tag}: source not found `{src_path}`"))?;
    let src = hierarchy
        .get(src_id)
        .ok_or_else(|| anyhow!("{tag}: source missing"))?;
    if src.kind != NodeKind::Paragraph {
        return Err(anyhow!("{tag}: `{src_path}` is not a paragraph"));
    }
    let dest_id = resolve_path(&hierarchy, &dest_path, tag)?
        .ok_or_else(|| anyhow!("{tag}: destination not found `{dest_path}`"))?;
    let dest = hierarchy
        .get(dest_id)
        .ok_or_else(|| anyhow!("{tag}: destination missing"))?;
    let dest_parent = if dest.kind == NodeKind::Paragraph {
        dest.parent_id
    } else {
        Some(dest_id)
    };

    if is_copy {
        let new_id = store
            .copy_paragraph_to_parent(cfg, &hierarchy, src_id, dest_parent)
            .map_err(|e| anyhow!("{tag}: {e}"))?;
        push(vm, Value::from_string(new_id.to_string()));
    } else {
        store
            .move_node_to_parent(cfg, &hierarchy, src_id, dest_parent)
            .map_err(|e| anyhow!("{tag}: {e}"))?;
    }
    Ok(vm)
}

// ── ink.tree.morph ──────────────────────────────────────────────────
// Stack: ( path -- )
// Cycle leaf flavour: Paragraph(typst) → Paragraph(hjson) →
// Script(bund) → Paragraph(typst). Same logic as the `Ctrl+B M`
// chord in the TUI.

fn ink_tree_morph(vm: &mut VM) -> BundResult<'_> {
    do_ink_tree_morph(vm).map_err(to_bund_err)
}

fn do_ink_tree_morph(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tree.morph";
    require_depth(vm, 1, tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: cannot morph root"))?;
    let node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    let (new_kind, new_ct) = match (node.kind, node.content_type.as_deref()) {
        (NodeKind::Paragraph, None | Some("typst")) => {
            (NodeKind::Paragraph, Some("hjson"))
        }
        (NodeKind::Paragraph, Some("hjson")) => (NodeKind::Script, Some("bund")),
        (NodeKind::Script, _) => (NodeKind::Paragraph, None),
        (k, ct) => {
            return Err(anyhow!(
                "{tag}: {} ({ct:?}) is not a text leaf",
                k.as_str()
            ));
        }
    };
    store
        .convert_leaf(&hierarchy, node_id, new_kind, new_ct)
        .map_err(|e| anyhow!("{tag}: {e}"))?;
    Ok(vm)
}

// ── ink.paragraph.set_status ─────────────────────────────────────────
// Stack: ( path status -- )
// status ∈ { "None", "Napkin", "First", "Second", "Third", "Final",
//           "Ready" }.

fn ink_paragraph_set_status(vm: &mut VM) -> BundResult<'_> {
    do_ink_paragraph_set_status(vm).map_err(to_bund_err)
}

fn do_ink_paragraph_set_status(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.paragraph.set_status";
    require_depth(vm, 2, tag)?;
    let status = value_to_string(pull(vm, tag)?, "status", tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    if !matches!(
        status.as_str(),
        "None" | "Napkin" | "First" | "Second" | "Third" | "Final" | "Ready"
    ) {
        return Err(anyhow!(
            "{tag}: unknown status `{status}`. Use None / Napkin / First / Second / Third / Final / Ready."
        ));
    }
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: empty path"))?;
    let mut node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    node.status = if status == "None" {
        None
    } else {
        Some(status)
    };
    node.modified_at = chrono::Utc::now();
    store
        .raw()
        .update_metadata(node.id, node.to_json())
        .map_err(|e| anyhow!("{tag}: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    Ok(vm)
}

// ── ink.paragraph.save ───────────────────────────────────────────────
// Stack: ( path body -- )
// Replaces the paragraph's content with `body`, mirrors to disk,
// re-embeds both vectors. Fires `hook.on_save`.

fn ink_paragraph_save(vm: &mut VM) -> BundResult<'_> {
    do_ink_paragraph_save(vm).map_err(to_bund_err)
}

fn do_ink_paragraph_save(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.paragraph.save";
    require_depth(vm, 2, tag)?;
    let body = value_to_string(pull(vm, tag)?, "body", tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: empty path"))?;
    let mut node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    if !matches!(node.kind, NodeKind::Paragraph | NodeKind::Script) {
        return Err(anyhow!(
            "{tag}: {} is not a text leaf — only paragraphs / scripts are saveable",
            node.kind.as_str()
        ));
    }
    // Mirror the in-process save path: rewrite the on-disk file,
    // then update the store + reembed.
    if let Some(rel) = node.file.clone() {
        let abs = store.project_root().join(rel);
        if let Some(parent) = abs.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| anyhow!("{tag} mkdir: {e}"))?;
        }
        std::fs::write(&abs, body.as_bytes())
            .map_err(|e| anyhow!("{tag} write: {e}"))?;
    }
    store
        .update_paragraph_content(&mut node, body.as_bytes())
        .map_err(|e| anyhow!("{tag}: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    Ok(vm)
}

// ── ink.db.sync / .checkpoint / .reindex ─────────────────────────────

fn ink_db_sync(vm: &mut VM) -> BundResult<'_> {
    do_ink_db_call(vm, "ink.db.sync", DbOp::Sync).map_err(to_bund_err)
}

fn ink_db_checkpoint(vm: &mut VM) -> BundResult<'_> {
    do_ink_db_call(vm, "ink.db.checkpoint", DbOp::Checkpoint).map_err(to_bund_err)
}

fn ink_db_reindex(vm: &mut VM) -> BundResult<'_> {
    do_ink_db_call(vm, "ink.db.reindex", DbOp::Reindex).map_err(to_bund_err)
}

enum DbOp {
    Sync,
    Checkpoint,
    Reindex,
}

fn do_ink_db_call<'a>(vm: &'a mut VM, tag: &str, op: DbOp) -> Result<&'a mut VM> {
    let store = active_store(tag)?;
    match op {
        DbOp::Sync => store.sync().map_err(|e| anyhow!("{tag}: {e}"))?,
        DbOp::Checkpoint => store.checkpoint().map_err(|e| anyhow!("{tag}: {e}"))?,
        DbOp::Reindex => {
            // Walk every Paragraph / Script node, re-read disk,
            // call update_paragraph_content where the bytes drifted.
            // Mirrors `cli/reindex.rs` without the orphan-adoption
            // path (that needs filesystem walking which is broader
            // than a Bund word should encompass).
            let hierarchy = Hierarchy::load(store)
                .map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
            let mut updated = 0usize;
            for node in hierarchy.iter() {
                if !matches!(node.kind, NodeKind::Paragraph | NodeKind::Script) {
                    continue;
                }
                let Some(rel) = node.file.as_ref() else {
                    continue;
                };
                let abs = store.project_root().join(rel);
                if !abs.is_file() {
                    continue;
                }
                let bytes = std::fs::read(&abs).map_err(|e| anyhow!("{tag} read: {e}"))?;
                let current = store
                    .get_content(node.id)
                    .map_err(|e| anyhow!("{tag} get: {e}"))?;
                if current.as_deref() == Some(bytes.as_slice()) {
                    continue;
                }
                let mut n = node.clone();
                store
                    .update_paragraph_content(&mut n, &bytes)
                    .map_err(|e| anyhow!("{tag} update: {e}"))?;
                updated += 1;
            }
            store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
            push(vm, Value::from_int(updated as i64));
            return Ok(vm);
        }
    }
    Ok(vm)
}

// ── ink.paragraph.set_target ─────────────────────────────────────────
// Stack: ( path target -- )
// Sets / clears the per-paragraph word-count goal. `target ≤ 0`
// clears the goal (also clears `target_hit_at_status` so the
// auto-promote machinery re-fires when a goal is set again).

fn ink_paragraph_set_target(vm: &mut VM) -> BundResult<'_> {
    do_ink_paragraph_set_target(vm).map_err(to_bund_err)
}

fn do_ink_paragraph_set_target(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.paragraph.set_target";
    require_depth(vm, 2, tag)?;
    let target = value_to_i64(pull(vm, tag)?, "target", tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: empty path"))?;
    let mut node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    if node.kind != NodeKind::Paragraph {
        return Err(anyhow!("{tag}: `{}` is not a paragraph", node.title));
    }
    if target <= 0 {
        node.target_words = None;
        node.target_hit_at_status = None;
    } else {
        node.target_words = Some(target.clamp(0, i32::MAX as i64) as i32);
    }
    node.modified_at = chrono::Utc::now();
    store
        .raw()
        .update_metadata(node.id, node.to_json())
        .map_err(|e| anyhow!("{tag}: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    Ok(vm)
}

// ── ink.paragraph.target ─────────────────────────────────────────────
// Stack: ( path -- int | NODATA )
// Returns the paragraph's word-count goal (None → NODATA).

fn ink_paragraph_target(vm: &mut VM) -> BundResult<'_> {
    do_ink_paragraph_target(vm).map_err(to_bund_err)
}

fn do_ink_paragraph_target(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.paragraph.target";
    require_depth(vm, 1, tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: empty path"))?;
    let node = hierarchy
        .get(node_id)
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    match node.target_words {
        Some(n) => push(vm, Value::from_int(n as i64)),
        None => push(vm, Value::nodata()),
    }
    Ok(vm)
}

// ── 1.2.6+ Tags ──────────────────────────────────────────────────────

// ── ink.tag.list ─────────────────────────────────────────────────────
// Stack: ( -- list )
// Pushes the de-duplicated sorted list of every tag in use across
// the project. Case is preserved; first occurrence wins on case
// collision.

fn ink_tag_list(vm: &mut VM) -> BundResult<'_> {
    do_ink_tag_list(vm).map_err(to_bund_err)
}

fn do_ink_tag_list(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tag.list";
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let mut seen: std::collections::BTreeMap<String, String> =
        std::collections::BTreeMap::new();
    for (n, _) in hierarchy.flatten() {
        for t in &n.tags {
            let key = t.to_ascii_lowercase();
            seen.entry(key).or_insert_with(|| t.clone());
        }
    }
    let items: Vec<Value> = seen
        .into_values()
        .map(Value::from_string)
        .collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── ink.tag.list_for ─────────────────────────────────────────────────
// Stack: ( path -- list )
// Returns the tag list of the node at `path`. Empty list when the
// node has no tags; NODATA when the path doesn't resolve.

fn ink_tag_list_for(vm: &mut VM) -> BundResult<'_> {
    do_ink_tag_list_for(vm).map_err(to_bund_err)
}

fn do_ink_tag_list_for(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tag.list_for";
    require_depth(vm, 1, tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let Some(id) = resolve_path(&hierarchy, &path, tag)? else {
        push(vm, Value::nodata());
        return Ok(vm);
    };
    let Some(node) = hierarchy.get(id) else {
        push(vm, Value::nodata());
        return Ok(vm);
    };
    let items: Vec<Value> = node
        .tags
        .iter()
        .map(|t| Value::from_string(t.clone()))
        .collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── ink.tag.search ───────────────────────────────────────────────────
// Stack: ( tag -- list )
// Returns the slug-path list of every paragraph carrying `tag`
// (case-insensitive match).

fn ink_tag_search(vm: &mut VM) -> BundResult<'_> {
    do_ink_tag_search(vm).map_err(to_bund_err)
}

fn do_ink_tag_search(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tag.search";
    require_depth(vm, 1, tag)?;
    let needle = value_to_string(pull(vm, tag)?, "tag", tag)?
        .trim()
        .to_ascii_lowercase();
    if needle.is_empty() {
        push(vm, Value::from_list(Vec::new()));
        return Ok(vm);
    }
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let mut hits: Vec<Value> = Vec::new();
    for (n, _depth) in hierarchy.flatten() {
        if !n.tags.iter().any(|t| t.to_ascii_lowercase() == needle) {
            continue;
        }
        let mut parts = n.path.clone();
        parts.push(n.slug.clone());
        hits.push(Value::from_string(parts.join("/")));
    }
    push(vm, Value::from_list(hits));
    Ok(vm)
}

// ── ink.tag.add ──────────────────────────────────────────────────────
// Stack: ( path tag -- )
// Adds `tag` to the node at `path` (no-op if already present).

fn ink_tag_add(vm: &mut VM) -> BundResult<'_> {
    do_ink_tag_add(vm).map_err(to_bund_err)
}

fn do_ink_tag_add(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tag.add";
    require_depth(vm, 2, tag)?;
    let new_tag = value_to_string(pull(vm, tag)?, "tag", tag)?
        .trim()
        .to_owned();
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    if new_tag.is_empty() {
        return Err(anyhow!("{tag}: empty tag"));
    }
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: empty path"))?;
    let mut node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    if !node.tags.iter().any(|t| t == &new_tag) {
        node.tags.push(new_tag);
        node.modified_at = chrono::Utc::now();
        store
            .raw()
            .update_metadata(node.id, node.to_json())
            .map_err(|e| anyhow!("{tag}: {e}"))?;
        store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    }
    Ok(vm)
}

// ── ink.tag.remove ───────────────────────────────────────────────────
// Stack: ( path tag -- )
// Removes `tag` from the node at `path` (no-op if absent). Case-
// sensitive match: must equal an existing entry to drop it.

fn ink_tag_remove(vm: &mut VM) -> BundResult<'_> {
    do_ink_tag_remove(vm).map_err(to_bund_err)
}

fn do_ink_tag_remove(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.tag.remove";
    require_depth(vm, 2, tag)?;
    let drop_tag = value_to_string(pull(vm, tag)?, "tag", tag)?;
    let path = value_to_string(pull(vm, tag)?, "path", tag)?;
    if drop_tag.trim().is_empty() {
        return Err(anyhow!("{tag}: empty tag"));
    }
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let node_id = resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: empty path"))?;
    let mut node = hierarchy
        .get(node_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node missing"))?;
    let before = node.tags.len();
    node.tags.retain(|t| t != &drop_tag);
    if node.tags.len() != before {
        node.modified_at = chrono::Utc::now();
        store
            .raw()
            .update_metadata(node.id, node.to_json())
            .map_err(|e| anyhow!("{tag}: {e}"))?;
        store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    }
    Ok(vm)
}

// ── 1.2.6+ Events (Phase 4 of the timeline feature) ────────────

fn require_timeline_enabled(tag: &str) -> Result<&'static crate::config::Config> {
    let cfg = crate::scripting::active_config()
        .ok_or_else(|| anyhow!("{tag}: no active Config"))?;
    if !cfg.timeline.enabled {
        return Err(anyhow!(
            "{tag}: requires `timeline.enabled: true` in inkhaven.hjson"
        ));
    }
    Ok(cfg)
}

fn calendar_for(tag: &str) -> Result<crate::timeline::Calendar> {
    let cfg = require_timeline_enabled(tag)?;
    Ok(crate::timeline::Calendar::from_config(
        cfg.timeline.calendar.clone(),
    ))
}

fn event_dict(node: &Node) -> Value {
    let ev = match node.event.as_ref() {
        Some(e) => e,
        None => return Value::nodata(),
    };
    let mut h = HashMap::new();
    h.insert("id".to_string(), Value::from_string(node.id.to_string()));
    h.insert("title".to_string(), Value::from_string(node.title.clone()));
    h.insert("slug".to_string(), Value::from_string(node.slug.clone()));
    h.insert(
        "path".to_string(),
        Value::from_string({
            let mut parts = node.path.clone();
            parts.push(node.slug.clone());
            parts.join("/")
        }),
    );
    h.insert("start_ticks".to_string(), Value::from_int(ev.start_ticks));
    h.insert(
        "end_ticks".to_string(),
        match ev.end_ticks {
            Some(t) => Value::from_int(t),
            None => Value::nodata(),
        },
    );
    h.insert(
        "precision".to_string(),
        Value::from_string(ev.precision.as_str().to_string()),
    );
    h.insert(
        "track".to_string(),
        match &ev.track {
            Some(t) => Value::from_string(t.clone()),
            None => Value::nodata(),
        },
    );
    h.insert(
        "is_orphan".to_string(),
        Value::from_bool(node.tags.iter().any(|t| t.eq_ignore_ascii_case("orphan"))),
    );
    h.insert(
        "linked_paragraphs".to_string(),
        Value::from_list(
            node.linked_paragraphs
                .iter()
                .map(|u| Value::from_string(u.to_string()))
                .collect(),
        ),
    );
    h.insert(
        "characters".to_string(),
        Value::from_list(
            ev.characters
                .iter()
                .map(|u| Value::from_string(u.to_string()))
                .collect(),
        ),
    );
    h.insert(
        "places".to_string(),
        Value::from_list(
            ev.places
                .iter()
                .map(|u| Value::from_string(u.to_string()))
                .collect(),
        ),
    );
    Value::from_dict(h)
}

// ── ink.event.list ───────────────────────────────────────────────────
// Stack: ( -- list )

fn ink_event_list(vm: &mut VM) -> BundResult<'_> {
    do_ink_event_list(vm).map_err(to_bund_err)
}

fn do_ink_event_list(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.event.list";
    let _cfg = require_timeline_enabled(tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let mut items: Vec<(i64, Value)> = hierarchy
        .iter()
        .filter_map(|n| {
            n.event.as_ref().map(|ev| (ev.start_ticks, event_dict(n)))
        })
        .collect();
    items.sort_by_key(|(start, _)| *start);
    let list: Vec<Value> = items.into_iter().map(|(_, v)| v).collect();
    push(vm, Value::from_list(list));
    Ok(vm)
}

// ── ink.event.list_orphans ───────────────────────────────────────────
// Stack: ( -- list )

fn ink_event_list_orphans(vm: &mut VM) -> BundResult<'_> {
    do_ink_event_list_orphans(vm).map_err(to_bund_err)
}

fn do_ink_event_list_orphans(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.event.list_orphans";
    let _cfg = require_timeline_enabled(tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let items: Vec<Value> = hierarchy
        .iter()
        .filter(|n| {
            n.event.is_some()
                && n.tags.iter().any(|t| t.eq_ignore_ascii_case("orphan"))
        })
        .map(event_dict)
        .collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── ink.thread.list ──────────────────────────────────────────────────
// Stack: ( -- list )
//
// 1.2.16+ Phase I.4.a — return every thread in
// the Threads system book as a list of dicts.
// Each dict carries the thread's UUID + title +
// slug + waypoint count.  Empty list when the
// Threads system book is absent (Threads only
// auto-spawns on 1.2.14+ projects).

fn ink_thread_list(vm: &mut VM) -> BundResult<'_> {
    do_ink_thread_list(vm).map_err(to_bund_err)
}

fn do_ink_thread_list(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.thread.list";
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let threads_root = match hierarchy.iter().find(|n| {
        n.kind == crate::store::NodeKind::Book
            && n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_THREADS)
    }) {
        Some(n) => n.clone(),
        None => {
            push(vm, Value::from_list(Vec::new()));
            return Ok(vm);
        }
    };
    let items: Vec<Value> = hierarchy
        .children_of(Some(threads_root.id))
        .into_iter()
        .filter(|n| n.kind == crate::store::NodeKind::Chapter)
        .map(|thread_chapter| {
            let waypoint_count = hierarchy
                .children_of(Some(thread_chapter.id))
                .into_iter()
                .filter(|n| n.kind == crate::store::NodeKind::Paragraph)
                .count();
            let mut h: HashMap<String, Value> = HashMap::new();
            h.insert(
                "id".into(),
                Value::from_string(thread_chapter.id.to_string()),
            );
            h.insert("title".into(), Value::from_string(&thread_chapter.title));
            h.insert("slug".into(), Value::from_string(&thread_chapter.slug));
            h.insert(
                "waypoint_count".into(),
                Value::from_int(waypoint_count as i64),
            );
            Value::from_dict(h)
        })
        .collect();
    push(vm, Value::from_list(items));
    Ok(vm)
}

// ── ink.event.add ────────────────────────────────────────────────────
// Stack: ( book-name title spec -- uuid )
//
// Creates a new event paragraph under the named book's
// Timeline chapter (creating the chapter lazily). `spec` is
// a calendar-formatted start string parsed by Calendar::parse
// — precision is inferred from its shape (year/month/day/etc).

fn ink_event_add(vm: &mut VM) -> BundResult<'_> {
    do_ink_event_add(vm).map_err(to_bund_err)
}

fn do_ink_event_add(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.event.add";
    require_depth(vm, 3, tag)?;
    let spec = value_to_string(pull(vm, tag)?, "spec", tag)?;
    let title = value_to_string(pull(vm, tag)?, "title", tag)?;
    let book_name = value_to_string(pull(vm, tag)?, "book-name", tag)?;
    if title.trim().is_empty() {
        return Err(anyhow!("{tag}: empty title"));
    }
    let cfg = require_timeline_enabled(tag)?;
    let calendar = calendar_for(tag)?;
    let (start, prec) = calendar
        .parse(&spec)
        .map_err(|e| anyhow!("{tag}: {e}"))?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let needle = book_name.trim().to_ascii_lowercase();
    let book = hierarchy
        .children_of(None)
        .into_iter()
        .find(|n| {
            n.kind == NodeKind::Book
                && n.system_tag.is_none()
                && (n.title.to_ascii_lowercase() == needle
                    || n.slug.to_ascii_lowercase() == needle)
        })
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: no user book matches `{book_name}`"))?;
    let timeline_chapter_id = store
        .ensure_timeline_chapter(cfg, book.id)
        .map_err(|e| anyhow!("{tag}: ensure_timeline_chapter: {e}"))?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy reload: {e}"))?;
    let chapter = hierarchy
        .get(timeline_chapter_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: Timeline chapter vanished after creation"))?;
    let mut node = store
        .create_node(
            cfg,
            &hierarchy,
            NodeKind::Paragraph,
            &title,
            Some(&chapter),
            None,
            InsertPosition::End,
        )
        .map_err(|e| anyhow!("{tag}: create_node: {e}"))?;
    node.event = Some(crate::store::node::EventData {
        start_ticks: start.ticks(),
        end_ticks: None,
        precision: prec,
        characters: Vec::new(),
        places: Vec::new(),
        track: None,
    });
    crate::store::reconcile_event_orphan_tag(&mut node);
    node.modified_at = chrono::Utc::now();
    store
        .raw()
        .update_metadata(node.id, node.to_json())
        .map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    let id_str = node.id.to_string();
    // Fire the hook so script-side observers can react.
    crate::scripting::hooks::fire(
        "hook.on_event_added",
        vec![Value::from_string(id_str.clone())],
    );
    push(vm, Value::from_string(id_str));
    Ok(vm)
}

// ── ink.event.set_end ────────────────────────────────────────────────
// Stack: ( uuid spec -- )
// Sets the event's end-ticks from a calendar-formatted string,
// or clears the duration when spec is "" / "none".

fn ink_event_set_end(vm: &mut VM) -> BundResult<'_> {
    do_ink_event_set_end(vm).map_err(to_bund_err)
}

fn do_ink_event_set_end(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.event.set_end";
    require_depth(vm, 2, tag)?;
    let spec = value_to_string(pull(vm, tag)?, "spec", tag)?;
    let id = value_to_uuid(pull(vm, tag)?, tag)?;
    let new_end: Option<i64> = if spec.trim().is_empty() || spec.eq_ignore_ascii_case("none") {
        None
    } else {
        let calendar = calendar_for(tag)?;
        let (p, _prec) = calendar
            .parse(&spec)
            .map_err(|e| anyhow!("{tag}: {e}"))?;
        Some(p.ticks())
    };
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let mut node = hierarchy
        .get(id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node {id} missing"))?;
    let Some(ev) = node.event.as_mut() else {
        return Err(anyhow!("{tag}: node {id} is not an event"));
    };
    if let Some(end_t) = new_end {
        if end_t < ev.start_ticks {
            return Err(anyhow!(
                "{tag}: end ({end_t}) < start ({}) — events can't run backwards",
                ev.start_ticks
            ));
        }
    }
    ev.end_ticks = new_end;
    node.modified_at = chrono::Utc::now();
    store
        .raw()
        .update_metadata(node.id, node.to_json())
        .map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    Ok(vm)
}

// ── ink.event.set_precision ──────────────────────────────────────────
// Stack: ( uuid precision -- )

fn ink_event_set_precision(vm: &mut VM) -> BundResult<'_> {
    do_ink_event_set_precision(vm).map_err(to_bund_err)
}

fn do_ink_event_set_precision(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.event.set_precision";
    require_depth(vm, 2, tag)?;
    let prec_str = value_to_string(pull(vm, tag)?, "precision", tag)?;
    let id = value_to_uuid(pull(vm, tag)?, tag)?;
    let _cfg = require_timeline_enabled(tag)?;
    let prec = crate::timeline::Precision::from_str(&prec_str).ok_or_else(|| {
        anyhow!("{tag}: unknown precision `{prec_str}` (try day/month/year/season/hour/week/tick)")
    })?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let mut node = hierarchy
        .get(id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node {id} missing"))?;
    let Some(ev) = node.event.as_mut() else {
        return Err(anyhow!("{tag}: node {id} is not an event"));
    };
    ev.precision = prec;
    node.modified_at = chrono::Utc::now();
    store
        .raw()
        .update_metadata(node.id, node.to_json())
        .map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    Ok(vm)
}

// ── ink.event.set_track ──────────────────────────────────────────────
// Stack: ( uuid track -- )
// Empty string clears the track.

fn ink_event_set_track(vm: &mut VM) -> BundResult<'_> {
    do_ink_event_set_track(vm).map_err(to_bund_err)
}

fn do_ink_event_set_track(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.event.set_track";
    require_depth(vm, 2, tag)?;
    let track = value_to_string(pull(vm, tag)?, "track", tag)?;
    let id = value_to_uuid(pull(vm, tag)?, tag)?;
    let _cfg = require_timeline_enabled(tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let mut node = hierarchy
        .get(id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: node {id} missing"))?;
    let Some(ev) = node.event.as_mut() else {
        return Err(anyhow!("{tag}: node {id} is not an event"));
    };
    ev.track = if track.trim().is_empty() {
        None
    } else {
        Some(track)
    };
    node.modified_at = chrono::Utc::now();
    store
        .raw()
        .update_metadata(node.id, node.to_json())
        .map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    Ok(vm)
}

// ── ink.event.link_paragraph ─────────────────────────────────────────
// Stack: ( uuid paragraph-path -- )
//
// Adds the paragraph at `path` to the event's
// linked_paragraphs (idempotent — no-op if already linked).
// Reconciles the orphan tag afterwards.

fn ink_event_link_paragraph(vm: &mut VM) -> BundResult<'_> {
    do_ink_event_link_paragraph(vm).map_err(to_bund_err)
}

fn do_ink_event_link_paragraph(vm: &mut VM) -> Result<&mut VM> {
    let tag = "ink.event.link_paragraph";
    require_depth(vm, 2, tag)?;
    let path = value_to_string(pull(vm, tag)?, "paragraph-path", tag)?;
    let event_id = value_to_uuid(pull(vm, tag)?, tag)?;
    let _cfg = require_timeline_enabled(tag)?;
    let store = active_store(tag)?;
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow!("{tag} hierarchy: {e}"))?;
    let para_id = super::helpers::resolve_path(&hierarchy, &path, tag)?
        .ok_or_else(|| anyhow!("{tag}: empty path"))?;
    {
        let para = hierarchy
            .get(para_id)
            .ok_or_else(|| anyhow!("{tag}: paragraph at `{path}` missing"))?;
        if para.kind != NodeKind::Paragraph {
            return Err(anyhow!("{tag}: `{path}` is not a Paragraph"));
        }
    }
    let mut node = hierarchy
        .get(event_id)
        .cloned()
        .ok_or_else(|| anyhow!("{tag}: event {event_id} missing"))?;
    if node.event.is_none() {
        return Err(anyhow!("{tag}: node {event_id} is not an event"));
    }
    if !node.linked_paragraphs.contains(&para_id) {
        node.linked_paragraphs.push(para_id);
    }
    crate::store::reconcile_event_orphan_tag(&mut node);
    node.modified_at = chrono::Utc::now();
    store
        .raw()
        .update_metadata(node.id, node.to_json())
        .map_err(|e| anyhow!("{tag}: update_metadata: {e}"))?;
    store.sync().map_err(|e| anyhow!("{tag} sync: {e}"))?;
    Ok(vm)
}
