import argparse
import asyncio
import contextlib
import hashlib
import io
import json
import os
import pickle
import sys
import time
import uuid
from pathlib import Path
from typing import Any, Optional, Union

from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter
from tqdm import tqdm

from .api import LeannBuilder, LeannChat, LeannSearcher
from .embedding_server_manager import EmbeddingServerManager
from .interactive_utils import create_cli_session
from .registry import register_project_directory
from .settings import (
    resolve_anthropic_base_url,
    resolve_ollama_host,
    resolve_openai_api_key,
    resolve_openai_base_url,
)
from .sync import FileSynchronizer


def _normalize_path(path: str) -> str:
    """Return absolute path string for consistent keys."""
    if not path:
        return path
    return str(Path(path).resolve())


@contextlib.contextmanager
def suppress_cpp_output(suppress: bool = True):
    """Context manager to suppress C++ stdout/stderr output from FAISS/HNSW
    while preserving Python print() output.

    C++ native code writes directly to OS file descriptors (fd 1 / fd 2).
    Python print() goes through sys.stdout / sys.stderr, which are Python
    file objects.  We redirect the OS fds to /dev/null (silencing C++) but
    point sys.stdout / sys.stderr at copies of the *original* fds so that
    Python output still reaches the terminal.
    """
    if not suppress:
        yield
        return

    # 1. Duplicate the original OS file descriptors
    saved_stdout_fd = os.dup(1)
    saved_stderr_fd = os.dup(2)

    # 2. Build Python file objects that write to the saved (real) fds.
    #    closefd=False so closing these wrappers won't close the duped fds.
    py_stdout = io.TextIOWrapper(
        io.FileIO(saved_stdout_fd, mode="w", closefd=False), encoding=sys.stdout.encoding or "utf-8"
    )
    py_stderr = io.TextIOWrapper(
        io.FileIO(saved_stderr_fd, mode="w", closefd=False), encoding=sys.stderr.encoding or "utf-8"
    )

    old_sys_stdout = sys.stdout
    old_sys_stderr = sys.stderr

    try:
        # 3. Redirect OS-level fds to /dev/null → silences C++ output
        devnull = os.open(os.devnull, os.O_WRONLY)
        os.dup2(devnull, 1)
        os.dup2(devnull, 2)
        os.close(devnull)

        # 4. Point Python's sys.stdout/stderr at the real terminal
        sys.stdout = py_stdout
        sys.stderr = py_stderr

        yield
    finally:
        # 5. Restore everything
        #    Flush wrappers first (they still need the saved fds to be open)
        py_stdout.flush()
        py_stderr.flush()

        sys.stdout = old_sys_stdout
        sys.stderr = old_sys_stderr

        os.dup2(saved_stdout_fd, 1)
        os.dup2(saved_stderr_fd, 2)
        os.close(saved_stdout_fd)
        os.close(saved_stderr_fd)


def extract_pdf_text_with_pymupdf(file_path: str) -> str | None:
    """Extract text from PDF using PyMuPDF for better quality."""
    try:
        import fitz  # PyMuPDF

        doc = fitz.open(file_path)
        text = ""
        for page in doc:
            text += page.get_text()
        doc.close()
        return text
    except ImportError:
        # Fallback to default reader
        return None


def extract_pdf_text_with_pdfplumber(file_path: str) -> str | None:
    """Extract text from PDF using pdfplumber for better quality."""
    try:
        import pdfplumber

        text = ""
        with pdfplumber.open(file_path) as pdf:
            for page in pdf.pages:
                text += page.extract_text() or ""
        return text
    except ImportError:
        # Fallback to default reader
        return None


class LeannCLI:
    def __init__(self):
        # Always use project-local .leann directory (like .git)
        self.indexes_dir = Path.cwd() / ".leann" / "indexes"
        self.indexes_dir.mkdir(parents=True, exist_ok=True)

        # Default parser for documents
        self.node_parser = SentenceSplitter(
            chunk_size=256, chunk_overlap=128, separator=" ", paragraph_separator="\n\n"
        )

        # Code-optimized parser
        self.code_parser = SentenceSplitter(
            chunk_size=512,  # Larger chunks for code context
            chunk_overlap=50,  # Less overlap to preserve function boundaries
            separator="\n",  # Split by lines for code
            paragraph_separator="\n\n",  # Preserve logical code blocks
        )

    def get_index_path(self, index_name: str) -> str:
        index_dir = self.indexes_dir / index_name
        return str(index_dir / "documents.leann")

    def index_exists(self, index_name: str) -> bool:
        index_dir = self.indexes_dir / index_name
        meta_file = index_dir / "documents.leann.meta.json"
        return meta_file.exists()

    def create_parser(self) -> argparse.ArgumentParser:
        parser = argparse.ArgumentParser(
            prog="leann",
            description="The smallest vector index in the world. RAG Everything with LEANN!",
            formatter_class=argparse.RawDescriptionHelpFormatter,
            epilog="""
Examples:
  leann build my-docs --docs ./documents                                  # Build index from directory
  leann build my-code --docs ./src ./tests ./config                      # Build index from multiple directories
  leann build my-files --docs ./file1.py ./file2.txt ./docs/             # Build index from files and directories
  leann build my-mixed --docs ./readme.md ./src/ ./config.json           # Build index from mixed files/dirs
  leann build my-ppts --docs ./ --file-types .pptx,.pdf                  # Index only PowerPoint and PDF files
  leann search my-docs "query"                                           # Search in my-docs index
  leann ask my-docs "question"                                           # Ask my-docs index
  leann react my-docs "complex question"                                 # Use ReAct agent for multiturn retrieval
  leann list                                                             # List all stored indexes
  leann remove my-docs                                                   # Remove an index (local first, then global)
            """,
        )

        # Global verbosity options
        verbosity_group = parser.add_mutually_exclusive_group()
        verbosity_group.add_argument(
            "-v",
            "--verbose",
            action="store_true",
            help="Show detailed output including C++ backend logs from FAISS/HNSW",
        )
        verbosity_group.add_argument(
            "-q",
            "--quiet",
            action="store_true",
            help="Suppress all non-essential output (default behavior)",
        )

        subparsers = parser.add_subparsers(dest="command", help="Available commands")

        # Build command
        build_parser = subparsers.add_parser("build", help="Build document index")
        build_parser.add_argument(
            "index_name", nargs="?", help="Index name (default: current directory name)"
        )
        build_parser.add_argument(
            "--docs",
            type=str,
            nargs="+",
            default=["."],
            help="Documents directories and/or files (default: current directory)",
        )
        build_parser.add_argument(
            "--backend-name",
            type=str,
            default="hnsw",
            choices=["hnsw", "diskann", "ivf"],
            help="Backend to use (default: hnsw)",
        )
        build_parser.add_argument(
            "--embedding-model",
            type=str,
            default="facebook/contriever",
            help="Embedding model (default: facebook/contriever)",
        )
        build_parser.add_argument(
            "--embedding-mode",
            type=str,
            default="sentence-transformers",
            choices=["sentence-transformers", "openai", "mlx", "ollama"],
            help="Embedding backend mode (default: sentence-transformers)",
        )
        build_parser.add_argument(
            "--embedding-host",
            type=str,
            default=None,
            help="Override Ollama-compatible embedding host",
        )
        build_parser.add_argument(
            "--embedding-api-base",
            type=str,
            default=None,
            help="Base URL for OpenAI-compatible embedding services",
        )
        build_parser.add_argument(
            "--embedding-api-key",
            type=str,
            default=None,
            help="API key for embedding service (defaults to OPENAI_API_KEY)",
        )
        build_parser.add_argument(
            "--embedding-prompt-template",
            type=str,
            default=None,
            help="Prompt template to prepend to all texts for embedding (e.g., 'query: ' for search)",
        )
        build_parser.add_argument(
            "--query-prompt-template",
            type=str,
            default=None,
            help="Prompt template for queries (different from build template for task-specific models)",
        )
        build_parser.add_argument(
            "--force",
            "-f",
            action="store_true",
            help="Force full rebuild of existing index (without this, build does incremental update: add new files only)",
        )
        build_parser.add_argument(
            "--graph-degree", type=int, default=32, help="Graph degree (default: 32)"
        )
        build_parser.add_argument(
            "--complexity", type=int, default=64, help="Build complexity (default: 64)"
        )
        build_parser.add_argument("--num-threads", type=int, default=1)
        build_parser.add_argument(
            "--compact",
            action=argparse.BooleanOptionalAction,
            default=False,
            help="Use compact (CSR) graph storage. Compact indices are read-only and cannot be updated incrementally. Default: false (allows incremental updates while still pruning embeddings for 97%% compression).",
        )
        build_parser.add_argument(
            "--recompute",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable recomputation (default: true)",
        )
        build_parser.add_argument(
            "--file-types",
            type=str,
            help="Comma-separated list of file extensions to include (e.g., '.txt,.pdf,.pptx'). If not specified, uses default supported types.",
        )
        build_parser.add_argument(
            "--include-hidden",
            action=argparse.BooleanOptionalAction,
            default=False,
            help="Include hidden files and directories (paths starting with '.') during indexing (default: false)",
        )
        build_parser.add_argument(
            "--doc-chunk-size",
            type=int,
            default=256,
            help="Document chunk size in TOKENS (default: 256). Final chunks may be larger due to overlap. For 512 token models: recommended 350 tokens (350 + 128 overlap = 478 max)",
        )
        build_parser.add_argument(
            "--doc-chunk-overlap",
            type=int,
            default=128,
            help="Document chunk overlap in TOKENS (default: 128). Added to chunk size, not included in it",
        )
        build_parser.add_argument(
            "--code-chunk-size",
            type=int,
            default=512,
            help="Code chunk size in TOKENS (default: 512). Final chunks may be larger due to overlap. For 512 token models: recommended 400 tokens (400 + 50 overlap = 450 max)",
        )
        build_parser.add_argument(
            "--code-chunk-overlap",
            type=int,
            default=50,
            help="Code chunk overlap in TOKENS (default: 50). Added to chunk size, not included in it",
        )
        build_parser.add_argument(
            "--use-ast-chunking",
            action="store_true",
            help="Enable AST-aware chunking for code files (requires astchunk)",
        )
        build_parser.add_argument(
            "--ast-chunk-size",
            type=int,
            default=300,
            help="AST chunk size in CHARACTERS (non-whitespace) (default: 300). Final chunks may be larger due to overlap and expansion. For 512 token models: recommended 300 chars (300 + 64 overlap ~= 480 tokens)",
        )
        build_parser.add_argument(
            "--ast-chunk-overlap",
            type=int,
            default=64,
            help="AST chunk overlap in CHARACTERS (default: 64). Added to chunk size, not included in it. ~1.2 tokens per character for code",
        )
        build_parser.add_argument(
            "--ast-fallback-traditional",
            action="store_true",
            default=True,
            help="Fall back to traditional chunking if AST chunking fails (default: True)",
        )

        # Watch command
        watch_parser = subparsers.add_parser(
            "watch",
            help="Monitor source files and auto-rebuild index when changes are detected",
        )
        watch_parser.add_argument("index_name", help="Index name")
        watch_parser.add_argument(
            "--interval",
            type=int,
            default=30,
            help="Poll interval in seconds (default: 30)",
        )
        watch_parser.add_argument(
            "--once",
            action="store_true",
            help="Check once for changes and exit (do not loop)",
        )
        watch_parser.add_argument(
            "--dry-run",
            action="store_true",
            help="Report changes without rebuilding (original watch behavior)",
        )

        # Search command
        search_parser = subparsers.add_parser("search", help="Search documents")
        search_parser.add_argument("index_name", help="Index name")
        search_parser.add_argument("query", help="Search query")
        search_parser.add_argument(
            "--top-k", type=int, default=5, help="Number of results (default: 5)"
        )
        search_parser.add_argument(
            "--complexity", type=int, default=64, help="Search complexity (default: 64)"
        )
        search_parser.add_argument("--beam-width", type=int, default=1)
        search_parser.add_argument("--prune-ratio", type=float, default=0.0)
        search_parser.add_argument(
            "--recompute",
            dest="recompute_embeddings",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable/disable embedding recomputation (default: enabled). Should not do a `no-recompute` search in a `recompute` build.",
        )
        search_parser.add_argument(
            "--pruning-strategy",
            choices=["global", "local", "proportional"],
            default="global",
            help="Pruning strategy (default: global)",
        )
        search_parser.add_argument(
            "--json",
            action="store_true",
            help="Output results as JSON array (machine-readable)",
        )
        search_parser.add_argument(
            "--non-interactive",
            action="store_true",
            help="Non-interactive mode: automatically select index without prompting",
        )
        search_parser.add_argument(
            "--show-metadata",
            action="store_true",
            help="Display file paths and metadata in search results",
        )
        search_parser.add_argument(
            "--embedding-prompt-template",
            type=str,
            default=None,
            help="Prompt template to prepend to query for embedding (e.g., 'query: ' for search)",
        )
        search_parser.add_argument(
            "--daemon",
            dest="use_daemon",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable/disable cross-process daemon reuse for embedding server (default: enabled)",
        )
        search_parser.add_argument(
            "--daemon-ttl",
            type=int,
            default=900,
            help="Daemon idle TTL in seconds (default: 900, 0 = never expire)",
        )
        search_parser.add_argument(
            "--warmup",
            dest="enable_warmup",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable/disable warmup when starting embedding server (default: enabled)",
        )

        # Warmup command
        warmup_parser = subparsers.add_parser("warmup", help="Warm up an index embedding server")
        warmup_parser.add_argument("index_name", help="Index name")
        warmup_parser.add_argument(
            "--daemon",
            dest="use_daemon",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable/disable daemon mode for warmup (default: enabled)",
        )
        warmup_parser.add_argument(
            "--daemon-ttl",
            type=int,
            default=900,
            help="Daemon idle TTL in seconds (default: 900, 0 = never expire)",
        )
        warmup_parser.add_argument(
            "--warmup",
            dest="enable_warmup",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable/disable warmup request itself (default: enabled)",
        )

        # Daemon command
        daemon_parser = subparsers.add_parser("daemon", help="Manage embedding daemons")
        daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command")

        daemon_start = daemon_subparsers.add_parser("start", help="Start daemon for an index")
        daemon_start.add_argument("index_name", help="Index name")
        daemon_start.add_argument(
            "--daemon-ttl",
            type=int,
            default=900,
            help="Daemon idle TTL in seconds (default: 900, 0 = never expire)",
        )
        daemon_start.add_argument(
            "--warmup",
            dest="enable_warmup",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable/disable startup warmup (default: enabled)",
        )

        daemon_stop = daemon_subparsers.add_parser("stop", help="Stop daemon(s)")
        daemon_stop.add_argument("index_name", nargs="?", help="Index name to stop")
        daemon_stop.add_argument(
            "--all",
            action="store_true",
            help="Stop all LEANN embedding daemons",
        )

        daemon_status = daemon_subparsers.add_parser("status", help="Show daemon status")
        daemon_status.add_argument("index_name", nargs="?", help="Optional index name filter")

        # Ask command
        ask_parser = subparsers.add_parser("ask", help="Ask questions")
        ask_parser.add_argument("index_name", help="Index name")
        ask_parser.add_argument(
            "query",
            nargs="?",
            help="Question to ask (omit for prompt or when using --interactive)",
        )
        ask_parser.add_argument(
            "--llm",
            type=str,
            default="ollama",
            choices=["simulated", "ollama", "hf", "openai", "anthropic"],
            help="LLM provider (default: ollama)",
        )
        ask_parser.add_argument(
            "--model", type=str, default="qwen3:8b", help="Model name (default: qwen3:8b)"
        )
        ask_parser.add_argument(
            "--host",
            type=str,
            default=None,
            help="Override Ollama-compatible host (defaults to LEANN_OLLAMA_HOST/OLLAMA_HOST)",
        )
        ask_parser.add_argument(
            "--interactive", "-i", action="store_true", help="Interactive chat mode"
        )
        ask_parser.add_argument(
            "--top-k", type=int, default=20, help="Retrieval count (default: 20)"
        )
        ask_parser.add_argument("--complexity", type=int, default=32)
        ask_parser.add_argument("--beam-width", type=int, default=1)
        ask_parser.add_argument("--prune-ratio", type=float, default=0.0)
        ask_parser.add_argument(
            "--recompute",
            dest="recompute_embeddings",
            action=argparse.BooleanOptionalAction,
            default=True,
            help="Enable/disable embedding recomputation during ask (default: enabled)",
        )
        ask_parser.add_argument(
            "--pruning-strategy",
            choices=["global", "local", "proportional"],
            default="global",
        )
        ask_parser.add_argument(
            "--thinking-budget",
            type=str,
            choices=["low", "medium", "high"],
            default=None,
            help="Thinking budget for reasoning models (low/medium/high). Supported by GPT-Oss:20b and other reasoning models.",
        )
        ask_parser.add_argument(
            "--api-base",
            type=str,
            default=None,
            help="Base URL for OpenAI-compatible APIs (e.g., http://localhost:10000/v1)",
        )
        ask_parser.add_argument(
            "--api-key",
            type=str,
            default=None,
            help="API key for cloud LLM providers (OpenAI, Anthropic)",
        )

        # React command (multiturn retrieval agent)
        react_parser = subparsers.add_parser(
            "react", help="Use ReAct agent for multiturn retrieval and reasoning"
        )
        react_parser.add_argument("index_name", help="Index name")
        react_parser.add_argument("query", help="Question to research")
        react_parser.add_argument(
            "--llm",
            type=str,
            default="ollama",
            choices=["simulated", "ollama", "hf", "openai", "anthropic"],
            help="LLM provider (default: ollama)",
        )
        react_parser.add_argument(
            "--model", type=str, default="qwen3:8b", help="Model name (default: qwen3:8b)"
        )
        react_parser.add_argument(
            "--host",
            type=str,
            default=None,
            help="Override Ollama-compatible host (defaults to LEANN_OLLAMA_HOST/OLLAMA_HOST)",
        )
        react_parser.add_argument(
            "--top-k", type=int, default=5, help="Number of results per search (default: 5)"
        )
        react_parser.add_argument(
            "--max-iterations",
            type=int,
            default=5,
            help="Maximum number of search iterations (default: 5)",
        )
        react_parser.add_argument(
            "--api-base",
            type=str,
            default=None,
            help="Base URL for OpenAI-compatible APIs (e.g., http://localhost:10000/v1)",
        )
        react_parser.add_argument(
            "--api-key",
            type=str,
            default=None,
            help="API key for cloud LLM providers (OpenAI, Anthropic)",
        )

        # List command
        subparsers.add_parser("list", help="List all indexes")

        # Remove command
        remove_parser = subparsers.add_parser("remove", help="Remove an index")
        remove_parser.add_argument("index_name", help="Index name to remove")
        remove_parser.add_argument(
            "--force", "-f", action="store_true", help="Force removal without confirmation"
        )

        # Serve command (HTTP API server)
        serve_parser = subparsers.add_parser(
            "serve", help="Start HTTP API server for LEANN vector DB"
        )
        serve_parser.add_argument(
            "--host", type=str, default=None, help="Host to bind to (default: 0.0.0.0)"
        )
        serve_parser.add_argument(
            "--port", type=int, default=None, help="Port to bind to (default: 8000)"
        )

        return parser

    def register_project_dir(self):
        """Register current project directory in global registry"""
        register_project_directory()

    def _build_gitignore_parser(self, docs_dir: str):
        """Build gitignore parser using gitignore-parser library."""
        from gitignore_parser import parse_gitignore

        # Try to parse the root .gitignore
        gitignore_path = Path(docs_dir) / ".gitignore"

        if gitignore_path.exists():
            try:
                # gitignore-parser automatically handles all subdirectory .gitignore files!
                matches = parse_gitignore(str(gitignore_path))
                print(f"📋 Loaded .gitignore from {docs_dir} (includes all subdirectories)")
                return matches
            except Exception as e:
                print(f"Warning: Could not parse .gitignore: {e}")
        else:
            print("📋 No .gitignore found")

        # Fallback: basic pattern matching for essential files
        essential_patterns = {".git", ".DS_Store", "__pycache__", "node_modules", ".venv", "venv"}

        def basic_matches(file_path):
            path_parts = Path(file_path).parts
            return any(part in essential_patterns for part in path_parts)

        return basic_matches

    def _should_exclude_file(self, file_path: Path, gitignore_matches) -> bool:
        """Check if a file should be excluded using gitignore parser.

        Always match against absolute, posix-style paths for consistency with
        gitignore_parser expectations.
        """
        try:
            absolute_path = file_path.resolve()
        except Exception:
            absolute_path = Path(str(file_path))
        return gitignore_matches(absolute_path.as_posix())

    def _is_git_submodule(self, path: Path) -> bool:
        """Check if a path is a git submodule."""
        try:
            # Find the git repo root
            current_dir = Path.cwd()
            while current_dir != current_dir.parent:
                if (current_dir / ".git").exists():
                    gitmodules_path = current_dir / ".gitmodules"
                    if gitmodules_path.exists():
                        # Read .gitmodules to check if this path is a submodule
                        gitmodules_content = gitmodules_path.read_text()
                        # Convert path to relative to git root
                        try:
                            relative_path = path.resolve().relative_to(current_dir)
                            # Check if this path appears in .gitmodules
                            return f"path = {relative_path}" in gitmodules_content
                        except ValueError:
                            # Path is not under git root
                            return False
                    break
                current_dir = current_dir.parent
            return False
        except Exception:
            # If anything goes wrong, assume it's not a submodule
            return False

    def list_indexes(self):
        # Get all project directories with .leann
        global_registry = Path.home() / ".leann" / "projects.json"
        all_projects = []

        if global_registry.exists():
            try:
                import json

                with open(global_registry) as f:
                    all_projects = json.load(f)
            except Exception:
                pass

        # Filter to only existing directories with .leann
        valid_projects = []
        for project_dir in all_projects:
            project_path = Path(project_dir)
            if project_path.exists() and (project_path / ".leann" / "indexes").exists():
                valid_projects.append(project_path)

        # Add current project if it has .leann but not in registry
        current_path = Path.cwd()
        if (current_path / ".leann" / "indexes").exists() and current_path not in valid_projects:
            valid_projects.append(current_path)

        # Separate current and other projects
        other_projects = []

        for project_path in valid_projects:
            if project_path != current_path:
                other_projects.append(project_path)

        print("📚 LEANN Indexes")
        print("=" * 50)

        total_indexes = 0
        current_indexes_count = 0

        # Show current project first (most important)
        print("\n🏠 Current Project")
        print(f"   {current_path}")
        print("   " + "─" * 45)

        current_indexes = self._discover_indexes_in_project(
            current_path, exclude_dirs=other_projects
        )
        if current_indexes:
            for idx in current_indexes:
                total_indexes += 1
                current_indexes_count += 1
                type_icon = "📁" if idx["type"] == "cli" else "📄"
                print(f"   {current_indexes_count}. {type_icon} {idx['name']} {idx['status']}")
                if idx["size_mb"] > 0:
                    print(f"      📦 Size: {idx['size_mb']:.1f} MB")
        else:
            print("   📭 No indexes in current project")

        # Show other projects (reference information)
        if other_projects:
            print("\n\n🗂️  Other Projects")
            print("   " + "─" * 45)

            for project_path in other_projects:
                project_indexes = self._discover_indexes_in_project(project_path)
                if not project_indexes:
                    continue

                print(f"\n   📂 {project_path.name}")
                print(f"      {project_path}")

                for idx in project_indexes:
                    total_indexes += 1
                    type_icon = "📁" if idx["type"] == "cli" else "📄"
                    print(f"      • {type_icon} {idx['name']} {idx['status']}")
                    if idx["size_mb"] > 0:
                        print(f"        📦 {idx['size_mb']:.1f} MB")

        # Summary and usage info
        print("\n" + "=" * 50)
        if total_indexes == 0:
            print("💡 Get started:")
            print("   leann build my-docs --docs ./documents")
        else:
            # Count only projects that have at least one discoverable index
            projects_count = 0
            for p in valid_projects:
                if p == current_path:
                    discovered = self._discover_indexes_in_project(p, exclude_dirs=other_projects)
                else:
                    discovered = self._discover_indexes_in_project(p)
                if len(discovered) > 0:
                    projects_count += 1
            print(f"📊 Total: {total_indexes} indexes across {projects_count} projects")

            if current_indexes_count > 0:
                print("\n💫 Quick start (current project):")
                # Get first index from current project for example
                current_indexes_dir = current_path / ".leann" / "indexes"
                if current_indexes_dir.exists():
                    current_index_dirs = [d for d in current_indexes_dir.iterdir() if d.is_dir()]
                    if current_index_dirs:
                        example_name = current_index_dirs[0].name
                        print(f'   leann search {example_name} "your query"')
                        print(f"   leann ask {example_name} --interactive")
            else:
                print("\n💡 Create your first index:")
                print("   leann build my-docs --docs ./documents")

    def _discover_indexes_in_project(
        self, project_path: Path, exclude_dirs: Optional[list[Path]] = None
    ):
        """Discover all indexes in a project directory (both CLI and apps formats)

        exclude_dirs: when provided, skip any APP-format index files that are
        located under these directories. This prevents duplicates when the
        current project is a parent directory of other registered projects.
        """
        indexes = []
        exclude_dirs = exclude_dirs or []
        # normalize to resolved paths once for comparison
        try:
            exclude_dirs_resolved = [p.resolve() for p in exclude_dirs]
        except Exception:
            exclude_dirs_resolved = exclude_dirs

        # 1. CLI format: .leann/indexes/index_name/
        cli_indexes_dir = project_path / ".leann" / "indexes"
        if cli_indexes_dir.exists():
            for index_dir in cli_indexes_dir.iterdir():
                if index_dir.is_dir():
                    meta_file = index_dir / "documents.leann.meta.json"
                    status = "✅" if meta_file.exists() else "❌"

                    size_mb = 0
                    if meta_file.exists():
                        try:
                            size_mb = sum(
                                f.stat().st_size for f in index_dir.iterdir() if f.is_file()
                            ) / (1024 * 1024)
                        except (OSError, PermissionError):
                            pass

                    indexes.append(
                        {
                            "name": index_dir.name,
                            "type": "cli",
                            "status": status,
                            "size_mb": size_mb,
                            "path": index_dir,
                        }
                    )

        # 2. Apps format: *.leann.meta.json files anywhere in the project
        cli_indexes_dir = project_path / ".leann" / "indexes"
        for meta_file in project_path.rglob("*.leann.meta.json"):
            if meta_file.is_file():
                # Skip CLI-built indexes (which store meta under .leann/indexes/<name>/)
                try:
                    if cli_indexes_dir.exists() and cli_indexes_dir in meta_file.parents:
                        continue
                except Exception:
                    pass
                # Skip meta files that live under excluded directories
                try:
                    meta_parent_resolved = meta_file.parent.resolve()
                    if any(
                        meta_parent_resolved.is_relative_to(ex_dir)
                        for ex_dir in exclude_dirs_resolved
                    ):
                        continue
                except Exception:
                    # best effort; if resolve or comparison fails, do not exclude
                    pass
                # Use the parent directory name as the app index display name
                display_name = meta_file.parent.name
                # Extract file base used to store files
                file_base = meta_file.name.replace(".leann.meta.json", "")

                # Apps indexes are considered complete if the .leann.meta.json file exists
                status = "✅"

                # Calculate total size of all related files (use file base)
                size_mb = 0
                try:
                    index_dir = meta_file.parent
                    for related_file in index_dir.glob(f"{file_base}.leann*"):
                        size_mb += related_file.stat().st_size / (1024 * 1024)
                except (OSError, PermissionError):
                    pass

                indexes.append(
                    {
                        "name": display_name,
                        "type": "app",
                        "status": status,
                        "size_mb": size_mb,
                        "path": meta_file,
                    }
                )

        return indexes

    def remove_index(self, index_name: str, force: bool = False):
        """Safely remove an index - always show all matches for transparency"""

        # Always do a comprehensive search for safety
        print(f"🔍 Searching for all indexes named '{index_name}'...")
        all_matches = self._find_all_matching_indexes(index_name)

        if not all_matches:
            print(f"❌ Index '{index_name}' not found in any project.")
            return False

        if len(all_matches) == 1:
            return self._remove_single_match(all_matches[0], index_name, force)
        else:
            return self._remove_from_multiple_matches(all_matches, index_name, force)

    def _find_all_matching_indexes(self, index_name: str):
        """Find all indexes with the given name across all projects"""
        matches = []

        # Get all registered projects
        global_registry = Path.home() / ".leann" / "projects.json"
        all_projects = []

        if global_registry.exists():
            try:
                import json

                with open(global_registry) as f:
                    all_projects = json.load(f)
            except Exception:
                pass

        # Always include current project
        current_path = Path.cwd()
        if str(current_path) not in all_projects:
            all_projects.append(str(current_path))

        # Search across all projects
        for project_dir in all_projects:
            project_path = Path(project_dir)
            if not project_path.exists():
                continue

            # 1) CLI-format index under .leann/indexes/<name>
            index_dir = project_path / ".leann" / "indexes" / index_name
            if index_dir.exists():
                is_current = project_path == current_path
                matches.append(
                    {
                        "project_path": project_path,
                        "index_dir": index_dir,
                        "is_current": is_current,
                        "kind": "cli",
                    }
                )

            # 2) App-format indexes
            # We support two ways of addressing apps:
            #   a) by the file base (e.g., `pdf_documents`)
            #   b) by the parent directory name (e.g., `new_txt`)
            seen_app_meta = set()

            # 2a) by file base
            for meta_file in project_path.rglob(f"{index_name}.leann.meta.json"):
                if meta_file.is_file():
                    # Skip CLI-built indexes' meta under .leann/indexes
                    try:
                        cli_indexes_dir = project_path / ".leann" / "indexes"
                        if cli_indexes_dir.exists() and cli_indexes_dir in meta_file.parents:
                            continue
                    except Exception:
                        pass
                    is_current = project_path == current_path
                    key = (str(project_path), str(meta_file))
                    if key in seen_app_meta:
                        continue
                    seen_app_meta.add(key)
                    matches.append(
                        {
                            "project_path": project_path,
                            "files_dir": meta_file.parent,
                            "meta_file": meta_file,
                            "is_current": is_current,
                            "kind": "app",
                            "display_name": meta_file.parent.name,
                            "file_base": meta_file.name.replace(".leann.meta.json", ""),
                        }
                    )

            # 2b) by parent directory name
            for meta_file in project_path.rglob("*.leann.meta.json"):
                if meta_file.is_file() and meta_file.parent.name == index_name:
                    # Skip CLI-built indexes' meta under .leann/indexes
                    try:
                        cli_indexes_dir = project_path / ".leann" / "indexes"
                        if cli_indexes_dir.exists() and cli_indexes_dir in meta_file.parents:
                            continue
                    except Exception:
                        pass
                    is_current = project_path == current_path
                    key = (str(project_path), str(meta_file))
                    if key in seen_app_meta:
                        continue
                    seen_app_meta.add(key)
                    matches.append(
                        {
                            "project_path": project_path,
                            "files_dir": meta_file.parent,
                            "meta_file": meta_file,
                            "is_current": is_current,
                            "kind": "app",
                            "display_name": meta_file.parent.name,
                            "file_base": meta_file.name.replace(".leann.meta.json", ""),
                        }
                    )

        # Sort: current project first, then by project name
        matches.sort(key=lambda x: (not x["is_current"], x["project_path"].name))
        return matches

    def _remove_single_match(self, match, index_name: str, force: bool):
        """Handle removal when only one match is found"""
        project_path = match["project_path"]
        is_current = match["is_current"]
        kind = match.get("kind", "cli")

        if is_current:
            location_info = "current project"
            emoji = "🏠"
        else:
            location_info = f"other project '{project_path.name}'"
            emoji = "📂"

        print(f"✅ Found 1 index named '{index_name}':")
        print(f"   {emoji} Location: {location_info}")
        if kind == "cli":
            print(f"   📍 Path: {project_path / '.leann' / 'indexes' / index_name}")
        else:
            print(f"   📍 Meta: {match['meta_file']}")

        if not force:
            if not is_current:
                print("\n⚠️  CROSS-PROJECT REMOVAL!")
                print("   This will delete the index from another project.")

            response = input(f"   ❓ Confirm removal from {location_info}? (y/N): ").strip().lower()
            if response not in ["y", "yes"]:
                print("   ❌ Removal cancelled.")
                return False

        if kind == "cli":
            return self._delete_index_directory(
                match["index_dir"],
                index_name,
                project_path if not is_current else None,
                is_app=False,
            )
        else:
            return self._delete_index_directory(
                match["files_dir"],
                match.get("display_name", index_name),
                project_path if not is_current else None,
                is_app=True,
                meta_file=match.get("meta_file"),
                app_file_base=match.get("file_base"),
            )

    def _remove_from_multiple_matches(self, matches, index_name: str, force: bool):
        """Handle removal when multiple matches are found"""

        print(f"⚠️  Found {len(matches)} indexes named '{index_name}':")
        print("   " + "─" * 50)

        for i, match in enumerate(matches, 1):
            project_path = match["project_path"]
            is_current = match["is_current"]
            kind = match.get("kind", "cli")

            if is_current:
                print(f"   {i}. 🏠 Current project ({'CLI' if kind == 'cli' else 'APP'})")
            else:
                print(f"   {i}. 📂 {project_path.name} ({'CLI' if kind == 'cli' else 'APP'})")

            # Show path details
            if kind == "cli":
                print(f"      📍 {project_path / '.leann' / 'indexes' / index_name}")
            else:
                print(f"      📍 {match['meta_file']}")

            # Show size info
            try:
                if kind == "cli":
                    size_mb = sum(
                        f.stat().st_size for f in match["index_dir"].iterdir() if f.is_file()
                    ) / (1024 * 1024)
                else:
                    file_base = match.get("file_base")
                    size_mb = 0.0
                    if file_base:
                        size_mb = sum(
                            f.stat().st_size
                            for f in match["files_dir"].glob(f"{file_base}.leann*")
                            if f.is_file()
                        ) / (1024 * 1024)
                print(f"      📦 Size: {size_mb:.1f} MB")
            except (OSError, PermissionError):
                pass

        print("   " + "─" * 50)

        if force:
            print("   ❌ Multiple matches found, but --force specified.")
            print("   Please run without --force to choose which one to remove.")
            return False

        try:
            choice = input(
                f"   ❓ Which one to remove? (1-{len(matches)}, or 'c' to cancel): "
            ).strip()
            if choice.lower() == "c":
                print("   ❌ Removal cancelled.")
                return False

            choice_idx = int(choice) - 1
            if 0 <= choice_idx < len(matches):
                selected_match = matches[choice_idx]
                project_path = selected_match["project_path"]
                is_current = selected_match["is_current"]
                kind = selected_match.get("kind", "cli")

                location = "current project" if is_current else f"'{project_path.name}' project"
                print(f"   🎯 Selected: Remove from {location}")

                # Final confirmation for safety
                confirm = input(
                    f"   ❓ FINAL CONFIRMATION - Type '{index_name}' to proceed: "
                ).strip()
                if confirm != index_name:
                    print("   ❌ Confirmation failed. Removal cancelled.")
                    return False

                if kind == "cli":
                    return self._delete_index_directory(
                        selected_match["index_dir"],
                        index_name,
                        project_path if not is_current else None,
                        is_app=False,
                    )
                else:
                    return self._delete_index_directory(
                        selected_match["files_dir"],
                        selected_match.get("display_name", index_name),
                        project_path if not is_current else None,
                        is_app=True,
                        meta_file=selected_match.get("meta_file"),
                        app_file_base=selected_match.get("file_base"),
                    )
            else:
                print("   ❌ Invalid choice. Removal cancelled.")
                return False

        except (ValueError, KeyboardInterrupt):
            print("\n   ❌ Invalid input. Removal cancelled.")
            return False

    def _delete_index_directory(
        self,
        index_dir: Path,
        index_display_name: str,
        project_path: Optional[Path] = None,
        is_app: bool = False,
        meta_file: Optional[Path] = None,
        app_file_base: Optional[str] = None,
    ):
        """Delete a CLI index directory or APP index files safely."""
        try:
            if is_app:
                removed = 0
                errors = 0
                # Delete only files that belong to this app index (based on file base)
                pattern_base = app_file_base or ""
                for f in index_dir.glob(f"{pattern_base}.leann*"):
                    try:
                        f.unlink()
                        removed += 1
                    except Exception:
                        errors += 1
                # Best-effort: also remove the meta file if specified and still exists
                if meta_file and meta_file.exists():
                    try:
                        meta_file.unlink()
                        removed += 1
                    except Exception:
                        errors += 1

                if removed > 0 and errors == 0:
                    if project_path:
                        print(
                            f"✅ App index '{index_display_name}' removed from {project_path.name}"
                        )
                    else:
                        print(f"✅ App index '{index_display_name}' removed successfully")
                    return True
                elif removed > 0 and errors > 0:
                    print(
                        f"⚠️  App index '{index_display_name}' partially removed (some files couldn't be deleted)"
                    )
                    return True
                else:
                    print(
                        f"❌ No files found to remove for app index '{index_display_name}' in {index_dir}"
                    )
                    return False
            else:
                import shutil

                shutil.rmtree(index_dir)

                if project_path:
                    print(f"✅ Index '{index_display_name}' removed from {project_path.name}")
                else:
                    print(f"✅ Index '{index_display_name}' removed successfully")
                return True
        except Exception as e:
            print(f"❌ Error removing index '{index_display_name}': {e}")
            return False

    def load_documents(
        self,
        docs_paths: Union[str, list],
        custom_file_types: Union[str, None] = None,
        include_hidden: bool = False,
        args: Optional[dict[str, Any]] = None,
    ):
        # Handle both single path (string) and multiple paths (list) for backward compatibility
        if isinstance(docs_paths, str):
            docs_paths = [docs_paths]

        # Separate files and directories
        files = []
        directories = []
        for path in docs_paths:
            path_obj = Path(path)
            if path_obj.is_file():
                files.append(str(path_obj))
            elif path_obj.is_dir():
                # Check if this is a git submodule - if so, skip it
                if self._is_git_submodule(path_obj):
                    print(f"⚠️  Skipping git submodule: {path}")
                    continue
                directories.append(str(path_obj))
            else:
                print(f"⚠️  Warning: Path '{path}' does not exist, skipping...")
                continue

        # Print summary of what we're processing
        total_items = len(files) + len(directories)
        items_desc = []
        if files:
            items_desc.append(f"{len(files)} file{'s' if len(files) > 1 else ''}")
        if directories:
            items_desc.append(
                f"{len(directories)} director{'ies' if len(directories) > 1 else 'y'}"
            )

        print(f"Loading documents from {' and '.join(items_desc)} ({total_items} total):")
        if files:
            print(f"  📄 Files: {', '.join([Path(f).name for f in files])}")
        if directories:
            print(f"  📁 Directories: {', '.join(directories)}")

        if custom_file_types:
            print(f"Using custom file types: {custom_file_types}")

        all_documents = []

        # Helper to detect hidden path components
        def _path_has_hidden_segment(p: Path) -> bool:
            return any(part.startswith(".") and part not in [".", ".."] for part in p.parts)

        # First, process individual files if any
        if files:
            print(f"\n🔄 Processing {len(files)} individual file{'s' if len(files) > 1 else ''}...")

            # Load individual files using SimpleDirectoryReader with input_files
            # Note: We skip gitignore filtering for explicitly specified files
            try:
                # Group files by their parent directory for efficient loading
                from collections import defaultdict

                files_by_dir = defaultdict(list)
                for file_path in files:
                    file_path_obj = Path(file_path)
                    if not include_hidden and _path_has_hidden_segment(file_path_obj):
                        print(f"  ⚠️  Skipping hidden file: {file_path}")
                        continue
                    parent_dir = str(file_path_obj.parent)
                    files_by_dir[parent_dir].append(str(file_path_obj))

                # Load files from each parent directory
                for parent_dir, file_list in files_by_dir.items():
                    print(
                        f"  Loading {len(file_list)} file{'s' if len(file_list) > 1 else ''} from {parent_dir}"
                    )
                    try:
                        file_docs = SimpleDirectoryReader(
                            parent_dir,
                            input_files=file_list,
                            # exclude_hidden only affects directory scans; input_files are explicit
                            filename_as_id=True,
                        ).load_data()
                        for doc in file_docs:
                            if not doc.metadata.get("source"):
                                doc.metadata["source"] = doc.metadata.get("file_path", "")
                        all_documents.extend(file_docs)
                        print(
                            f"    ✅ Loaded {len(file_docs)} document{'s' if len(file_docs) > 1 else ''}"
                        )
                    except Exception as e:
                        print(f"    ❌ Warning: Could not load files from {parent_dir}: {e}")

            except Exception as e:
                print(f"❌ Error processing individual files: {e}")

        # Define file extensions to process
        if custom_file_types:
            # Parse custom file types from comma-separated string
            code_extensions = [ext.strip() for ext in custom_file_types.split(",") if ext.strip()]
            # Ensure extensions start with a dot
            code_extensions = [ext if ext.startswith(".") else f".{ext}" for ext in code_extensions]
        else:
            # Use default supported file types
            code_extensions = [
                # Original document types
                ".txt",
                ".md",
                ".docx",
                ".pptx",
                # Code files for Claude Code integration
                ".py",
                ".js",
                ".ts",
                ".jsx",
                ".tsx",
                ".java",
                ".cpp",
                ".c",
                ".h",
                ".hpp",
                ".cs",
                ".go",
                ".rs",
                ".rb",
                ".php",
                ".swift",
                ".kt",
                ".scala",
                ".r",
                ".sql",
                ".sh",
                ".bash",
                ".zsh",
                ".fish",
                ".ps1",
                ".bat",
                # Config and markup files
                ".json",
                ".yaml",
                ".yml",
                ".xml",
                ".toml",
                ".ini",
                ".cfg",
                ".conf",
                ".html",
                ".css",
                ".scss",
                ".less",
                ".vue",
                ".svelte",
                # Data science
                ".ipynb",
                ".R",
                ".py",
                ".jl",
            ]

        # Process each directory
        if directories:
            print(
                f"\n🔄 Processing {len(directories)} director{'ies' if len(directories) > 1 else 'y'}..."
            )

        for docs_dir in directories:
            print(f"Processing directory: {docs_dir}")
            # Build gitignore parser for each directory
            gitignore_matches = self._build_gitignore_parser(docs_dir)

            # Try to use better PDF parsers first, but only if PDFs are requested
            documents = []
            # Use resolved absolute paths to avoid mismatches (symlinks, relative vs absolute)
            docs_path = Path(docs_dir).resolve()

            # Check if we should process PDFs
            should_process_pdfs = custom_file_types is None or ".pdf" in custom_file_types

            if should_process_pdfs:
                for file_path in docs_path.rglob("*.pdf"):
                    # Check if file matches any exclude pattern
                    try:
                        # Ensure both paths are resolved before computing relativity
                        file_path_resolved = file_path.resolve()
                        # Determine directory scope using the non-resolved path to avoid
                        # misclassifying symlinked entries as outside the docs directory
                        relative_path = file_path.relative_to(docs_path)
                        if not include_hidden and _path_has_hidden_segment(relative_path):
                            continue
                        # Use absolute path for gitignore matching
                        if self._should_exclude_file(file_path_resolved, gitignore_matches):
                            continue
                    except ValueError:
                        # Skip files that can't be made relative to docs_path
                        print(f"⚠️  Skipping file outside directory scope: {file_path}")
                        continue

                    print(f"Processing PDF: {file_path}")

                    # Try PyMuPDF first (best quality)
                    text = extract_pdf_text_with_pymupdf(str(file_path))
                    if text is None:
                        # Try pdfplumber
                        text = extract_pdf_text_with_pdfplumber(str(file_path))

                    if text:
                        # Create a simple document structure
                        from llama_index.core import Document

                        doc = Document(text=text, metadata={"source": str(file_path)})
                        documents.append(doc)
                    else:
                        # Fallback to default reader
                        print(f"Using default reader for {file_path}")
                        try:
                            default_docs = SimpleDirectoryReader(
                                str(file_path.parent),
                                exclude_hidden=not include_hidden,
                                filename_as_id=True,
                                required_exts=[file_path.suffix],
                            ).load_data()
                            documents.extend(default_docs)
                        except Exception as e:
                            print(f"Warning: Could not process {file_path}: {e}")

            # Load other file types with default reader
            # Exclude PDFs from code_extensions if they were already processed separately
            other_file_extensions = code_extensions
            if should_process_pdfs and ".pdf" in code_extensions:
                other_file_extensions = [ext for ext in code_extensions if ext != ".pdf"]

            try:
                # Create a custom file filter function using our PathSpec
                def file_filter(
                    file_path: str, docs_dir=docs_dir, gitignore_matches=gitignore_matches
                ) -> bool:
                    """Return True if file should be included (not excluded)"""
                    try:
                        docs_path_obj = Path(docs_dir).resolve()
                        file_path_obj = Path(file_path).resolve()
                        # Use absolute path for gitignore matching
                        _ = file_path_obj.relative_to(docs_path_obj)  # validate scope
                        return not self._should_exclude_file(file_path_obj, gitignore_matches)
                    except (ValueError, OSError):
                        return True  # Include files that can't be processed

                # Only load other file types if there are extensions to process
                if other_file_extensions:
                    other_docs = SimpleDirectoryReader(
                        docs_dir,
                        recursive=True,
                        encoding="utf-8",
                        required_exts=other_file_extensions,
                        file_extractor={},  # Use default extractors
                        exclude_hidden=not include_hidden,
                        filename_as_id=True,
                    ).load_data(show_progress=True)
                else:
                    other_docs = []

                # Filter documents after loading based on gitignore rules
                filtered_docs = []
                for doc in other_docs:
                    file_path = doc.metadata.get("file_path", "")
                    if file_filter(file_path):
                        doc.metadata["source"] = file_path
                        filtered_docs.append(doc)

                documents.extend(filtered_docs)
            except ValueError as e:
                if "No files found" in str(e):
                    print(f"No additional files found for other supported types in {docs_dir}.")
                else:
                    raise e

            all_documents.extend(documents)
            print(f"Loaded {len(documents)} documents from {docs_dir}")

        documents = all_documents

        all_texts = []

        # Define code file extensions for intelligent chunking
        code_file_exts = {
            ".py",
            ".js",
            ".ts",
            ".jsx",
            ".tsx",
            ".java",
            ".cpp",
            ".c",
            ".h",
            ".hpp",
            ".cs",
            ".go",
            ".rs",
            ".rb",
            ".php",
            ".swift",
            ".kt",
            ".scala",
            ".r",
            ".sql",
            ".sh",
            ".bash",
            ".zsh",
            ".fish",
            ".ps1",
            ".bat",
            ".json",
            ".yaml",
            ".yml",
            ".xml",
            ".toml",
            ".ini",
            ".cfg",
            ".conf",
            ".html",
            ".css",
            ".scss",
            ".less",
            ".vue",
            ".svelte",
            ".ipynb",
            ".R",
            ".jl",
        }

        print("start chunking documents")

        # Check if AST chunking is requested
        use_ast = getattr(args, "use_ast_chunking", False)

        if use_ast:
            print("🧠 Using AST-aware chunking for code files")
            try:
                # Import enhanced chunking utilities from packaged module
                from .chunking_utils import create_text_chunks

                # Use enhanced chunking with AST support
                chunk_texts = create_text_chunks(
                    documents,
                    chunk_size=self.node_parser.chunk_size,
                    chunk_overlap=self.node_parser.chunk_overlap,
                    use_ast_chunking=True,
                    ast_chunk_size=getattr(args, "ast_chunk_size", 768),
                    ast_chunk_overlap=getattr(args, "ast_chunk_overlap", 96),
                    code_file_extensions=None,  # Use defaults
                    ast_fallback_traditional=getattr(args, "ast_fallback_traditional", True),
                )

                # create_text_chunks now returns list[dict] with metadata preserved
                all_texts.extend(chunk_texts)

            except ImportError as e:
                print(
                    f"⚠️  AST chunking utilities not available in package ({e}), falling back to traditional chunking"
                )
                use_ast = False

        if not use_ast:
            # Use traditional chunking logic
            for doc in tqdm(documents, desc="Chunking documents", unit="doc"):
                # Check if this is a code file based on source path
                source_path = doc.metadata.get("source", "")
                file_path = doc.metadata.get("file_path", "")
                is_code_file = any(
                    (source_path or file_path).endswith(ext) for ext in code_file_exts
                )

                # For code files, prepend line numbers so chunks carry them
                if is_code_file:
                    from llama_index.core.schema import MediaResource

                    original_text = doc.get_content()
                    lines = original_text.split("\n")
                    width = len(str(len(lines)))
                    numbered = "\n".join(f"{i + 1:>{width}}|{line}" for i, line in enumerate(lines))
                    doc.text_resource = MediaResource(text=numbered)

                # Extract metadata to preserve with chunks
                chunk_metadata = {
                    "file_path": file_path or source_path,
                    "file_name": doc.metadata.get("file_name", ""),
                    "source": source_path,
                }

                # Add optional metadata if available
                if "creation_date" in doc.metadata:
                    chunk_metadata["creation_date"] = doc.metadata["creation_date"]
                if "last_modified_date" in doc.metadata:
                    chunk_metadata["last_modified_date"] = doc.metadata["last_modified_date"]

                # Use appropriate parser based on file type
                parser = self.code_parser if is_code_file else self.node_parser
                nodes = parser.get_nodes_from_documents([doc])

                for node in nodes:
                    text = node.get_content()
                    # For code chunks, trim a partial first line left by overlap
                    # (a valid line starts with digits followed by '|')
                    if is_code_file and text and not text[0].isdigit():
                        first_nl = text.find("\n")
                        if first_nl != -1:
                            text = text[first_nl + 1 :]
                    all_texts.append({"text": text, "metadata": chunk_metadata.copy()})

        print(f"Loaded {len(documents)} documents, {len(all_texts)} chunks")
        return all_texts

    def _parse_file_types(self, custom_file_types: Optional[str]) -> Optional[list[str]]:
        if not custom_file_types:
            return None
        extensions = [ext.strip() for ext in custom_file_types.split(",") if ext.strip()]
        return [ext if ext.startswith(".") else f".{ext}" for ext in extensions]

    def _sync_ignore_patterns(self, include_hidden: bool) -> Optional[list[str]]:
        if include_hidden:
            return None
        return ["**/.*"]

    def _build_embedding_options(self, args) -> dict[str, Any]:
        """Build embedding provider options dict from CLI args."""
        opts: dict[str, Any] = {}
        if args.embedding_mode == "ollama":
            opts["host"] = resolve_ollama_host(args.embedding_host)
        elif args.embedding_mode == "openai":
            opts["base_url"] = resolve_openai_base_url(args.embedding_api_base)
            resolved_key = resolve_openai_api_key(args.embedding_api_key)
            if resolved_key:
                opts["api_key"] = resolved_key
        if args.query_prompt_template:
            if args.embedding_prompt_template:
                opts["build_prompt_template"] = args.embedding_prompt_template
            opts["query_prompt_template"] = args.query_prompt_template
        elif args.embedding_prompt_template:
            opts["prompt_template"] = args.embedding_prompt_template
        return opts

    def _resolve_sync_roots(self, docs_paths: list[str]) -> list[str]:
        roots: set[str] = set()
        for path in docs_paths:
            path_obj = Path(path).resolve()
            if path_obj.is_dir():
                roots.add(str(path_obj))
            elif path_obj.is_file():
                roots.add(str(path_obj.parent))
        return sorted(roots)

    def _create_synchronizers(
        self,
        index_dir: Path,
        roots: list[str],
        include_extensions: Optional[list[str]] = None,
        ignore_patterns: Optional[list[str]] = None,
    ) -> list[FileSynchronizer]:
        """Create FileSynchronizers with snapshots stored in the index dir. Shared by build and watch."""
        synchronizers: list[FileSynchronizer] = []
        for root in roots:
            tag = hashlib.sha256(root.encode()).hexdigest()[:12]
            snapshot_path = str(index_dir / f"sync_{tag}.pickle")
            try:
                fs = FileSynchronizer(
                    root_dir=root,
                    ignore_patterns=ignore_patterns,
                    include_extensions=include_extensions,
                    snapshot_path=snapshot_path,
                )
                synchronizers.append(fs)
            except Exception as exc:
                print(f"Warning: Failed to init synchronizer for {root}: {exc}")
        return synchronizers

    def _build_synchronizers(
        self,
        docs_paths: list[str],
        index_dir: Path,
        file_types: Optional[str] = None,
        include_hidden: bool = False,
    ) -> list[FileSynchronizer]:
        """Create FileSynchronizers for build from docs_paths."""
        roots = self._resolve_sync_roots(docs_paths)
        include_extensions = self._parse_file_types(file_types)
        ignore_patterns = self._sync_ignore_patterns(include_hidden)
        return self._create_synchronizers(index_dir, roots, include_extensions, ignore_patterns)

    def _detect_build_changes(
        self,
        synchronizers: list[FileSynchronizer],
    ) -> tuple[set[str], set[str], set[str]]:
        """Detect added/removed/modified files across all source roots using content hashes."""
        all_added: set[str] = set()
        all_removed: set[str] = set()
        all_modified: set[str] = set()
        for fs in synchronizers:
            added, removed, modified = fs.detect_changes()
            all_added.update(added)
            all_removed.update(removed)
            all_modified.update(modified)
        return all_added, all_removed, all_modified

    def _commit_synchronizers(self, synchronizers: list[FileSynchronizer]) -> None:
        """Persist all synchronizer snapshots after a successful build."""
        for fs in synchronizers:
            fs.commit()

    @staticmethod
    def _assign_chunk_ids(chunks: list[dict]) -> None:
        """Assign stable IDs to chunks based on their file path and position."""
        from collections import defaultdict

        by_path: dict[str, list] = defaultdict(list)
        for c in chunks:
            p = c.get("metadata", {}).get("file_path") or c.get("metadata", {}).get("source") or ""
            by_path[_normalize_path(p)].append(c)
        for path_key, path_chunks in by_path.items():
            for idx, c in enumerate(path_chunks):
                sid = hashlib.sha256(f"{path_key}:{idx}".encode()).hexdigest()[:16]
                c.setdefault("metadata", {})["id"] = sid
                c["id"] = sid

    @staticmethod
    def _assign_unique_chunk_ids(chunks: list[dict]) -> None:
        """Assign unique IDs for incremental (avoids collision when path lookup misses some old ids)."""
        for c in chunks:
            sid = uuid.uuid4().hex[:16]
            c.setdefault("metadata", {})["id"] = sid
            c["id"] = sid

    def _chunks_for_paths(self, all_texts: list[dict], paths: set[str]) -> list[dict]:
        """Filter chunks belonging to the given file paths."""
        return [
            c
            for c in all_texts
            if _normalize_path(
                c.get("metadata", {}).get("file_path") or c.get("metadata", {}).get("source") or ""
            )
            in paths
        ]

    def _make_incremental_builder(self, args) -> "LeannBuilder":
        return LeannBuilder(
            backend_name=args.backend_name,
            embedding_model=args.embedding_model,
            embedding_mode=args.embedding_mode,
            embedding_options=self._build_embedding_options(args) or None,
            graph_degree=args.graph_degree,
            complexity=args.complexity,
            is_compact=args.compact,
            is_recompute=args.recompute,
            num_threads=args.num_threads,
        )

    def _incremental_add_only(
        self,
        index_path: str,
        all_texts: list[dict],
        args,
        new_paths: set[str],
    ) -> bool:
        """Add-only incremental update (works for HNSW and IVF)."""
        new_chunks = self._chunks_for_paths(all_texts, new_paths)
        if not new_chunks:
            return False
        self._assign_chunk_ids(new_chunks)
        builder = self._make_incremental_builder(args)
        for chunk in new_chunks:
            builder.add_text(chunk["text"], metadata=chunk["metadata"])
        print(
            f"Incremental update: adding {len(new_chunks)} chunks from {len(new_paths)} new file(s)..."
        )
        builder.update_index(index_path)
        print(f"Index updated at {index_path}")
        return True

    def _incremental_ivf_remove_only(
        self, index_path: str, index_dir: Path, removed_paths: set[str], args
    ) -> bool:
        """IVF remove-only fast path: remove chunk IDs without loading or chunking documents."""
        passages_file = index_dir / "documents.leann.passages.jsonl"
        if not passages_file.exists():
            return False
        offset_file = index_dir / "documents.leann.passages.idx"
        live_ids: set[str] | None = None
        if offset_file.exists():
            with open(offset_file, "rb") as f:
                live_ids = set(pickle.load(f).keys())
        chunk_ids_by_file = self._load_chunk_ids_by_file(passages_file, live_ids=live_ids)
        roots = self._load_sync_roots(index_dir)
        ids_to_remove: list[str] = []
        seen_ids: set[str] = set()
        for p in removed_paths:
            for key in self._path_lookup_keys(p, roots):
                for pid in chunk_ids_by_file.get(key, []):
                    if pid not in seen_ids:
                        seen_ids.add(pid)
                        ids_to_remove.append(pid)
        if not ids_to_remove:
            return False
        print(
            f"Incremental IVF update (-{len(removed_paths)} removed): removing {len(ids_to_remove)} old chunks..."
        )
        builder = self._make_incremental_builder(args)
        builder.update_index(index_path, remove_passage_ids=ids_to_remove)
        print(f"Index updated at {index_path}")
        return True

    def _path_lookup_keys(self, path: str, roots: list[str]) -> list[str]:
        """Return path variations for lookup (sync paths may be relative to roots)."""
        keys = [path, _normalize_path(path)]
        for root in roots:
            candidate = str((Path(root) / path).resolve())
            if candidate not in keys:
                keys.append(candidate)
        return keys

    def _incremental_ivf_update(
        self,
        index_path: str,
        index_dir: Path,
        all_texts: list[dict],
        args,
        new_paths: set[str],
        removed_paths: set[str],
        modified_paths: set[str],
        sync_roots: list[str],
    ) -> bool:
        """IVF incremental update: remove old chunks for modified/removed files, add new chunks."""
        passages_file = index_dir / "documents.leann.passages.jsonl"
        offset_file = index_dir / "documents.leann.passages.idx"
        live_ids: set[str] | None = None
        if offset_file.exists():
            with open(offset_file, "rb") as f:
                live_ids = set(pickle.load(f).keys())
        chunk_ids_by_file = (
            self._load_chunk_ids_by_file(passages_file, live_ids=live_ids)
            if passages_file.exists()
            else {}
        )

        # Collect old chunk IDs to remove (modified + removed files)
        # Try all path variations: same file can have different path formats in passages
        ids_to_remove: list[str] = []
        seen_ids: set[str] = set()
        for p in modified_paths | removed_paths:
            for key in self._path_lookup_keys(p, sync_roots):
                for pid in chunk_ids_by_file.get(key, []):
                    if pid not in seen_ids:
                        seen_ids.add(pid)
                        ids_to_remove.append(pid)

        # Collect new chunks to add (modified + new files)
        # Build path set for matching: chunks may have paths in different formats
        changed_paths = new_paths | modified_paths
        path_set: set[str] = set()
        for p in changed_paths:
            path_set.update(self._path_lookup_keys(p, sync_roots))
        new_chunks = self._chunks_for_paths(all_texts, path_set)
        # Use unique IDs: passages can have mixed path formats so we may miss some ids_to_remove
        self._assign_unique_chunk_ids(new_chunks)

        if not ids_to_remove and not new_chunks:
            return False

        builder = self._make_incremental_builder(args)
        for chunk in new_chunks:
            builder.add_text(chunk["text"], metadata=chunk["metadata"])

        parts = []
        if ids_to_remove:
            parts.append(f"removing {len(ids_to_remove)} old chunks")
        if new_chunks:
            parts.append(f"adding {len(new_chunks)} new chunks")
        file_parts = []
        if new_paths:
            file_parts.append(f"+{len(new_paths)} added")
        if modified_paths:
            file_parts.append(f"~{len(modified_paths)} modified")
        if removed_paths:
            file_parts.append(f"-{len(removed_paths)} removed")
        print(f"Incremental IVF update ({', '.join(file_parts)}): {', '.join(parts)}...")

        builder.update_index(
            index_path, remove_passage_ids=ids_to_remove if ids_to_remove else None
        )
        print(f"Index updated at {index_path}")
        return True

    @staticmethod
    def _log_rebuild_reason(
        meta: dict, args, new_paths: set, removed_paths: set, modified_paths: set
    ) -> None:
        """Print a human-readable explanation of why incremental update is not possible."""
        if removed_paths or modified_paths:
            reasons = []
            if removed_paths:
                reasons.append(f"{len(removed_paths)} file(s) removed")
            if modified_paths:
                reasons.append(f"{len(modified_paths)} file(s) modified")
            print(
                f"Incremental update not possible ({', '.join(reasons)}); falling back to full rebuild."
            )
            return

        blockers = []
        if meta.get("backend_name") not in ("hnsw", "ivf"):
            blockers.append(
                f"backend '{meta.get('backend_name')}' does not support incremental updates"
            )
        if meta.get("is_compact", meta.get("backend_kwargs", {}).get("is_compact", True)):
            blockers.append("index is compact (read-only); rebuild with --no-compact to enable")
        if meta.get("embedding_model") != args.embedding_model:
            blockers.append(
                f"embedding model changed ('{meta.get('embedding_model')}' -> '{args.embedding_model}')"
            )
        if meta.get("embedding_mode") != args.embedding_mode:
            blockers.append(
                f"embedding mode changed ('{meta.get('embedding_mode')}' -> '{args.embedding_mode}')"
            )
        if blockers:
            print(
                f"Incremental update not possible: {'; '.join(blockers)}. Falling back to full rebuild."
            )
        else:
            changes = []
            if new_paths:
                changes.append(f"+{len(new_paths)} added")
            summary = ", ".join(changes) if changes else "unknown reason"
            print(f"Full rebuild starting ({summary})...")

    def _write_sync_config(
        self,
        index_dir: Path,
        roots: list[str],
        include_extensions: Optional[list[str]],
        ignore_patterns: Optional[list[str]],
    ) -> None:
        sync_config_path = index_dir / "sync_roots.json"
        config = {
            "roots": roots,
            "include_extensions": include_extensions,
            "ignore_patterns": ignore_patterns,
        }
        with open(sync_config_path, "w", encoding="utf-8") as f:
            json.dump(config, f, indent=2)

    def _load_sync_roots(self, index_dir: Path) -> list[str]:
        """Load sync roots from index dir (for path resolution in incremental updates)."""
        sync_config_path = index_dir / "sync_roots.json"
        if not sync_config_path.exists():
            return []
        try:
            with open(sync_config_path, encoding="utf-8") as f:
                config = json.load(f)
            return config.get("roots") or []
        except (json.JSONDecodeError, OSError):
            return []

    def _resolve_index_for_watch(self, index_name: str) -> Optional[dict[str, Path]]:
        if self.index_exists(index_name):
            index_dir = self.indexes_dir / index_name
            passages_file = index_dir / "documents.leann.passages.jsonl"
            return {"index_dir": index_dir, "passages_file": passages_file}

        all_matches = self._find_all_matching_indexes(index_name)
        if not all_matches:
            print(
                f"Index '{index_name}' not found. Use 'leann build {index_name} --docs <dir> [<dir2> ...]' to create it."
            )
            return None

        if len(all_matches) == 1:
            match = all_matches[0]
        else:
            current_matches = [m for m in all_matches if m.get("is_current")]
            match = current_matches[0] if current_matches else all_matches[0]
            location_desc = (
                "current project"
                if match.get("is_current")
                else f"project '{match['project_path'].name}'"
            )
            print(
                f"Found {len(all_matches)} indexes named '{index_name}', using index from {location_desc}"
            )

        if match.get("kind") == "cli":
            index_dir = match["index_dir"]
            passages_file = index_dir / "documents.leann.passages.jsonl"
        else:
            index_dir = match["meta_file"].parent
            file_base = match["file_base"]
            passages_file = index_dir / f"{file_base}.passages.jsonl"

        return {"index_dir": index_dir, "passages_file": passages_file}

    def _load_chunk_ids_by_file(
        self, passages_file: Path, live_ids: set[str] | None = None
    ) -> dict[str, list[str]]:
        """Load chunk IDs grouped by file path from passages.jsonl.

        If *live_ids* is provided, skip entries whose ID is not in the set
        (filters out stale entries left by prior incremental updates).
        """
        chunk_ids_by_file: dict[str, list[str]] = {}
        with open(passages_file, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    data = json.loads(line)
                except json.JSONDecodeError:
                    continue
                metadata = data.get("metadata") or {}
                file_path = metadata.get("file_path") or metadata.get("source")
                if not file_path:
                    continue
                chunk_id = data.get("id")
                if chunk_id is None:
                    continue
                if live_ids is not None and str(chunk_id) not in live_ids:
                    continue
                normalized_path = str(Path(file_path).resolve())
                chunk_ids_by_file.setdefault(normalized_path, []).append(str(chunk_id))
                if file_path != normalized_path:
                    chunk_ids_by_file.setdefault(file_path, []).append(str(chunk_id))
        return chunk_ids_by_file

    async def build_index(self, args):
        docs_paths = args.docs
        # Use current directory name if index_name not provided
        if args.index_name:
            index_name = args.index_name
        else:
            index_name = Path.cwd().name
            print(f"Using current directory name as index: '{index_name}'")

        index_dir = self.indexes_dir / index_name
        index_path = self.get_index_path(index_name)

        # Display all paths being indexed with file/directory distinction
        files = [p for p in docs_paths if Path(p).is_file()]
        directories = [p for p in docs_paths if Path(p).is_dir()]

        print(f"📂 Indexing {len(docs_paths)} path{'s' if len(docs_paths) > 1 else ''}:")
        if files:
            print(f"  📄 Files ({len(files)}):")
            for i, file_path in enumerate(files, 1):
                print(f"    {i}. {Path(file_path).resolve()}")
        if directories:
            print(f"  📁 Directories ({len(directories)}):")
            for i, dir_path in enumerate(directories, 1):
                print(f"    {i}. {Path(dir_path).resolve()}")

        # Configure chunking based on CLI args before loading documents
        # Guard against invalid configurations
        doc_chunk_size = max(1, int(args.doc_chunk_size))
        doc_chunk_overlap = max(0, int(args.doc_chunk_overlap))
        if doc_chunk_overlap >= doc_chunk_size:
            print(
                f"⚠️  Adjusting doc chunk overlap from {doc_chunk_overlap} to {doc_chunk_size - 1} (must be < chunk size)"
            )
            doc_chunk_overlap = doc_chunk_size - 1

        code_chunk_size = max(1, int(args.code_chunk_size))
        code_chunk_overlap = max(0, int(args.code_chunk_overlap))
        if code_chunk_overlap >= code_chunk_size:
            print(
                f"⚠️  Adjusting code chunk overlap from {code_chunk_overlap} to {code_chunk_size - 1} (must be < chunk size)"
            )
            code_chunk_overlap = code_chunk_size - 1

        self.node_parser = SentenceSplitter(
            chunk_size=doc_chunk_size,
            chunk_overlap=doc_chunk_overlap,
            separator=" ",
            paragraph_separator="\n\n",
        )
        self.code_parser = SentenceSplitter(
            chunk_size=code_chunk_size,
            chunk_overlap=code_chunk_overlap,
            separator="\n",
            paragraph_separator="\n\n",
        )

        # Detect changes first so we can skip load_documents for remove-only
        index_dir.mkdir(parents=True, exist_ok=True)
        synchronizers = self._build_synchronizers(
            docs_paths, index_dir, file_types=args.file_types, include_hidden=args.include_hidden
        )

        if index_dir.exists() and not args.force and synchronizers:
            meta_path = index_dir / "documents.leann.meta.json"
            new_paths, removed_paths, modified_paths = self._detect_build_changes(synchronizers)

            if not new_paths and not removed_paths and not modified_paths:
                print("Index up to date.")
                return

            # Show change summary (add / modify / delete) before build
            change_parts = []
            if new_paths:
                change_parts.append(f"+{len(new_paths)} added")
            if modified_paths:
                change_parts.append(f"~{len(modified_paths)} modified")
            if removed_paths:
                change_parts.append(f"-{len(removed_paths)} removed")
            if change_parts:
                print(f"Changes: {', '.join(change_parts)}")

            if meta_path.exists():
                with open(meta_path, encoding="utf-8") as f:
                    meta = json.load(f)

                backend_name = meta.get("backend_name")
                is_compact = meta.get(
                    "is_compact", meta.get("backend_kwargs", {}).get("is_compact", True)
                )
                same_embedding = (
                    meta.get("embedding_model") == args.embedding_model
                    and meta.get("embedding_mode") == args.embedding_mode
                )

                # IVF supports remove+add, so it can handle modified and removed files incrementally
                can_ivf_update = backend_name == "ivf" and not is_compact and same_embedding
                # HNSW only supports add (no remove), so it needs add-only changes
                can_add_only = (
                    not removed_paths
                    and not modified_paths
                    and backend_name in ("hnsw", "ivf")
                    and not is_compact
                    and same_embedding
                )

                # Remove-only fast path: no load/chunk, just remove IDs from index
                if can_ivf_update and removed_paths and not new_paths and not modified_paths:
                    result = self._incremental_ivf_remove_only(
                        index_path, index_dir, removed_paths, args
                    )
                    if result:
                        self._commit_synchronizers(synchronizers)
                        self._write_sync_config(
                            index_dir,
                            self._resolve_sync_roots(docs_paths),
                            self._parse_file_types(args.file_types),
                            self._sync_ignore_patterns(args.include_hidden),
                        )
                        self.register_project_dir()
                        return

                # Load only changed files (no need to load/chunk the entire corpus)
                # Resolve paths relative to sync roots (sync returns paths relative to each root)
                roots = self._resolve_sync_roots(docs_paths)
                paths_to_load = new_paths | modified_paths
                resolved_paths: list[str] = []
                for p in paths_to_load:
                    path_obj = Path(p)
                    if path_obj.is_absolute() and path_obj.exists():
                        resolved_paths.append(p)
                    else:
                        for root in roots:
                            candidate = Path(root) / p
                            if candidate.exists():
                                resolved_paths.append(str(candidate.resolve()))
                                break
                        else:
                            resolved_paths.append(p)  # fallback: pass as-is
                all_texts = self.load_documents(
                    resolved_paths,
                    args.file_types,
                    include_hidden=args.include_hidden,
                    args=args,
                )
                # Proceed even when all_texts is empty (e.g. file emptied): we still need to remove old chunks
                if not all_texts and not (can_ivf_update and (modified_paths or removed_paths)):
                    print("No documents found")
                    return

                if can_ivf_update and (new_paths or modified_paths or removed_paths):
                    result = self._incremental_ivf_update(
                        index_path,
                        index_dir,
                        all_texts,
                        args,
                        new_paths,
                        removed_paths,
                        modified_paths,
                        roots,
                    )
                    if result:
                        self._commit_synchronizers(synchronizers)
                        self._write_sync_config(
                            index_dir,
                            self._resolve_sync_roots(docs_paths),
                            self._parse_file_types(args.file_types),
                            self._sync_ignore_patterns(args.include_hidden),
                        )
                        self.register_project_dir()
                        return

                elif can_add_only and new_paths:
                    result = self._incremental_add_only(
                        index_path,
                        all_texts,
                        args,
                        new_paths,
                    )
                    if result:
                        self._commit_synchronizers(synchronizers)
                        self._write_sync_config(
                            index_dir,
                            self._resolve_sync_roots(docs_paths),
                            self._parse_file_types(args.file_types),
                            self._sync_ignore_patterns(args.include_hidden),
                        )
                        self.register_project_dir()
                        return

                else:
                    self._log_rebuild_reason(meta, args, new_paths, removed_paths, modified_paths)

        # Full rebuild: load documents if not already loaded (first build or force)
        try:
            _ = all_texts
        except NameError:
            all_texts = self.load_documents(
                docs_paths, args.file_types, include_hidden=args.include_hidden, args=args
            )
        if not all_texts:
            print("No documents found")
            return

        print(f"Building index '{index_name}' with {args.backend_name} backend...")

        builder = LeannBuilder(
            backend_name=args.backend_name,
            embedding_model=args.embedding_model,
            embedding_mode=args.embedding_mode,
            embedding_options=self._build_embedding_options(args) or None,
            graph_degree=args.graph_degree,
            complexity=args.complexity,
            is_compact=args.compact,
            is_recompute=args.recompute,
            num_threads=args.num_threads,
        )

        for chunk in all_texts:
            builder.add_text(chunk["text"], metadata=chunk["metadata"])

        builder.build_index(index_path)
        for fs in synchronizers:
            fs.create_snapshot()
        self._write_sync_config(
            index_dir,
            self._resolve_sync_roots(docs_paths),
            self._parse_file_types(args.file_types),
            self._sync_ignore_patterns(args.include_hidden),
        )
        print(f"Index built at {index_path}")
        self.register_project_dir()

    def _watch_check_changes(self, index_name: str) -> tuple[set[str], set[str], set[str]]:
        """Check for file changes using the same snapshots as build (index_dir)."""
        resolved = self._resolve_index_for_watch(index_name)
        if not resolved:
            return set(), set(), set()

        index_dir = resolved["index_dir"]
        sync_config_path = index_dir / "sync_roots.json"
        if not sync_config_path.exists():
            return set(), set(), set()

        with open(sync_config_path, encoding="utf-8") as f:
            config = json.load(f)

        roots = config.get("roots") or []
        if not roots:
            return set(), set(), set()

        synchronizers = self._create_synchronizers(
            index_dir,
            roots,
            include_extensions=config.get("include_extensions"),
            ignore_patterns=config.get("ignore_patterns"),
        )
        return self._detect_build_changes(synchronizers)

    def _watch_report_changes(
        self,
        index_name: str,
        added: set[str],
        removed: set[str],
        modified: set[str],
    ) -> None:
        """Print a summary of detected file changes."""
        resolved = self._resolve_index_for_watch(index_name)
        passages_file = resolved["passages_file"] if resolved else None

        chunk_ids_by_file: dict[str, list[str]] = {}
        if passages_file and passages_file.exists():
            chunk_ids_by_file = self._load_chunk_ids_by_file(passages_file)

        print("\n=== Changes detected ===")
        for label, paths in (
            ("added", sorted(added)),
            ("removed", sorted(removed)),
            ("modified", sorted(modified)),
        ):
            if not paths:
                continue
            print(f"\n{label} ({len(paths)}):")
            for file_path in paths:
                normalized_path = str(Path(file_path).resolve())
                chunk_ids = chunk_ids_by_file.get(normalized_path) or chunk_ids_by_file.get(
                    file_path, []
                )
                chunk_display = ", ".join(chunk_ids) if chunk_ids else "(not in index)"
                print(f"  - {file_path}")
                print(f"    chunks: {chunk_display}")

    async def _watch_trigger_build(self, index_name: str) -> None:
        """Trigger an idempotent build for the given index, reusing its stored config."""
        resolved = self._resolve_index_for_watch(index_name)
        if not resolved:
            return
        index_dir = resolved["index_dir"]
        sync_config_path = index_dir / "sync_roots.json"
        if not sync_config_path.exists():
            return
        with open(sync_config_path, encoding="utf-8") as f:
            config = json.load(f)
        roots = config.get("roots") or []
        if not roots:
            return

        meta_path = index_dir / "documents.leann.meta.json"
        if not meta_path.exists():
            print(f"Index metadata missing for '{index_name}', cannot rebuild.")
            return
        with open(meta_path, encoding="utf-8") as f:
            meta = json.load(f)

        parser = self.create_parser()
        build_args_list = [
            "build",
            index_name,
            "--docs",
            *roots,
            "--backend-name",
            meta.get("backend_name", "hnsw"),
            "--embedding-model",
            meta.get("embedding_model", "all-MiniLM-L6-v2"),
            "--embedding-mode",
            meta.get("embedding_mode", "sentence-transformers"),
        ]
        bkw = meta.get("backend_kwargs", {})
        if not bkw.get("is_compact", False):
            build_args_list.append("--no-compact")
        if bkw.get("is_recompute", True):
            build_args_list.append("--recompute")

        build_args = parser.parse_args(build_args_list)
        await self.build_index(build_args)

    async def watch_index(self, args):
        index_name = args.index_name
        resolved = self._resolve_index_for_watch(index_name)
        if not resolved:
            return

        index_dir = resolved["index_dir"]
        sync_config_path = index_dir / "sync_roots.json"
        if not sync_config_path.exists():
            print(
                f"Sync config not found for index '{index_name}'. "
                f"Run 'leann build {index_name} --docs <dir>' first."
            )
            return

        dry_run = getattr(args, "dry_run", False)
        once = getattr(args, "once", False)
        interval = getattr(args, "interval", 5)

        if once:
            added, removed, modified = self._watch_check_changes(index_name)
            if not added and not removed and not modified:
                print("No changes detected.")
                return
            self._watch_report_changes(index_name, added, removed, modified)
            if not dry_run:
                await self._watch_trigger_build(index_name)
            return

        print(f"Watching index '{index_name}' (interval={interval}s, ctrl-c to stop)...")
        try:
            while True:
                added, removed, modified = self._watch_check_changes(index_name)
                if added or removed or modified:
                    self._watch_report_changes(index_name, added, removed, modified)
                    if not dry_run:
                        await self._watch_trigger_build(index_name)
                await asyncio.sleep(interval)
        except KeyboardInterrupt:
            print("\nWatch stopped.")

    def _resolve_index_path(
        self,
        index_name: str,
        *,
        non_interactive: bool = True,
        purpose: str = "use",
        quiet: bool = False,
    ) -> Optional[str]:
        """Resolve index path from current project or registered projects."""
        _print = (lambda *a, **kw: print(*a, file=sys.stderr, **kw)) if quiet else print

        if self.index_exists(index_name):
            return self.get_index_path(index_name)

        all_matches = self._find_all_matching_indexes(index_name)
        if not all_matches:
            _print(
                f"Index '{index_name}' not found. Use 'leann build {index_name} --docs <dir> [<dir2> ...]' to create it."
            )
            return None

        def _match_to_path(match: dict[str, Any]) -> str:
            if match["kind"] == "cli":
                return str(match["index_dir"] / "documents.leann")
            meta_file = match["meta_file"]
            file_base = match["file_base"]
            return str(meta_file.parent / f"{file_base}.leann")

        if len(all_matches) == 1:
            match = all_matches[0]
            project_info = (
                "current project"
                if match["is_current"]
                else f"project '{match['project_path'].name}'"
            )
            _print(f"Using index '{index_name}' from {project_info}")
            return _match_to_path(match)

        if non_interactive:
            current_matches = [m for m in all_matches if m["is_current"]]
            match = current_matches[0] if current_matches else all_matches[0]
            location_desc = (
                "current project"
                if match["is_current"]
                else f"project '{match['project_path'].name}'"
            )
            _print(
                f"Found {len(all_matches)} indexes named '{index_name}', using index from {location_desc}"
            )
            return _match_to_path(match)

        print(f"Found {len(all_matches)} indexes named '{index_name}':")
        for i, match in enumerate(all_matches, 1):
            project_path = match["project_path"]
            is_current = match["is_current"]
            kind = match.get("kind", "cli")
            if is_current:
                print(f"   {i}. 🏠 Current project ({'CLI' if kind == 'cli' else 'APP'})")
            else:
                print(f"   {i}. 📂 {project_path.name} ({'CLI' if kind == 'cli' else 'APP'})")

        try:
            choice = input(f"Which index to {purpose}? (1-{len(all_matches)}): ").strip()
            choice_idx = int(choice) - 1
            if 0 <= choice_idx < len(all_matches):
                match = all_matches[choice_idx]
                project_info = (
                    "current project"
                    if match["is_current"]
                    else f"project '{match['project_path'].name}'"
                )
                print(f"Using index '{index_name}' from {project_info}")
                return _match_to_path(match)
            print("Invalid choice. Aborting.")
            return None
        except (ValueError, KeyboardInterrupt):
            print("Invalid input. Aborting.")
            return None

    async def search_documents(self, args):
        index_name = args.index_name
        query = args.query
        json_mode = getattr(args, "json", False)

        index_path = self._resolve_index_path(
            index_name,
            non_interactive=args.non_interactive,
            purpose="search",
            quiet=json_mode,
        )
        if not index_path:
            return

        # Build provider_options for runtime override
        provider_options = {}
        if args.embedding_prompt_template:
            provider_options["prompt_template"] = args.embedding_prompt_template

        if json_mode:
            sys.stdout.flush()
            saved_fd = os.dup(1)
            devnull = os.open(os.devnull, os.O_WRONLY)
            os.dup2(devnull, 1)
            os.close(devnull)

        try:
            searcher = LeannSearcher(
                index_path=index_path,
                enable_warmup=args.enable_warmup,
                use_daemon=args.use_daemon,
                daemon_ttl_seconds=args.daemon_ttl,
            )
            results = searcher.search(
                query,
                top_k=args.top_k,
                complexity=args.complexity,
                beam_width=args.beam_width,
                prune_ratio=args.prune_ratio,
                recompute_embeddings=args.recompute_embeddings,
                pruning_strategy=args.pruning_strategy,
                provider_options=provider_options if provider_options else None,
            )
        finally:
            if json_mode:
                import ctypes

                libc = ctypes.CDLL(None)
                libc.fflush(None)
                os.dup2(saved_fd, 1)
                os.close(saved_fd)

        if json_mode:
            json_results = [
                {
                    "id": r.id,
                    "score": r.score,
                    "text": r.text,
                    "metadata": r.metadata,
                }
                for r in results
            ]
            print(json.dumps(json_results, ensure_ascii=False, indent=2))
            return

        print(f"Search results for '{query}' (top {len(results)}):")
        for i, result in enumerate(results, 1):
            print(f"{i}. Score: {result.score:.3f}")

            if args.show_metadata and result.metadata:
                file_path = result.metadata.get("file_path", "")
                if file_path:
                    print(f"   File: {file_path}")

                file_name = result.metadata.get("file_name", "")
                if file_name and file_name != file_path:
                    print(f"   Name: {file_name}")

                if "creation_date" in result.metadata:
                    print(f"   Created: {result.metadata['creation_date']}")
                if "last_modified_date" in result.metadata:
                    print(f"   Modified: {result.metadata['last_modified_date']}")

            print(f"   {result.text}")
            print(f"   Source: {result.metadata.get('source', '')}")
            print()

    async def warmup_index(self, args):
        index_path = self._resolve_index_path(
            args.index_name,
            non_interactive=True,
            purpose="warm up",
        )
        if not index_path:
            return

        searcher = LeannSearcher(
            index_path=index_path,
            recompute_embeddings=True,
            enable_warmup=args.enable_warmup,
            use_daemon=args.use_daemon,
            daemon_ttl_seconds=args.daemon_ttl,
        )
        if args.enable_warmup:
            searcher.warmup()
        print(
            f"Warmed index '{args.index_name}' (daemon={'on' if args.use_daemon else 'off'}, ttl={args.daemon_ttl}s)"
        )

    async def daemon_command(self, args):
        if not args.daemon_command:
            print("Please specify one of: start, stop, status")
            return

        if args.daemon_command == "status":
            records = EmbeddingServerManager.list_daemons()
            if args.index_name:
                index_path = self._resolve_index_path(
                    args.index_name,
                    non_interactive=True,
                    purpose="check daemon status for",
                )
                if not index_path:
                    return
                meta_path = str(Path(f"{index_path}.meta.json").resolve())
                records = [
                    r
                    for r in records
                    if r.get("config_signature", {}).get("passages_file") == meta_path
                ]

            if not records:
                print("No active embedding daemons.")
                return

            print(f"Active embedding daemons: {len(records)}")
            for record in records:
                cfg = record.get("config_signature", {})
                print(
                    f"- pid={record.get('pid')} port={record.get('port')} backend={record.get('backend_module_name')} model={cfg.get('model_name')}"
                )
            return

        if args.daemon_command == "start":
            index_path = self._resolve_index_path(
                args.index_name,
                non_interactive=True,
                purpose="start daemon for",
            )
            if not index_path:
                return

            searcher = LeannSearcher(
                index_path=index_path,
                recompute_embeddings=True,
                enable_warmup=args.enable_warmup,
                use_daemon=True,
                daemon_ttl_seconds=args.daemon_ttl,
            )
            searcher.warmup()
            print(
                f"Daemon started for '{args.index_name}' (ttl={args.daemon_ttl}s, warmup={'on' if args.enable_warmup else 'off'})"
            )
            return

        if args.daemon_command == "stop":
            if args.all:
                stopped = EmbeddingServerManager.stop_daemons()
                print(f"Stopped {stopped} daemon(s).")
                return

            if not args.index_name:
                print("Provide an index name or pass --all.")
                return

            index_path = self._resolve_index_path(
                args.index_name,
                non_interactive=True,
                purpose="stop daemon for",
            )
            if not index_path:
                return
            meta_path = str(Path(f"{index_path}.meta.json").resolve())
            stopped = EmbeddingServerManager.stop_daemons(passages_file=meta_path)
            print(f"Stopped {stopped} daemon(s) for index '{args.index_name}'.")

    async def ask_questions(self, args):
        index_name = args.index_name
        index_path = self.get_index_path(index_name)

        if not self.index_exists(index_name):
            print(
                f"Index '{index_name}' not found. Use 'leann build {index_name} --docs <dir> [<dir2> ...]' to create it."
            )
            return

        print(f"Starting chat with index '{index_name}'...")
        print(f"Using {args.model} ({args.llm})")

        llm_config = {"type": args.llm, "model": args.model}
        if args.llm == "ollama":
            llm_config["host"] = resolve_ollama_host(args.host)
        elif args.llm == "openai":
            llm_config["base_url"] = resolve_openai_base_url(args.api_base)
            resolved_api_key = resolve_openai_api_key(args.api_key)
            if resolved_api_key:
                llm_config["api_key"] = resolved_api_key
        elif args.llm == "anthropic":
            # For Anthropic, pass base_url and API key if provided
            if args.api_base:
                llm_config["base_url"] = resolve_anthropic_base_url(args.api_base)
            if args.api_key:
                llm_config["api_key"] = args.api_key

        chat = LeannChat(index_path=index_path, llm_config=llm_config)

        llm_kwargs: dict[str, Any] = {}
        if args.thinking_budget:
            llm_kwargs["thinking_budget"] = args.thinking_budget

        def _ask_once(prompt: str) -> None:
            query_start_time = time.time()
            response = chat.ask(
                prompt,
                top_k=args.top_k,
                complexity=args.complexity,
                beam_width=args.beam_width,
                prune_ratio=args.prune_ratio,
                recompute_embeddings=args.recompute_embeddings,
                pruning_strategy=args.pruning_strategy,
                llm_kwargs=llm_kwargs,
            )
            query_completion_time = time.time() - query_start_time
            print(f"LEANN: {response}")
            print(f"The query took {query_completion_time:.3f} seconds to finish")

        initial_query = (args.query or "").strip()

        if args.interactive:
            # Create interactive session
            session = create_cli_session(index_name)

            if initial_query:
                _ask_once(initial_query)

            session.run_interactive_loop(_ask_once)
        else:
            query = initial_query or input("Enter your question: ").strip()
            if not query:
                print("No question provided. Exiting.")
                return

            _ask_once(query)

    async def react_agent(self, args):
        """Run ReAct agent for multiturn retrieval."""
        index_name = args.index_name
        query = args.query

        # Find the index (similar to search_documents)
        index_path = self.get_index_path(index_name)
        if self.index_exists(index_name):
            pass
        else:
            all_matches = self._find_all_matching_indexes(index_name)
            if not all_matches:
                print(
                    f"Index '{index_name}' not found. Use 'leann build {index_name} --docs <dir> [<dir2> ...]' to create it."
                )
                return
            elif len(all_matches) == 1:
                match = all_matches[0]
                if match["kind"] == "cli":
                    index_path = str(match["index_dir"] / "documents.leann")
                else:
                    meta_file = match["meta_file"]
                    file_base = match["file_base"]
                    index_path = str(meta_file.parent / f"{file_base}.leann")
            else:
                # Multiple matches - use first one for now
                match = all_matches[0]
                if match["kind"] == "cli":
                    index_path = str(match["index_dir"] / "documents.leann")
                else:
                    meta_file = match["meta_file"]
                    file_base = match["file_base"]
                    index_path = str(meta_file.parent / f"{file_base}.leann")
                print(f"Found {len(all_matches)} indexes named '{index_name}', using first match")

        print(f"🤖 Starting ReAct agent with index '{index_name}'...")
        print(f"Using {args.model} ({args.llm})")

        llm_config = {"type": args.llm, "model": args.model}
        if args.llm == "ollama":
            llm_config["host"] = resolve_ollama_host(args.host)
        elif args.llm == "openai":
            llm_config["base_url"] = resolve_openai_base_url(args.api_base)
            resolved_api_key = resolve_openai_api_key(args.api_key)
            if resolved_api_key:
                llm_config["api_key"] = resolved_api_key
        elif args.llm == "anthropic":
            if args.api_base:
                llm_config["base_url"] = resolve_anthropic_base_url(args.api_base)
            if args.api_key:
                llm_config["api_key"] = args.api_key

        from .react_agent import create_react_agent

        agent = create_react_agent(
            index_path=index_path,
            llm_config=llm_config,
            max_iterations=args.max_iterations,
        )

        print(f"\n🔍 Question: {query}\n")
        answer = agent.run(query, top_k=args.top_k)
        print(f"\n✅ Final Answer:\n{answer}\n")

        if agent.search_history:
            print(f"\n📊 Search History ({len(agent.search_history)} iterations):")
            for entry in agent.search_history:
                print(
                    f"  {entry['iteration']}. {entry['action']} ({entry['results_count']} results)"
                )

    async def serve_api(self, args):
        """Start the HTTP API server."""
        import os

        try:
            from .server import main as server_main

            # Override host/port if provided via CLI args
            if args.host:
                os.environ["LEANN_SERVER_HOST"] = args.host
            if args.port:
                os.environ["LEANN_SERVER_PORT"] = str(args.port)

            # Run the server (this is blocking, so we don't await it)
            # The server_main function handles uvicorn.run which blocks
            server_main()
        except ImportError as e:
            print(
                "❌ HTTP server dependencies not installed.\n"
                "Install them with:\n"
                "  uv pip install 'leann-core[server]'\n"
                "or:\n"
                "  uv pip install 'fastapi>=0.115' 'pydantic>=2' 'uvicorn[standard]'\n"
            )
            raise SystemExit(1) from e
        except Exception as e:
            print(f"❌ Error starting server: {e}")
            raise SystemExit(1) from e

    async def run(self, args=None):
        parser = self.create_parser()

        if args is None:
            args = parser.parse_args()

        if not args.command:
            parser.print_help()
            return

        # Determine whether to suppress C++ output
        # Default is to suppress (quiet mode), unless --verbose is specified
        suppress = not getattr(args, "verbose", False)

        if args.command == "list":
            self.list_indexes()
        elif args.command == "remove":
            self.remove_index(args.index_name, args.force)
        elif args.command == "build":
            with suppress_cpp_output(suppress):
                await self.build_index(args)
        elif args.command == "watch":
            await self.watch_index(args)
        elif args.command == "search":
            with suppress_cpp_output(suppress):
                await self.search_documents(args)
        elif args.command == "warmup":
            with suppress_cpp_output(suppress):
                await self.warmup_index(args)
        elif args.command == "daemon":
            await self.daemon_command(args)
        elif args.command == "ask":
            with suppress_cpp_output(suppress):
                await self.ask_questions(args)
        elif args.command == "react":
            with suppress_cpp_output(suppress):
                await self.react_agent(args)
        elif args.command == "serve":
            await self.serve_api(args)
        else:
            parser.print_help()


def main():
    import logging

    import dotenv

    dotenv.load_dotenv()

    # Set clean logging for CLI usage
    logging.getLogger().setLevel(logging.WARNING)  # Only show warnings and errors

    cli = LeannCLI()
    asyncio.run(cli.run())


if __name__ == "__main__":
    main()
