"""Index storage with save/load, byte-offset content retrieval, and incremental indexing."""

import functools
import hashlib
import json
import logging
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from filelock import FileLock
from datetime import datetime
from pathlib import Path
from typing import Optional

from ..parser.symbols import Symbol

logger = logging.getLogger(__name__)

# Bump this when the index schema changes in an incompatible way.
INDEX_VERSION = 4


@functools.lru_cache(maxsize=16)
def _load_index_json_cached(index_path: str, mtime_ns: int) -> Optional[dict]:
    """Cache parsed JSON keyed on (path, mtime). mtime_ns ensures
    automatic invalidation when the file changes on disk."""
    try:
        with open(index_path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        logger.debug("Failed to load index JSON: %s", index_path, exc_info=True)
        return None


def _invalidate_index_cache() -> None:
    """Clear the index JSON cache after writes."""
    _load_index_json_cached.cache_clear()


def _file_hash(content: str) -> str:
    """SHA-256 hash of file content string."""
    return hashlib.sha256(content.encode("utf-8")).hexdigest()


def _get_git_head(repo_path: Path) -> Optional[str]:
    """Get current HEAD commit hash for a git repo, or None."""
    try:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=str(repo_path),
            capture_output=True, text=True, timeout=5,
            stdin=subprocess.DEVNULL,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except Exception:
        logger.debug("Failed to get git HEAD for %s", repo_path, exc_info=True)
    return None


@dataclass
class CodeIndex:
    """Index for a repository's source code."""
    repo: str                    # "owner/repo"
    owner: str
    name: str
    indexed_at: str              # ISO timestamp
    source_files: list[str]      # All indexed file paths
    languages: dict[str, int]    # Language -> file count
    symbols: list[dict]          # Serialized Symbol dicts (without source content)
    index_version: int = INDEX_VERSION
    file_hashes: dict[str, str] = field(default_factory=dict)  # file_path -> sha256
    git_head: str = ""           # HEAD commit hash at index time (for git repos)
    file_summaries: dict[str, str] = field(default_factory=dict)  # file_path -> summary
    source_root: str = ""        # Absolute source root for local indexes, empty for remote
    file_languages: dict[str, str] = field(default_factory=dict)  # file_path -> language
    display_name: str = ""       # User-facing name (for local hashed repo IDs)
    imports: Optional[dict[str, list[dict]]] = None  # file_path -> [{specifier, names}]; None = not indexed yet (pre-v1.3.0)
    context_metadata: dict = field(default_factory=dict)  # Provider metadata (e.g., dbt_columns)
    file_blob_shas: dict[str, str] = field(default_factory=dict)  # file_path -> GitHub blob SHA (remote repos only)

    def __post_init__(self) -> None:
        if not self.display_name:
            self.display_name = self.name
        # Build O(1) lookup structures once at load time.
        self._symbol_index: dict[str, dict] = {s["id"]: s for s in self.symbols if "id" in s}
        self._source_file_set: set[str] = set(self.source_files)

    def get_symbol(self, symbol_id: str) -> Optional[dict]:
        """Find a symbol by ID (O(1))."""
        return self._symbol_index.get(symbol_id)

    def has_source_file(self, file_path: str) -> bool:
        """Check whether a file is present in the index."""
        return file_path in self._source_file_set

    def search(self, query: str, kind: Optional[str] = None, file_pattern: Optional[str] = None, limit: int = 0) -> list[dict]:
        """Search symbols with weighted scoring.

        Args:
            limit: When > 0, use a bounded heap to cap memory at ~limit items.
                   Results may slightly exceed limit to preserve ties at the boundary.
                   When 0, return all matches (legacy behavior).
        """
        import heapq

        query_lower = query.lower()
        query_words = set(query_lower.split())

        if limit > 0:
            # Bounded heap: keep only the top `limit` scores using a min-heap
            heap: list[tuple[int, int, dict]] = []  # (score, counter, sym)
            counter = 0
            for sym in self.symbols:
                if kind and sym.get("kind") != kind:
                    continue
                if file_pattern and not self._match_pattern(sym.get("file", ""), file_pattern):
                    continue
                score = self._score_symbol(sym, query_lower, query_words)
                if score > 0:
                    counter += 1
                    if len(heap) < limit:
                        heapq.heappush(heap, (score, counter, sym))
                    elif score > heap[0][0]:
                        heapq.heapreplace(heap, (score, counter, sym))
            return [sym for _, _, sym in sorted(heap, key=lambda x: x[0], reverse=True)]
        else:
            scored = []
            for sym in self.symbols:
                if kind and sym.get("kind") != kind:
                    continue
                if file_pattern and not self._match_pattern(sym.get("file", ""), file_pattern):
                    continue
                score = self._score_symbol(sym, query_lower, query_words)
                if score > 0:
                    scored.append((score, sym))
            scored.sort(key=lambda x: x[0], reverse=True)
            return [sym for _, sym in scored]

    def _match_pattern(self, file_path: str, pattern: str) -> bool:
        """Match file path against glob pattern."""
        import fnmatch
        return fnmatch.fnmatch(file_path, pattern) or fnmatch.fnmatch(file_path, f"*/{pattern}")

    def _score_symbol(self, sym: dict, query_lower: str, query_words: set) -> int:
        """Calculate search score for a symbol."""
        score = 0

        # 1. Exact name match (highest weight)
        name_lower = sym.get("name", "").lower()
        if query_lower == name_lower:
            score += 20
        elif query_lower in name_lower:
            score += 10

        # 2. Name word overlap
        for word in query_words:
            if word in name_lower:
                score += 5

        # 3. Signature match
        sig_lower = sym.get("signature", "").lower()
        if query_lower in sig_lower:
            score += 8
        for word in query_words:
            if word in sig_lower:
                score += 2

        # 4. Summary match
        summary_lower = sym.get("summary", "").lower()
        if query_lower in summary_lower:
            score += 5
        for word in query_words:
            if word in summary_lower:
                score += 1

        # 5. Keyword match
        keywords = set(sym.get("keywords", []))
        matching_keywords = query_words & keywords
        score += len(matching_keywords) * 3

        # 6. Docstring match
        doc_lower = sym.get("docstring", "").lower()
        for word in query_words:
            if word in doc_lower:
                score += 1

        return score


class IndexStore:
    """Storage for code indexes with byte-offset content retrieval."""

    def __init__(self, base_path: Optional[str] = None):
        """Initialize store.

        Args:
            base_path: Base directory for storage. Defaults to ~/.code-index/
        """
        if base_path:
            self.base_path = Path(base_path)
        else:
            self.base_path = Path.home() / ".code-index"

        self.base_path.mkdir(parents=True, exist_ok=True)

    def _safe_repo_component(self, value: str, field_name: str) -> str:
        """Validate and sanitize owner/name components used in on-disk cache paths.

        Characters outside [A-Za-z0-9._-] (e.g. spaces) are replaced with hyphens
        so that directories with special characters in their names can be indexed.
        Path separators are still rejected outright.
        """
        import re

        if not value or value in {".", ".."}:
            raise ValueError(f"Invalid {field_name}: {value!r}")
        if "/" in value or "\\" in value:
            raise ValueError(f"Invalid {field_name}: {value!r}")
        # Sanitize invalid characters to hyphens rather than raising
        value = re.sub(r"[^A-Za-z0-9._-]", "-", value)
        value = re.sub(r"-+", "-", value).strip("-")
        if not value:
            raise ValueError(f"Invalid {field_name}: sanitized to empty string")
        return value

    def _repo_slug(self, owner: str, name: str) -> str:
        """Stable and safe slug used for index/content file paths."""
        safe_owner = self._safe_repo_component(owner, "owner")
        safe_name = self._safe_repo_component(name, "name")
        return f"{safe_owner}-{safe_name}"

    def _index_path(self, owner: str, name: str) -> Path:
        """Path to index JSON file."""
        return self.base_path / f"{self._repo_slug(owner, name)}.json"

    def _lock_path(self, owner: str, name: str) -> Path:
        """Path to per-repo advisory lock file."""
        return self.base_path / f"{self._repo_slug(owner, name)}.json.lock"

    def _meta_path(self, owner: str, name: str) -> Path:
        """Path to lightweight metadata sidecar for list_repos."""
        return self.base_path / f"{self._repo_slug(owner, name)}.meta.json"

    def _checksum_path(self, index_path: Path) -> Path:
        """Path to SHA-256 integrity checksum sidecar."""
        return index_path.with_suffix(".json.sha256")

    def _write_checksum(self, index_path: Path, index_bytes: bytes) -> None:
        """Write SHA-256 checksum sidecar for index integrity verification."""
        sha = hashlib.sha256(index_bytes).hexdigest()
        try:
            self._checksum_path(index_path).write_text(sha, encoding="utf-8")
        except Exception:
            logger.debug("Failed to write checksum sidecar for %s", index_path, exc_info=True)

    def _verify_checksum(self, index_path: Path) -> bool:
        """Verify index file against its SHA-256 checksum sidecar.

        Returns True if no sidecar exists (backwards-compatible) or checksum matches.
        Logs a warning on mismatch but still returns True (non-blocking).
        """
        sha_path = self._checksum_path(index_path)
        if not sha_path.exists():
            return True  # No sidecar — old index, skip check
        try:
            expected = sha_path.read_text(encoding="utf-8").strip()
            actual = hashlib.sha256(index_path.read_bytes()).hexdigest()
            if actual != expected:
                logger.warning(
                    "Index integrity check failed for %s — expected %s, got %s. "
                    "The index may have been modified externally. Re-index to fix.",
                    index_path, expected[:12], actual[:12],
                )
            return True
        except Exception:
            logger.debug("Checksum verification error for %s", index_path, exc_info=True)
            return True  # Err on the side of loading

    def _write_meta_sidecar(self, index: "CodeIndex") -> None:
        """Write a small metadata sidecar alongside the full index."""
        meta = {
            "repo": index.repo,
            "indexed_at": index.indexed_at,
            "symbol_count": len(index.symbols),
            "file_count": len(index.source_files),
            "languages": index.languages,
            "index_version": index.index_version,
            "git_head": index.git_head,
            "display_name": index.display_name,
            "source_root": index.source_root,
        }
        meta_path = self._meta_path(index.owner, index.name)
        try:
            fd, tmp_name = tempfile.mkstemp(dir=meta_path.parent, suffix=".meta.tmp")
            try:
                with os.fdopen(fd, "w", encoding="utf-8") as f:
                    json.dump(meta, f)
                Path(tmp_name).replace(meta_path)
            except Exception:
                Path(tmp_name).unlink(missing_ok=True)
        except Exception:
            logger.debug("Failed to write meta sidecar", exc_info=True)

    def _content_dir(self, owner: str, name: str) -> Path:
        """Path to raw content directory."""
        return self.base_path / self._repo_slug(owner, name)

    def _safe_content_path(self, content_dir: Path, relative_path: str) -> Optional[Path]:
        """Resolve a content path and ensure it stays within content_dir.

        Prevents path traversal when writing/reading cached raw files from
        untrusted repository paths.
        """
        try:
            base = content_dir.resolve()
            candidate = (content_dir / relative_path).resolve()
            if os.path.commonpath([str(base), str(candidate)]) != str(base):
                return None
            return candidate
        except (OSError, ValueError):
            return None

    def _write_cached_text(self, path: Path, content: str) -> None:
        """Write cached text without newline translation."""
        with open(path, "w", encoding="utf-8", newline="") as f:
            f.write(content)

    def _read_cached_text(self, path: Path) -> Optional[str]:
        """Read cached text without newline normalization."""
        try:
            with open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
                return f.read()
        except OSError:
            return None

    def _repo_metadata_from_data(self, data: dict, owner: str, name: str) -> tuple[str, str, str]:
        """Normalize repo/owner/name fields from stored JSON."""
        repo_id = data.get("repo", f"{owner}/{name}")
        if "/" in repo_id:
            repo_owner, repo_name = repo_id.split("/", 1)
        else:
            repo_owner, repo_name = owner, name
        return repo_id, data.get("owner", repo_owner), data.get("name", repo_name)

    def _file_languages_from_symbols(self, symbols: list[dict]) -> dict[str, str]:
        """Compute file -> language using symbol metadata."""
        file_languages: dict[str, str] = {}
        for sym in symbols:
            file_path = sym.get("file")
            language = sym.get("language")
            if file_path and language and file_path not in file_languages:
                file_languages[file_path] = language
        return file_languages

    def _file_languages_for_paths(
        self,
        paths: list[str],
        symbols: list[dict],
        existing: Optional[dict[str, str]] = None,
    ) -> dict[str, str]:
        """Fill file -> language for the given paths using symbols then extension fallback."""
        from ..parser.languages import get_language_for_path

        symbol_languages = self._file_languages_from_symbols(symbols)
        file_languages = dict(existing or {})

        for file_path in paths:
            language = (
                symbol_languages.get(file_path)
                or file_languages.get(file_path)
                or get_language_for_path(file_path)
                or ""
            )
            if language:
                file_languages[file_path] = language

        return {file_path: file_languages[file_path] for file_path in paths if file_path in file_languages}

    def _languages_from_file_languages(self, file_languages: dict[str, str]) -> dict[str, int]:
        """Compute language -> file count from stored file language metadata."""
        counts: dict[str, int] = {}
        for language in file_languages.values():
            counts[language] = counts.get(language, 0) + 1
        return counts

    def save_index(
        self,
        owner: str,
        name: str,
        source_files: list[str],
        symbols: list[Symbol],
        raw_files: dict[str, str],
        languages: Optional[dict[str, int]] = None,
        file_hashes: Optional[dict[str, str]] = None,
        git_head: str = "",
        file_summaries: Optional[dict[str, str]] = None,
        source_root: str = "",
        file_languages: Optional[dict[str, str]] = None,
        display_name: str = "",
        imports: Optional[dict[str, list[dict]]] = None,
        context_metadata: Optional[dict] = None,
        file_blob_shas: Optional[dict[str, str]] = None,
    ) -> "CodeIndex":
        """Save index and raw files to storage."""
        normalized_source_files = sorted(dict.fromkeys(source_files or list(raw_files.keys())))
        serialized_symbols = [self._symbol_to_dict(s) for s in symbols]
        merged_file_languages = self._file_languages_for_paths(
            normalized_source_files,
            serialized_symbols,
            existing=file_languages,
        )
        resolved_languages = languages or self._languages_from_file_languages(merged_file_languages)

        # Compute file hashes if not provided
        if file_hashes is None:
            file_hashes = {fp: _file_hash(content) for fp, content in raw_files.items()}

        # Create index
        index = CodeIndex(
            repo=f"{owner}/{name}",
            owner=owner,
            name=name,
            indexed_at=datetime.now().isoformat(),
            source_files=normalized_source_files,
            languages=resolved_languages,
            symbols=serialized_symbols,
            index_version=INDEX_VERSION,
            file_hashes=file_hashes,
            git_head=git_head,
            file_summaries=file_summaries or {},
            source_root=source_root,
            file_languages=merged_file_languages,
            display_name=display_name or name,
            imports=imports if imports is not None else {},
            context_metadata=context_metadata or {},
            file_blob_shas=file_blob_shas or {},
        )

        # Lock, write index + raw files atomically
        index_path = self._index_path(owner, name)
        with FileLock(str(self._lock_path(owner, name))):
            index_json_bytes = json.dumps(self._index_to_dict(index), indent=2).encode("utf-8")
            fd, tmp_name = tempfile.mkstemp(dir=index_path.parent, suffix=".json.tmp")
            try:
                with os.fdopen(fd, "wb") as f:
                    f.write(index_json_bytes)
                Path(tmp_name).replace(index_path)
            except Exception:
                Path(tmp_name).unlink(missing_ok=True)
                raise

            # Write integrity checksum sidecar
            self._write_checksum(index_path, index_json_bytes)

            # Save raw files
            content_dir = self._content_dir(owner, name)
            content_dir.mkdir(parents=True, exist_ok=True)

            for file_path, content in raw_files.items():
                file_dest = self._safe_content_path(content_dir, file_path)
                if not file_dest:
                    raise ValueError(f"Unsafe file path in raw_files: {file_path}")
                file_dest.parent.mkdir(parents=True, exist_ok=True)
                self._write_cached_text(file_dest, content)

        self._write_meta_sidecar(index)
        _invalidate_index_cache()
        return index

    def has_index(self, owner: str, name: str) -> bool:
        """Return True if an index file exists on disk (regardless of version)."""
        return self._index_path(owner, name).exists()

    def load_index(self, owner: str, name: str) -> Optional[CodeIndex]:
        """Load index from storage. Rejects incompatible versions.

        Uses a process-level LRU cache keyed on (path, mtime_ns) so
        repeated tool calls in the same session skip disk I/O and JSON
        deserialization. The cache auto-invalidates when the file changes.
        """
        index_path = self._index_path(owner, name)

        if not index_path.exists():
            return None

        # Integrity checksum verification
        if not self._verify_checksum(index_path):
            return None

        mtime_ns = index_path.stat().st_mtime_ns
        data = _load_index_json_cached(str(index_path), mtime_ns)
        if data is None:
            return None

        # Schema validation: require essential fields
        if not isinstance(data, dict) or "indexed_at" not in data:
            logger.warning(
                "Index schema validation failed for %s/%s — missing required fields; re-index to fix",
                owner, name,
            )
            return None

        # Version check
        stored_version = data.get("index_version", 1)
        if stored_version > INDEX_VERSION:
            logger.warning(
                "load_index version_mismatch — stored v%d > current v%d for %s/%s; rejecting index",
                stored_version, INDEX_VERSION, owner, name,
            )
            return None  # Future version we can't read

        repo_id, stored_owner, stored_name = self._repo_metadata_from_data(data, owner, name)
        source_files = data.get("source_files", [])
        symbols = data.get("symbols", [])
        file_languages = self._file_languages_for_paths(
            source_files,
            symbols,
            existing=data.get("file_languages"),
        )
        languages = self._languages_from_file_languages(file_languages)
        if not languages:
            languages = data.get("languages", {})

        return CodeIndex(
            repo=repo_id,
            owner=stored_owner,
            name=stored_name,
            indexed_at=data["indexed_at"],
            source_files=source_files,
            languages=languages,
            symbols=symbols,
            index_version=stored_version,
            file_hashes=data.get("file_hashes", {}),
            git_head=data.get("git_head", ""),
            file_summaries=data.get("file_summaries", {}),
            source_root=data.get("source_root", ""),
            file_languages=file_languages,
            display_name=data.get("display_name", stored_name),
            imports=data["imports"] if "imports" in data else None,
            context_metadata=data.get("context_metadata", {}),
            file_blob_shas=data.get("file_blob_shas", {}),
        )

    def get_symbol_content(self, owner: str, name: str, symbol_id: str, _index: Optional["CodeIndex"] = None) -> Optional[str]:
        """Read symbol source using stored byte offsets.

        Pass _index to avoid a redundant load_index() call when the caller
        already holds a loaded index.
        """
        index = _index or self.load_index(owner, name)
        if not index:
            return None

        symbol = index.get_symbol(symbol_id)
        if not symbol:
            return None

        file_path = self._safe_content_path(self._content_dir(owner, name), symbol["file"])
        if not file_path:
            return None

        if not file_path.exists():
            return None

        with open(file_path, "rb") as f:
            f.seek(symbol["byte_offset"])
            source_bytes = f.read(symbol["byte_length"])

        return source_bytes.decode("utf-8", errors="replace")

    def get_file_content(
        self,
        owner: str,
        name: str,
        file_path: str,
        _index: Optional["CodeIndex"] = None,
    ) -> Optional[str]:
        """Read a cached file's full content."""
        index = _index or self.load_index(owner, name)
        if not index or not index.has_source_file(file_path):
            return None

        content_path = self._safe_content_path(self._content_dir(owner, name), file_path)
        if not content_path or not content_path.exists():
            return None

        return self._read_cached_text(content_path)

    def detect_changes(
        self,
        owner: str,
        name: str,
        current_files: dict[str, str],
    ) -> tuple[list[str], list[str], list[str]]:
        """Detect changed, new, and deleted files by comparing hashes."""
        current_hashes = {fp: _file_hash(content) for fp, content in current_files.items()}
        return self.detect_changes_from_hashes(owner, name, current_hashes)

    def detect_changes_from_hashes(
        self,
        owner: str,
        name: str,
        current_hashes: dict[str, str],
    ) -> tuple[list[str], list[str], list[str]]:
        """Detect changed, new, and deleted files from precomputed hashes.

        Like detect_changes() but avoids requiring full file contents in memory.
        """
        index = self.load_index(owner, name)
        if not index:
            return [], list(current_hashes.keys()), []

        old_hashes = index.file_hashes

        old_set = set(old_hashes.keys())
        new_set = set(current_hashes.keys())

        new_files = list(new_set - old_set)
        deleted_files = list(old_set - new_set)
        changed_files = [
            fp for fp in (old_set & new_set)
            if old_hashes[fp] != current_hashes[fp]
        ]

        return changed_files, new_files, deleted_files

    def incremental_save(
        self,
        owner: str,
        name: str,
        changed_files: list[str],
        new_files: list[str],
        deleted_files: list[str],
        new_symbols: list[Symbol],
        raw_files: dict[str, str],
        languages: Optional[dict[str, int]] = None,
        git_head: str = "",
        file_summaries: Optional[dict[str, str]] = None,
        file_languages: Optional[dict[str, str]] = None,
        imports: Optional[dict[str, list[dict]]] = None,
        context_metadata: Optional[dict] = None,
        file_blob_shas: Optional[dict[str, str]] = None,
        file_hashes: Optional[dict[str, str]] = None,
    ) -> Optional[CodeIndex]:
        """Incrementally update an existing index.

        Removes symbols for deleted/changed files, adds new symbols,
        updates raw content, and saves atomically. Uses a per-repo file
        lock to prevent concurrent read-modify-write races.
        """
        with FileLock(str(self._lock_path(owner, name))):
            index = self.load_index(owner, name)
            if not index:
                return None

            # Remove symbols for deleted and changed files
            files_to_remove = set(deleted_files) | set(changed_files)
            kept_symbols = [s for s in index.symbols if s.get("file") not in files_to_remove]

            # Add new symbols
            new_symbol_dicts = [self._symbol_to_dict(s) for s in new_symbols]
            all_symbols_dicts = kept_symbols + new_symbol_dicts

            changed_or_new_files = sorted(set(changed_files) | set(new_files))
            merged_file_languages = dict(index.file_languages)
            for file_path in deleted_files:
                merged_file_languages.pop(file_path, None)
            merged_file_languages.update(
                self._file_languages_for_paths(
                    changed_or_new_files,
                    new_symbol_dicts,
                    existing={**index.file_languages, **(file_languages or {})},
                )
            )

            recomputed_languages = self._languages_from_file_languages(merged_file_languages)
            if not recomputed_languages and languages:
                recomputed_languages = languages

            # Update source files list
            old_files = set(index.source_files)
            for f in deleted_files:
                old_files.discard(f)
            for f in new_files:
                old_files.add(f)
            for f in changed_files:
                old_files.add(f)

            # Update file hashes — prefer precomputed hashes to avoid
            # recomputing from raw_files content
            merged_hashes = dict(index.file_hashes)
            for f in deleted_files:
                merged_hashes.pop(f, None)
            if file_hashes:
                merged_hashes.update(file_hashes)
            else:
                for fp, content in raw_files.items():
                    merged_hashes[fp] = _file_hash(content)

            # Merge file summaries: keep old, remove deleted, update changed/new
            merged_summaries = dict(index.file_summaries)
            for f in deleted_files:
                merged_summaries.pop(f, None)
            for f in changed_or_new_files:
                merged_summaries.pop(f, None)
            if file_summaries:
                merged_summaries.update(file_summaries)

            # Merge import graph: keep existing, remove deleted, update changed/new
            # index.imports is None for pre-v1.3.0 indexes; use {} as base if so
            merged_imports = dict(index.imports) if index.imports is not None else {}
            for f in deleted_files:
                merged_imports.pop(f, None)
            for f in changed_files:
                merged_imports.pop(f, None)
            if imports:
                merged_imports.update(imports)

            # Merge blob SHAs: keep old, remove deleted, update changed/new
            merged_blob_shas = dict(index.file_blob_shas)
            for f in deleted_files:
                merged_blob_shas.pop(f, None)
            if file_blob_shas:
                merged_blob_shas.update(file_blob_shas)

            # Build updated index
            updated_source_files = sorted(old_files)
            updated = CodeIndex(
                repo=f"{owner}/{name}",
                owner=owner,
                name=name,
                indexed_at=datetime.now().isoformat(),
                source_files=updated_source_files,
                languages=recomputed_languages,
                symbols=all_symbols_dicts,
                index_version=INDEX_VERSION,
                file_hashes=merged_hashes,
                git_head=git_head,
                file_summaries=merged_summaries,
                source_root=index.source_root,
                file_languages={fp: merged_file_languages[fp] for fp in updated_source_files if fp in merged_file_languages},
                display_name=index.display_name,
                imports=merged_imports,
                context_metadata=context_metadata if context_metadata else index.context_metadata,
                file_blob_shas=merged_blob_shas,
            )

            # Save atomically: unpredictable temp file prevents symlink attacks
            index_path = self._index_path(owner, name)
            index_json_bytes = json.dumps(self._index_to_dict(updated), indent=2).encode("utf-8")
            fd, tmp_name = tempfile.mkstemp(dir=index_path.parent, suffix=".json.tmp")
            try:
                with os.fdopen(fd, "wb") as f:
                    f.write(index_json_bytes)
                Path(tmp_name).replace(index_path)
            except Exception:
                Path(tmp_name).unlink(missing_ok=True)
                raise

            # Write integrity checksum sidecar
            self._write_checksum(index_path, index_json_bytes)

            # Update raw files
            content_dir = self._content_dir(owner, name)
            content_dir.mkdir(parents=True, exist_ok=True)

            # Remove deleted files from content dir
            for fp in deleted_files:
                dead = self._safe_content_path(content_dir, fp)
                if not dead:
                    continue
                if dead.exists():
                    dead.unlink()

            # Write changed + new files
            for fp, content in raw_files.items():
                dest = self._safe_content_path(content_dir, fp)
                if not dest:
                    raise ValueError(f"Unsafe file path in raw_files: {fp}")
                dest.parent.mkdir(parents=True, exist_ok=True)
                self._write_cached_text(dest, content)

        self._write_meta_sidecar(updated)
        _invalidate_index_cache()
        return updated

    def _languages_from_symbols(self, symbols: list[dict]) -> dict[str, int]:
        """Compute language->file_count from serialized symbols."""
        return self._languages_from_file_languages(self._file_languages_from_symbols(symbols))

    def _repo_entry_from_data(self, data: dict) -> Optional[dict]:
        """Build a repo listing entry from index or sidecar data."""
        repo_id = data.get("repo")
        if not repo_id:
            return None
        # Sidecar has symbol_count/file_count directly; full index has lists
        symbol_count = data.get("symbol_count")
        if symbol_count is None:
            symbol_count = len(data.get("symbols", []))
        file_count = data.get("file_count")
        if file_count is None:
            file_count = len(data.get("source_files", []))
        repo_entry = {
            "repo": repo_id,
            "indexed_at": data.get("indexed_at", ""),
            "symbol_count": symbol_count,
            "file_count": file_count,
            "languages": data.get("languages", {}),
            "index_version": data.get("index_version", 1),
        }
        if data.get("git_head"):
            repo_entry["git_head"] = data["git_head"]
        if data.get("display_name"):
            repo_entry["display_name"] = data["display_name"]
        if data.get("source_root"):
            if os.environ.get("JCODEMUNCH_REDACT_SOURCE_ROOT", "") == "1":
                repo_entry["source_root"] = data.get("display_name", "") or ""
            else:
                repo_entry["source_root"] = data["source_root"]
        return repo_entry

    def list_repos(self) -> list[dict]:
        """List all indexed repositories.

        Reads lightweight .meta.json sidecars when available, falling back
        to full index JSON for older indexes that lack a sidecar.
        """
        repos = []
        seen_slugs: set[str] = set()

        # Pass 1: read lightweight sidecars
        for meta_file in self.base_path.glob("*.meta.json"):
            try:
                slug = meta_file.name.removesuffix(".meta.json")
                seen_slugs.add(slug)
                with open(meta_file, "r", encoding="utf-8") as f:
                    data = json.load(f)
                entry = self._repo_entry_from_data(data)
                if entry:
                    repos.append(entry)
            except Exception:
                logger.debug("Skipping corrupted meta sidecar: %s", meta_file, exc_info=True)
                continue

        # Pass 2: fall back to full JSON for indexes without sidecars
        for index_file in self.base_path.glob("*.json"):
            slug = index_file.name.removesuffix(".json")
            if slug in seen_slugs or slug.endswith(".meta"):
                continue
            try:
                with open(index_file, "r", encoding="utf-8") as f:
                    data = json.load(f)
                entry = self._repo_entry_from_data(data)
                if entry:
                    repos.append(entry)
            except Exception:
                logger.debug("Skipping corrupted index file: %s", index_file, exc_info=True)
                continue

        repos.sort(key=lambda repo: repo["repo"])
        return repos

    def delete_index(self, owner: str, name: str) -> bool:
        """Delete an index, its sidecar, and its raw files."""
        index_path = self._index_path(owner, name)
        meta_path = self._meta_path(owner, name)
        lock_path = self._lock_path(owner, name)
        content_dir = self._content_dir(owner, name)

        deleted = False

        if index_path.exists():
            index_path.unlink()
            deleted = True

        meta_path.unlink(missing_ok=True)
        lock_path.unlink(missing_ok=True)

        if content_dir.exists():
            shutil.rmtree(content_dir)
            deleted = True

        return deleted

    def _symbol_to_dict(self, symbol: Symbol) -> dict:
        """Convert Symbol to dict (without source content)."""
        return {
            "id": symbol.id,
            "file": symbol.file,
            "name": symbol.name,
            "qualified_name": symbol.qualified_name,
            "kind": symbol.kind,
            "language": symbol.language,
            "signature": symbol.signature,
            "docstring": symbol.docstring,
            "summary": symbol.summary,
            "decorators": symbol.decorators,
            "keywords": symbol.keywords,
            "parent": symbol.parent,
            "line": symbol.line,
            "end_line": symbol.end_line,
            "byte_offset": symbol.byte_offset,
            "byte_length": symbol.byte_length,
            "content_hash": symbol.content_hash,
        }

    def _index_to_dict(self, index: CodeIndex) -> dict:
        """Convert CodeIndex to dict."""
        return {
            "repo": index.repo,
            "owner": index.owner,
            "name": index.name,
            "indexed_at": index.indexed_at,
            "source_files": index.source_files,
            "languages": index.languages,
            "symbols": index.symbols,
            "index_version": index.index_version,
            "file_hashes": index.file_hashes,
            "git_head": index.git_head,
            "file_summaries": index.file_summaries,
            "source_root": index.source_root,
            "file_languages": index.file_languages,
            "display_name": index.display_name,
            **({} if index.imports is None else {"imports": index.imports}),
            **({"context_metadata": index.context_metadata} if index.context_metadata else {}),
            **({"file_blob_shas": index.file_blob_shas} if index.file_blob_shas else {}),
        }
