"""MCP and HTTP server for Carve.

Provides both MCP (stdio) and HTTP (FastAPI) transport layers
for the Carve tree manipulation tools.
"""

from __future__ import annotations

import json
import os
import string
import subprocess
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from carve.models import Operation, ProjectStore, Store, TreeStore, UndoEntry
from carve.parser import TreeSitterParser
from carve.settings import load_settings
from carve.tools import (
    _find_parent_and_index,
    _format_and_resync,
    _reparse_file,
    _sync_file_to_disk,
    _write_source,
    ancestors,
    batch,
    configure_parser,
    delete,
    error_locate,
    find_references,
    insert,
    inspect,
    move,
    project_import,
    project_serialize,
    search,
    subtree,
    ts_query,
    update,
    validate_snippet,
)

# =============================================================================
# Agent Guide
# =============================================================================

AGENT_GUIDE = """\
# Carve Agent Guide

Carve is a tree-as-truth code manipulation server. The AST is the source of truth;
source files are regenerated from it after every mutation.

## Node IDs

Node IDs are hierarchical paths built from the AST structure:

- `module` — file root
- `module::ClassName` — a class
- `module::ClassName::method_name` — a method inside a class
- `module::function_name` — a top-level function
- `module::ClassName::method_name::expression_statement[0]` — a statement inside a method

In multi-file projects, file paths become the prefix:
`src/app.py::ClassName::method_name`

## Navigation Tools

| Tool | Purpose | When to use |
|------|---------|-------------|
| `subtree` | Render tree downward from a node | Get structural overview of a class or module |
| `search` | Find nodes by regex pattern and/or type | Locate functions, classes, or statements |
| `inspect` | Full source and metadata for one node | Read a specific node's implementation |
| `ancestors` | Path from a node up to root | Understand where a node sits in the hierarchy |
| `find_references` | Cross-file dependency tracing | Find what uses or depends on a node |
| `error_locate` | Map stack traces to node IDs | Debug errors by finding the relevant nodes |
| `ts_query` | Run tree-sitter S-expression queries | Advanced structural queries |

### Tips

- Use `search` with `node_type` to narrow results — but note that node types
  vary by language (see "Node Types by Language" below).
- Short patterns match every descendant — use specific patterns like
  `ClassName$` or combine with `node_type`.
- Use `subtree` with `depth=1` for a quick overview, increase depth as needed.

## Mutation Tools

| Tool | Purpose |
|------|---------|
| `update` | Replace a node's source code |
| `insert` | Add a new node (code or file) |
| `delete` | Remove a node |
| `move` | Relocate a node within or across files |
| `batch` | Apply multiple mutations in one pass (one reparse per file) |

### Target the Smallest Node

Always update the smallest node that covers your change. Carve nodes go down to
individual statements (`expression_statement`, `assert_statement`, `return_statement`, etc.).

```
# GOOD — surgical edit of one statement
update(node_id="...::test_foo::assert_statement[1]", snippet='assert result == "new"')

# BAD — replacing entire function to change one line
update(node_id="...::test_foo", snippet="def test_foo(): ...")
```

### Positions for `insert`

- `"start"` — first child of parent
- `"end"` — last child of parent
- `"before:<node_id>"` — before a specific sibling
- `"after:<node_id>"` — after a specific sibling

## Sidecar Metadata — IMPORTANT

Every mutation tool (`insert`, `update`, `delete`, `move`, `batch`) accepts optional
metadata parameters that are logged to `.crv` sidecar files:

| Parameter | Description |
|-----------|-------------|
| `prompt` | The user/agent prompt that triggered this change |
| `model` | The AI model that generated the code (e.g. `"claude-opus-4-6"`) |
| `notes` | Free-form notes explaining the rationale for the change |

**You MUST pass these parameters on every mutation call.** The sidecar log is the
audit trail for all changes — without metadata, the log entries are empty and useless.

Example:
```
update(
    node_id="module::Calculator::add",
    snippet="def add(self, a, b): return a + b",
    prompt="Simplify the add method",
    model="claude-opus-4-6",
    notes="Removed type annotations per user request"
)
```

## Batch Operations

Use `batch` when making multiple independent edits in the same file. It applies all
mutations in a single pass with one reparse, which is faster and produces a single
undo entry.

```
batch(operations=[
    {"tool": "update", "kwargs": {"node_id": "...", "snippet": "..."}},
    {"tool": "delete", "kwargs": {"node_id": "..."}},
    {"tool": "insert", "kwargs": {"parent_id": "...", "position": "end", "snippet": "..."}},
], notes="Refactored error handling")
```

## Undo / Redo

- `undo` — revert the last mutation
- `redo` — re-apply the last undone mutation
- `checkpoint(name)` — create a named savepoint
- `undo(to="checkpoint_name")` — revert all mutations back to a checkpoint
- `history` — view the undo/redo stacks

Create checkpoints before major multi-step changes so you can roll back cleanly.

## Creating Files

Use `insert` on a directory node with the `file_name` parameter to create new files:

```
insert(
    parent_id="src/app",
    position="end",
    snippet="from flask import Flask\\n\\napp = Flask(__name__)",
    file_name="app.py",
    prompt="Create Flask app entry point",
    model="claude-opus-4-6",
    notes="New file"
)
```

New file creation skips tree-sitter validation, so it works even for languages
with incomplete grammars.

## Serialize Before Testing

Carve holds mutations in-memory. Files on disk are only updated when you call
`project_serialize`. Always serialize before running tests, linters, or any
tool that reads from disk.

## File-Level Updates

For complete rewrites where every line changes, update the file node directly
rather than surgically editing dozens of individual nodes:

```
update(node_id="src/app.py", snippet="...entire new file content...")
```

Reserve surgical (statement-level) edits for incremental changes.

## Injected Languages (HTML)

Carve automatically sub-parses `<script>` and `<style>` contents inside HTML files
as JavaScript and CSS respectively. The injected nodes become regular children in
the tree with proper node IDs:

```
file.html::element[1]::element[2]::script::myFunction
file.html::element[1]::element[1]::style::#my-selector
```

Tips for searching injected nodes:
- **Search by name, not file path.** Use `pattern="myFunction"` rather than
  `pattern="myFile.html.*script"` with `node_type`. Name-based searches reliably
  find injected-language nodes.
- **Use `scope` to limit to a file** instead of encoding the file path into `pattern`.
- **Use `subtree` on `::script` or `::style` nodes** to explore their contents.
- Remember that injected JS uses JavaScript node types (e.g. `function_declaration`),
  not Python types (e.g. `function_definition`).

## Node Types by Language

Node type names come directly from each language's tree-sitter grammar. They are
NOT consistent across languages. Common types:

| Concept  | Python               | JavaScript/TS                              | Go                    |
|----------|----------------------|--------------------------------------------|-----------------------|
| Function | function_definition  | function_declaration                       | function_declaration  |
| Class    | class_definition     | class_declaration                          | type_declaration      |
| Variable | expression_statement | variable_declaration / lexical_declaration | short_var_declaration |
| Import   | import_statement     | import_statement                           | import_declaration    |

CSS (in HTML `<style>`): `rule_set`, `keyframes_statement`, `media_statement`

Use `subtree` to explore unfamiliar node types in your files.

## Workflow Summary

1. **Navigate** — use `search`, `subtree`, `inspect` to understand the code
2. **Checkpoint** — create a checkpoint before complex changes
3. **Mutate** — use `update`/`insert`/`delete` at the most specific node level,
   always passing `prompt`, `model`, and `notes`
4. **Serialize** — call `project_serialize` to write changes to disk
5. **Verify** — run tests, linters, or use `inspect` to confirm your changes
6. **Batch** — group related independent edits into a single `batch` call
"""

# =============================================================================
# CarveServer - Core server wrapper
# =============================================================================


class CarveServer:
    """Wraps a TreeStore or ProjectStore and exposes Carve tools.

    Provides a clean interface for all tree operations, suitable for
    wrapping with MCP or HTTP transports.
    """

    def __init__(
        self,
        file_path: str | Path | None = None,
        *,
        read_only: bool = False,
        no_create: bool = False,
        no_delete: bool = False,
        allow_custom_tools: bool = False,
    ) -> None:
        """Initialize the server.

        Args:
            file_path: Optional path to a source file or directory to load.
                        Resolution chain: --path > carvesettings.toml > CARVE_WORKSPACE > CWD.
            read_only: Block all mutations (insert, update, delete, move).
            no_create: Block creating new files (insert into directory).
            no_delete: Block deleting file/directory nodes.
            allow_custom_tools: Enable custom project tools from carvesettings.toml.
        """
        self._store: Store | None = None
        self.read_only = read_only
        self.no_create = no_create
        self.no_delete = no_delete
        self.allow_custom_tools = allow_custom_tools
        self._undo_entries: list[UndoEntry] = []
        self._redo_entries: list[UndoEntry] = []
        self._recording: bool = True
        self._max_undo: int = 100

        # Resolution chain: --path > carvesettings.toml > CARVE_WORKSPACE > CWD
        path: str | Path | None = file_path
        settings = load_settings(Path.cwd())
        if path is None and settings.workspace:
            path = settings.workspace
        if path is None:
            path = os.environ.get("CARVE_WORKSPACE")

        self._settings = settings

        # Create parser with user overrides from carvesettings.toml
        self._parser = TreeSitterParser(
            extension_overrides=settings.language_map or None,
            import_overrides=settings.import_map or None,
        )
        configure_parser(self._parser)

        if path is not None:
            p = Path(path)
            if p.exists():
                self._store = project_import(str(p))

    @property
    def store(self) -> Store | None:
        """Get the current store."""
        return self._store

    def load_source(self, source: str, language: str) -> dict[str, Any]:
        """Load source code directly into the store.

        Args:
            source: Source code string
            language: Programming language

        Returns:
            Dict with status information
        """
        try:
            self._store = self._parser.to_tree_store(source, language)
            return {
                "success": True,
                "language": self._store.language,
                "root_id": self._store.root.node_id if self._store.root else None,
            }
        except Exception as e:
            return {"error": str(e)}

    def status(self) -> dict[str, Any]:
        """Get current store status.

        Returns:
            Dict with store state information
        """
        if self._store is None:
            return {"loaded": False}

        result: dict[str, Any] = {
            "loaded": True,
            "root_id": self._store.root.node_id if self._store.root else None,
            "node_count": len(self._store.all_nodes()),
        }
        if isinstance(self._store, ProjectStore):
            result["project_path"] = self._store.project_path
            result["file_count"] = len(self._store.file_sources)
        else:
            result["file_path"] = self._store.file_path
            result["language"] = self._store.language
        return result

    def _require_store(self) -> Store:
        """Get the store, raising error if not loaded."""
        if self._store is None:
            raise ValueError(
                "No workspace loaded. Set --path, CARVE_WORKSPACE, or carvesettings.toml workspace."
            )
        return self._store

    def _check_writable(self) -> None:
        """Raise if mutations are blocked by --read-only."""
        if self.read_only:
            raise PermissionError("Server is in read-only mode. Mutations are disabled.")

    def _check_create(self) -> None:
        """Raise if file creation is blocked by --no-create or --read-only."""
        self._check_writable()
        if self.no_create:
            raise PermissionError("File creation is disabled (--no-create).")

    def _check_delete(self, node_id: str) -> None:
        """Raise if deleting file/directory nodes is blocked by --no-delete or --read-only."""
        self._check_writable()
        if self.no_delete:
            store = self._require_store()
            node = store.get_node(node_id)
            if node is not None and node.node_type in ("file", "directory"):
                raise PermissionError(
                    f"Deleting file/directory nodes is disabled (--no-delete): {node_id}"
                )

    # Undo/redo helpers

    def _push_undo(self, entry: UndoEntry) -> None:
        """Push an undo entry, clear redo stack, enforce max depth."""
        self._undo_entries.append(entry)
        self._redo_entries.clear()
        if len(self._undo_entries) > self._max_undo:
            self._undo_entries.pop(0)

    def _execute_operation(self, op: Operation) -> dict[str, Any]:
        """Execute an operation with recording and sidecar writes suppressed."""
        import carve.tools as _tools

        self._recording = False
        _tools._sidecar_suppressed = True
        try:
            if op.tool == "insert":
                return self.insert(
                    op.kwargs["parent_id"],
                    op.kwargs["position"],
                    op.kwargs["snippet"],
                    op.kwargs.get("language"),
                    file_name=op.kwargs.get("file_name"),
                )
            elif op.tool == "update":
                return self.update(op.kwargs["node_id"], op.kwargs["snippet"])
            elif op.tool == "delete":
                return self.delete(op.kwargs["node_id"])
            elif op.tool == "move":
                return self.move(
                    op.kwargs["node_id"],
                    op.kwargs["dest_parent_id"],
                    op.kwargs["position"],
                )
            elif op.tool == "_move_reverse":
                # Undo/redo of cross-file moves: delete from current location,
                # re-insert at original via delete+insert (avoids stale byte offsets).
                del_result = self.delete(op.kwargs["node_id"])
                if "error" in del_result:
                    return del_result
                return self.insert(
                    op.kwargs["dest_parent_id"],
                    op.kwargs["position"],
                    op.kwargs["snippet"],
                    op.kwargs.get("language"),
                )
            elif op.tool == "batch_restore":
                store = self._require_store()
                snapshots = op.kwargs["snapshots"]
                languages = op.kwargs["languages"]
                for file_id_key, source in snapshots.items():
                    file_id = None if file_id_key == "__single__" else file_id_key
                    _write_source(store, source, file_id)
                    lang = languages.get(file_id_key, "")
                    if lang:
                        _reparse_file(store, file_id, source, lang)
                    _sync_file_to_disk(store, file_id)
                    _format_and_resync(store, file_id)
                return {"success": True}
            return {"error": f"Unknown tool: {op.tool}"}
        finally:
            self._recording = True
            _tools._sidecar_suppressed = False

    def _capture_position(self, node_id: str) -> tuple[str, str]:
        """Capture a node's parent_id and position string for undo.

        Returns (parent_id, position) where position is "start" or "after:sibling_id".
        """
        store = self._require_store()
        parent, idx = _find_parent_and_index(store, node_id)
        if parent is None:
            return ("", "start")
        parent_id = parent.node_id
        if idx == 0:
            position = "start"
        else:
            prev_sibling = parent.children[idx - 1]
            position = f"after:{prev_sibling.node_id}"
        return parent_id, position

    # Navigation tools

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

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

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

    def search(
        self,
        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."""
        return search(self._require_store(), pattern, node_type, scope)

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

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

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

    # Mutation tools

    def insert(
        self,
        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.

        When parent is a directory, creates a file (file_name required).
        """
        store = self._require_store()
        node = store.get_node(parent_id)
        if node is not None and node.node_type == "directory":
            self._check_create()
        else:
            self._check_writable()
        result = insert(
            store,
            parent_id,
            position,
            snippet,
            language,
            file_name=file_name,
            prompt=prompt,
            model=model,
            notes=notes,
        )
        if self._recording and "error" not in result:
            new_id = result.get("node_id", "")
            self._push_undo(
                UndoEntry(
                    description=f"insert {new_id}",
                    undo_op=Operation(tool="delete", kwargs={"node_id": new_id}),
                    redo_op=Operation(
                        tool="insert",
                        kwargs={
                            "parent_id": parent_id,
                            "position": position,
                            "snippet": snippet,
                            "language": language,
                            "file_name": file_name,
                        },
                    ),
                )
            )
        return result

    def update(
        self,
        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."""
        self._check_writable()
        store = self._require_store()
        # Capture old source before mutation
        old_source: str | None = None
        if self._recording:
            node = store.get_node(node_id)
            if node is not None:
                old_source = node.source
        result = update(store, node_id, snippet, prompt, model, notes=notes)
        if self._recording and "error" not in result and old_source is not None:
            self._push_undo(
                UndoEntry(
                    description=f"update {node_id}",
                    undo_op=Operation(
                        tool="update",
                        kwargs={
                            "node_id": node_id,
                            "snippet": old_source,
                        },
                    ),
                    redo_op=Operation(
                        tool="update",
                        kwargs={
                            "node_id": node_id,
                            "snippet": snippet,
                        },
                    ),
                )
            )
        return result

    def delete(self, node_id: str, notes: str | None = None) -> dict[str, Any]:
        """Remove a node from the tree."""
        self._check_delete(node_id)
        store = self._require_store()
        # Capture state before deletion for undo
        old_source: str | None = None
        parent_id: str = ""
        position: str = "start"
        lang: str | None = None
        fname: str | None = None
        if self._recording:
            node = store.get_node(node_id)
            if node is not None:
                old_source = node.source
                parent_id, position = self._capture_position(node_id)
                if node.node_type == "file":
                    # For file nodes, capture language and file_name
                    if isinstance(store, ProjectStore):
                        lang = store.file_languages.get(node_id)
                    fname = node_id.split("/")[-1]
                    # Capture full file source for file nodes
                    if isinstance(store, ProjectStore):
                        old_source = store.file_sources.get(node_id, node.source)
        result = delete(store, node_id, notes=notes)
        if self._recording and "error" not in result and old_source is not None:
            insert_kwargs: dict[str, Any] = {
                "parent_id": parent_id,
                "position": position,
                "snippet": old_source,
            }
            if lang is not None:
                insert_kwargs["language"] = lang
            if fname is not None:
                insert_kwargs["file_name"] = fname
            self._push_undo(
                UndoEntry(
                    description=f"delete {node_id}",
                    undo_op=Operation(tool="insert", kwargs=insert_kwargs),
                    redo_op=Operation(tool="delete", kwargs={"node_id": node_id}),
                )
            )
        return result

    def _is_cross_file(self, node_id: str, dest_parent_id: str) -> bool:
        """Check if a move would cross file boundaries."""
        store = self._require_store()
        if not isinstance(store, ProjectStore):
            return False
        src_file = store.get_file_for_node(node_id)
        dest_file = store.get_file_for_node(dest_parent_id)
        return src_file != dest_file

    def move(
        self,
        node_id: str,
        dest_parent_id: str,
        position: str,
        notes: str | None = None,
    ) -> dict[str, Any]:
        """Move a node to a new location in the tree."""
        self._check_writable()
        store = self._require_store()
        # Capture original position and source before move
        original_parent_id: str = ""
        original_position: str = "start"
        old_source: str | None = None
        node = store.get_node(node_id)
        if node is not None:
            old_source = node.source
        if self._recording:
            original_parent_id, original_position = self._capture_position(node_id)

        # Cross-file moves use delete+insert to avoid stale byte offsets
        if self._is_cross_file(node_id, dest_parent_id) and old_source is not None:
            was_recording = self._recording
            self._recording = False
            try:
                del_result = self.delete(node_id, notes=notes)
                if "error" in del_result:
                    return del_result
                ins_result = self.insert(
                    dest_parent_id,
                    position,
                    old_source,
                )
                if "error" in ins_result:
                    return ins_result
                result: dict[str, Any] = {
                    "success": True,
                    "node_id": ins_result.get("node_id", node_id),
                }
            finally:
                self._recording = was_recording
        else:
            result = move(
                store,
                node_id,
                dest_parent_id,
                position,
                notes=notes,
            )

        if self._recording and "error" not in result and old_source is not None:
            new_node_id = result.get("node_id", node_id)
            self._push_undo(
                UndoEntry(
                    description=f"move {node_id} -> {dest_parent_id}",
                    undo_op=Operation(
                        tool="_move_reverse",
                        kwargs={
                            "node_id": new_node_id,
                            "dest_parent_id": original_parent_id,
                            "position": original_position,
                            "snippet": old_source,
                        },
                    ),
                    redo_op=Operation(
                        tool="_move_reverse",
                        kwargs={
                            "node_id": node_id,
                            "dest_parent_id": dest_parent_id,
                            "position": position,
                            "snippet": old_source,
                        },
                    ),
                )
            )
        return result

    # Batch mutations

    def batch(
        self,
        operations: list[dict[str, Any]],
        notes: str | None = None,
    ) -> dict[str, Any]:
        """Apply multiple independent mutations in a single pass.

        Args:
            operations: List of dicts with "tool" and "kwargs" keys.
                        Supported tools: insert, update, delete.
            notes: Optional notes for all ops without their own notes.
        """
        store = self._require_store()

        # Permission checks
        self._check_writable()
        for op in operations:
            tool = op.get("tool")
            kwargs = op.get("kwargs", {})
            if tool == "insert":
                parent = store.get_node(kwargs.get("parent_id", ""))
                if parent is not None and parent.node_type == "directory":
                    self._check_create()
            elif tool == "delete":
                self._check_delete(kwargs.get("node_id", ""))

        # Snapshot sources before mutation (for undo)
        before_snapshots: dict[str, str] = {}
        before_languages: dict[str, str] = {}
        if isinstance(store, ProjectStore):
            for file_id, src in store.file_sources.items():
                before_snapshots[file_id] = src
                lang = store.file_languages.get(file_id)
                if lang:
                    before_languages[file_id] = lang
        else:
            before_snapshots["__single__"] = store.source
            if store.language:
                before_languages["__single__"] = store.language

        result = batch(store, operations, notes=notes)

        if self._recording and result.get("success"):
            # Snapshot after
            after_snapshots: dict[str, str] = {}
            after_languages: dict[str, str] = {}
            if isinstance(store, ProjectStore):
                for file_id, src in store.file_sources.items():
                    after_snapshots[file_id] = src
                    lang = store.file_languages.get(file_id)
                    if lang:
                        after_languages[file_id] = lang
            else:
                after_snapshots["__single__"] = store.source
                if store.language:
                    after_languages["__single__"] = store.language

            self._push_undo(
                UndoEntry(
                    description=f"batch ({len(operations)} ops)",
                    undo_op=Operation(
                        tool="batch_restore",
                        kwargs={
                            "snapshots": before_snapshots,
                            "languages": before_languages,
                        },
                    ),
                    redo_op=Operation(
                        tool="batch_restore",
                        kwargs={
                            "snapshots": after_snapshots,
                            "languages": after_languages,
                        },
                    ),
                )
            )

        return result

    # Undo/redo tools

    def undo(self, to: str | None = None) -> dict[str, Any]:
        """Undo the last operation, or undo to a named checkpoint.

        Args:
            to: Optional checkpoint name to undo back to.
        """
        self._check_writable()
        if not self._undo_entries:
            return {"error": "Nothing to undo"}

        if to is not None:
            return self._undo_to_checkpoint(to)

        entry = self._undo_entries.pop()
        if entry.undo_op is None:
            # Checkpoint-only entry — just move to redo and report
            self._redo_entries.append(entry)
            return {
                "success": True,
                "description": f"checkpoint '{entry.checkpoint_name}'",
                "undone_count": 0,
            }
        try:
            result = self._execute_operation(entry.undo_op)
        except Exception as e:
            self._undo_entries.append(entry)
            return {"error": f"Undo failed: {e}"}
        if "error" in result:
            # Put entry back on failure
            self._undo_entries.append(entry)
            return {"error": f"Undo failed: {result['error']}"}
        # For undo of delete/move (re-insert), update redo_op with new node_id
        if (
            entry.undo_op.tool in ("insert", "_move_reverse")
            and "node_id" in result
            and entry.redo_op is not None
        ):
            entry.redo_op.kwargs["node_id"] = result["node_id"]
        self._redo_entries.append(entry)
        return {"success": True, "description": entry.description, "undone_count": 1}

    def _undo_to_checkpoint(self, name: str) -> dict[str, Any]:
        """Undo all operations back to a named checkpoint."""
        # Find the checkpoint
        checkpoint_idx = None
        for i in range(len(self._undo_entries) - 1, -1, -1):
            if self._undo_entries[i].checkpoint_name == name:
                checkpoint_idx = i
                break
        if checkpoint_idx is None:
            return {"error": f"Checkpoint not found: {name}"}

        undone = 0
        # Undo everything above the checkpoint (LIFO)
        while len(self._undo_entries) > checkpoint_idx + 1:
            entry = self._undo_entries.pop()
            if entry.undo_op is not None:
                try:
                    result = self._execute_operation(entry.undo_op)
                except Exception as e:
                    self._undo_entries.append(entry)
                    return {
                        "error": f"Undo failed at step {undone + 1}: {e}",
                        "undone_count": undone,
                    }
                if "error" in result:
                    self._undo_entries.append(entry)
                    return {
                        "error": f"Undo failed at step {undone + 1}: {result['error']}",
                        "undone_count": undone,
                    }
                if (
                    entry.undo_op.tool in ("insert", "_move_reverse")
                    and "node_id" in result
                    and entry.redo_op is not None
                ):
                    entry.redo_op.kwargs["node_id"] = result["node_id"]
            self._redo_entries.append(entry)
            if entry.undo_op is not None:
                undone += 1

        # Also move the checkpoint itself to redo
        cp_entry = self._undo_entries.pop()
        self._redo_entries.append(cp_entry)

        return {
            "success": True,
            "description": f"undo to checkpoint '{name}'",
            "undone_count": undone,
        }

    def redo(self) -> dict[str, Any]:
        """Redo the last undone operation."""
        self._check_writable()
        if not self._redo_entries:
            return {"error": "Nothing to redo"}
        entry = self._redo_entries.pop()
        if entry.redo_op is None:
            # Checkpoint-only entry
            self._undo_entries.append(entry)
            return {"success": True, "description": f"checkpoint '{entry.checkpoint_name}'"}
        try:
            result = self._execute_operation(entry.redo_op)
        except Exception as e:
            self._redo_entries.append(entry)
            return {"error": f"Redo failed: {e}"}
        if "error" in result:
            self._redo_entries.append(entry)
            return {"error": f"Redo failed: {result['error']}"}
        # For redo of insert/move, update undo_op with new node_id
        if (
            entry.redo_op.tool in ("insert", "move", "_move_reverse")
            and "node_id" in result
            and entry.undo_op is not None
        ):
            entry.undo_op.kwargs["node_id"] = result["node_id"]
        self._undo_entries.append(entry)
        return {"success": True, "description": entry.description}

    def checkpoint(self, name: str) -> dict[str, Any]:
        """Create a named checkpoint in the undo stack.

        Args:
            name: Unique name for this checkpoint.
        """
        # Check for duplicate names in both stacks
        for entry in self._undo_entries:
            if entry.checkpoint_name == name:
                return {"error": f"Checkpoint name already exists: {name}"}
        for entry in self._redo_entries:
            if entry.checkpoint_name == name:
                return {"error": f"Checkpoint name already exists: {name}"}
        entry = UndoEntry(
            description=f"checkpoint '{name}'",
            checkpoint_name=name,
        )
        self._undo_entries.append(entry)
        return {"success": True, "name": name, "position": len(self._undo_entries) - 1}

    def history(self) -> dict[str, Any]:
        """Return the undo and redo stacks for display."""

        def _entry_summary(e: UndoEntry) -> dict[str, Any]:
            summary: dict[str, Any] = {
                "description": e.description,
                "timestamp": e.timestamp.isoformat(),
            }
            if e.checkpoint_name is not None:
                summary["checkpoint"] = e.checkpoint_name
            if e.undo_op is not None:
                summary["operation"] = e.undo_op.tool
            return summary

        return {
            "undo": [_entry_summary(e) for e in self._undo_entries],
            "redo": [_entry_summary(e) for e in self._redo_entries],
        }

    def validate_snippet(self, snippet: str, language: str | None = None) -> dict[str, Any]:
        """Validate a code snippet without inserting it."""
        lang = language
        if not lang and self._store is not None:
            if isinstance(self._store, TreeStore):
                lang = self._store.language
            elif isinstance(self._store, ProjectStore) and self._store.file_languages:
                # Use the first file's language as default
                lang = next(iter(self._store.file_languages.values()), None)
        if not lang:
            return {"error": "No language specified and no file loaded"}
        return validate_snippet(snippet, lang)

    # Custom project tools

    @staticmethod
    def _parse_placeholders(template: str) -> list[str]:
        """Extract placeholder names from a command template."""
        return [
            field_name
            for _, field_name, _, _ in string.Formatter().parse(template)
            if field_name is not None
        ]

    def list_custom_tools(self) -> list[dict[str, Any]]:
        """List custom tools defined in carvesettings.toml.

        Returns:
            List of dicts with name, description, and parameters for each tool.
        """
        tools: list[dict[str, Any]] = []
        for name, template in self._settings.tools.items():
            params = self._parse_placeholders(template)
            tools.append(
                {
                    "name": name,
                    "description": template,
                    "parameters": params,
                }
            )
        return tools

    def run_custom_tool(self, name: str, arguments: dict[str, str]) -> dict[str, Any]:
        """Run a custom project tool.

        Args:
            name: Tool name (must exist in settings.tools).
            arguments: Placeholder values to substitute into the command template.

        Returns:
            Dict with success, stdout, stderr, returncode.
        """
        if not self.allow_custom_tools:
            raise PermissionError(
                "Custom tools are disabled. Start the server with --allow-custom-tools."
            )
        if name not in self._settings.tools:
            return {"error": f"Unknown custom tool: {name}"}

        template = self._settings.tools[name]
        expected = self._parse_placeholders(template)
        missing = [p for p in expected if p not in arguments]
        if missing:
            return {"error": f"Missing required arguments: {', '.join(missing)}"}

        cmd = template.format(**arguments)

        # Determine project root from store or cwd
        project_root: str | None = None
        if self._store is not None:
            if isinstance(self._store, ProjectStore):
                project_root = self._store.project_path
            elif self._store.file_path:
                project_root = str(Path(self._store.file_path).parent)
        if project_root is None:
            project_root = os.getcwd()

        try:
            proc = subprocess.run(
                cmd,
                shell=True,
                capture_output=True,
                text=True,
                timeout=60,
                cwd=project_root,
            )
            return {
                "success": proc.returncode == 0,
                "stdout": proc.stdout,
                "stderr": proc.stderr,
                "returncode": proc.returncode,
            }
        except subprocess.TimeoutExpired:
            return {
                "success": False,
                "stdout": "",
                "stderr": "Command timed out after 60 seconds",
                "returncode": -1,
            }

    def serialize(self, output_path: str | None = None) -> dict[str, Any]:
        """Serialize the current tree to source code.

        Args:
            output_path: Optional path to write output to

        Returns:
            Dict with source code and output info
        """
        if self._store is None:
            return {
                "error": (
                    "No workspace loaded."
                    " Set --path, CARVE_WORKSPACE, or carvesettings.toml workspace."
                )
            }

        try:
            output = project_serialize(self._store, output_path)
            if isinstance(output, dict):
                # Multi-file result
                result: dict[str, Any] = {
                    "success": True,
                    "files": {path: len(src) for path, src in output.items()},
                    "file_count": len(output),
                }
            else:
                # Single-file result
                result = {
                    "success": True,
                    "source": output,
                    "length": len(output),
                }
            if output_path:
                result["output_path"] = str(Path(output_path).absolute())
            return result
        except Exception as e:
            return {"error": str(e)}


# =============================================================================
# MCP Server
# =============================================================================


def create_mcp_server(carve_server: CarveServer | None = None) -> Any:
    """Create an MCP server exposing Carve tools.

    Args:
        carve_server: Optional CarveServer instance. Creates new one if not provided.

    Returns:
        Configured MCP Server instance
    """
    from mcp.server import Server
    from mcp.types import Resource, TextContent, TextResourceContents, Tool
    from pydantic import AnyUrl

    server = Server("carve")
    cs = carve_server or CarveServer()

    # Define tools
    @server.list_tools()  # type: ignore[no-untyped-call, untyped-decorator]
    async def list_tools() -> list[Tool]:
        """List available tools."""
        tools = [
            Tool(
                name="subtree",
                description="Render tree structure downward from a node",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "node_id": {"type": "string", "description": "Node ID to start from"},
                        "depth": {
                            "type": "integer",
                            "description": "Depth to traverse (default 2)",
                            "default": 2,
                        },
                        "include_source": {
                            "type": "boolean",
                            "description": "Include full source for all nodes",
                            "default": False,
                        },
                    },
                    "required": ["node_id"],
                },
            ),
            Tool(
                name="ancestors",
                description="Return the path from a node up to root",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "node_id": {"type": "string", "description": "Node ID to start from"},
                        "levels": {
                            "type": "integer",
                            "description": "Max levels to traverse (null = all)",
                        },
                    },
                    "required": ["node_id"],
                },
            ),
            Tool(
                name="inspect",
                description="Return full source and info for a single node",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "node_id": {"type": "string", "description": "Node ID to inspect"},
                        "include_metadata": {
                            "type": "boolean",
                            "description": "Include prompt/model metadata",
                            "default": False,
                        },
                    },
                    "required": ["node_id"],
                },
            ),
            Tool(
                name="search",
                description=(
                    "Find nodes by name pattern and/or type. "
                    "Node IDs are hierarchical (e.g. file::Class::method), so a short "
                    "pattern matches every descendant too. Always combine with "
                    "node_type (e.g. 'function_definition', 'class_definition') to "
                    "limit results, or use a specific pattern like 'ClassName$'."
                ),
                inputSchema={
                    "type": "object",
                    "properties": {
                        "pattern": {
                            "type": "string",
                            "description": (
                                "Regex pattern matched against the full node ID. "
                                "Use specific patterns (e.g. 'MyClass$') or combine "
                                "with node_type to avoid matching every descendant."
                            ),
                        },
                        "node_type": {
                            "type": "string",
                            "description": (
                                "Filter by node type (e.g. 'function_definition', "
                                "'class_definition', 'expression_statement')"
                            ),
                        },
                        "scope": {"type": "string", "description": "Limit to subtree"},
                    },
                },
            ),
            Tool(
                name="find_references",
                description="Find what uses/depends on a node",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "node_id": {
                            "type": "string",
                            "description": "Node ID to find references for",
                        },
                    },
                    "required": ["node_id"],
                },
            ),
            Tool(
                name="error_locate",
                description="Map a stack trace to Carve node IDs",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "traceback": {
                            "type": "string",
                            "description": "Full stack trace / error output string",
                        },
                    },
                    "required": ["traceback"],
                },
            ),
            Tool(
                name="ts_query",
                description="Run a tree-sitter S-expression query against source code",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "pattern": {
                            "type": "string",
                            "description": (
                                "Tree-sitter query "
                                "(e.g. '(function_definition name: (identifier) @fn)')"
                            ),
                        },
                        "scope": {
                            "type": "string",
                            "description": (
                                "Carve node_id to limit scope (file, directory, or code node)"
                            ),
                        },
                    },
                    "required": ["pattern"],
                },
            ),
            Tool(
                name="insert",
                description=(
                    "Insert a new node into the tree. "
                    "When parent is a directory, creates a file (file_name required)."
                ),
                inputSchema={
                    "type": "object",
                    "properties": {
                        "parent_id": {"type": "string", "description": "Parent node ID"},
                        "position": {
                            "type": "string",
                            "description": "Position: start, end, before:id, after:id",
                        },
                        "snippet": {"type": "string", "description": "Source code to insert"},
                        "language": {"type": "string", "description": "Language override"},
                        "file_name": {
                            "type": "string",
                            "description": (
                                "File name (e.g. 'utils.py'). "
                                "Required when parent is a directory node."
                            ),
                        },
                        "prompt": {"type": "string", "description": "Prompt that triggered insert"},
                        "model": {"type": "string", "description": "Model that generated code"},
                        "notes": {"type": "string", "description": "Agent notes for sidecar log"},
                    },
                    "required": ["parent_id", "position", "snippet"],
                },
            ),
            Tool(
                name="update",
                description="Replace a node's implementation",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "node_id": {"type": "string", "description": "Node ID to update"},
                        "snippet": {"type": "string", "description": "New source code"},
                        "prompt": {"type": "string", "description": "Prompt that triggered update"},
                        "model": {"type": "string", "description": "Model that generated code"},
                        "notes": {"type": "string", "description": "Agent notes for sidecar log"},
                    },
                    "required": ["node_id", "snippet"],
                },
            ),
            Tool(
                name="delete",
                description="Remove a node from the tree",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "node_id": {"type": "string", "description": "Node ID to delete"},
                        "notes": {"type": "string", "description": "Agent notes for sidecar log"},
                    },
                    "required": ["node_id"],
                },
            ),
            Tool(
                name="move",
                description="Move a node to a new location in the tree",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "node_id": {"type": "string", "description": "Node ID to move"},
                        "dest_parent_id": {
                            "type": "string",
                            "description": "Destination parent node ID",
                        },
                        "position": {
                            "type": "string",
                            "description": "Position: start, end, before:id, after:id",
                        },
                        "notes": {"type": "string", "description": "Agent notes for sidecar log"},
                    },
                    "required": ["node_id", "dest_parent_id", "position"],
                },
            ),
            Tool(
                name="batch",
                description=(
                    "Apply multiple independent mutations in a single pass "
                    "with one reparse per file"
                ),
                inputSchema={
                    "type": "object",
                    "properties": {
                        "operations": {
                            "type": "array",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "tool": {
                                        "type": "string",
                                        "enum": ["insert", "update", "delete"],
                                    },
                                    "kwargs": {"type": "object"},
                                },
                                "required": ["tool", "kwargs"],
                            },
                        },
                        "notes": {
                            "type": "string",
                            "description": "Agent notes for sidecar log",
                        },
                    },
                    "required": ["operations"],
                },
            ),
            Tool(
                name="project_serialize",
                description="Generate source code from the current tree",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "output_path": {
                            "type": "string",
                            "description": "Optional path to write source to",
                        },
                    },
                },
            ),
            Tool(
                name="undo",
                description="Undo the last mutation, or undo to a named checkpoint",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "to": {
                            "type": "string",
                            "description": "Checkpoint name to undo back to (optional)",
                        },
                    },
                },
            ),
            Tool(
                name="redo",
                description="Redo the last undone operation",
                inputSchema={
                    "type": "object",
                    "properties": {},
                },
            ),
            Tool(
                name="checkpoint",
                description="Create a named checkpoint in the undo stack",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "description": "Unique name for this checkpoint",
                        },
                    },
                    "required": ["name"],
                },
            ),
            Tool(
                name="history",
                description="Show the undo and redo stacks",
                inputSchema={
                    "type": "object",
                    "properties": {},
                },
            ),
        ]

        # Append custom project tools if enabled
        if cs.allow_custom_tools:
            for tool_def in cs.list_custom_tools():
                properties: dict[str, Any] = {}
                required: list[str] = []
                for param in tool_def["parameters"]:
                    properties[param] = {
                        "type": "string",
                        "description": f"Value for {{{param}}}",
                    }
                    required.append(param)
                schema: dict[str, Any] = {
                    "type": "object",
                    "properties": properties,
                }
                if required:
                    schema["required"] = required
                tools.append(
                    Tool(
                        name=f"run_{tool_def['name']}",
                        description=f"Run: {tool_def['description']}",
                        inputSchema=schema,
                    )
                )

        return tools

    @server.call_tool()  # type: ignore[untyped-decorator]
    async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
        """Handle tool calls."""
        try:
            result: Any
            if name == "subtree":
                result = cs.subtree(
                    arguments["node_id"],
                    arguments.get("depth", 2),
                    arguments.get("include_source", False),
                )
            elif name == "ancestors":
                result = cs.ancestors(
                    arguments["node_id"],
                    arguments.get("levels"),
                )
            elif name == "inspect":
                result = cs.inspect(
                    arguments["node_id"],
                    arguments.get("include_metadata", False),
                )
            elif name == "search":
                result = cs.search(
                    arguments.get("pattern"),
                    arguments.get("node_type"),
                    arguments.get("scope"),
                )
            elif name == "find_references":
                result = cs.find_references(arguments["node_id"])
            elif name == "error_locate":
                result = cs.error_locate(arguments["traceback"])
            elif name == "ts_query":
                result = cs.ts_query(
                    arguments["pattern"],
                    arguments.get("scope"),
                )
            elif name == "insert":
                result = cs.insert(
                    arguments["parent_id"],
                    arguments["position"],
                    arguments["snippet"],
                    arguments.get("language"),
                    file_name=arguments.get("file_name"),
                    prompt=arguments.get("prompt"),
                    model=arguments.get("model"),
                    notes=arguments.get("notes"),
                )
            elif name == "update":
                result = cs.update(
                    arguments["node_id"],
                    arguments["snippet"],
                    arguments.get("prompt"),
                    arguments.get("model"),
                    notes=arguments.get("notes"),
                )
            elif name == "delete":
                result = cs.delete(arguments["node_id"], notes=arguments.get("notes"))
            elif name == "move":
                result = cs.move(
                    arguments["node_id"],
                    arguments["dest_parent_id"],
                    arguments["position"],
                    notes=arguments.get("notes"),
                )
            elif name == "batch":
                result = cs.batch(arguments["operations"], notes=arguments.get("notes"))
            elif name == "project_serialize":
                result = cs.serialize(arguments.get("output_path"))
            elif name == "undo":
                result = cs.undo(arguments.get("to"))
            elif name == "redo":
                result = cs.redo()
            elif name == "checkpoint":
                result = cs.checkpoint(arguments["name"])
            elif name == "history":
                result = cs.history()
            elif name.startswith("run_") and cs.allow_custom_tools:
                tool_name = name[4:]  # strip "run_" prefix
                result = cs.run_custom_tool(tool_name, arguments)
            else:
                result = {"error": f"Unknown tool: {name}"}

            return [TextContent(type="text", text=json.dumps(result, indent=2))]

        except (ValueError, PermissionError) as e:
            return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
        except Exception as e:
            return [TextContent(type="text", text=json.dumps({"error": str(e)}))]

    @server.list_resources()  # type: ignore[no-untyped-call, untyped-decorator]
    async def list_resources() -> list[Resource]:
        """List available resources."""
        return [
            Resource(
                uri=AnyUrl("carve://guide"),
                name="Carve Agent Guide",
                description=(
                    "Comprehensive guide for AI agents on how to use Carve tools effectively"
                ),
                mimeType="text/markdown",
            )
        ]

    @server.read_resource()  # type: ignore[no-untyped-call, untyped-decorator]
    async def read_resource(uri: AnyUrl) -> list[TextResourceContents]:
        """Read a resource by URI."""
        if str(uri) == "carve://guide":
            return [
                TextResourceContents(
                    uri=uri,
                    text=AGENT_GUIDE,
                    mimeType="text/markdown",
                )
            ]
        raise ValueError(f"Unknown resource: {uri}")

    return server


async def run_mcp_server(carve_server: CarveServer | None = None) -> None:
    """Run the MCP server on stdio.

    Args:
        carve_server: Optional CarveServer instance
    """
    from mcp.server.stdio import stdio_server

    server = create_mcp_server(carve_server)
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream, server.create_initialization_options())


# =============================================================================
# HTTP API (FastAPI)
# =============================================================================


# Request models
class SubtreeRequest(BaseModel):
    """Request for subtree endpoint."""

    node_id: str
    depth: int = 2
    include_source: bool = False


class AncestorsRequest(BaseModel):
    """Request for ancestors endpoint."""

    node_id: str
    levels: int | None = None


class InspectRequest(BaseModel):
    """Request for inspect endpoint."""

    node_id: str
    include_metadata: bool = False


class SearchRequest(BaseModel):
    """Request for search endpoint."""

    pattern: str | None = None
    node_type: str | None = None
    scope: str | None = None


class FindReferencesRequest(BaseModel):
    """Request for find_references endpoint."""

    node_id: str


class ErrorLocateRequest(BaseModel):
    """Request for error_locate endpoint."""

    traceback: str


class TsQueryRequest(BaseModel):
    """Request for ts_query endpoint."""

    pattern: str
    scope: str | None = None


class InsertRequest(BaseModel):
    """Request for insert endpoint."""

    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


class UpdateRequest(BaseModel):
    """Request for update endpoint."""

    node_id: str
    snippet: str
    prompt: str | None = None
    model: str | None = None
    notes: str | None = None


class DeleteRequest(BaseModel):
    """Request for delete endpoint."""

    node_id: str
    notes: str | None = None


class MoveRequest(BaseModel):
    """Request for move endpoint."""

    node_id: str
    dest_parent_id: str
    position: str
    notes: str | None = None


class BatchRequest(BaseModel):
    """Request for batch endpoint."""

    operations: list[dict[str, Any]]
    notes: str | None = None


class ValidateRequest(BaseModel):
    """Request for validate endpoint."""

    snippet: str
    language: str | None = None


class SerializeRequest(BaseModel):
    """Request for project serialize endpoint."""

    output_path: str | None = None


class UndoRequest(BaseModel):
    """Request for undo endpoint."""

    to: str | None = None


class CheckpointRequest(BaseModel):
    """Request for checkpoint endpoint."""

    name: str


class RunToolRequest(BaseModel):
    """Request for custom tool endpoint."""

    arguments: dict[str, str] = {}


def create_http_app(carve_server: CarveServer | None = None) -> FastAPI:
    """Create a FastAPI app exposing Carve tools.

    Args:
        carve_server: Optional CarveServer instance. Creates new one if not provided.

    Returns:
        Configured FastAPI app
    """
    app = FastAPI(
        title="Carve",
        description="Text Editor for AI Agents",
        version="0.1.1",
    )

    # Store server in app state
    cs = carve_server or CarveServer()

    @app.exception_handler(PermissionError)
    async def permission_handler(_request: Request, exc: PermissionError) -> JSONResponse:
        return JSONResponse(status_code=403, content={"error": str(exc)})

    def get_server() -> CarveServer:
        return cs

    # Agent guide endpoint

    @app.get("/guide")
    def http_guide() -> dict[str, str]:
        """Agent guide for using Carve effectively."""
        return {"content": AGENT_GUIDE}

    # Navigation endpoints

    @app.post("/tree/subtree")
    def http_subtree(request: SubtreeRequest) -> dict[str, Any]:
        """Render tree structure downward from a node."""
        server = get_server()
        try:
            return server.subtree(request.node_id, request.depth, request.include_source)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/ancestors")
    def http_ancestors(request: AncestorsRequest) -> list[dict[str, Any]]:
        """Return the path from a node up to root."""
        server = get_server()
        try:
            return server.ancestors(request.node_id, request.levels)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/inspect")
    def http_inspect(request: InspectRequest) -> dict[str, Any]:
        """Return full source and info for a single node."""
        server = get_server()
        try:
            return server.inspect(request.node_id, request.include_metadata)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/search")
    def http_search(request: SearchRequest) -> list[dict[str, Any]]:
        """Find nodes by name pattern and/or type."""
        server = get_server()
        try:
            return server.search(request.pattern, request.node_type, request.scope)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/find_references")
    def http_find_references(request: FindReferencesRequest) -> dict[str, Any]:
        """Find what uses/depends on a node."""
        server = get_server()
        try:
            return server.find_references(request.node_id)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/error_locate")
    def http_error_locate(request: ErrorLocateRequest) -> dict[str, Any]:
        """Map a stack trace to Carve node IDs."""
        server = get_server()
        try:
            return server.error_locate(request.traceback)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/ts_query")
    def http_ts_query(request: TsQueryRequest) -> dict[str, Any]:
        """Run a tree-sitter S-expression query against source code."""
        server = get_server()
        try:
            return server.ts_query(request.pattern, request.scope)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    # Mutation endpoints

    @app.post("/tree/insert")
    def http_insert(request: InsertRequest) -> dict[str, Any]:
        """Insert a new node into the tree."""
        server = get_server()
        try:
            return server.insert(
                request.parent_id,
                request.position,
                request.snippet,
                request.language,
                file_name=request.file_name,
                prompt=request.prompt,
                model=request.model,
                notes=request.notes,
            )
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/update")
    def http_update(request: UpdateRequest) -> dict[str, Any]:
        """Replace a node's implementation."""
        server = get_server()
        try:
            return server.update(
                request.node_id,
                request.snippet,
                request.prompt,
                request.model,
                notes=request.notes,
            )
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/delete")
    def http_delete(request: DeleteRequest) -> dict[str, Any]:
        """Remove a node from the tree."""
        server = get_server()
        try:
            return server.delete(request.node_id, notes=request.notes)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/move")
    def http_move(request: MoveRequest) -> dict[str, Any]:
        """Move a node to a new location in the tree."""
        server = get_server()
        try:
            return server.move(
                request.node_id,
                request.dest_parent_id,
                request.position,
                notes=request.notes,
            )
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/batch")
    def http_batch(request: BatchRequest) -> dict[str, Any]:
        """Apply multiple independent mutations in a single pass."""
        server = get_server()
        try:
            return server.batch(request.operations, notes=request.notes)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/validate")
    def http_validate(request: ValidateRequest) -> dict[str, Any]:
        """Validate a code snippet without inserting it."""
        server = get_server()
        return server.validate_snippet(request.snippet, request.language)

    # Project endpoints

    @app.get("/project/status")
    def http_status() -> dict[str, Any]:
        """Get current store status."""
        server = get_server()
        return server.status()

    @app.post("/project/serialize")
    def http_serialize(request: SerializeRequest) -> dict[str, Any]:
        """Serialize the current tree to source code.

        Returns the reconstructed source. If output_path is provided,
        also writes the source to that file.
        """
        server = get_server()
        result = server.serialize(request.output_path)
        if "error" in result:
            raise HTTPException(status_code=400, detail=result["error"])
        return result

    # Undo/redo endpoints

    @app.post("/tree/undo")
    def http_undo(request: UndoRequest) -> dict[str, Any]:
        """Undo the last mutation or undo to a checkpoint."""
        server = get_server()
        try:
            return server.undo(request.to)
        except (ValueError, PermissionError) as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/redo")
    def http_redo() -> dict[str, Any]:
        """Redo the last undone operation."""
        server = get_server()
        try:
            return server.redo()
        except (ValueError, PermissionError) as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

    @app.post("/tree/checkpoint")
    def http_checkpoint(request: CheckpointRequest) -> dict[str, Any]:
        """Create a named checkpoint."""
        server = get_server()
        return server.checkpoint(request.name)

    @app.get("/tree/history")
    def http_history() -> dict[str, Any]:
        """Show the undo and redo stacks."""
        server = get_server()
        return server.history()

    # Custom project tools endpoint

    @app.post("/tools/{tool_name}")
    def http_run_tool(tool_name: str, request: RunToolRequest) -> dict[str, Any]:
        """Run a custom project tool."""
        server = get_server()
        try:
            result = server.run_custom_tool(tool_name, request.arguments)
            if "error" in result:
                raise HTTPException(status_code=400, detail=result["error"])
            return result
        except PermissionError as e:
            raise HTTPException(status_code=403, detail=str(e)) from e

    return app


def run_http_server(
    carve_server: CarveServer | None = None,
    host: str = "127.0.0.1",
    port: int = 8080,
) -> None:
    """Run the HTTP server.

    Args:
        carve_server: Optional CarveServer instance
        host: Host to bind to
        port: Port to listen on
    """
    import uvicorn

    app = create_http_app(carve_server)
    uvicorn.run(app, host=host, port=port)
