"""
ProjectIndexer — Source-code-aware file indexer for NAVIG CLI.

Indexes **all** project source files (not just .md/.txt like MemoryIndexer)
into a local SQLite FTS5 database for fast BM25 retrieval.

Architecture:
    - Content-type-aware chunking (code: 80 lines/10 overlap, docs: 50/5)
    - SHA-256 incremental hashing — skips unchanged files
    - .navigignore + .gitignore support
    - SQLite FTS5 for keyword search (BM25 ranking)
    - Per-file cap of 3 chunks per query result
    - Content-type priority multipliers

Storage: .navig/project_index.db (project-local)

Usage:
    indexer = ProjectIndexer(Path('/path/to/project'))
    indexer.scan()                       # full scan
    indexer.update_incremental()          # rescan changed files only
    results = indexer.search('login auth handler', top_k=10)
    stats = indexer.stats()
"""

from __future__ import annotations

import fnmatch
import hashlib
import logging
import os
import re
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

logger = logging.getLogger("navig.memory.project_indexer")


# ============================================================================
# Configuration
# ============================================================================

@dataclass
class ProjectIndexConfig:
    """Tuning knobs for the project indexer."""

    # Chunking
    code_chunk_lines: int = 80
    code_chunk_overlap: int = 10
    doc_chunk_lines: int = 50
    doc_chunk_overlap: int = 5

    # Limits
    max_files: int = 8_000
    max_chunked_files: int = 3_000
    max_total_chunks: int = 30_000
    max_file_size: int = 1_000_000  # 1 MB

    # Query
    max_results: int = 15
    max_chunks_per_file: int = 3
    max_chars: int = 25_000

    # BM25 tuning
    bm25_k1: float = 1.2
    bm25_b: float = 0.75


# ============================================================================
# Content type classification
# ============================================================================

CONTENT_TYPES = ("code", "config", "docs", "wiki", "plans", "memory")

_CODE_EXTS: Set[str] = {
    ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
    ".py", ".rb", ".go", ".rs", ".java", ".kt", ".scala", ".cs",
    ".cpp", ".c", ".h", ".hpp",
    ".php", ".swift", ".dart", ".lua", ".r", ".jl",
    ".vue", ".svelte", ".astro",
    ".sql", ".graphql", ".gql",
    ".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd",
    ".html", ".css", ".scss", ".less", ".sass",
    ".prisma", ".proto",
}

_CONFIG_EXTS: Set[str] = {
    ".json", ".yaml", ".yml", ".toml", ".xml", ".ini", ".env",
}

_DOCS_EXTS: Set[str] = {
    ".md", ".mdx", ".txt", ".rst",
}

_INDEXABLE_EXTS: Set[str] = _CODE_EXTS | _CONFIG_EXTS | _DOCS_EXTS

_INDEXABLE_FILENAMES: Set[str] = {
    "dockerfile", "makefile", "cmakelists.txt", "gemfile",
    "rakefile", "procfile", "vagrantfile", ".env",
    ".gitignore", ".navigignore", ".editorconfig",
}


def classify_content_type(rel_path: str) -> str:
    """Classify a file path into a content type."""
    lower = rel_path.lower().replace("\\", "/")

    # .navig brain sub-dirs
    if "/.navig/" in f"/{lower}" or lower.startswith(".navig/"):
        if "/wiki/" in lower:
            return "wiki"
        if "/plans/" in lower or "/briefs/" in lower:
            return "plans"
        if "/memory/" in lower or "/workspace/" in lower:
            return "memory"

    ext = os.path.splitext(lower)[1]
    basename = os.path.basename(lower)

    # Config files by name (e.g. .env, .editorconfig)
    if basename in (".env", ".editorconfig") or basename.startswith(".env."):
        return "config"

    if ext in _DOCS_EXTS:
        return "docs"
    if ext in _CONFIG_EXTS:
        return "config"
    return "code"


def content_type_priority(ct: str) -> float:
    """Priority multiplier for BM25 score boosting."""
    return {
        "code": 1.0,
        "config": 0.8,
        "docs": 0.9,
        "wiki": 1.1,
        "plans": 1.2,
        "memory": 0.7,
    }.get(ct, 0.8)


# ============================================================================
# Ignore rules (.navigignore / .gitignore)
# ============================================================================

DEFAULT_EXCLUDES: List[str] = [
    ".git", "node_modules", ".next", ".nuxt", "dist", "build", ".cache",
    ".vscode", ".idea", "__pycache__", "*.pyc", ".DS_Store", "Thumbs.db",
    "vendor", "target", ".turbo", "coverage", ".nyc_output",
    "*.min.js", "*.min.css", "package-lock.json", "pnpm-lock.yaml",
    "yarn.lock", "*.lock", "*.map", "*.woff", "*.woff2", "*.ttf",
    "*.eot", "*.ico", "*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg",
    "*.mp4", "*.mp3", "*.pdf", "*.zip", "*.tar", "*.gz", "*.vsix",
    ".venv", ".env.local", ".tsbuildinfo",
]


def _load_ignore_rules(project_root: Path) -> List[str]:
    """Load .navigignore then .gitignore patterns."""
    patterns = list(DEFAULT_EXCLUDES)
    for name in (".navigignore", ".gitignore"):
        p = project_root / name
        if p.is_file():
            try:
                for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
                    line = line.strip()
                    if line and not line.startswith("#"):
                        patterns.append(line)
            except Exception:  # noqa: BLE001
                pass  # best-effort; failure is non-critical
    return patterns


def _is_ignored(rel_path: str, ignore_patterns: List[str], is_dir: bool = False) -> bool:
    """Check if a relative path matches any ignore pattern."""
    parts = rel_path.replace("\\", "/").split("/")
    for pattern in ignore_patterns:
        pat = pattern.rstrip("/")
        # Match against any path component
        for part in parts:
            if fnmatch.fnmatch(part, pat):
                return True
        # Match against full relative path
        if fnmatch.fnmatch(rel_path.replace("\\", "/"), pat):
            return True
        if fnmatch.fnmatch(rel_path.replace("\\", "/"), f"**/{pat}"):
            return True
    return False


def _is_indexable(rel_path: str) -> bool:
    """Check if a file should be indexed based on extension."""
    lower = os.path.basename(rel_path).lower()
    if lower in _INDEXABLE_FILENAMES:
        return True
    ext = os.path.splitext(lower)[1]
    return ext in _INDEXABLE_EXTS


# ============================================================================
# Chunking
# ============================================================================

@dataclass
class Chunk:
    """A chunk of file content."""
    file_path: str
    content: str
    start_line: int
    end_line: int
    content_type: str
    section_title: str
    content_hash: str


def _extract_section_title(lines: List[str], content_type: str) -> str:
    """Extract a meaningful section title from chunk lines."""
    for line in lines[:5]:
        stripped = line.strip()
        if not stripped:
            continue
        if content_type == "docs" and stripped.startswith("#"):
            return stripped.lstrip("#").strip()[:80]
        if content_type == "code":
            # Function/class definitions
            m = re.match(
                r"^(?:export\s+)?(?:async\s+)?(?:def|function|class|interface|type|enum|const|let|var|pub\s+fn|fn|func|impl)\s+(\w+)",
                stripped,
            )
            if m:
                return m.group(1)[:80]
    return ""


def chunk_file(
    rel_path: str,
    content: str,
    config: ProjectIndexConfig,
) -> List[Chunk]:
    """Split file content into overlapping chunks."""
    ct = classify_content_type(rel_path)

    if ct in ("code",):
        chunk_size = config.code_chunk_lines
        overlap = config.code_chunk_overlap
    else:
        chunk_size = config.doc_chunk_lines
        overlap = config.doc_chunk_overlap

    lines = content.splitlines()
    if not lines:
        return []

    chunks: List[Chunk] = []
    step = max(1, chunk_size - overlap)
    i = 0

    while i < len(lines):
        end = min(i + chunk_size, len(lines))
        chunk_lines = lines[i:end]
        chunk_text = "\n".join(chunk_lines)

        if chunk_text.strip():
            h = hashlib.sha256(chunk_text.encode("utf-8", errors="replace")).hexdigest()[:16]
            title = _extract_section_title(chunk_lines, ct)
            chunks.append(Chunk(
                file_path=rel_path,
                content=chunk_text,
                start_line=i + 1,
                end_line=end,
                content_type=ct,
                section_title=title,
                content_hash=h,
            ))

        if end >= len(lines):
            break
        i += step

    return chunks


# ============================================================================
# Search result
# ============================================================================

@dataclass
class ProjectSearchResult:
    """A single search result from the project index."""
    file_path: str
    content: str
    start_line: int
    end_line: int
    content_type: str
    section_title: str
    score: float
    rank: int = 0

    def to_context_string(self) -> str:
        """Format as LLM-friendly context block."""
        header = f"--- {self.file_path}:{self.start_line}-{self.end_line}"
        if self.section_title:
            header += f"  ({self.section_title})"
        return f"{header}\n{self.content}"


# ============================================================================
# ProjectIndexer
# ============================================================================

class ProjectIndexer:
    """
    Indexes project source files into SQLite FTS5 for BM25 retrieval.

    Usage:
        indexer = ProjectIndexer(Path('.'))
        indexer.scan()
        results = indexer.search('auth login handler')
    """

    DB_NAME = "project_index.db"

    def __init__(
        self,
        project_root: Path,
        config: Optional[ProjectIndexConfig] = None,
    ):
        self.project_root = project_root.resolve()
        self.config = config or ProjectIndexConfig()
        self._ignore_patterns = _load_ignore_rules(self.project_root)

        # DB lives in .navig/
        navig_dir = self.project_root / ".navig"
        navig_dir.mkdir(parents=True, exist_ok=True)
        self._db_path = navig_dir / self.DB_NAME

        self._conn: Optional[sqlite3.Connection] = None
        self._file_hashes: Dict[str, str] = {}  # rel_path -> content_hash
        self._last_scan_duration: Optional[float] = None

        self._ensure_db()

    # ----------------------------------------------------------------
    # Database setup
    # ----------------------------------------------------------------

    def _ensure_db(self) -> None:
        """Create tables if they don't exist."""
        conn = self._get_conn()
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS file_meta (
                path TEXT PRIMARY KEY,
                content_type TEXT NOT NULL,
                content_hash TEXT NOT NULL,
                line_count INTEGER NOT NULL,
                char_count INTEGER NOT NULL,
                mtime REAL NOT NULL,
                indexed_at REAL NOT NULL
            );

            CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
                file_path,
                content,
                start_line UNINDEXED,
                end_line UNINDEXED,
                content_type UNINDEXED,
                section_title,
                content_hash UNINDEXED,
                tokenize='porter unicode61'
            );

            CREATE TABLE IF NOT EXISTS index_meta (
                key TEXT PRIMARY KEY,
                value TEXT
            );
        """)
        conn.commit()

        # Load existing file hashes for incremental checks
        for row in conn.execute("SELECT path, content_hash FROM file_meta"):
            self._file_hashes[row[0]] = row[1]

    def _get_conn(self) -> sqlite3.Connection:
        """Get or create SQLite connection."""
        if self._conn is None:
            self._conn = sqlite3.connect(str(self._db_path), timeout=10)
            self._conn.execute("PRAGMA journal_mode=WAL")
            self._conn.execute("PRAGMA synchronous=NORMAL")
            self._conn.execute("PRAGMA cache_size=-8000")  # 8 MB
        return self._conn

    # ----------------------------------------------------------------
    # Discovery
    # ----------------------------------------------------------------

    def _discover_files(self) -> List[Tuple[str, float]]:
        """Walk the project tree and return (rel_path, mtime) tuples."""
        files: List[Tuple[str, float]] = []
        max_files = self.config.max_files

        for dirpath, dirnames, filenames in os.walk(self.project_root):
            # Skip ignored directories (in-place filter)
            rel_dir = os.path.relpath(dirpath, self.project_root)
            if rel_dir != "." and _is_ignored(rel_dir, self._ignore_patterns, is_dir=True):
                dirnames.clear()
                continue

            # Filter out ignored subdirectories
            dirnames[:] = [
                d for d in dirnames
                if not _is_ignored(
                    os.path.relpath(os.path.join(dirpath, d), self.project_root),
                    self._ignore_patterns,
                    is_dir=True,
                )
            ]

            for fname in filenames:
                if len(files) >= max_files:
                    break

                fpath = os.path.join(dirpath, fname)
                rel = os.path.relpath(fpath, self.project_root)

                if _is_ignored(rel, self._ignore_patterns):
                    continue
                if not _is_indexable(rel):
                    continue

                try:
                    st = os.stat(fpath)
                    if st.st_size > self.config.max_file_size:
                        continue
                    files.append((rel, st.st_mtime))
                except OSError:
                    continue

            if len(files) >= max_files:
                break

        return files

    # ----------------------------------------------------------------
    # Full scan
    # ----------------------------------------------------------------

    def scan(self) -> Dict[str, int]:
        """
        Full scan: discover files, read, chunk, and index.

        Returns:
            Stats dict with files_discovered, files_indexed, chunks_created, duration_s.
        """
        t0 = time.monotonic()
        conn = self._get_conn()

        # Phase 1: discover
        discovered = self._discover_files()
        logger.info("[ProjectIndexer] Discovered %d files", len(discovered))

        # Phase 2: read + chunk + index
        chunks_created = 0
        files_indexed = 0
        files_skipped = 0

        # Clear old data for full scan
        conn.execute("DELETE FROM chunks")
        conn.execute("DELETE FROM file_meta")
        conn.commit()
        self._file_hashes.clear()

        # Limit chunked files
        to_chunk = discovered[: self.config.max_chunked_files]

        for rel_path, mtime in to_chunk:
            abs_path = self.project_root / rel_path

            try:
                content = abs_path.read_text(encoding="utf-8", errors="replace")
            except Exception as e:
                logger.debug("[ProjectIndexer] Read error %s: %s", rel_path, e)
                continue

            content_hash = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:16]
            ct = classify_content_type(rel_path)
            line_count = content.count("\n") + 1

            # Store file metadata
            conn.execute(
                "INSERT OR REPLACE INTO file_meta VALUES (?, ?, ?, ?, ?, ?, ?)",
                (rel_path, ct, content_hash, line_count, len(content), mtime, time.time()),
            )
            self._file_hashes[rel_path] = content_hash

            # Chunk and index
            chunks = chunk_file(rel_path, content, self.config)
            if chunks_created + len(chunks) > self.config.max_total_chunks:
                chunks = chunks[: self.config.max_total_chunks - chunks_created]

            for chunk in chunks:
                conn.execute(
                    "INSERT INTO chunks (file_path, content, start_line, end_line, content_type, section_title, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (chunk.file_path, chunk.content, chunk.start_line, chunk.end_line, chunk.content_type, chunk.section_title, chunk.content_hash),
                )
                chunks_created += 1

            files_indexed += 1

            if chunks_created >= self.config.max_total_chunks:
                break

        conn.commit()

        duration = time.monotonic() - t0
        self._last_scan_duration = duration

        # Store scan metadata
        conn.execute(
            "INSERT OR REPLACE INTO index_meta VALUES ('last_scan', ?)",
            (str(time.time()),),
        )
        conn.execute(
            "INSERT OR REPLACE INTO index_meta VALUES ('last_scan_duration', ?)",
            (str(round(duration, 2)),),
        )
        conn.commit()

        stats = {
            "files_discovered": len(discovered),
            "files_indexed": files_indexed,
            "files_skipped": files_skipped,
            "chunks_created": chunks_created,
            "duration_s": round(duration, 2),
        }
        logger.info("[ProjectIndexer] Scan complete: %s", stats)
        return stats

    # ----------------------------------------------------------------
    # Incremental update
    # ----------------------------------------------------------------

    def update_incremental(self) -> Dict[str, int]:
        """
        Rescan only changed/new files (mtime + hash check).

        Returns:
            Stats dict with files_checked, files_updated, chunks_added, duration_s.
        """
        t0 = time.monotonic()
        conn = self._get_conn()

        discovered = self._discover_files()
        discovered_set = {rel for rel, _ in discovered}

        files_updated = 0
        chunks_added = 0

        # Remove deleted files
        existing_paths = set(self._file_hashes.keys())
        deleted = existing_paths - discovered_set
        for rel in deleted:
            conn.execute("DELETE FROM chunks WHERE file_path = ?", (rel,))
            conn.execute("DELETE FROM file_meta WHERE path = ?", (rel,))
            self._file_hashes.pop(rel, None)

        # Check for new/changed files
        for rel_path, mtime in discovered:
            abs_path = self.project_root / rel_path

            # Quick mtime check — skip if mtime unchanged (avoids file read)
            old_hash = self._file_hashes.get(rel_path)
            if old_hash is not None:
                row = conn.execute(
                    "SELECT mtime FROM file_meta WHERE path = ?", (rel_path,)
                ).fetchone()
                if row and row[0] == mtime:
                    continue  # mtime unchanged → skip

            try:
                content = abs_path.read_text(encoding="utf-8", errors="replace")
            except Exception:
                continue

            new_hash = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:16]

            if new_hash == old_hash:
                # mtime changed but content didn't — update mtime only
                conn.execute("UPDATE file_meta SET mtime = ? WHERE path = ?", (mtime, rel_path))
                continue

            # File changed — re-index it
            conn.execute("DELETE FROM chunks WHERE file_path = ?", (rel_path,))
            conn.execute("DELETE FROM file_meta WHERE path = ?", (rel_path,))

            ct = classify_content_type(rel_path)
            line_count = content.count("\n") + 1

            conn.execute(
                "INSERT OR REPLACE INTO file_meta VALUES (?, ?, ?, ?, ?, ?, ?)",
                (rel_path, ct, new_hash, line_count, len(content), mtime, time.time()),
            )
            self._file_hashes[rel_path] = new_hash

            chunks = chunk_file(rel_path, content, self.config)
            for chunk in chunks:
                conn.execute(
                    "INSERT INTO chunks (file_path, content, start_line, end_line, content_type, section_title, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (chunk.file_path, chunk.content, chunk.start_line, chunk.end_line, chunk.content_type, chunk.section_title, chunk.content_hash),
                )
                chunks_added += 1

            files_updated += 1

        conn.commit()

        duration = time.monotonic() - t0
        stats = {
            "files_checked": len(discovered),
            "files_deleted": len(deleted),
            "files_updated": files_updated,
            "chunks_added": chunks_added,
            "duration_s": round(duration, 2),
        }
        logger.debug("[ProjectIndexer] Incremental update: %s", stats)
        return stats

    # ----------------------------------------------------------------
    # Search
    # ----------------------------------------------------------------

    def search(
        self,
        query: str,
        top_k: Optional[int] = None,
        max_chars: Optional[int] = None,
        content_type_filter: Optional[str] = None,
    ) -> List[ProjectSearchResult]:
        """
        BM25 search over the project index.

        Returns ranked chunks, limited by top_k and max_chars, with
        per-file cap of max_chunks_per_file.

        Args:
            query: Natural language search query.
            top_k: Max results (defaults to config.max_results).
            max_chars: Max total characters (defaults to config.max_chars).
            content_type_filter: Restrict results to a single content type
                (e.g. 'wiki', 'code', 'docs', 'plans'). None = all types.

        Returns:
            List of ProjectSearchResult sorted by score descending.
        """
        if not query.strip():
            return []

        top_k = top_k or self.config.max_results
        max_chars = max_chars or self.config.max_chars
        conn = self._get_conn()

        # Clean up query for FTS5
        safe_query = self._sanitize_fts_query(query)
        if not safe_query:
            return []

        try:
            if content_type_filter:
                rows = conn.execute(
                    """
                    SELECT file_path, content, start_line, end_line, content_type,
                           section_title, bm25(chunks) AS score
                    FROM chunks
                    WHERE chunks MATCH ?
                      AND content_type = ?
                    ORDER BY bm25(chunks)
                    LIMIT ?
                    """,
                    (safe_query, content_type_filter, top_k * 3),
                ).fetchall()
            else:
                rows = conn.execute(
                    """
                    SELECT file_path, content, start_line, end_line, content_type,
                           section_title, bm25(chunks) AS score
                    FROM chunks
                    WHERE chunks MATCH ?
                    ORDER BY bm25(chunks)
                    LIMIT ?
                    """,
                    (safe_query, top_k * 3),  # fetch extra for per-file dedup
                ).fetchall()
        except sqlite3.OperationalError as e:
            logger.debug("[ProjectIndexer] FTS5 query error: %s", e)
            return []

        # Apply content-type boosting + per-file cap
        results: List[ProjectSearchResult] = []
        file_counts: Dict[str, int] = {}
        total_chars = 0

        for row in rows:
            fpath, content, sl, el, ct, title, raw_score = row
            boost = content_type_priority(ct)
            score = abs(raw_score) * boost  # BM25 returns negative scores

            fc = file_counts.get(fpath, 0)
            if fc >= self.config.max_chunks_per_file:
                continue

            if total_chars + len(content) > max_chars:
                continue

            results.append(ProjectSearchResult(
                file_path=fpath,
                content=content,
                start_line=sl,
                end_line=el,
                content_type=ct,
                section_title=title,
                score=round(score, 4),
            ))
            file_counts[fpath] = fc + 1
            total_chars += len(content)

            if len(results) >= top_k:
                break

        # Assign ranks
        results.sort(key=lambda r: r.score, reverse=True)
        for i, r in enumerate(results):
            r.rank = i + 1

        return results

    @staticmethod
    def _sanitize_fts_query(query: str) -> str:
        """Convert natural language query to FTS5 safe query."""
        # Remove special FTS5 characters
        cleaned = re.sub(r'[^\w\s]', ' ', query)
        tokens = cleaned.split()
        # Keep meaningful tokens
        tokens = [t for t in tokens if len(t) >= 2]
        if not tokens:
            return ""
        # Join with OR for broader matching
        return " OR ".join(tokens)

    # ----------------------------------------------------------------
    # Context assembly (for LLM injection)
    # ----------------------------------------------------------------

    def get_context(
        self,
        query: str,
        max_chars: int = 8_000,
        content_type_filter: Optional[str] = None,
        top_k: int = 8,
    ) -> str:
        """
        Retrieve and format the most relevant chunks as an LLM context string.

        Args:
            query: Natural language query.
            max_chars: Budget for total context characters.
            content_type_filter: Restrict to a content type (e.g. 'wiki').
            top_k: Maximum chunks to include.

        Returns:
            Formatted multi-block context string, or empty string if nothing found.
        """
        results = self.search(
            query,
            top_k=top_k,
            max_chars=max_chars,
            content_type_filter=content_type_filter,
        )
        if not results:
            return ""
        return "\n\n".join(r.to_context_string() for r in results)

    # ----------------------------------------------------------------
    # Stats
    # ----------------------------------------------------------------

    def stats(self) -> Dict[str, int]:
        """Return index statistics."""
        conn = self._get_conn()

        file_count = conn.execute("SELECT COUNT(*) FROM file_meta").fetchone()[0]
        chunk_count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
        total_chars = conn.execute("SELECT COALESCE(SUM(char_count), 0) FROM file_meta").fetchone()[0]

        last_scan = None
        row = conn.execute("SELECT value FROM index_meta WHERE key = 'last_scan'").fetchone()
        if row:
            last_scan = float(row[0])

        return {
            "file_count": file_count,
            "chunk_count": chunk_count,
            "total_chars": total_chars,
            "total_size_kb": round(total_chars / 1024, 1),
            "db_path": str(self._db_path),
            "last_scan": last_scan,
            "last_scan_duration": self._last_scan_duration,
        }

    # ----------------------------------------------------------------
    # File tree summary
    # ----------------------------------------------------------------

    def file_tree_summary(self, max_lines: int = 80) -> str:
        """Generate a compact file tree for LLM context injection."""
        conn = self._get_conn()
        rows = conn.execute("SELECT path, content_type FROM file_meta ORDER BY path").fetchall()

        if not rows:
            return "(no files indexed)"

        lines: List[str] = [f"Project: {self.project_root.name} ({len(rows)} files)"]

        # Group by top-level directory
        groups: Dict[str, List[str]] = {}
        for path, ct in rows:
            parts = path.replace("\\", "/").split("/")
            key = parts[0] if len(parts) > 1 else "."
            groups.setdefault(key, []).append(path)

        for folder in sorted(groups.keys()):
            files = groups[folder]
            lines.append(f"  {folder}/ ({len(files)} files)")
            if len(lines) >= max_lines:
                lines.append(f"  ... and {len(rows) - sum(len(v) for v in groups.values())} more")
                break

        return "\n".join(lines[:max_lines])

    # ----------------------------------------------------------------
    # Lifecycle
    # ----------------------------------------------------------------

    def close(self) -> None:
        """Close the database connection."""
        if self._conn:
            try:
                self._conn.close()
            except Exception:  # noqa: BLE001
                pass  # best-effort; failure is non-critical
            self._conn = None

    def drop_index(self) -> None:
        """Delete the entire index database."""
        self.close()
        try:
            self._db_path.unlink(missing_ok=True)
        except Exception as e:
            logger.warning("[ProjectIndexer] Failed to delete index: %s", e)
        self._file_hashes.clear()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()
