"""Navigation and mutation tools for Carve.

Provides the core navigation API (subtree, ancestors, inspect, search)
and mutation API (insert, update, delete, validate_snippet).
Also provides project-level tools (project_import, project_serialize).
These tools enable AI agents to explore, understand, and modify code structure
efficiently without reading entire files.
"""

from __future__ import annotations

import logging
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any

from carve.models import NodeMetadata, ProjectStore, Store, TreeNode, TreeStore

if TYPE_CHECKING:
    from carve.parser import TreeSitterParser
    from carve.settings import CarveSettings


@dataclass
class _PendingMutation:
    """A planned text-level splice for batch application."""

    op_type: str  # "insert" | "update" | "delete"
    file_id: str | None  # None for TreeStore
    start_byte: int  # splice start in original source
    end_byte: int  # splice end (== start_byte for pure insert)
    replacement: str  # text to splice in (empty for delete)
    op_index: int  # position in original batch list
    result_info: dict[str, Any] = field(default_factory=dict)
    sidecar_kwargs: dict[str, Any] = field(default_factory=dict)


def subtree(
    store: Store,
    node_id: str,
    depth: int = 2,
    include_source: bool = False,
) -> dict[str, Any]:
    """Render tree downward from a node.

    At depth 0: full source is included for the target node.
    At depth 1+: only signatures (first line) are shown.

    Args:
        store: The TreeStore to query
        node_id: ID of the node to start from
        depth: How many levels deep to traverse (default 2)
        include_source: If True, include full source for all nodes

    Returns:
        Dict with node_id, node_type, signature, source (at depth 0),
        and children (if depth > 0)
    """
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}"}

    return _render_subtree(node, depth, include_source, is_root=True)


def _render_subtree(
    node: TreeNode,
    depth: int,
    include_source: bool,
    is_root: bool = False,
) -> dict[str, Any]:
    """Recursively render a subtree."""
    result: dict[str, Any] = {
        "node_id": node.node_id,
        "node_type": node.node_type,
        "signature": node.signature(),
    }

    # Include full source at depth 0 (the target) or if include_source is True
    if is_root or include_source:
        result["source"] = node.source

    # Include children if we haven't reached the depth limit
    if depth > 0 and node.children:
        result["children"] = [
            _render_subtree(child, depth - 1, include_source, is_root=False)
            for child in node.children
        ]

    return result


def ancestors(
    store: Store,
    node_id: str,
    levels: int | None = None,
) -> list[dict[str, Any]]:
    """Return the path from node up to root.

    Args:
        store: The TreeStore to query
        node_id: ID of the node to start from
        levels: Maximum number of levels to traverse (None = all the way to root)

    Returns:
        List of dicts with node_id and type, from the target up to root
    """
    if store.root is None:
        return []

    # Find the path from root to the target node
    path = _find_path_to_node(store.root, node_id)

    if not path:
        return [{"error": f"Node not found: {node_id}"}]

    # Reverse to get target-first order (as specified: "from node up to root")
    path.reverse()

    # Apply levels limit if specified
    if levels is not None and levels > 0:
        path = path[:levels]

    return [{"node_id": n.node_id, "type": n.node_type} for n in path]


def _find_path_to_node(
    current: TreeNode, target_id: str, path: list[TreeNode] | None = None
) -> list[TreeNode] | None:
    """Find the path from current node to target, returning the path if found."""
    if path is None:
        path = []

    path.append(current)

    if current.node_id == target_id:
        return path

    for child in current.children:
        result = _find_path_to_node(child, target_id, path.copy())
        if result is not None:
            return result

    return None


def inspect(
    store: Store,
    node_id: str,
    include_metadata: bool = False,
) -> dict[str, Any]:
    """Return full source and info for a single node.

    Args:
        store: The TreeStore to query
        node_id: ID of the node to inspect
        include_metadata: If True, include prompt/model/version metadata

    Returns:
        Dict with node_id, type, source, references, and referenced_by
    """
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}"}

    result: dict[str, Any] = {
        "node_id": node.node_id,
        "type": node.node_type,
        "source": node.source,
        "start_point": node.start_point,
        "end_point": node.end_point,
    }

    # Find references (what this node calls) and referenced_by (what calls this)
    references, referenced_by = _find_references(store, node)
    result["references"] = references
    result["referenced_by"] = referenced_by

    if include_metadata:
        result["metadata"] = {
            "prompt": node.metadata.prompt,
            "model": node.metadata.model,
            "version": node.metadata.version,
            "created_at": node.metadata.created_at.isoformat(),
            "updated_at": node.metadata.updated_at.isoformat(),
        }

    return result


def _is_named_definition(node_id: str) -> bool:
    """Check if a node ID represents a top-level named definition.

    Returns True for nodes like ``module::User::get_orders`` where
    *every* component in the ``::``-separated path is a real name
    (no ``[index]`` fragments).  Returns False for structural children
    like ``module::User::parameters[1]::status`` (parameter named
    "status" inside an indexed parent).
    """
    parts = node_id.split("::")
    return all("[" not in part for part in parts)


def _prune_ancestors(node_ids: list[str]) -> list[str]:
    """Remove any node ID that is an ancestor of another in the list.

    A is an ancestor of B if B starts with ``A::`` — their IDs share
    the hierarchical prefix.  This keeps only the most specific matches.
    """
    # Sort shortest-first so we can efficiently check prefixes
    id_set = set(node_ids)
    pruned: list[str] = []
    for nid in node_ids:
        # Keep nid unless some other ID in the set has nid as a prefix
        is_ancestor = any(other.startswith(nid + "::") for other in id_set if other != nid)
        if not is_ancestor:
            pruned.append(nid)
    return pruned


def _find_references(store: Store, node: TreeNode) -> tuple[list[str], list[str]]:
    """Find what a node references and what references it.

    Uses word-boundary name matching, restricted to *named definition*
    nodes (functions, classes, methods — not indexed leaves like
    ``identifier[0]`` or ``parameters[1]``).  Results are pruned to
    remove ancestor nodes, keeping only the most specific matches.
    """
    references: list[str] = []
    referenced_by: list[str] = []

    all_nodes = store.all_nodes()

    # Get the name part from node_id (last component)
    node_name = node.node_id.split("::")[-1] if "::" in node.node_id else node.node_id

    # Build a map of simple names to full node_ids — only for named nodes.
    # This filters out indexed leaves like identifier[0], parameters[1],
    # keyword_argument[0] which cause false-positive matches.
    name_to_ids: dict[str, list[str]] = {}
    for n in all_nodes:
        if n.node_id == node.node_id:
            continue
        if not _is_named_definition(n.node_id):
            continue
        simple_name = n.node_id.split("::")[-1] if "::" in n.node_id else n.node_id
        if simple_name and simple_name not in ("module", "program", "document", "file"):
            if simple_name not in name_to_ids:
                name_to_ids[simple_name] = []
            name_to_ids[simple_name].append(n.node_id)

    # Find references: other named nodes whose names appear in this node's source
    for name, ids in name_to_ids.items():
        if re.search(rf"\b{re.escape(name)}\b", node.source):
            references.extend(ids)

    # Find referenced_by: other named nodes whose source mentions this node's name.
    # Only check named nodes to avoid ancestor-chain bloat (a parent's source
    # includes its children's source, so every ancestor would match).
    if node_name and node_name not in ("module", "program", "document", "file"):
        for n in all_nodes:
            if n.node_id == node.node_id:
                continue
            if not _is_named_definition(n.node_id):
                continue
            if re.search(rf"\b{re.escape(node_name)}\b", n.source):
                referenced_by.append(n.node_id)

    # Deduplicate, then prune ancestor chains to keep only the most specific
    references = _prune_ancestors(list(dict.fromkeys(references)))
    referenced_by = _prune_ancestors(list(dict.fromkeys(referenced_by)))

    return references, referenced_by


def find_references(
    store: Store,
    node_id: str,
) -> dict[str, Any]:
    """Find what uses a node and what the node depends on.

    A dedicated tool for dependency analysis. Returns richer information
    than ``inspect()``'s inline references — each entry includes the
    node's type and signature, making it useful for impact analysis
    before refactoring or deletion.

    Args:
        store: The TreeStore or ProjectStore to query
        node_id: ID of the node to find references for

    Returns:
        Dict with:
        - node_id: str - the queried node
        - references: list - nodes this node depends on (outgoing),
          each with node_id, type, and signature
        - referenced_by: list - nodes that depend on this node (incoming),
          each with node_id, type, and signature
        - error: str - error message (on failure)
    """
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}"}

    ref_ids, refby_ids = _find_references(store, node)

    references: list[dict[str, str]] = []
    for rid in ref_ids:
        rnode = store.get_node(rid)
        if rnode is not None:
            references.append(
                {
                    "node_id": rnode.node_id,
                    "type": rnode.node_type,
                    "signature": rnode.signature(),
                }
            )

    referenced_by: list[dict[str, str]] = []
    for rid in refby_ids:
        rnode = store.get_node(rid)
        if rnode is not None:
            referenced_by.append(
                {
                    "node_id": rnode.node_id,
                    "type": rnode.node_type,
                    "signature": rnode.signature(),
                }
            )

    return {
        "node_id": node_id,
        "references": references,
        "referenced_by": referenced_by,
    }


def search(
    store: Store,
    pattern: str | None = None,
    node_type: str | None = None,
    scope: str | None = None,
) -> list[dict[str, Any]]:
    """Find nodes by name pattern and/or type.

    Args:
        store: The TreeStore to query
        pattern: Regex pattern to match against node names/IDs
        node_type: Filter by node type (e.g., 'function_definition')
        scope: Limit search to a subtree (node_id prefix)

    Returns:
        List of matching nodes with node_id, type, and signature
    """
    results: list[dict[str, Any]] = []

    # Determine which nodes to search
    if scope:
        scope_node = store.get_node(scope)
        if scope_node is None:
            return [{"error": f"Scope node not found: {scope}"}]
        nodes = scope_node.walk()
    else:
        nodes = store.all_nodes()

    # Compile regex pattern if provided
    compiled_pattern: re.Pattern[str] | None = None
    if pattern:
        try:
            compiled_pattern = re.compile(pattern, re.IGNORECASE)
        except re.error as e:
            return [{"error": f"Invalid regex pattern: {e}"}]

    for node in nodes:
        # Apply type filter
        if node_type and node.node_type != node_type:
            continue

        # Apply pattern filter (match against node's own name component)
        if compiled_pattern:
            # Match against the last component of the ID (the node's own name)
            name_part = node.node_id.rsplit("::", 1)[-1]
            if not compiled_pattern.search(name_part):
                continue

        results.append(
            {
                "node_id": node.node_id,
                "type": node.node_type,
                "signature": node.signature(),
            }
        )

    return results


# =============================================================================
# Error Location
# =============================================================================

# Stack-trace frame patterns.
# Python:  '  File "path.py", line 10, in func_name'
_PYTHON_FRAME_RE = re.compile(
    r'^\s*File "(?P<file>[^"]+)", line (?P<line>\d+)(?:, in (?P<func>\S+))?'
)
# JavaScript / Node.js:  '    at funcName (path.js:10:5)' or '    at path.js:10:5'
_JS_FRAME_RE = re.compile(
    r"^\s*at\s+(?:(?P<func>[^\s(]+)\s+)?\(?(?P<file>[^:)]+):(?P<line>\d+):\d+\)?"
)


def _parse_traceback(traceback: str) -> list[dict[str, str | int]]:
    """Extract (file, line, func) frames from a stack trace string.

    Supports Python tracebacks and JavaScript/Node.js stack traces.
    Returns frames in the order they appear in the traceback.
    """
    frames: list[dict[str, str | int]] = []
    for line in traceback.splitlines():
        m = _PYTHON_FRAME_RE.match(line)
        if m:
            frames.append(
                {
                    "file": m.group("file"),
                    "line": int(m.group("line")),
                    "func": m.group("func") or "",
                }
            )
            continue
        m = _JS_FRAME_RE.match(line)
        if m:
            frames.append(
                {
                    "file": m.group("file"),
                    "line": int(m.group("line")),
                    "func": m.group("func") or "",
                }
            )
    return frames


def _find_deepest_node_at_line(root: TreeNode, line: int) -> TreeNode | None:
    """Find the deepest *named definition* node whose span contains *line*.

    Only considers nodes with a real name in the ID hierarchy (e.g.
    ``module::Foo::bar``) — not anonymous/indexed leaf nodes like
    ``identifier[0]`` or ``parameters[1]``.  This gives the most useful
    result for stack-trace mapping: the function/class/method that
    contains the error line.

    Args:
        root: The root node to search within (file root or module root).
        line: 0-indexed line number.

    Returns:
        The most specific named TreeNode containing that line, or None.
    """
    best: TreeNode | None = None
    for node in root.walk():
        if node.start_point[0] <= line <= node.end_point[0]:
            # Skip anonymous/indexed nodes (e.g. "identifier[0]", "parameters[1]")
            # and body wrappers — we only want named definitions + root.
            last_part = node.node_id.rsplit("::", 1)[-1]
            if "[" in last_part:
                continue
            if best is None or (
                node.start_point[0] >= best.start_point[0]
                and node.end_point[0] <= best.end_point[0]
                and node is not best
            ):
                best = node
    return best


def error_locate(
    store: Store,
    traceback: str,
) -> dict[str, Any]:
    """Map a stack trace to Carve node IDs.

    Parses a Python traceback or JavaScript stack trace, extracts file
    paths and line numbers, and resolves each frame to the deepest
    Carve tree node that contains that line.  This lets an AI agent
    jump straight from an error to the relevant ``inspect`` / ``update``
    targets.

    Args:
        store: The TreeStore or ProjectStore to search
        traceback: The full stack trace / error output string

    Returns:
        Dict with:
        - frames: list of resolved frames, each with file, line, func,
          node_id (if matched), node_type, and signature
        - error: str - error message (on failure)
    """
    parsed = _parse_traceback(traceback)
    if not parsed:
        return {"error": "No stack frames found in traceback"}

    results: list[dict[str, Any]] = []

    for frame in parsed:
        file_path = str(frame["file"])
        line_1indexed = int(frame["line"])
        line_0indexed = line_1indexed - 1  # tree-sitter uses 0-indexed lines
        func_name = str(frame["func"])

        entry: dict[str, Any] = {
            "file": file_path,
            "line": line_1indexed,
            "func": func_name,
        }

        # Find the root node to search within
        search_root: TreeNode | None = None

        if isinstance(store, ProjectStore):
            # Try to match the file path against known file node IDs.
            # file_ids look like "project/subdir/file.py"; traceback paths
            # can be absolute or relative.  We match by suffix.
            matched_file_id: str | None = None
            for file_id in store.file_sources:
                # Normalise separators and compare by suffix
                if file_path.endswith(file_id) or file_id.endswith(file_path):
                    matched_file_id = file_id
                    break
                # Also try matching just the filename / relative tail
                file_tail = Path(file_path).name
                fid_tail = file_id.rsplit("/", 1)[-1]
                if file_tail == fid_tail:
                    matched_file_id = file_id
                    break
            if matched_file_id is not None:
                search_root = store.get_node(matched_file_id)
                entry["file_node_id"] = matched_file_id
        else:
            # TreeStore — single file, match if filename matches or just use it
            if store.file_path:
                store_name = Path(store.file_path).name
                trace_name = Path(file_path).name
                if store_name == trace_name:
                    search_root = store.root
            else:
                # No file_path on store (in-memory) — assume it's the right file
                search_root = store.root

        if search_root is None:
            entry["node_id"] = None
            results.append(entry)
            continue

        # Find the deepest node at this line
        node = _find_deepest_node_at_line(search_root, line_0indexed)
        if node is not None:
            entry["node_id"] = node.node_id
            entry["node_type"] = node.node_type
            entry["signature"] = node.signature()
        else:
            entry["node_id"] = None

        results.append(entry)

    return {"frames": results}


# =============================================================================
# Tree-sitter Query Tool
# =============================================================================


def _find_enclosing_named_node(root: TreeNode, start_byte: int, end_byte: int) -> TreeNode | None:
    """Find the deepest named node whose byte range contains a capture.

    Walks the Carve tree depth-first, returning the most specific node
    whose last ID component is a real name (no ``[index]`` suffix) that
    fully encloses the given byte range.  Only the last component is
    checked, so nodes like ``document::element[0]::script::greet`` are
    valid — this matches the logic in ``_find_deepest_node_at_line``.

    Args:
        root: Root node to search within.
        start_byte: Start byte of the capture.
        end_byte: End byte of the capture.

    Returns:
        The deepest enclosing named TreeNode, or None.
    """
    best: TreeNode | None = None
    for node in root.walk():
        if node.start_byte <= start_byte and node.end_byte >= end_byte:
            last_part = node.node_id.rsplit("::", 1)[-1]
            if "[" in last_part:
                continue
            if best is None or (
                node.start_byte >= best.start_byte
                and node.end_byte <= best.end_byte
                and node is not best
            ):
                best = node
    return best


def _resolve_query_targets(
    store: Store, scope: str | None
) -> list[tuple[str | None, str, str, tuple[int, int] | None]]:
    """Resolve the scope parameter to a list of (file_id, source, language, byte_range) targets.

    Returns a list of tuples to query against:
    - file_id: file node ID (None for TreeStore)
    - source: source string
    - language: language name
    - byte_range: optional (start_byte, end_byte) to scope the query within a file

    Raises:
        ValueError: If scope references a node that doesn't exist.
    """
    targets: list[tuple[str | None, str, str, tuple[int, int] | None]] = []

    if isinstance(store, ProjectStore):
        if scope is None:
            # Query all files
            for file_id, source in store.file_sources.items():
                lang = store.file_languages.get(file_id)
                if lang:
                    targets.append((file_id, source, lang, None))
        elif scope in store.file_sources:
            # Scope is a file node ID
            lang = store.file_languages.get(scope)
            if lang:
                targets.append((scope, store.file_sources[scope], lang, None))
        else:
            # Check if scope is a directory or code node
            node = store.get_node(scope)
            if node is None:
                raise ValueError(f"Scope node not found: {scope}")

            if node.node_type == "directory":
                # Query all files under this directory
                for file_id, source in store.file_sources.items():
                    if file_id.startswith(scope + "/"):
                        lang = store.file_languages.get(file_id)
                        if lang:
                            targets.append((file_id, source, lang, None))
            else:
                # Code node — scope to its byte range within the containing file
                containing_file = store.get_file_for_node(scope)
                if containing_file is None:
                    raise ValueError(f"Cannot find file for node: {scope}")
                lang = store.file_languages.get(containing_file)
                if lang:
                    targets.append(
                        (
                            containing_file,
                            store.file_sources[containing_file],
                            lang,
                            (node.start_byte, node.end_byte),
                        )
                    )
    else:
        # TreeStore
        lang = store.language
        if not lang:
            raise ValueError("Store has no language set")

        if scope is None:
            targets.append((None, store.source, lang, None))
        else:
            node = store.get_node(scope)
            if node is None:
                raise ValueError(f"Scope node not found: {scope}")
            targets.append((None, store.source, lang, (node.start_byte, node.end_byte)))

    return targets


def _find_injection_regions(ts_node: Any) -> list[tuple[str, int, int, Any, Any]]:
    """Find language injection regions in a tree-sitter CST.

    Walks the raw tree-sitter Node tree to find injection containers
    (e.g., ``script_element`` → javascript, ``style_element`` → css)
    and returns info about their raw_text content.

    Returns:
        List of (injected_language, start_byte, end_byte, start_point, end_point).
    """
    from carve.parser import INJECTION_MAP

    regions: list[tuple[str, int, int, Any, Any]] = []

    injected_lang = INJECTION_MAP.get(ts_node.type)
    if injected_lang is not None:
        for child in ts_node.children:
            if child.type == "raw_text" and child.start_byte < child.end_byte:
                regions.append(
                    (
                        injected_lang,
                        child.start_byte,
                        child.end_byte,
                        child.start_point,
                        child.end_point,
                    )
                )
                break

    for child in ts_node.children:
        regions.extend(_find_injection_regions(child))

    return regions


def _process_ts_matches(
    raw_matches: list[Any],
    source_bytes: bytes,
    carve_root: TreeNode | None,
    file_id: str | None,
    matches: list[dict[str, Any]],
) -> None:
    """Process raw tree-sitter query matches into the output format.

    Extracts text, type, positions from each capture and maps to enclosing
    Carve node IDs.  Appends results to *matches* in place.
    """
    for pattern_index, captures_dict in raw_matches:
        match_entry: dict[str, Any] = {
            "pattern_index": pattern_index,
            "captures": {},
        }
        if file_id is not None:
            match_entry["file"] = file_id

        for capture_name, nodes in captures_dict.items():
            capture_list: list[dict[str, Any]] = []
            for ts_node in nodes:
                text = source_bytes[ts_node.start_byte : ts_node.end_byte].decode(
                    "utf-8", errors="replace"
                )
                capture_entry: dict[str, Any] = {
                    "text": text,
                    "node_type": ts_node.type,
                    "start_point": [ts_node.start_point[0], ts_node.start_point[1]],
                    "end_point": [ts_node.end_point[0], ts_node.end_point[1]],
                }

                # Map to enclosing Carve node ID
                if carve_root is not None:
                    enclosing = _find_enclosing_named_node(
                        carve_root, ts_node.start_byte, ts_node.end_byte
                    )
                    if enclosing is not None:
                        capture_entry["node_id"] = enclosing.node_id

                capture_list.append(capture_entry)

            match_entry["captures"][capture_name] = capture_list

        matches.append(match_entry)


def ts_query(
    store: Store,
    pattern: str,
    scope: str | None = None,
) -> dict[str, Any]:
    """Run a tree-sitter S-expression query against source code.

    Reparses source from the in-memory store (not disk), runs the query on
    the tree-sitter CST, and maps captures back to enclosing Carve node IDs.
    Read-only — no side effects on the store.

    Supports language injections: for HTML files with ``<script>`` or
    ``<style>`` elements, the query is also run against the injected
    JS/CSS content using the appropriate language parser.

    Args:
        store: The TreeStore or ProjectStore to query.
        pattern: Tree-sitter S-expression query pattern.
        scope: Optional Carve node_id to limit scope.

    Returns:
        Dict with ``matches`` list on success, or ``error`` string on failure.
    """
    from tree_sitter import Language as TSLanguage
    from tree_sitter import Parser as TSParser
    from tree_sitter import Query, QueryCursor, QueryError, Range

    from carve.parser import get_language

    # Resolve targets
    try:
        targets = _resolve_query_targets(store, scope)
    except ValueError as e:
        return {"error": str(e)}

    if not targets:
        return {"matches": []}

    # Collect unique languages and try to compile the query for each
    languages = {t[2] for t in targets}
    query_cache: dict[str, Query] = {}
    failed_languages: set[str] = set()
    last_query_error: str = ""

    for lang in languages:
        try:
            lang_obj = get_language(lang)
            query_cache[lang] = Query(lang_obj, pattern)
        except QueryError as e:
            failed_languages.add(lang)
            last_query_error = str(e)

    # Don't return error yet — injected languages may still succeed

    parser = _get_parser()
    matches: list[dict[str, Any]] = []
    all_attempted: set[str] = set(languages)

    for file_id, source, lang, byte_range in targets:
        source_bytes = source.encode("utf-8")
        ts_root = parser.parse(source_bytes, lang)

        # Find the Carve tree root for node ID mapping
        if isinstance(store, ProjectStore) and file_id is not None:
            carve_root = store.get_node(file_id)
        else:
            carve_root = store.root

        # Query with main language
        if lang not in failed_languages:
            q = query_cache[lang]
            cursor = QueryCursor(q)
            if byte_range is not None:
                cursor.set_byte_range(byte_range[0], byte_range[1])
            raw_matches = cursor.matches(ts_root)
            _process_ts_matches(raw_matches, source_bytes, carve_root, file_id, matches)

        # Handle language injections (JS/CSS inside HTML)
        injection_regions = _find_injection_regions(ts_root)
        for inj_lang, inj_start, inj_end, inj_start_pt, inj_end_pt in injection_regions:
            # Apply byte_range scoping — skip injections outside scope
            if byte_range is not None and (inj_end <= byte_range[0] or inj_start >= byte_range[1]):
                continue

            # Try to compile query for injected language (lazy, once per language)
            all_attempted.add(inj_lang)
            if inj_lang not in query_cache and inj_lang not in failed_languages:
                try:
                    query_cache[inj_lang] = Query(get_language(inj_lang), pattern)
                except QueryError:
                    failed_languages.add(inj_lang)

            if inj_lang in failed_languages:
                continue

            # Parse the injected region with the appropriate language parser
            inj_lang_obj: TSLanguage = get_language(inj_lang)
            inj_parser = TSParser(inj_lang_obj)
            inj_parser.included_ranges = [
                Range(
                    start_point=inj_start_pt,
                    end_point=inj_end_pt,
                    start_byte=inj_start,
                    end_byte=inj_end,
                )
            ]
            inj_tree = inj_parser.parse(source_bytes)
            inj_root = inj_tree.root_node

            inj_cursor = QueryCursor(query_cache[inj_lang])
            if byte_range is not None:
                eff_start = max(byte_range[0], inj_start)
                eff_end = min(byte_range[1], inj_end)
                inj_cursor.set_byte_range(eff_start, eff_end)

            inj_matches = inj_cursor.matches(inj_root)
            _process_ts_matches(inj_matches, source_bytes, carve_root, file_id, matches)

    # If pattern failed for ALL attempted languages (including injected), return error
    if all_attempted and failed_languages >= all_attempted:
        return {"error": f"Invalid query pattern: {last_query_error}"}

    return {"matches": matches}


# =============================================================================
# Mutation Tools
# =============================================================================


_configured_parser: TreeSitterParser | None = None


def configure_parser(parser: TreeSitterParser) -> None:
    """Set the module-level parser instance used by all tool functions.

    Called by ``CarveServer`` after creating a parser with user overrides.
    """
    global _configured_parser
    _configured_parser = parser


def _get_parser() -> TreeSitterParser:
    """Return the configured parser, or create a default one."""
    if _configured_parser is not None:
        return _configured_parser
    from carve.parser import TreeSitterParser

    return TreeSitterParser()


_CARVE_COMMENT_RE = re.compile(r"^\s*(?:#|//)\s*carve:.*$")


def _strip_carve_comments(snippet: str) -> str:
    """Strip ``# carve:`` and ``// carve:`` metadata comments from a snippet.

    Carve metadata comments are embedded in ``inspect`` output to guide the AI.
    They must be removed before the snippet is parsed or inserted into the tree.
    """
    lines = snippet.split("\n")
    return "\n".join(line for line in lines if not _CARVE_COMMENT_RE.match(line))


def _reindent_snippet(snippet: str, target_col: int, indent_first_line: bool = True) -> str:
    """Re-indent a snippet for splicing into source at a given column.

    Adds *target_col* spaces to each non-empty line (or all but the first
    when *indent_first_line* is ``False``).  This converts snippets written
    with relative indentation into the absolute indentation needed by the
    destination file.

    Args:
        snippet: The code snippet (will be stripped)
        target_col: Column position for the base indent
        indent_first_line: If ``False``, the first line is kept as-is.
            Use ``False`` for ``update()``, where the source splice already
            positions the first line correctly.
    """
    lines = snippet.strip().split("\n")
    if not lines:
        return ""

    result: list[str] = []
    for i, line in enumerate(lines):
        if not line.strip():
            result.append("")
        elif i == 0 and not indent_first_line:
            result.append(line)
        else:
            result.append(" " * target_col + line)

    return "\n".join(result)


# ---------------------------------------------------------------------------
# Byte-offset helpers
#
# tree-sitter reports byte offsets into the UTF-8 encoded source, but
# ``store.file_sources`` / ``store.source`` hold Python *str* objects whose
# indices are character positions.  The helpers below bridge the two worlds
# so that every splice / slice / length operation uses the correct domain.
# ---------------------------------------------------------------------------


def _byte_splice(source: str, start: int, end: int, replacement: str) -> str:
    """Replace ``source[start:end]`` (byte offsets) with *replacement*."""
    sb = source.encode("utf-8")
    return (sb[:start] + replacement.encode("utf-8") + sb[end:]).decode("utf-8")


def _byte_slice(source: str, start: int = 0, end: int | None = None) -> str:
    """Slice *source* using byte offsets, returning a ``str``."""
    return source.encode("utf-8")[start:end].decode("utf-8")


def _byte_len(s: str) -> int:
    """Return the UTF-8 byte length of *s*."""
    return len(s.encode("utf-8"))


def _byte_rfind(source: str, sub: str, start: int = 0, end: int | None = None) -> int:
    """``rfind`` on the UTF-8 encoded *source*, returning a byte offset."""
    return source.encode("utf-8").rfind(sub.encode("utf-8"), start, end)


def _byte_char_at(source: str, byte_offset: int) -> str:
    """Return the character at *byte_offset* in the UTF-8 encoding of *source*."""
    return source.encode("utf-8")[byte_offset : byte_offset + 1].decode("latin-1")


def _resolve_source(store: Store, node_id: str) -> tuple[str, str | None]:
    """Get the source string and file_id for a node.

    Returns (source_string, file_id_or_None).
    For TreeStore: returns (store.source, None).
    For ProjectStore: returns (file_source, file_node_id).
    """
    if isinstance(store, ProjectStore):
        file_id = store.get_file_for_node(node_id)
        if file_id is None:
            raise ValueError(f"Cannot find file for node: {node_id}")
        return store.file_sources[file_id], file_id
    return store.source, None


def _write_source(store: Store, source: str, file_id: str | None) -> None:
    """Write back a modified source string."""
    if isinstance(store, ProjectStore):
        assert file_id is not None
        store.file_sources[file_id] = source
        # Also update the file node's source field
        file_node = store.get_node(file_id)
        if file_node is not None:
            file_node.source = source
    else:
        store.source = source


def _sync_file_to_disk(store: Store, file_id: str | None) -> None:
    """Write the affected file to disk after a mutation.

    This keeps the filesystem in sync with the tree on every mutation,
    so external tools (LSP, linters, test runners) always see current code.

    For TreeStore: writes store.source to store.file_path (if set).
    For ProjectStore: writes the file source to its path under project_path.
    """
    if isinstance(store, ProjectStore):
        if file_id is None or store.project_path is None:
            return
        root_name = store.root.node_id if store.root else ""
        rel_path = file_id[len(root_name) + 1 :] if file_id.startswith(root_name + "/") else file_id
        out = Path(store.project_path) / rel_path
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(store.file_sources[file_id])
    else:
        if store.file_path is None:
            return
        path = Path(store.file_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(store.source)


log = logging.getLogger(__name__)


def _get_settings(store: Store) -> CarveSettings:
    """Resolve project root from *store* and load ``carvesettings.toml``."""
    from carve.settings import CarveSettings, load_settings

    if isinstance(store, ProjectStore) and store.project_path:
        return load_settings(Path(store.project_path))

    # TreeStore — walk up from file_path looking for carvesettings.toml
    if isinstance(store, TreeStore) and store.file_path:
        current = Path(store.file_path).resolve().parent
        for _ in range(50):  # safety limit
            if (current / "carvesettings.toml").is_file():
                return load_settings(current)
            parent = current.parent
            if parent == current:
                break
            current = parent

    return CarveSettings()


def _resolve_disk_path_and_language(
    store: Store, file_id: str | None
) -> tuple[Path | None, str | None]:
    """Return the absolute disk path and language for a file in the store."""
    if isinstance(store, ProjectStore):
        if file_id is None or store.project_path is None:
            return None, None
        root_name = store.root.node_id if store.root else ""
        rel_path = file_id[len(root_name) + 1 :] if file_id.startswith(root_name + "/") else file_id
        disk_path = Path(store.project_path) / rel_path
        language = store.file_languages.get(file_id)
        return disk_path, language

    # TreeStore
    if store.file_path is None:
        return None, None
    return Path(store.file_path), store.language or None


def _run_formatter(file_path: Path, language: str, settings: CarveSettings) -> bool:
    """Run the configured formatter for *language* on *file_path*.

    Returns ``True`` if the formatter ran successfully.
    """
    template = settings.formatters.get(language)
    if not template:
        return False
    cmd = template.replace("{file}", str(file_path))
    try:
        result = subprocess.run(
            cmd,
            shell=True,
            capture_output=True,
            timeout=30,  # noqa: S602
        )
    except (subprocess.TimeoutExpired, OSError) as exc:
        log.warning("Formatter failed for %s: %s", file_path, exc)
        return False
    if result.returncode != 0:
        log.warning(
            "Formatter exited %d for %s: %s",
            result.returncode,
            file_path,
            result.stderr.decode(errors="replace")[:200],
        )
        return False
    return True


def _collect_metadata(root: TreeNode) -> dict[str, NodeMetadata]:
    """Collect metadata from all nodes in a tree, keyed by node_id."""
    result: dict[str, NodeMetadata] = {}
    for node in root.walk():
        result[node.node_id] = node.metadata
    return result


def _restore_metadata(root: TreeNode, saved: dict[str, NodeMetadata]) -> None:
    """Restore saved metadata to nodes with matching node_ids."""
    for node in root.walk():
        if node.node_id in saved:
            node.metadata = saved[node.node_id]


def _reparse_file(store: Store, file_id: str | None, source: str, language: str) -> None:
    """Reparse *source* and replace all children/offsets in the store tree.

    Preserves node metadata (prompt, model, version, history) from old
    nodes by matching node_ids after the reparse.
    """
    from carve.parser import _rewrite_node_ids

    parser = _get_parser()
    new_store = parser.to_tree_store(source, language)
    if new_store.root is None:
        return

    if isinstance(store, ProjectStore) and file_id is not None:
        file_node = store.get_node(file_id)
        if file_node is None:
            return
        # Save metadata from old children before replacing
        saved_meta = _collect_metadata(file_node)
        old_root_id = new_store.root.node_id
        _rewrite_node_ids(new_store.root, old_root_id, file_id)
        file_node.children = new_store.root.children
        file_node.source = source
        file_node.start_byte = new_store.root.start_byte
        file_node.end_byte = new_store.root.end_byte
        file_node.start_point = new_store.root.start_point
        file_node.end_point = new_store.root.end_point
        _restore_metadata(file_node, saved_meta)
    else:
        # TreeStore — replace root entirely but keep all metadata
        if store.root is None or new_store.root is None:
            return
        saved_meta = _collect_metadata(store.root)
        old_root_id = new_store.root.node_id
        _rewrite_node_ids(new_store.root, old_root_id, store.root.node_id)
        store.root = new_store.root
        _restore_metadata(store.root, saved_meta)


def _format_and_resync(store: Store, file_id: str | None) -> None:
    """Run the configured formatter on the disk file, then resync the store."""
    disk_path, language = _resolve_disk_path_and_language(store, file_id)
    if disk_path is None or language is None:
        return
    settings = _get_settings(store)
    if not _run_formatter(disk_path, language, settings):
        return
    # Re-read formatted source and update store
    formatted_source = disk_path.read_text(encoding="utf-8")
    _write_source(store, formatted_source, file_id)
    _reparse_file(store, file_id, formatted_source, language)


# =============================================================================
# Sidecar (.crv) helpers
# =============================================================================


def _node_id_to_file_relative(store: Store, node_id: str) -> str:
    """Convert a full node ID to a file-relative ID.

    TreeStore: strips root prefix (``module::User::foo`` → ``User::foo``).
    ProjectStore: strips file prefix (``proj/app.py::User::foo`` → ``User::foo``).
    Returns ``""`` if the node IS the root/file node.
    """
    if isinstance(store, ProjectStore):
        file_id = store.get_file_for_node(node_id)
        if file_id is None or file_id == node_id:
            return ""
        # Strip "file_id::" prefix
        prefix = file_id + "::"
        if node_id.startswith(prefix):
            return node_id[len(prefix) :]
        return node_id
    # TreeStore — strip root prefix
    if store.root is None:
        return node_id
    root_id = store.root.node_id
    if root_id == node_id:
        return ""
    prefix = root_id + "::"
    if node_id.startswith(prefix):
        return node_id[len(prefix) :]
    return node_id


def _resolve_sidecar_path(store: Store, file_id: str | None) -> Path | None:
    """Return the ``.crv`` sidecar path for a file in the store."""
    disk_path, _ = _resolve_disk_path_and_language(store, file_id)
    if disk_path is None:
        return None
    return disk_path.with_name(disk_path.name + ".crv")


_sidecar_suppressed: bool = False


def _write_sidecar_entry(
    store: Store,
    file_id: str | None,
    operation: str,
    node_id: str,
    *,
    model: str | None = None,
    prompt: str | None = None,
    notes: str | None = None,
) -> None:
    """Append one entry to the sidecar JSON array file.

    Never raises — sidecar writes must not break mutations.
    """
    if _sidecar_suppressed:
        return

    settings = _get_settings(store)
    if not settings.sidecars:
        return

    crv_path = _resolve_sidecar_path(store, file_id)
    if crv_path is None:
        return

    from datetime import UTC, datetime

    entry = {
        "operation": operation,
        "node_id": _node_id_to_file_relative(store, node_id),
        "timestamp": datetime.now(UTC).isoformat(),
        "model": model,
        "prompt": prompt,
        "notes": notes,
    }
    try:
        import json as _json

        entries = _read_sidecar(crv_path) if crv_path.exists() else []
        entries.append(entry)
        crv_path.parent.mkdir(parents=True, exist_ok=True)
        crv_path.write_text(_json.dumps(entries, indent=2) + "\n", encoding="utf-8")
    except OSError as exc:
        log.warning("Failed to write sidecar %s: %s", crv_path, exc)


def _read_sidecar(crv_path: Path) -> list[dict[str, Any]]:
    """Read a JSON array sidecar file.

    Returns an empty list on any parse or IO error (graceful degradation).
    """
    import json as _json

    try:
        data = _json.loads(crv_path.read_text(encoding="utf-8"))
        if isinstance(data, list):
            return data
        return []
    except (OSError, ValueError, _json.JSONDecodeError):
        return []


def _restore_metadata_from_sidecar(store: Store) -> None:
    """Restore node metadata from sidecar files after import."""
    if isinstance(store, ProjectStore):
        file_ids: list[str] = list(store.file_sources.keys())
    elif store.file_path is not None:
        file_ids = [None]  # type: ignore[list-item]
    else:
        return

    for file_id in file_ids:
        crv_path = _resolve_sidecar_path(store, file_id)
        if crv_path is None or not crv_path.exists():
            continue

        entries = _read_sidecar(crv_path)
        if not entries:
            continue

        # Build latest state per relative node_id (last entry wins)
        latest: dict[str, dict[str, Any]] = {}
        history_map: dict[str, list[dict[str, Any]]] = {}
        version_map: dict[str, int] = {}
        for entry in entries:
            rel_id = entry.get("node_id", "")
            if rel_id not in history_map:
                history_map[rel_id] = []
                version_map[rel_id] = 0
            # Push previous latest to history
            if rel_id in latest:
                prev = latest[rel_id]
                history_map[rel_id].append(
                    {
                        "version": version_map[rel_id],
                        "prompt": prev.get("prompt"),
                        "model": prev.get("model"),
                        "timestamp": prev.get("timestamp"),
                    }
                )
            version_map[rel_id] = version_map[rel_id] + 1
            latest[rel_id] = entry

        # Apply to nodes
        for rel_id, entry in latest.items():
            if not rel_id:
                continue
            # Reconstruct full node_id
            if isinstance(store, ProjectStore) and file_id is not None:
                full_id = f"{file_id}::{rel_id}"
            elif store.root is not None:
                full_id = f"{store.root.node_id}::{rel_id}"
            else:
                continue

            node = store.get_node(full_id)
            if node is None:
                continue

            node.metadata.prompt = entry.get("prompt")
            node.metadata.model = entry.get("model")
            node.metadata.version = version_map[rel_id]
            node.metadata.history = history_map.get(rel_id, [])


def _resolve_language(store: Store, node_id: str) -> str | None:
    """Get the language for a node."""
    if isinstance(store, ProjectStore):
        return store.get_language_for_node(node_id)
    return store.language or None


def _get_file_root(store: Store, node_id: str) -> TreeNode | None:
    """Get the root node to scope offset updates to.

    For TreeStore: returns store.root (whole tree).
    For ProjectStore: returns the file node containing node_id.
    """
    if isinstance(store, ProjectStore):
        file_id = store.get_file_for_node(node_id)
        if file_id:
            return store.get_node(file_id)
        return None
    return store.root


def _find_parent_and_index(store: Store, node_id: str) -> tuple[TreeNode | None, int]:
    """Find the parent of a node and the index within parent's children.

    Returns:
        Tuple of (parent_node, child_index) or (None, -1) if not found
    """
    if store.root is None:
        return None, -1

    # If node_id is the root, it has no parent
    if store.root.node_id == node_id:
        return None, -1

    def search(parent: TreeNode) -> tuple[TreeNode | None, int]:
        for idx, child in enumerate(parent.children):
            if child.node_id == node_id:
                return parent, idx
            # Search recursively
            result = search(child)
            if result[0] is not None:
                return result
        return None, -1

    return search(store.root)


def validate_snippet(
    snippet: str,
    language: str,
) -> dict[str, Any]:
    """Parse snippet and check for syntax errors.

    Args:
        snippet: Code snippet to validate
        language: Programming language (e.g., 'python', 'javascript')

    Returns:
        Dict with:
        - valid: bool - whether the snippet is syntactically valid
        - errors: list - list of parse errors
        - node_type: str - the type of the root node in the parsed snippet
    """
    from carve.parser import LanguageNotSupportedError

    try:
        parser = _get_parser()
        root = parser.parse(snippet, language)

        errors: list[dict[str, Any]] = []

        # Check for syntax errors by looking for ERROR nodes
        def collect_errors(node: Any) -> None:
            if node.type == "ERROR" or node.is_missing:
                error_info: dict[str, Any] = {
                    "type": "syntax_error" if node.type == "ERROR" else "missing_node",
                    "start_point": (node.start_point[0], node.start_point[1]),
                    "end_point": (node.end_point[0], node.end_point[1]),
                    "text": snippet[node.start_byte : node.end_byte][:50],
                }
                errors.append(error_info)
            for child in node.children:
                collect_errors(child)

        collect_errors(root)

        # Get the main node type (skip module/program wrapper for most cases)
        node_type = root.type
        if root.child_count == 1:
            # If there's a single child, that's likely the real content
            first_child = root.children[0]
            if first_child.type not in ("ERROR",):
                node_type = first_child.type

        return {
            "valid": len(errors) == 0,
            "errors": errors,
            "node_type": node_type,
        }

    except LanguageNotSupportedError as e:
        return {
            "valid": False,
            "errors": [{"type": "unsupported_language", "message": str(e)}],
            "node_type": "",
        }
    except Exception as e:
        return {
            "valid": False,
            "errors": [{"type": "parse_error", "message": str(e)}],
            "node_type": "",
        }


def _validate_in_context(source: str, language: str) -> dict[str, Any]:
    """Validate a complete source file by parsing with tree-sitter.

    This is the contextual validation step of the draft/promote model.
    A snippet may be individually valid but break the file when spliced
    in (e.g. wrong indentation relative to siblings, unclosed blocks
    from a bad splice, or duplicate definitions).

    Args:
        source: The complete file source (with snippet already spliced in)
        language: Programming language

    Returns:
        Dict with ``valid`` (bool) and ``errors`` (list of error dicts)
    """
    parser = _get_parser()
    source_bytes = source.encode("utf-8")
    root = parser.parse(source_bytes, language)

    errors: list[dict[str, Any]] = []

    def collect_errors(node: Any) -> None:
        if node.type == "ERROR" or node.is_missing:
            errors.append(
                {
                    "type": "context_error" if node.type == "ERROR" else "missing_node",
                    "start_point": (node.start_point[0], node.start_point[1]),
                    "end_point": (node.end_point[0], node.end_point[1]),
                    "text": source_bytes[node.start_byte : node.end_byte].decode(
                        "utf-8", errors="replace"
                    )[:100],
                }
            )
        for child in node.children:
            collect_errors(child)

    collect_errors(root)
    return {"valid": len(errors) == 0, "errors": errors}


def insert(
    store: Store,
    parent_id: str,
    position: str,
    snippet: str,
    language: str | None = None,
    *,
    file_name: str | None = None,
    prompt: str | None = None,
    model: str | None = None,
    notes: str | None = None,
) -> dict[str, Any]:
    """Insert a new node into the tree using draft/promote validation.

    When the parent is a directory node, creates a new file (file_name required).
    Otherwise, the snippet is first validated in isolation, then spliced into a
    *draft* copy of the file source.  Tree-sitter re-parses the draft; only if
    the full file is error-free is the mutation promoted to the live tree.

    Args:
        store: The tree store to modify (TreeStore or ProjectStore)
        parent_id: ID of the parent node where the new node will be inserted
        position: Where to insert - "start", "end", "before:sibling_id", "after:sibling_id"
        snippet: Source code for the new node
        language: Optional language override
        file_name: Required when parent is a directory node; name of the file to create

    Returns:
        Dict with:
        - node_id: str - the ID of the newly created node (on success)
        - error: str - error message (on failure)
        - errors: list - parse errors (on failure)
    """
    # Find the parent node
    parent = store.get_node(parent_id)
    if parent is None:
        return {"error": f"Parent node not found: {parent_id}"}

    # If parent is a directory node, delegate to file creation
    if parent.node_type == "directory":
        if not file_name:
            return {"error": "file_name is required when inserting into a directory node"}
        return create_file(
            store,
            parent_id,
            file_name,
            snippet,
            language,
            prompt=prompt,
            model=model,
            notes=notes,
        )

    # Resolve language
    lang = language or _resolve_language(store, parent_id)
    if isinstance(store, TreeStore):
        lang = lang or store.language
    if not lang:
        return {"error": "No language specified and store has no language set"}

    # --- Step 1: Strip carve metadata comments ---
    clean_snippet = _strip_carve_comments(snippet)

    # --- Step 2: Validate snippet in isolation (fast pre-check) ---
    validation = validate_snippet(clean_snippet, lang)
    if not validation["valid"]:
        return {"error": "Invalid snippet", "errors": validation["errors"]}

    # Parse the snippet to get a TreeNode
    parser = _get_parser()
    snippet_store = parser.to_tree_store(clean_snippet, lang)
    if snippet_store.root is None:
        return {"error": "Failed to parse snippet"}

    # Get the first meaningful child (skip the module/program wrapper)
    new_node: TreeNode | None = (
        snippet_store.root.children[0] if snippet_store.root.children else snippet_store.root
    )

    if new_node is None:
        return {"error": "Snippet produced no parseable nodes"}

    # Generate a proper node_id based on parent
    base_name = new_node.node_id.split("::")[-1] if "::" in new_node.node_id else new_node.node_id
    if base_name in ("module", "program", "document") and new_node.children:
        child = new_node.children[0]
        base_name = child.node_id.split("::")[-1] if "::" in child.node_id else child.node_id

    new_node_id = f"{parent_id}::{base_name}"
    old_prefix = new_node.node_id
    new_node.node_id = new_node_id

    # Rewrite children's IDs so they use the store-scoped prefix
    from carve.parser import _rewrite_node_ids

    _rewrite_node_ids(new_node, old_prefix, new_node_id)

    # Find the actual container for children (might be parent or a block child)
    actual_container = parent
    for child in parent.children:
        if child.node_type in ("block", "statement_block", "class_body"):
            actual_container = child
            break

    # --- Step 3: Resolve source and calculate insert position from UNMODIFIED tree ---
    source, file_id = _resolve_source(store, parent_id)

    insert_idx: int
    insert_byte: int
    at_line_start: bool = False
    if position == "start":
        insert_idx = 0
        if actual_container.children:
            child_start = actual_container.children[0].start_byte
            line_start = _byte_rfind(source, "\n", 0, child_start)
            insert_byte = line_start + 1 if line_start >= 0 else 0
            at_line_start = True
        else:
            insert_byte = actual_container.start_byte
    elif position == "end":
        insert_idx = len(actual_container.children)
        if actual_container.children:
            insert_byte = actual_container.children[-1].end_byte
        else:
            insert_byte = actual_container.end_byte
    elif position.startswith("before:"):
        sibling_id = position[7:]
        found = False
        for idx, child in enumerate(actual_container.children):
            child_name = child.node_id.split("::")[-1]
            if child.node_id == sibling_id or child_name == sibling_id:
                child_start = child.start_byte
                line_start = _byte_rfind(source, "\n", 0, child_start)
                insert_byte = line_start + 1 if line_start >= 0 else 0
                insert_idx = idx
                found = True
                at_line_start = True
                break
        if not found:
            return {"error": f"Sibling node not found: {sibling_id}"}
    elif position.startswith("after:"):
        sibling_id = position[6:]
        found = False
        for idx, child in enumerate(actual_container.children):
            child_name = child.node_id.split("::")[-1]
            if child.node_id == sibling_id or child_name == sibling_id:
                insert_byte = child.end_byte
                insert_idx = idx + 1
                found = True
                break
        if not found:
            return {"error": f"Sibling node not found: {sibling_id}"}
    else:
        return {
            "error": f"Invalid position: {position}. Use 'start', 'end', 'before:id', or 'after:id'"
        }

    # Determine target indentation from existing siblings
    target_col = 0
    if actual_container.children:
        for sibling in actual_container.children:
            target_col = sibling.start_point[1]
            break

    # Re-indent snippet for correct absolute indentation
    indented_snippet = _reindent_snippet(clean_snippet, target_col, indent_first_line=True)

    # Build the formatted snippet with proper blank-line separators.
    # Look at what comes after the insert point to decide
    # whether we need a leading/trailing blank line (matches move() logic).
    after = _byte_slice(source, insert_byte)

    if at_line_start:
        needs_trailing_blank = after and not after.startswith("\n")
        formatted_snippet = indented_snippet + ("\n\n" if needs_trailing_blank else "\n")
    else:
        needs_trailing_blank = after and not after.startswith("\n\n") and after != "\n"
        formatted_snippet = "\n\n" + indented_snippet + ("\n" if needs_trailing_blank else "")

    # --- Step 4: Create draft source (tree is still untouched) ---
    draft_source = _byte_splice(source, insert_byte, insert_byte, formatted_snippet)

    # --- Step 5: Validate draft in context ---
    context_result = _validate_in_context(draft_source, lang)
    if not context_result["valid"]:
        return {
            "error": "Snippet is valid alone but breaks file in context",
            "errors": context_result["errors"],
        }

    # --- Step 6: Promote – apply the mutation to the live tree ---
    actual_container.children.insert(insert_idx, new_node)
    _write_source(store, draft_source, file_id)

    # Update byte offsets so they match tree-sitter conventions:
    # start_byte at the first non-whitespace char (e.g. 'd' of 'def'),
    # end_byte just past the last character of the node.
    fmt_bytes = formatted_snippet.encode("utf-8")
    ind_bytes = indented_snippet.encode("utf-8")
    snippet_offset_in_fmt = fmt_bytes.index(ind_bytes)
    new_node.start_byte = insert_byte + snippet_offset_in_fmt + target_col
    new_node.end_byte = insert_byte + snippet_offset_in_fmt + len(ind_bytes)
    new_node.source = _byte_slice(draft_source, new_node.start_byte, new_node.end_byte)

    # Set start_point so update() can derive the correct column
    before_draft = _byte_slice(draft_source, 0, new_node.start_byte)
    new_node.start_point = (before_draft.count("\n"), target_col)

    # Update byte offsets for all subsequent nodes
    inserted_len = len(fmt_bytes)
    scope_root = _get_file_root(store, parent_id)
    if scope_root and inserted_len != 0:

        def shift_offsets(n: TreeNode, after: int, diff: int) -> None:
            if n is new_node:
                return  # Skip new node subtree (has snippet-relative offsets)
            if n.start_byte >= after:
                n.start_byte += diff
                n.end_byte += diff
            for child in n.children:
                shift_offsets(child, after, diff)

        shift_offsets(scope_root, insert_byte, inserted_len)

    _sync_file_to_disk(store, file_id)
    _format_and_resync(store, file_id)
    _write_sidecar_entry(
        store,
        file_id,
        "insert",
        new_node_id,
        model=model,
        prompt=prompt,
        notes=notes,
    )
    return {"node_id": new_node_id}


def update(
    store: Store,
    node_id: str,
    snippet: str,
    prompt: str | None = None,
    model: str | None = None,
    notes: str | None = None,
) -> dict[str, Any]:
    """Replace a node's implementation using draft/promote validation.

    The snippet is validated in isolation, then spliced into a *draft* copy
    of the file source.  Tree-sitter re-parses the draft; only if the full
    file is error-free is the mutation promoted to the live tree.  Metadata
    is recorded only after validation passes.

    For text files (no tree-sitter grammar), the node source is replaced
    directly without validation.

    Args:
        store: The tree store to modify (TreeStore or ProjectStore)
        node_id: ID of the node to update
        snippet: New source code for the node
        prompt: Optional prompt/intent that triggered this update
        model: Optional model name that generated the code

    Returns:
        Dict with:
        - success: bool - whether the update was successful
        - node_id: str - the (possibly updated) node ID
        - version: int - the new version number
        - error: str - error message (on failure)
    """
    # Find the node
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}"}

    # Resolve language
    lang = _resolve_language(store, node_id) or node.language
    if not lang:
        return {"error": "No language available for validation"}

    # --- Text file fast path: no grammar, skip all validation ---
    if lang == "text":
        node.metadata.record_update(prompt=prompt, model=model)
        node.source = snippet
        node.children = []
        node.end_byte = node.start_byte + _byte_len(snippet)
        _write_source(store, snippet, node_id if isinstance(store, ProjectStore) else None)
        file_id = node_id if isinstance(store, ProjectStore) else None
        _sync_file_to_disk(store, file_id)
        _write_sidecar_entry(
            store,
            file_id,
            "update",
            node_id,
            model=model,
            prompt=prompt,
            notes=notes,
        )
        return {
            "success": True,
            "node_id": node.node_id,
            "version": node.metadata.version,
        }

    # --- Step 1: Strip carve metadata comments ---
    clean_snippet = _strip_carve_comments(snippet)

    # --- Step 2: Validate snippet in isolation (fast pre-check) ---
    validation = validate_snippet(clean_snippet, lang)
    if not validation["valid"]:
        return {"error": "Invalid snippet", "errors": validation["errors"]}

    # Parse the new snippet
    parser = _get_parser()
    new_store = parser.to_tree_store(clean_snippet, lang)
    if new_store.root is None:
        return {"error": "Failed to parse snippet"}

    new_content: TreeNode | None = (
        new_store.root.children[0] if new_store.root.children else new_store.root
    )

    if new_content is None:
        return {"error": "Snippet produced no parseable nodes"}

    # --- Step 3: Build draft source ---
    old_start = node.start_byte
    old_end = node.end_byte
    source, file_id = _resolve_source(store, node_id)

    # Derive the node's column from the file source (more reliable than
    # start_point which may be stale after earlier mutations).
    # Work in bytes since old_start is a tree-sitter byte offset.
    source_bytes = source.encode("utf-8")
    last_nl = source_bytes.rfind(b"\n", 0, old_start)
    node_col = old_start - last_nl - 1 if last_nl >= 0 else old_start

    # Re-indent snippet to match the original node's column position.
    # The first line is NOT indented because source[:old_start] already
    # provides the leading whitespace for that line.
    adjusted_snippet = _reindent_snippet(clean_snippet, node_col, indent_first_line=False)

    draft_source = _byte_splice(source, old_start, old_end, adjusted_snippet)

    # --- Step 4: Validate draft in context ---
    context_result = _validate_in_context(draft_source, lang)
    if not context_result["valid"]:
        return {
            "error": "Snippet is valid alone but breaks file in context",
            "errors": context_result["errors"],
        }

    # --- Step 5: Promote - apply the mutation to the live tree ---
    node.metadata.record_update(prompt=prompt, model=model)
    node.source = adjusted_snippet
    node.children = new_content.children
    node.node_type = new_content.node_type

    # Rewrite children's IDs from snippet-local prefix to store-scoped prefix
    from carve.parser import _rewrite_node_ids

    _rewrite_node_ids(node, new_content.node_id, node.node_id)

    _write_source(store, draft_source, file_id)

    # Update byte offsets
    adjusted_byte_len = _byte_len(adjusted_snippet)
    size_diff = adjusted_byte_len - (old_end - old_start)
    node.end_byte = node.start_byte + adjusted_byte_len

    # Update positions of subsequent and ancestor nodes (scoped to file)
    def update_offsets(n: TreeNode, after_byte: int, diff: int) -> None:
        if n is node:
            return  # Skip the updated node itself (already handled)
        if n.start_byte > after_byte:
            # Subsequent sibling: shift both start and end
            n.start_byte += diff
            n.end_byte += diff
        elif n.end_byte > after_byte:
            # Ancestor: contains the updated range, expand end only
            n.end_byte += diff
        for child in n.children:
            update_offsets(child, after_byte, diff)

    scope_root = _get_file_root(store, node_id)
    if scope_root and size_diff != 0:
        update_offsets(scope_root, old_start, size_diff)

    _sync_file_to_disk(store, file_id)
    _format_and_resync(store, file_id)
    _write_sidecar_entry(
        store,
        file_id,
        "update",
        node_id,
        model=model,
        prompt=prompt,
        notes=notes,
    )
    return {
        "success": True,
        "node_id": node.node_id,
        "version": node.metadata.version,
    }


def delete(
    store: Store,
    node_id: str,
    notes: str | None = None,
) -> dict[str, Any]:
    """Remove a node from the tree.

    Args:
        store: The tree store to modify (TreeStore or ProjectStore)
        node_id: ID of the node to delete

    Returns:
        Dict with:
        - success: bool - whether the deletion was successful
        - warnings: list - warnings about other nodes that reference the deleted node
        - error: str - error message (on failure)
    """
    # Find the node
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}"}

    # Find the parent
    parent, idx = _find_parent_and_index(store, node_id)
    if parent is None:
        return {"error": f"Cannot delete root node or node not found: {node_id}"}

    # Check for references to this node (warnings only, don't block)
    warnings: list[str] = []
    _, referenced_by = _find_references(store, node)
    if referenced_by:
        warnings = [f"Node '{ref}' references the deleted node" for ref in referenced_by]

    # Remove from parent's children
    parent.children.pop(idx)

    # Handle file/directory node deletion in ProjectStore
    if isinstance(store, ProjectStore) and node.node_type in ("file", "directory"):
        # Collect all file IDs under this node to clean up
        def collect_file_ids(n: TreeNode) -> list[str]:
            ids: list[str] = []
            if n.node_type == "file" and n.node_id in store.file_sources:
                ids.append(n.node_id)
            for child in n.children:
                ids.extend(collect_file_ids(child))
            return ids

        file_ids = collect_file_ids(node)
        for fid in file_ids:
            _write_sidecar_entry(store, fid, "delete", node_id, notes=notes)
            store.file_sources.pop(fid, None)
            store.file_languages.pop(fid, None)

        # Delete from disk
        if store.project_path:
            root_name = store.root.node_id if store.root else ""
            rel = node_id[len(root_name) + 1 :] if node_id.startswith(root_name + "/") else node_id
            disk_path = Path(store.project_path) / rel
            if disk_path.is_dir():
                import shutil

                shutil.rmtree(disk_path, ignore_errors=True)
            elif disk_path.is_file():
                disk_path.unlink(missing_ok=True)
    else:
        # Code node deletion — splice source
        old_start = node.start_byte
        old_end = node.end_byte

        source, file_id = _resolve_source(store, node_id)

        # Also remove surrounding whitespace/newlines to keep source clean.
        # Use byte-level comparisons since old_start is a byte offset.
        source_bytes = source.encode("utf-8")
        while old_start > 0 and source_bytes[old_start - 1 : old_start] in (b" ", b"\t"):
            old_start -= 1
        if old_start > 0 and source_bytes[old_start - 1 : old_start] == b"\n":
            old_start -= 1

        new_source = _byte_splice(source, old_start, old_end, "")
        _write_source(store, new_source, file_id)

        # Update positions of subsequent and ancestor nodes (scoped to file)
        size_diff = old_end - old_start

        def update_offsets(n: TreeNode, after_byte: int, diff: int) -> None:
            if n.start_byte > after_byte:
                # Subsequent sibling: shift both start and end
                n.start_byte -= diff
                n.end_byte -= diff
            elif n.end_byte > after_byte:
                # Ancestor: contains the deleted range, shrink end only
                n.end_byte -= diff
            for child in n.children:
                update_offsets(child, after_byte, diff)

        scope_root = _get_file_root(store, node_id)
        if scope_root and size_diff != 0:
            update_offsets(scope_root, old_start, size_diff)

        _sync_file_to_disk(store, file_id)
        _format_and_resync(store, file_id)
        _write_sidecar_entry(store, file_id, "delete", node_id, notes=notes)

    result: dict[str, Any] = {"success": True}
    if warnings:
        result["warnings"] = warnings

    return result


# =============================================================================
# Batch Mutations
# =============================================================================


def _plan_update(
    store: Store,
    node_id: str,
    snippet: str,
    prompt: str | None = None,
    model: str | None = None,
    notes: str | None = None,
    op_index: int = 0,
) -> _PendingMutation | dict[str, Any]:
    """Plan an update mutation without applying it.

    Returns a _PendingMutation on success, or an error dict on failure.
    """
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}", "op_index": op_index}

    lang = _resolve_language(store, node_id) or node.language
    if not lang:
        return {"error": "No language available for validation", "op_index": op_index}

    clean_snippet = _strip_carve_comments(snippet)

    validation = validate_snippet(clean_snippet, lang)
    if not validation["valid"]:
        return {"error": "Invalid snippet", "errors": validation["errors"], "op_index": op_index}

    old_start = node.start_byte
    old_end = node.end_byte
    source, file_id = _resolve_source(store, node_id)

    source_bytes = source.encode("utf-8")
    last_nl = source_bytes.rfind(b"\n", 0, old_start)
    node_col = old_start - last_nl - 1 if last_nl >= 0 else old_start

    adjusted_snippet = _reindent_snippet(clean_snippet, node_col, indent_first_line=False)

    return _PendingMutation(
        op_type="update",
        file_id=file_id,
        start_byte=old_start,
        end_byte=old_end,
        replacement=adjusted_snippet,
        op_index=op_index,
        result_info={"node_id": node_id},
        sidecar_kwargs={
            "operation": "update",
            "node_id": node_id,
            "model": model,
            "prompt": prompt,
            "notes": notes,
        },
    )


def _plan_delete(
    store: Store,
    node_id: str,
    notes: str | None = None,
    op_index: int = 0,
) -> _PendingMutation | dict[str, Any]:
    """Plan a delete mutation without applying it.

    Returns a _PendingMutation on success, or an error dict on failure.
    """
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}", "op_index": op_index}

    parent, idx = _find_parent_and_index(store, node_id)
    if parent is None:
        return {
            "error": f"Cannot delete root node or node not found: {node_id}",
            "op_index": op_index,
        }

    # Reject file/directory nodes in batch — too complex
    if node.node_type in ("file", "directory"):
        return {
            "error": "Cannot delete file/directory nodes in batch",
            "op_index": op_index,
        }

    old_start = node.start_byte
    old_end = node.end_byte
    source, file_id = _resolve_source(store, node_id)

    # Expand to include surrounding whitespace (byte-level comparisons)
    source_bytes = source.encode("utf-8")
    while old_start > 0 and source_bytes[old_start - 1 : old_start] in (b" ", b"\t"):
        old_start -= 1
    if old_start > 0 and source_bytes[old_start - 1 : old_start] == b"\n":
        old_start -= 1

    return _PendingMutation(
        op_type="delete",
        file_id=file_id,
        start_byte=old_start,
        end_byte=old_end,
        replacement="",
        op_index=op_index,
        result_info={"node_id": node_id},
        sidecar_kwargs={
            "operation": "delete",
            "node_id": node_id,
            "notes": notes,
        },
    )


def _plan_insert(
    store: Store,
    parent_id: str,
    position: str,
    snippet: str,
    language: str | None = None,
    *,
    prompt: str | None = None,
    model: str | None = None,
    notes: str | None = None,
    op_index: int = 0,
) -> _PendingMutation | dict[str, Any]:
    """Plan an insert mutation without applying it.

    Returns a _PendingMutation on success, or an error dict on failure.
    """
    parent = store.get_node(parent_id)
    if parent is None:
        return {"error": f"Parent node not found: {parent_id}", "op_index": op_index}

    # Reject directory parent — file creation not supported in batch
    if parent.node_type == "directory":
        return {
            "error": "Cannot create files in batch (directory parent not supported)",
            "op_index": op_index,
        }

    lang = language or _resolve_language(store, parent_id)
    if isinstance(store, TreeStore):
        lang = lang or store.language
    if not lang:
        return {
            "error": "No language specified and store has no language set",
            "op_index": op_index,
        }

    clean_snippet = _strip_carve_comments(snippet)

    validation = validate_snippet(clean_snippet, lang)
    if not validation["valid"]:
        return {"error": "Invalid snippet", "errors": validation["errors"], "op_index": op_index}

    # Parse snippet to get node info for ID generation
    parser = _get_parser()
    snippet_store = parser.to_tree_store(clean_snippet, lang)
    if snippet_store.root is None:
        return {"error": "Failed to parse snippet", "op_index": op_index}

    new_node: TreeNode | None = (
        snippet_store.root.children[0] if snippet_store.root.children else snippet_store.root
    )
    if new_node is None:
        return {"error": "Snippet produced no parseable nodes", "op_index": op_index}

    base_name = new_node.node_id.split("::")[-1] if "::" in new_node.node_id else new_node.node_id
    if base_name in ("module", "program", "document") and new_node.children:
        child = new_node.children[0]
        base_name = child.node_id.split("::")[-1] if "::" in child.node_id else child.node_id

    new_node_id = f"{parent_id}::{base_name}"

    # Find actual container
    actual_container = parent
    for child in parent.children:
        if child.node_type in ("block", "statement_block", "class_body"):
            actual_container = child
            break

    source, file_id = _resolve_source(store, parent_id)

    insert_byte: int
    at_line_start: bool = False
    if position == "start":
        if actual_container.children:
            child_start = actual_container.children[0].start_byte
            line_start = _byte_rfind(source, "\n", 0, child_start)
            insert_byte = line_start + 1 if line_start >= 0 else 0
            at_line_start = True
        else:
            insert_byte = actual_container.start_byte
    elif position == "end":
        if actual_container.children:
            insert_byte = actual_container.children[-1].end_byte
        else:
            insert_byte = actual_container.end_byte
    elif position.startswith("before:"):
        sibling_id = position[7:]
        found = False
        for child in actual_container.children:
            child_name = child.node_id.split("::")[-1]
            if child.node_id == sibling_id or child_name == sibling_id:
                child_start = child.start_byte
                line_start = _byte_rfind(source, "\n", 0, child_start)
                insert_byte = line_start + 1 if line_start >= 0 else 0
                at_line_start = True
                found = True
                break
        if not found:
            return {"error": f"Sibling node not found: {sibling_id}", "op_index": op_index}
    elif position.startswith("after:"):
        sibling_id = position[6:]
        found = False
        for child in actual_container.children:
            child_name = child.node_id.split("::")[-1]
            if child.node_id == sibling_id or child_name == sibling_id:
                insert_byte = child.end_byte
                found = True
                break
        if not found:
            return {"error": f"Sibling node not found: {sibling_id}", "op_index": op_index}
    else:
        return {
            "error": f"Invalid position: {position}. "
            "Use 'start', 'end', 'before:id', or 'after:id'",
            "op_index": op_index,
        }

    # Determine target indentation
    target_col = 0
    if actual_container.children:
        for sibling in actual_container.children:
            target_col = sibling.start_point[1]
            break

    indented_snippet = _reindent_snippet(clean_snippet, target_col, indent_first_line=True)

    after = _byte_slice(source, insert_byte)

    if at_line_start:
        needs_trailing_blank = bool(after) and not after.startswith("\n")
        formatted_snippet = indented_snippet + ("\n\n" if needs_trailing_blank else "\n")
    else:
        needs_trailing_blank = bool(after) and not after.startswith("\n\n") and after != "\n"
        formatted_snippet = "\n\n" + indented_snippet + ("\n" if needs_trailing_blank else "")

    return _PendingMutation(
        op_type="insert",
        file_id=file_id,
        start_byte=insert_byte,
        end_byte=insert_byte,  # pure insert — no bytes replaced
        replacement=formatted_snippet,
        op_index=op_index,
        result_info={"node_id": new_node_id},
        sidecar_kwargs={
            "operation": "insert",
            "node_id": new_node_id,
            "model": model,
            "prompt": prompt,
            "notes": notes,
        },
    )


def batch(
    store: Store,
    operations: list[dict[str, Any]],
    notes: str | None = None,
) -> dict[str, Any]:
    """Apply multiple independent mutations in a single pass with one reparse per file.

    Supported operations: insert, update, delete. Move is not supported.

    Phases:
    1. Plan — validate each op independently, compute byte-level splices
    2. Conflict check — reject overlapping byte ranges within the same file
    3. Apply splices — bottom-up (highest offset first) to avoid invalidation
    4. Validate — _validate_in_context on each affected file
    5. Commit — write source, sync to disk, reparse, write sidecars
    6. Return per-op results

    Args:
        store: The tree store to modify (TreeStore or ProjectStore)
        operations: List of dicts with "tool" and "kwargs" keys
        notes: Optional notes applied to all ops without their own notes

    Returns:
        Dict with success flag and per-op results, or error on failure
    """
    if not operations:
        return {"success": True, "results": []}

    # --- Phase 1: Plan all operations ---
    planned: list[_PendingMutation] = []
    errors: list[dict[str, Any]] = []

    for i, op in enumerate(operations):
        tool = op.get("tool")
        kwargs = op.get("kwargs", {})

        if tool == "update":
            result = _plan_update(
                store,
                kwargs.get("node_id", ""),
                kwargs.get("snippet", ""),
                kwargs.get("prompt"),
                kwargs.get("model"),
                kwargs.get("notes", notes),
                op_index=i,
            )
        elif tool == "delete":
            result = _plan_delete(
                store,
                kwargs.get("node_id", ""),
                kwargs.get("notes", notes),
                op_index=i,
            )
        elif tool == "insert":
            result = _plan_insert(
                store,
                kwargs.get("parent_id", ""),
                kwargs.get("position", ""),
                kwargs.get("snippet", ""),
                kwargs.get("language"),
                prompt=kwargs.get("prompt"),
                model=kwargs.get("model"),
                notes=kwargs.get("notes", notes),
                op_index=i,
            )
        else:
            result = {
                "error": f"Unsupported batch tool: {tool}. Only insert, update, delete allowed.",
                "op_index": i,
            }

        if isinstance(result, dict):
            errors.append(result)
        else:
            planned.append(result)

    # All-or-nothing: reject entire batch if any op failed planning
    if errors:
        return {"success": False, "errors": errors}

    # --- Phase 2: Conflict check ---
    # Group by file_id, sort by start_byte, check for overlaps
    by_file: dict[str | None, list[_PendingMutation]] = {}
    for pm in planned:
        by_file.setdefault(pm.file_id, []).append(pm)

    for _file_id, file_ops in by_file.items():
        file_ops.sort(key=lambda m: m.start_byte)
        for j in range(len(file_ops) - 1):
            a = file_ops[j]
            b = file_ops[j + 1]
            # For pure inserts at the same byte, allow (they don't overlap content)
            if a.end_byte > b.start_byte:
                return {
                    "success": False,
                    "errors": [
                        {
                            "error": (
                                f"Overlapping byte ranges: op {a.op_index} "
                                f"[{a.start_byte}:{a.end_byte}] and op {b.op_index} "
                                f"[{b.start_byte}:{b.end_byte}]"
                            ),
                            "op_index": a.op_index,
                        }
                    ],
                }

    # --- Phase 3: Apply splices bottom-up per file ---
    modified_sources: dict[str | None, str] = {}
    for file_id, file_ops in by_file.items():
        if file_id is not None:
            source = store.file_sources[file_id]  # type: ignore[union-attr]
        elif isinstance(store, ProjectStore):
            continue  # shouldn't happen
        else:
            source = store.source

        # Sort by start_byte descending (bottom-up) to preserve offsets
        file_ops.sort(key=lambda m: m.start_byte, reverse=True)
        for pm in file_ops:
            source = _byte_splice(source, pm.start_byte, pm.end_byte, pm.replacement)

        modified_sources[file_id] = source

    # --- Phase 4: Validate modified sources in context ---
    for file_id, source in modified_sources.items():
        lang = _resolve_language_for_file(store, file_id)
        if not lang:
            continue
        context_result = _validate_in_context(source, lang)
        if not context_result["valid"]:
            return {
                "success": False,
                "errors": [
                    {
                        "error": "Batch produces invalid source after splicing",
                        "context_errors": context_result["errors"],
                        "file_id": file_id,
                    }
                ],
            }

    # --- Phase 5: Commit — write source, sync, reparse, sidecar ---
    for file_id, source in modified_sources.items():
        lang = _resolve_language_for_file(store, file_id)
        _write_source(store, source, file_id)
        if lang:
            _reparse_file(store, file_id, source, lang)
        _sync_file_to_disk(store, file_id)
        _format_and_resync(store, file_id)

    # Write sidecar entries
    for pm in planned:
        kw = pm.sidecar_kwargs
        if kw:
            _write_sidecar_entry(
                store,
                pm.file_id,
                kw.get("operation", pm.op_type),
                kw.get("node_id", ""),
                model=kw.get("model"),
                prompt=kw.get("prompt"),
                notes=kw.get("notes"),
            )

    # --- Phase 6: Build results ---
    results: list[dict[str, Any]] = [{}] * len(operations)
    for pm in planned:
        results[pm.op_index] = {"success": True, **pm.result_info}

    return {"success": True, "results": results}


def _resolve_language_for_file(store: Store, file_id: str | None) -> str | None:
    """Get the language for a file_id (helper for batch)."""
    if isinstance(store, ProjectStore):
        if file_id is None:
            return None
        return store.file_languages.get(file_id)
    return store.language or None


def move(
    store: Store,
    node_id: str,
    dest_parent_id: str,
    position: str,
    notes: str | None = None,
) -> dict[str, Any]:
    """Relocate a node within the tree using draft/promote validation.

    Atomically moves a node from its current location to a new parent
    (or reorders it within the same parent). The node's metadata is
    preserved. Source is re-indented for the destination context.

    Args:
        store: The tree store to modify (TreeStore or ProjectStore)
        node_id: ID of the node to move
        dest_parent_id: ID of the destination parent node
        position: Where to place the node - "start", "end", "before:id", "after:id"

    Returns:
        Dict with:
        - success: bool - whether the move was successful
        - node_id: str - the (possibly updated) node ID
        - error: str - error message (on failure)
    """
    from carve.parser import _rewrite_node_ids

    # --- Step 1: Validate inputs ---
    node = store.get_node(node_id)
    if node is None:
        return {"error": f"Node not found: {node_id}"}

    # Cannot move root
    old_parent, old_idx = _find_parent_and_index(store, node_id)
    if old_parent is None:
        return {"error": f"Cannot move root node: {node_id}"}

    dest_parent = store.get_node(dest_parent_id)
    if dest_parent is None:
        return {"error": f"Destination parent not found: {dest_parent_id}"}

    # Prevent cycle: dest must not be a descendant of node
    if node.find_descendant(dest_parent_id) is not None:
        return {"error": "Cannot move a node into its own subtree"}

    # Resolve language
    lang = _resolve_language(store, node_id) or node.language
    if not lang:
        return {"error": "No language available for validation"}

    # Resolve the actual container for the old parent (block/statement_block/class_body)
    old_container = old_parent
    for child in old_parent.children:
        if child.node_type in ("block", "statement_block", "class_body"):
            old_container = child
            break

    # Resolve the actual container for the dest parent
    dest_container = dest_parent
    for child in dest_parent.children:
        if child.node_type in ("block", "statement_block", "class_body"):
            dest_container = child
            break

    # Find the node's index in the old container
    old_container_idx = -1
    for i, child in enumerate(old_container.children):
        if child.node_id == node_id:
            old_container_idx = i
            break
    if old_container_idx == -1:
        return {"error": f"Node not found in parent's children: {node_id}"}

    # --- Step 2: Extract node source (full slice with leading whitespace) ---
    source, file_id = _resolve_source(store, node_id)

    remove_start = node.start_byte
    remove_end = node.end_byte

    # Expand removal to include leading whitespace back to previous newline.
    # Use byte-level comparisons since remove_start/remove_end are byte offsets.
    source_bytes = source.encode("utf-8")
    source_byte_len = len(source_bytes)
    while remove_start > 0 and source_bytes[remove_start - 1 : remove_start] in (b" ", b"\t"):
        remove_start -= 1
    if remove_start > 0 and source_bytes[remove_start - 1 : remove_start] == b"\n":
        remove_start -= 1

    # Also consume trailing blank lines at the removal site so we don't
    # leave gaps (e.g. 3 blank lines where the node used to be).
    while remove_end < source_byte_len and source_bytes[remove_end : remove_end + 1] in (
        b" ",
        b"\t",
    ):
        remove_end += 1
    if remove_end < source_byte_len and source_bytes[remove_end : remove_end + 1] == b"\n":
        remove_end += 1

    # If removing from the very start of the file, also strip any
    # remaining leading blank lines so we don't get a blank line at BOF.
    if remove_start == 0:
        while remove_end < source_byte_len and source_bytes[remove_end : remove_end + 1] == b"\n":
            remove_end += 1

    node_source_raw = node.source
    remove_len = remove_end - remove_start

    # --- Step 3: Build draft source ---
    # First, remove the node from source
    post_removal = _byte_splice(source, remove_start, remove_end, "")

    # Compute the new node ID
    base_name = node_id.split("::")[-1] if "::" in node_id else node_id
    new_node_id = (
        f"{dest_parent_id}::{base_name}"
        if dest_parent_id != node_id.rsplit("::", 1)[0]
        else node_id
    )

    # Resolve insert position in the post-removal source.
    # Adjust byte offsets for the removal.  Bytes inside the removed range
    # are clamped to remove_start (they no longer exist in post_removal).
    def adjusted_byte(byte: int) -> int:
        if byte >= remove_end:
            return byte - remove_len
        if byte > remove_start:
            return remove_start  # inside removed range → clamp
        return byte

    insert_idx: int
    insert_byte: int
    at_line_start: bool = False

    if position == "start":
        insert_idx = 0
        if dest_container.children:
            # Skip the node being moved if it's in dest_container
            first_child = None
            for ch in dest_container.children:
                if ch.node_id != node_id:
                    first_child = ch
                    break
            if first_child is not None:
                child_start = adjusted_byte(first_child.start_byte)
                line_start = _byte_rfind(post_removal, "\n", 0, child_start)
                insert_byte = line_start + 1 if line_start >= 0 else 0
                at_line_start = True
            else:
                insert_byte = adjusted_byte(dest_container.start_byte)
        else:
            insert_byte = adjusted_byte(dest_container.start_byte)
    elif position == "end":
        insert_idx = len(dest_container.children)
        # Adjust for the node we're about to remove from this container
        if dest_container is old_container:
            insert_idx -= 1
        if dest_container.children:
            last_child = None
            for ch in reversed(dest_container.children):
                if ch.node_id != node_id:
                    last_child = ch
                    break
            if last_child is not None:
                insert_byte = adjusted_byte(last_child.end_byte)
            else:
                insert_byte = adjusted_byte(dest_container.end_byte)
        else:
            insert_byte = adjusted_byte(dest_container.end_byte)
    elif position.startswith("before:"):
        sibling_id = position[7:]
        found = False
        for idx, child in enumerate(dest_container.children):
            if child.node_id == node_id:
                continue
            child_name = child.node_id.split("::")[-1]
            if child.node_id == sibling_id or child_name == sibling_id:
                child_start = adjusted_byte(child.start_byte)
                line_start = _byte_rfind(post_removal, "\n", 0, child_start)
                insert_byte = line_start + 1 if line_start >= 0 else 0
                insert_idx = idx
                found = True
                at_line_start = True
                break
        if not found:
            return {"error": f"Sibling node not found: {sibling_id}"}
    elif position.startswith("after:"):
        sibling_id = position[6:]
        found = False
        for idx, child in enumerate(dest_container.children):
            if child.node_id == node_id:
                continue
            child_name = child.node_id.split("::")[-1]
            if child.node_id == sibling_id or child_name == sibling_id:
                insert_byte = adjusted_byte(child.end_byte)
                insert_idx = idx + 1
                found = True
                break
        if not found:
            return {"error": f"Sibling node not found: {sibling_id}"}
    else:
        return {
            "error": f"Invalid position: {position}. Use 'start', 'end', 'before:id', or 'after:id'"
        }

    # Determine target indentation from dest siblings
    target_col = 0
    if dest_container.children:
        for sibling in dest_container.children:
            if sibling.node_id != node_id:
                target_col = sibling.start_point[1]
                break

    # If no siblings, infer from dest parent
    if target_col == 0 and dest_container.children == []:
        # For class bodies, indent from parent
        target_col = dest_parent.start_point[1] + 4  # default indent

    # Strip existing indentation from node source before re-indenting.
    # node.source starts at the keyword (e.g. "def") with no leading spaces,
    # but inner lines carry their absolute file indentation.  We use the
    # node's column position as the base indent to strip.
    base_indent = node.start_point[1]
    lines = node_source_raw.split("\n")
    dedented_lines: list[str] = []
    for line in lines:
        if not line.strip():
            dedented_lines.append("")
        elif len(line) - len(line.lstrip()) >= base_indent:
            dedented_lines.append(line[base_indent:])
        else:
            dedented_lines.append(line.lstrip())
    dedented_source = "\n".join(dedented_lines)

    # Re-indent for destination
    indented_snippet = _reindent_snippet(dedented_source, target_col, indent_first_line=True)

    # Build the formatted snippet with proper blank-line separators.
    # Look at what comes after the insert point to decide
    # whether we need a leading/trailing blank line.
    after = _byte_slice(post_removal, insert_byte)

    if at_line_start:
        # We're inserting at the start of a line.
        # Need a blank line after the snippet if the next content isn't blank.
        needs_trailing_blank = after and not after.startswith("\n")
        formatted_snippet = indented_snippet + ("\n\n" if needs_trailing_blank else "\n")
    else:
        # We're inserting after a node's end_byte (after: / end positions).
        # Need a blank line before (separator from previous node) and
        # a blank line after if the next line isn't already blank.
        needs_trailing_blank = after and not after.startswith("\n\n") and after != "\n"
        formatted_snippet = "\n\n" + indented_snippet + ("\n" if needs_trailing_blank else "")

    # Splice into post-removal source
    draft_source = _byte_splice(post_removal, insert_byte, insert_byte, formatted_snippet)

    # --- Step 4: Validate draft ---
    context_result = _validate_in_context(draft_source, lang)
    if not context_result["valid"]:
        return {
            "error": "Move would produce invalid source",
            "errors": context_result["errors"],
        }

    # --- Step 5: Promote — apply the mutation to the live tree ---
    # Remove node from old container
    old_container.children.pop(old_container_idx)

    # Rewrite node IDs if parent changed
    old_prefix = node.node_id
    if new_node_id != node.node_id:
        _rewrite_node_ids(node, old_prefix, new_node_id)

    # Adjust insert_idx for the removal if same container
    if dest_container is old_container and old_container_idx < insert_idx:
        insert_idx -= 1

    # Insert node into dest container
    dest_container.children.insert(insert_idx, node)

    # Write the draft source
    _write_source(store, draft_source, file_id)

    # Recalculate the moved node's byte offsets from the draft.
    # Find where `indented_snippet` actually starts inside `formatted_snippet`.
    fmt_bytes = formatted_snippet.encode("utf-8")
    ind_bytes = indented_snippet.encode("utf-8")
    snippet_offset_in_fmt = fmt_bytes.index(ind_bytes)
    node.start_byte = insert_byte + snippet_offset_in_fmt + target_col
    node.end_byte = insert_byte + snippet_offset_in_fmt + len(ind_bytes)
    node.source = _byte_slice(draft_source, node.start_byte, node.end_byte)

    # Set start_point
    before_draft = _byte_slice(draft_source, 0, node.start_byte)
    node.start_point = (before_draft.count("\n"), target_col)

    # Shift byte offsets for all other nodes.
    # Two passes: (1) adjust for the removal, (2) adjust for the insertion.
    # Nodes that *contain* the removal/insertion range only shift their end_byte.
    scope_root = _get_file_root(store, dest_parent_id)
    if scope_root:

        def shift_for_move(n: TreeNode) -> None:
            if n is node:
                return  # Skip the moved node subtree (already set)

            # --- Pass 1: removal shift (offsets are still in original coordinates) ---
            if n.start_byte > remove_start:
                # Node is entirely after the removal
                n.start_byte -= remove_len
                n.end_byte -= remove_len
            elif n.end_byte > remove_start:
                # Node contains the removal (start before, end after)
                n.end_byte -= remove_len

            # --- Pass 2: insertion shift (offsets now in post-removal coordinates) ---
            fmt_byte_len = _byte_len(formatted_snippet)
            if n.start_byte > insert_byte:
                # Node is entirely after the insertion point
                n.start_byte += fmt_byte_len
                n.end_byte += fmt_byte_len
            elif n.end_byte >= insert_byte:
                # Node contains the insertion point
                n.end_byte += fmt_byte_len

            for child in n.children:
                shift_for_move(child)

        shift_for_move(scope_root)

    _sync_file_to_disk(store, file_id)
    _format_and_resync(store, file_id)
    _write_sidecar_entry(store, file_id, "move", new_node_id, notes=notes)
    return {"success": True, "node_id": new_node_id}


# =============================================================================
# Project Tools
# =============================================================================


def create_file(
    store: Store,
    parent_id: str,
    file_name: str,
    source: str = "",
    language: str | None = None,
    prompt: str | None = None,
    model: str | None = None,
    notes: str | None = None,
) -> dict[str, Any]:
    """Create a new file in a ProjectStore directory.

    Parses the source with tree-sitter, builds a file node, and adds it
    to the parent directory's children.

    Args:
        store: Must be a ProjectStore
        parent_id: ID of a directory node to create the file in
        file_name: Name of the file (e.g. "utils.py")
        source: Initial source code for the file
        language: Optional language override; auto-detected from extension if omitted

    Returns:
        Dict with node_id and language on success, or error on failure
    """
    from carve.parser import LanguageNotSupportedError, _rewrite_node_ids, detect_language

    if not isinstance(store, ProjectStore):
        return {"error": "create_file requires a ProjectStore (load a directory first)"}

    parent = store.get_node(parent_id)
    if parent is None:
        return {"error": f"Parent node not found: {parent_id}"}
    if parent.node_type != "directory":
        return {"error": f"Parent must be a directory node, got: {parent.node_type}"}

    # Detect language from extension
    lang = language
    if not lang:
        try:
            lang = detect_language(file_name)
        except LanguageNotSupportedError as e:
            return {"error": str(e)}

    file_id = f"{parent_id}/{file_name}"

    # Check for duplicates
    if file_id in store.file_sources:
        return {"error": f"File already exists: {file_id}"}

    # Create intermediate directory nodes for nested paths (like mkdir -p)
    parts = PurePosixPath(file_name).parts
    attach_parent = parent
    if len(parts) > 1:
        for dir_part in parts[:-1]:
            dir_id = f"{attach_parent.node_id}/{dir_part}"
            existing = store.get_node(dir_id)
            if existing is not None:
                if existing.node_type != "directory":
                    return {"error": f"Path component is not a directory: {dir_id}"}
                attach_parent = existing
            else:
                dir_node = TreeNode(
                    node_id=dir_id,
                    node_type="directory",
                    source="",
                    children=[],
                    language="",
                )
                attach_parent.children.append(dir_node)
                attach_parent = dir_node

    # Parse the source to build a file node
    parser = _get_parser()
    file_store = parser.to_tree_store(source or "", lang)
    if file_store.root is None:
        return {"error": "Failed to parse source"}

    # Transform root (module/program) into a file node with project-scoped IDs
    file_node = file_store.root
    old_root_id = file_node.node_id
    _rewrite_node_ids(file_node, old_root_id, file_id)
    file_node.node_type = "file"

    # Add to the correct parent (may be an intermediate dir) and register in store
    attach_parent.children.append(file_node)
    store.file_sources[file_id] = source or ""
    store.file_languages[file_id] = lang

    _sync_file_to_disk(store, file_id)
    _format_and_resync(store, file_id)
    _write_sidecar_entry(
        store,
        file_id,
        "create_file",
        file_id,
        model=model,
        prompt=prompt,
        notes=notes,
    )
    return {"node_id": file_id, "language": lang}


def project_import(file_path: str | Path) -> Store:
    """Import a source file or directory into Carve.

    If file_path is a file, returns a TreeStore (single-file mode).
    If file_path is a directory, returns a ProjectStore (multi-file mode).

    Args:
        file_path: Path to a source file or project directory

    Returns:
        A TreeStore (for files) or ProjectStore (for directories)

    Raises:
        FileNotFoundError: If the path does not exist
    """
    path = Path(file_path)
    if not path.exists():
        raise FileNotFoundError(f"Path not found: {file_path}")

    parser = _get_parser()
    result: Store
    if path.is_dir():
        result = parser.to_project_store(path)
    else:
        result = parser.to_tree_store_from_file(path)
    _restore_metadata_from_sidecar(result)
    return result


def project_serialize(
    store: Store,
    output_path: str | Path | None = None,
) -> str | dict[str, str]:
    """Generate source code from the tree.

    For TreeStore (single-file): returns source string, optionally writes one file.
    For ProjectStore (multi-file): returns dict of {relative_path: source},
    optionally writes all files under output_path.

    Args:
        store: The store to serialize (TreeStore or ProjectStore)
        output_path: Optional path to write output to

    Returns:
        Source string (TreeStore) or dict of file paths to source (ProjectStore)
    """
    if isinstance(store, ProjectStore):
        # Default to the store's own project path so formatter always runs
        if output_path is None and store.project_path is not None:
            pp = Path(store.project_path)
            if pp.is_absolute():
                output_path = store.project_path
        result: dict[str, str] = {}
        for file_id, source in store.file_sources.items():
            # Strip the project root prefix to get relative path
            # file_id is like "myproject/app/models.py", strip "myproject/"
            root_name = store.root.node_id if store.root else ""
            if file_id.startswith(root_name + "/"):
                rel_path = file_id[len(root_name) + 1 :]
            else:
                rel_path = file_id
            result[rel_path] = source
            if output_path is not None:
                file_out = Path(output_path) / rel_path
                file_out.parent.mkdir(parents=True, exist_ok=True)
                file_out.write_text(source)
                # Run formatter on written file
                language = store.file_languages.get(file_id)
                if language:
                    settings = _get_settings(store)
                    if _run_formatter(file_out, language, settings):
                        formatted = file_out.read_text(encoding="utf-8")
                        result[rel_path] = formatted
        return result

    # Single-file: return source string
    # Default to the store's own file path so formatter always runs
    if output_path is None and store.file_path is not None:
        fp = Path(store.file_path)
        if fp.is_absolute():
            output_path = store.file_path
    source = store.source
    if output_path is not None:
        path = Path(output_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(source)
        # Run formatter on written file
        language = store.language or None
        if language:
            settings = _get_settings(store)
            if _run_formatter(path, language, settings):
                source = path.read_text(encoding="utf-8")
    return source
