"""Reindex orchestrator: full rebuild and incremental reindex."""

# beadloom:domain=infrastructure

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any

from beadloom import __version__
from beadloom.context_oracle.code_indexer import extract_symbols, supported_extensions
from beadloom.doc_sync.doc_indexer import chunk_markdown, index_docs
from beadloom.graph.loader import load_graph
from beadloom.infrastructure.db import SCHEMA_VERSION, create_schema, open_db, set_meta
from beadloom.infrastructure.git_activity import analyze_git_activity
from beadloom.infrastructure.health import take_snapshot

if TYPE_CHECKING:
    import sqlite3
    from pathlib import Path

# Tables to drop on reindex (order matters for FK constraints).
_TABLES_TO_DROP = [
    "search_index",
    "sync_state",
    "code_imports",
    "rules",
    "code_symbols",
    "chunks",
    "docs",
    "edges",
    "nodes",
    "meta",
]

# File extension -> language label for route extraction.
_EXT_TO_LANG: dict[str, str] = {
    ".py": "python",
    ".ts": "typescript",
    ".tsx": "typescript",
    ".js": "javascript",
    ".jsx": "javascript",
    ".go": "go",
    ".java": "java",
    ".kt": "kotlin",
    ".kts": "kotlin",
    ".graphql": "graphql",
    ".gql": "graphql",
    ".proto": "protobuf",
}

# File extensions to scan for code symbols.
_CODE_EXTENSIONS = frozenset(
    {
        ".py",
        ".ts",
        ".tsx",
        ".js",
        ".jsx",
        ".vue",
        ".go",
        ".rs",
        ".kt",
        ".kts",
        ".java",
        ".swift",
        ".m",
        ".mm",
        ".c",
        ".h",
        ".cpp",
        ".hpp",
    }
)

# Default scan directories when config.yml has no scan_paths.
_DEFAULT_SCAN_DIRS = ("src", "lib", "app")


@dataclass
class ReindexResult:
    """Summary of a reindex operation."""

    nodes_loaded: int = 0
    edges_loaded: int = 0
    docs_indexed: int = 0
    chunks_indexed: int = 0
    symbols_indexed: int = 0
    imports_indexed: int = 0
    rules_loaded: int = 0
    nothing_changed: bool = False
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)


@dataclass
class _SyncPairSnapshot:
    """Preserved per-pair sync data across reindex for two-phase detection."""

    doc_hash_at_last_edit: str
    code_hash_at_sync: str


def _snapshot_sync_baselines(
    conn: sqlite3.Connection,
) -> tuple[dict[str, str], dict[tuple[str, str], _SyncPairSnapshot]]:
    """Snapshot sync_state data before table drop.

    Returns
    -------
    tuple[dict[str, str], dict[tuple[str, str], _SyncPairSnapshot]]
        ``(symbols_by_ref, pair_snapshots)`` where:
        - *symbols_by_ref* maps ``ref_id -> symbols_hash`` for symbol drift detection
        - *pair_snapshots* maps ``(doc_path, code_path) -> _SyncPairSnapshot``
          preserving two-phase sync data across reindex
    """
    try:
        rows = conn.execute(
            "SELECT ref_id, symbols_hash, doc_path, code_path, "
            "doc_hash_at_last_edit, code_hash_at_sync FROM sync_state"
        ).fetchall()
    except Exception:  # Table doesn't exist on first run
        return {}, {}

    symbols: dict[str, str] = {}
    pairs: dict[tuple[str, str], _SyncPairSnapshot] = {}
    for row in rows:
        sym_hash: str = row["symbols_hash"] or ""
        if sym_hash:
            symbols[row["ref_id"]] = sym_hash
        # sqlite3.Row `in` checks values not keys; use .keys()
        has_edit_col = "doc_hash_at_last_edit" in row.keys()  # noqa: SIM118
        edit_hash: str = row["doc_hash_at_last_edit"] if has_edit_col else ""
        if edit_hash:
            pairs[(row["doc_path"], row["code_path"])] = _SyncPairSnapshot(
                doc_hash_at_last_edit=edit_hash,
                code_hash_at_sync=row["code_hash_at_sync"],
            )
    return symbols, pairs


def _drop_all_tables(conn: sqlite3.Connection) -> None:
    """Drop all application tables to allow a clean re-create."""
    for table in _TABLES_TO_DROP:
        conn.execute(f"DROP TABLE IF EXISTS {table}")
    conn.commit()


def _build_doc_ref_map(
    graph_dir: Path,
    project_root: Path,
    docs_dir: Path,
) -> tuple[dict[str, str], list[str]]:
    """Build a mapping of relative doc path → ref_id from YAML graph nodes.

    Scans YAML graph files for nodes with ``docs`` lists and maps each
    doc path to the node's ref_id.

    Parameters
    ----------
    graph_dir:
        Path to ``.beadloom/_graph`` directory containing YAML files.
    project_root:
        Root of the project.
    docs_dir:
        Resolved documentation directory (absolute path).

    Returns
    -------
    tuple[dict[str, str], list[str]]
        ``(ref_map, warnings)`` where *warnings* lists any doc path conflicts
        (i.e. a doc referenced by more than one node).
    """
    import yaml

    ref_map: dict[str, str] = {}
    warnings: list[str] = []
    for yml_path in sorted(graph_dir.glob("*.yml")):
        text = yml_path.read_text(encoding="utf-8")
        data = yaml.safe_load(text)
        if data is None:
            continue
        for node in data.get("nodes") or []:
            ref_id = node.get("ref_id", "")
            for doc_path_str in node.get("docs") or []:
                # Normalize: if path starts with docs dir prefix, strip to make it relative.
                abs_path = project_root / doc_path_str
                if abs_path.is_relative_to(docs_dir):
                    rel = str(abs_path.relative_to(docs_dir))
                else:
                    rel = doc_path_str
                if rel in ref_map and ref_map[rel] != ref_id:
                    warnings.append(
                        f"Doc '{rel}' referenced by both '{ref_map[rel]}' and '{ref_id}'; "
                        f"keeping '{ref_map[rel]}'"
                    )
                else:
                    ref_map[rel] = ref_id
    return ref_map, warnings


def _index_code_files(
    project_root: Path,
    conn: sqlite3.Connection,
    seen_ref_ids: set[str],
) -> tuple[int, list[str]]:
    """Scan source files, extract symbols, and insert into SQLite.

    Returns (symbols_indexed, warnings).
    """
    count = 0
    warnings: list[str] = []

    # Scan directories from config.yml (or defaults).
    scan_dirs = [project_root / d for d in resolve_scan_paths(project_root)]
    for scan_dir in scan_dirs:
        if not scan_dir.is_dir():
            continue
        for file_path in sorted(scan_dir.rglob("*")):
            if file_path.suffix not in _CODE_EXTENSIONS:
                continue
            if not file_path.is_file():
                continue

            symbols = extract_symbols(file_path)
            rel_path = str(file_path.relative_to(project_root))

            for sym in symbols:
                conn.execute(
                    "INSERT INTO code_symbols (file_path, symbol_name, kind, "
                    "line_start, line_end, annotations, file_hash) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (
                        rel_path,
                        sym["symbol_name"],
                        sym["kind"],
                        sym["line_start"],
                        sym["line_end"],
                        json.dumps(sym["annotations"], ensure_ascii=False),
                        sym["file_hash"],
                    ),
                )
                count += 1

                # Create touches_code edges for annotated symbols.
                annotations: dict[str, Any] = sym["annotations"]
                for _key, ref_id in annotations.items():
                    if ref_id in seen_ref_ids:
                        conn.execute(
                            "INSERT OR IGNORE INTO edges "
                            "(src_ref_id, dst_ref_id, kind) VALUES (?, ?, ?)",
                            (ref_id, ref_id, "touches_code"),
                        )

    conn.commit()
    return count, warnings


def _build_initial_sync_state(
    conn: sqlite3.Connection,
    *,
    preserved_symbols: dict[str, str] | None = None,
    preserved_pairs: dict[tuple[str, str], _SyncPairSnapshot] | None = None,
) -> None:
    """Populate sync_state table from docs and code_symbols with shared ref_ids.

    Parameters
    ----------
    preserved_symbols:
        Optional mapping of ``ref_id → symbols_hash`` from a previous sync
        state.  When provided, the old hash is kept so that
        :func:`~beadloom.doc_sync.engine.check_sync` can detect symbol drift
        (new/removed public symbols since the last time docs were marked
        synced).  When *None* (default), a fresh hash is computed — used on
        first full reindex to establish a baseline.
    preserved_pairs:
        Optional mapping of ``(doc_path, code_path) → _SyncPairSnapshot``
        from a previous sync state.  When provided, ``doc_hash_at_last_edit``
        is preserved across reindex so that two-phase sync detection can
        identify docs that haven't been updated since code changed.
        If the doc hasn't been edited since the last known edit, the old
        ``code_hash_at_sync`` baseline is also preserved (not reset to
        current) so that :func:`~beadloom.doc_sync.engine.check_sync` can
        detect code drift relative to the last doc edit.
    """
    from beadloom.doc_sync.engine import _compute_symbols_hash, build_sync_state

    now = datetime.now(tz=timezone.utc).isoformat()
    pairs = build_sync_state(conn)
    for pair in pairs:
        pair_key = (pair.doc_path, pair.code_path)
        snapshot = (preserved_pairs or {}).get(pair_key)

        # Two-phase sync: if doc_hash_at_last_edit is set and doc hasn't
        # been edited since, preserve the old code_hash_at_sync baseline
        # so check_sync can detect code drift since last doc edit.
        if (
            snapshot is not None
            and snapshot.doc_hash_at_last_edit
            and snapshot.doc_hash_at_last_edit == pair.doc_hash
        ):
            # Doc unchanged since last edit — keep old code baseline.
            effective_code_hash = snapshot.code_hash_at_sync
        else:
            effective_code_hash = pair.code_hash

        conn.execute(
            "INSERT OR IGNORE INTO sync_state "
            "(doc_path, code_path, ref_id, code_hash_at_sync, doc_hash_at_sync, "
            "synced_at, status) VALUES (?, ?, ?, ?, ?, ?, 'ok')",
            (pair.doc_path, pair.code_path, pair.ref_id, effective_code_hash, pair.doc_hash, now),
        )
        # Preserve old symbols hash to detect drift, or compute fresh baseline.
        if preserved_symbols is not None and pair.ref_id in preserved_symbols:
            symbols_hash = preserved_symbols[pair.ref_id]
        else:
            symbols_hash = _compute_symbols_hash(conn, pair.ref_id)

        # Preserve doc_hash_at_last_edit from previous state.
        doc_hash_at_last_edit = snapshot.doc_hash_at_last_edit if snapshot else ""

        conn.execute(
            "UPDATE sync_state SET symbols_hash = ?, doc_hash_at_last_edit = ? "
            "WHERE doc_path = ? AND code_path = ?",
            (symbols_hash, doc_hash_at_last_edit, pair.doc_path, pair.code_path),
        )
    conn.commit()


def _serialize_node_matcher(matcher: object) -> dict[str, object]:
    """Serialize a NodeMatcher to a JSON-safe dict (non-None fields only)."""
    from beadloom.graph.rule_engine import NodeMatcher

    assert isinstance(matcher, NodeMatcher)
    result: dict[str, object] = {}
    if matcher.ref_id is not None:
        result["ref_id"] = matcher.ref_id
    if matcher.kind is not None:
        result["kind"] = matcher.kind
    if matcher.tag is not None:
        result["tag"] = matcher.tag
    if matcher.exclude is not None:
        result["exclude"] = list(matcher.exclude)
    return result


def _serialize_rule(rule: object) -> tuple[str, dict[str, object]]:
    """Serialize a parsed Rule object to (rule_type, rule_json_dict).

    Supports all 7 v3 rule types: DenyRule, RequireRule, CycleRule,
    ImportBoundaryRule, ForbidEdgeRule, LayerRule, CardinalityRule.
    """
    from beadloom.graph.rule_engine import (
        CardinalityRule,
        CycleRule,
        DenyRule,
        ForbidEdgeRule,
        ImportBoundaryRule,
        LayerRule,
        RequireRule,
    )

    rule_def: dict[str, object]

    if isinstance(rule, DenyRule):
        rule_def = {
            "from": _serialize_node_matcher(rule.from_matcher),
            "to": _serialize_node_matcher(rule.to_matcher),
        }
        if rule.unless_edge:
            rule_def["unless_edge"] = list(rule.unless_edge)
        return ("deny", rule_def)

    if isinstance(rule, RequireRule):
        rule_def = {
            "for": _serialize_node_matcher(rule.for_matcher),
            "has_edge_to": _serialize_node_matcher(rule.has_edge_to),
        }
        if rule.edge_kind is not None:
            rule_def["edge_kind"] = rule.edge_kind
        return ("require", rule_def)

    if isinstance(rule, CycleRule):
        edge_kind: str | list[str] = (
            list(rule.edge_kind) if isinstance(rule.edge_kind, tuple) else rule.edge_kind
        )
        rule_def = {
            "edge_kind": edge_kind,
            "max_depth": rule.max_depth,
        }
        return ("forbid_cycles", rule_def)

    if isinstance(rule, LayerRule):
        rule_def = {
            "layers": [{"name": ld.name, "tag": ld.tag} for ld in rule.layers],
            "enforce": rule.enforce,
            "allow_skip": rule.allow_skip,
            "edge_kind": rule.edge_kind,
        }
        return ("layers", rule_def)

    if isinstance(rule, CardinalityRule):
        rule_def = {
            "for": _serialize_node_matcher(rule.for_matcher),
        }
        if rule.max_symbols is not None:
            rule_def["max_symbols"] = rule.max_symbols
        if rule.max_files is not None:
            rule_def["max_files"] = rule.max_files
        if rule.min_doc_coverage is not None:
            rule_def["min_doc_coverage"] = rule.min_doc_coverage
        return ("cardinality", rule_def)

    if isinstance(rule, ImportBoundaryRule):
        rule_def = {
            "from_glob": rule.from_glob,
            "to_glob": rule.to_glob,
        }
        return ("forbid_import", rule_def)

    if isinstance(rule, ForbidEdgeRule):
        rule_def = {
            "from": _serialize_node_matcher(rule.from_matcher),
            "to": _serialize_node_matcher(rule.to_matcher),
        }
        if rule.edge_kind is not None:
            rule_def["edge_kind"] = rule.edge_kind
        return ("forbid_edge", rule_def)

    # Should never happen with known Rule types, but guard against future additions.
    msg = f"Unknown rule type: {type(rule).__name__}"
    raise TypeError(msg)


def _load_rules_into_db(
    rules_path: Path,
    conn: sqlite3.Connection,
    result: ReindexResult,
) -> None:
    """Load architecture rules from rules.yml into the rules table."""
    from beadloom.graph.rule_engine import load_rules

    try:
        rules = load_rules(rules_path)
    except ValueError as exc:
        result.errors.append(f"Rules loading error: {exc}")
        return

    for rule in rules:
        rule_type, rule_def = _serialize_rule(rule)

        conn.execute(
            "INSERT INTO rules (name, description, rule_type, rule_json, enabled) "
            "VALUES (?, ?, ?, ?, 1)",
            (rule.name, rule.description, rule_type, json.dumps(rule_def)),
        )
        result.rules_loaded += 1

    conn.commit()


def _resolve_docs_dir(project_root: Path) -> Path:
    """Resolve docs directory from config.yml or use default ``docs``.

    Checks ``.beadloom/config.yml`` for a ``docs_dir`` key.  If present,
    returns ``project_root / <value>``.  Otherwise falls back to
    ``project_root / "docs"``.
    """
    config_path = project_root / ".beadloom" / "config.yml"
    if config_path.exists():
        import yaml

        config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        docs_path = config.get("docs_dir")
        if isinstance(docs_path, str) and docs_path:
            return project_root / docs_path
    return project_root / "docs"


def resolve_scan_paths(project_root: Path) -> list[str]:
    """Resolve source scan directories from config.yml.

    Reads ``scan_paths`` from ``.beadloom/config.yml``.  Falls back to
    ``["src", "lib", "app"]`` when config is absent or has no scan_paths.
    """
    config_path = project_root / ".beadloom" / "config.yml"
    if config_path.exists():
        import yaml

        config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        paths = config.get("scan_paths")
        if isinstance(paths, list) and paths:
            return [str(p) for p in paths]
    return list(_DEFAULT_SCAN_DIRS)


def _store_test_mappings(
    project_root: Path,
    conn: sqlite3.Connection,
) -> None:
    """Run test mapper and merge results into ``nodes.extra["tests"]``.

    Builds a ``source_dirs`` dict from nodes that have a non-null ``source``
    field, calls :func:`~beadloom.context_oracle.test_mapper.map_tests`, and
    updates each node's ``extra`` JSON blob with the test mapping data.
    """
    from beadloom.context_oracle.test_mapper import aggregate_parent_tests, map_tests

    # Build source_dirs: {ref_id: source_path} for nodes with a source field.
    rows = conn.execute("SELECT ref_id, source FROM nodes WHERE source IS NOT NULL").fetchall()
    source_dirs: dict[str, str] = {row["ref_id"]: row["source"] for row in rows}

    if not source_dirs:
        return

    mappings = map_tests(project_root, source_dirs)

    # Build parent->children hierarchy from part_of edges for aggregation.
    parent_children: dict[str, list[str]] = {}
    edge_rows = conn.execute(
        "SELECT src_ref_id, dst_ref_id FROM edges WHERE kind = 'part_of'"
    ).fetchall()
    for edge_row in edge_rows:
        child_id = edge_row["src_ref_id"]
        parent_id = edge_row["dst_ref_id"]
        if parent_id not in parent_children:
            parent_children[parent_id] = []
        parent_children[parent_id].append(child_id)

    # Aggregate child test counts up to parent (domain) nodes.
    mappings = aggregate_parent_tests(mappings, parent_children)

    for ref_id, mapping in mappings.items():
        # Read existing extra JSON.
        row = conn.execute("SELECT extra FROM nodes WHERE ref_id = ?", (ref_id,)).fetchone()
        if row is None:
            continue

        extra: dict[str, object] = json.loads(row["extra"]) if row["extra"] else {}
        extra["tests"] = {
            "framework": mapping.framework,
            "test_files": mapping.test_files,
            "test_count": mapping.test_count,
            "coverage_estimate": mapping.coverage_estimate,
        }
        conn.execute(
            "UPDATE nodes SET extra = ? WHERE ref_id = ?",
            (json.dumps(extra, ensure_ascii=False), ref_id),
        )

    conn.commit()


def _update_node_extra(
    conn: sqlite3.Connection,
    ref_id: str,
    key: str,
    value: object,
) -> None:
    """Merge a key/value into a node's ``extra`` JSON column.

    Reads the current ``extra`` JSON, sets ``extra[key] = value``, and
    writes it back.  Does nothing if *ref_id* does not exist.
    """
    row = conn.execute("SELECT extra FROM nodes WHERE ref_id = ?", (ref_id,)).fetchone()
    if row is None:
        return
    current: dict[str, object] = json.loads(row["extra"]) if row["extra"] else {}
    current[key] = value
    conn.execute(
        "UPDATE nodes SET extra = ? WHERE ref_id = ?",
        (json.dumps(current, ensure_ascii=False), ref_id),
    )


def _extract_and_store_routes(
    project_root: Path,
    conn: sqlite3.Connection,
) -> None:
    """Scan source files for API routes and store them in ``nodes.extra``.

    Iterates over all known scan directories, extracts routes via
    :func:`~beadloom.context_oracle.route_extractor.extract_routes`, and
    aggregates them.  When routes are found, stores them under the
    ``"routes"`` key in each node's ``extra`` JSON column.

    Files without routes are skipped (no empty arrays stored).
    """
    from beadloom.context_oracle.route_extractor import extract_routes

    all_routes: list[dict[str, object]] = []

    scan_dirs = [project_root / d for d in resolve_scan_paths(project_root)]
    for scan_dir in scan_dirs:
        if not scan_dir.is_dir():
            continue
        for file_path in sorted(scan_dir.rglob("*")):
            if not file_path.is_file():
                continue
            lang = _EXT_TO_LANG.get(file_path.suffix)
            if lang is None:
                continue

            routes = extract_routes(file_path, lang)
            if not routes:
                continue

            rel_path = str(file_path.relative_to(project_root))
            for route in routes:
                all_routes.append(
                    {
                        "method": route.method,
                        "path": route.path,
                        "handler": route.handler,
                        "file": rel_path,
                        "line": route.line,
                        "framework": route.framework,
                    }
                )

    if not all_routes:
        return

    # Scope routes to nodes whose source path covers the route's file.
    # Build node source paths mapping.
    node_rows = conn.execute("SELECT ref_id, source FROM nodes").fetchall()
    for node_row in node_rows:
        ref_id: str = node_row["ref_id"]
        source: str | None = node_row["source"]
        if source is None:
            continue

        # Normalise source to a directory prefix for matching.
        # e.g. "src/beadloom/context_oracle" matches "src/beadloom/context_oracle/builder.py"
        source_prefix = source.rstrip("/")

        node_routes = [
            r for r in all_routes
            if str(r.get("file", "")).startswith(source_prefix)
        ]
        if node_routes:
            _update_node_extra(conn, ref_id, "routes", node_routes)
    conn.commit()


def _store_git_activity(
    conn: sqlite3.Connection,
    project_root: Path,
) -> None:
    """Analyze git activity and store results in ``nodes.extra["activity"]``.

    Builds a ``source_dirs`` mapping from nodes that have a ``source`` field,
    runs :func:`analyze_git_activity`, and merges activity data into the
    existing ``extra`` JSON column for each matching node.

    Gracefully does nothing when git is unavailable (``analyze_git_activity``
    returns an empty dict in that case).
    """
    # Build ref_id -> source_path mapping from nodes with source field.
    rows = conn.execute("SELECT ref_id, source FROM nodes WHERE source IS NOT NULL").fetchall()
    source_dirs: dict[str, str] = {}
    for row in rows:
        src: str = row["source"]
        if src.strip():
            source_dirs[row["ref_id"]] = src

    if not source_dirs:
        return

    activities = analyze_git_activity(project_root, source_dirs)

    for ref_id, activity in activities.items():
        # Read existing extra.
        node_row = conn.execute("SELECT extra FROM nodes WHERE ref_id = ?", (ref_id,)).fetchone()
        if node_row is None:
            continue

        extra_raw: str = node_row["extra"] if node_row["extra"] else "{}"
        extra: dict[str, Any] = json.loads(extra_raw)

        # Merge activity data.
        extra["activity"] = {
            "level": activity.activity_level,
            "commits_30d": activity.commits_30d,
            "commits_90d": activity.commits_90d,
            "last_commit": activity.last_commit_date,
            "top_contributors": activity.top_contributors,
        }

        conn.execute(
            "UPDATE nodes SET extra = ? WHERE ref_id = ?",
            (json.dumps(extra, ensure_ascii=False), ref_id),
        )

    conn.commit()


def reindex(project_root: Path, *, docs_dir: Path | None = None) -> ReindexResult:
    """Full reindex: drop all tables, re-create schema, reload everything.

    Parameters
    ----------
    project_root:
        Root of the project (where ``.beadloom/`` lives).
    docs_dir:
        Optional explicit documentation directory.  When *None* the
        directory is resolved from ``.beadloom/config.yml`` (key
        ``docs_dir``) with a fallback to ``<project_root>/docs``.

    Returns
    -------
    ReindexResult
        Summary with counts and diagnostics.
    """
    result = ReindexResult()

    db_path = project_root / ".beadloom" / "beadloom.db"
    db_path.parent.mkdir(parents=True, exist_ok=True)

    conn = open_db(db_path)

    # Snapshot sync baselines before drop.
    preserved_symbols, preserved_pairs = _snapshot_sync_baselines(conn)

    # Drop + re-create.
    _drop_all_tables(conn)
    create_schema(conn)

    # 1. Load YAML graph.
    graph_dir = project_root / ".beadloom" / "_graph"
    if graph_dir.is_dir():
        graph_result = load_graph(graph_dir, conn)
        result.nodes_loaded = graph_result.nodes_loaded
        result.edges_loaded = graph_result.edges_loaded
        result.errors.extend(graph_result.errors)
        result.warnings.extend(graph_result.warnings)

    # Collect known ref_ids for edge creation.
    seen_ref_ids = {row[0] for row in conn.execute("SELECT ref_id FROM nodes").fetchall()}

    # 1b. Store deep config in root node's extra.
    from beadloom.onboarding.config_reader import read_deep_config

    deep_config = read_deep_config(project_root)
    root_row = conn.execute(
        "SELECT ref_id, extra FROM nodes WHERE source = '' OR source IS NULL"
    ).fetchone()
    if root_row is not None:
        existing_extra: dict[str, Any] = json.loads(root_row["extra"] or "{}")
        existing_extra["config"] = deep_config
        conn.execute(
            "UPDATE nodes SET extra = ? WHERE ref_id = ?",
            (json.dumps(existing_extra, ensure_ascii=False), root_row["ref_id"]),
        )
        conn.commit()

    # 2. Index documents.
    if docs_dir is None:
        docs_dir = _resolve_docs_dir(project_root)
    if docs_dir.is_dir():
        if graph_dir.is_dir():
            ref_map, doc_ref_warnings = _build_doc_ref_map(
                graph_dir,
                project_root,
                docs_dir,
            )
            result.warnings.extend(doc_ref_warnings)
        else:
            ref_map = {}
        doc_result = index_docs(docs_dir, conn, ref_id_map=ref_map)
        result.docs_indexed = doc_result.docs_indexed
        result.chunks_indexed = doc_result.chunks_indexed

    # 3. Index code symbols.
    symbols_count, sym_warnings = _index_code_files(project_root, conn, seen_ref_ids)
    result.symbols_indexed = symbols_count
    result.warnings.extend(sym_warnings)

    # 3b. Extract and index code imports.
    from beadloom.graph.import_resolver import index_imports

    result.imports_indexed = index_imports(project_root, conn)

    # 3c. Load architecture rules from rules.yml.
    rules_path = project_root / ".beadloom" / "_graph" / "rules.yml"
    if rules_path.is_file():
        _load_rules_into_db(rules_path, conn, result)

    # 3d. Map test files to source nodes and store in nodes.extra.
    _store_test_mappings(project_root, conn)

    # 3e. Analyze git activity and store in nodes.extra.
    _store_git_activity(conn, project_root)

    # 3f. Extract API routes and store in nodes.extra.
    _extract_and_store_routes(project_root, conn)

    # 4. Build initial sync state.
    _build_initial_sync_state(
        conn,
        preserved_symbols=preserved_symbols,
        preserved_pairs=preserved_pairs,
    )

    # 5. Populate FTS5 search index.
    from beadloom.context_oracle.search import populate_search_index

    populate_search_index(conn)

    # 5b. Clear persistent bundle cache (invalidated by full reindex).
    conn.execute("DELETE FROM bundle_cache")
    conn.commit()

    # 6. Set meta.
    now = datetime.now(tz=timezone.utc).isoformat()
    set_meta(conn, "last_reindex_at", now)
    set_meta(conn, "beadloom_version", __version__)
    set_meta(conn, "schema_version", SCHEMA_VERSION)

    # 7. Take health snapshot for trend tracking.
    take_snapshot(conn)

    # 8. Populate file_index for subsequent incremental runs.
    current_files = _scan_project_files(project_root, docs_dir)
    _populate_file_index(conn, current_files)

    # 9. Store parser fingerprint for incremental reindex to detect new parsers.
    _store_parser_fingerprint(conn, _compute_parser_fingerprint())

    conn.close()
    return result


# ---------------------------------------------------------------------------
# Incremental reindex helpers
# ---------------------------------------------------------------------------


def _compute_parser_fingerprint() -> str:
    """Compute a fingerprint of currently available tree-sitter parsers.

    Returns a sorted, comma-separated string of supported extensions.
    When new parser packages are installed, this string changes, signalling
    that a full code reindex is needed.
    """
    exts = supported_extensions()
    return ",".join(sorted(exts))


def _get_stored_parser_fingerprint(conn: sqlite3.Connection) -> str | None:
    """Read the stored parser fingerprint from file_index.

    Uses a sentinel row with ``path='__parser_fingerprint__'``.
    Returns ``None`` if not stored.
    """
    try:
        row = conn.execute(
            "SELECT hash FROM file_index WHERE path = '__parser_fingerprint__'"
        ).fetchone()
    except Exception:
        return None
    return row["hash"] if row else None


def _store_parser_fingerprint(conn: sqlite3.Connection, fingerprint: str) -> None:
    """Store the parser fingerprint in file_index as a sentinel row."""
    now = datetime.now(tz=timezone.utc).isoformat()
    conn.execute(
        "INSERT INTO file_index (path, hash, kind, indexed_at) "
        "VALUES ('__parser_fingerprint__', ?, 'code', ?) "
        "ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, "
        "indexed_at=excluded.indexed_at",
        (fingerprint, now),
    )
    conn.commit()


def _compute_file_hash(path: Path) -> str:
    """Compute SHA-256 hash of a file."""
    return hashlib.sha256(path.read_bytes()).hexdigest()


def _scan_project_files(
    project_root: Path,
    docs_dir: Path,
) -> dict[str, tuple[str, str]]:
    """Scan project files and return ``{relative_path: (sha256, kind)}``."""
    files: dict[str, tuple[str, str]] = {}

    # Graph YAML files
    graph_dir = project_root / ".beadloom" / "_graph"
    if graph_dir.is_dir():
        for f in sorted(graph_dir.glob("*.yml")):
            rel = str(f.relative_to(project_root))
            files[rel] = (_compute_file_hash(f), "graph")

    # Doc files
    if docs_dir.is_dir():
        for f in sorted(docs_dir.rglob("*.md")):
            rel = str(f.relative_to(project_root))
            files[rel] = (_compute_file_hash(f), "doc")

    # Code files
    for dirname in resolve_scan_paths(project_root):
        scan_dir = project_root / dirname
        if not scan_dir.is_dir():
            continue
        for f in sorted(scan_dir.rglob("*")):
            if f.suffix not in _CODE_EXTENSIONS or not f.is_file():
                continue
            rel = str(f.relative_to(project_root))
            files[rel] = (_compute_file_hash(f), "code")

    return files


def _get_stored_file_index(
    conn: sqlite3.Connection,
) -> dict[str, tuple[str, str]]:
    """Read file_index from DB. Returns ``{path: (hash, kind)}``."""
    try:
        rows = conn.execute("SELECT path, hash, kind FROM file_index").fetchall()
    except Exception:  # table may not exist on first run
        return {}
    return {
        row["path"]: (row["hash"], row["kind"]) for row in rows if not row["path"].startswith("__")
    }


def _diff_files(
    current: dict[str, tuple[str, str]],
    stored: dict[str, tuple[str, str]],
) -> tuple[set[str], set[str], set[str]]:
    """Compare current vs stored files. Returns ``(changed, added, deleted)``."""
    changed: set[str] = set()
    added: set[str] = set()

    for path, (hash_, _kind) in current.items():
        if path not in stored:
            added.add(path)
        elif stored[path][0] != hash_:
            changed.add(path)

    deleted = stored.keys() - current.keys()
    return changed, added, deleted


def _graph_yaml_changed(
    current_files: dict[str, tuple[str, str]],
    stored_files: dict[str, tuple[str, str]],
) -> bool:
    """Check whether any graph YAML file was added, removed, or modified.

    This is a direct comparison of hashes for files with ``kind == "graph"``
    in *current_files* vs *stored_files*.  Unlike :func:`_diff_files` (which
    relies on ``file_index`` being perfectly in sync), this function
    explicitly filters by kind so that graph changes are never missed — even
    when ``file_index`` is stale or partially populated.
    """
    current_graph = {p: h for p, (h, k) in current_files.items() if k == "graph"}
    stored_graph = {p: h for p, (h, k) in stored_files.items() if k == "graph"}

    # Different set of graph files → change.
    if current_graph.keys() != stored_graph.keys():
        return True

    # Same set — compare hashes.
    return any(current_graph[p] != stored_graph[p] for p in current_graph)


def _populate_file_index(
    conn: sqlite3.Connection,
    current_files: dict[str, tuple[str, str]],
) -> None:
    """Replace the entire file_index with *current_files*."""
    conn.execute("DELETE FROM file_index")
    now = datetime.now(tz=timezone.utc).isoformat()
    for path, (hash_, kind) in current_files.items():
        conn.execute(
            "INSERT INTO file_index (path, hash, kind, indexed_at) VALUES (?, ?, ?, ?)",
            (path, hash_, kind, now),
        )
    conn.commit()


def _update_file_index(
    conn: sqlite3.Connection,
    current_files: dict[str, tuple[str, str]],
    changed: set[str],
    added: set[str],
    deleted: set[str],
) -> None:
    """Incrementally update file_index for affected paths."""
    now = datetime.now(tz=timezone.utc).isoformat()
    for path in deleted:
        conn.execute("DELETE FROM file_index WHERE path = ?", (path,))
    for path in changed | added:
        hash_, kind = current_files[path]
        conn.execute(
            "INSERT INTO file_index (path, hash, kind, indexed_at) "
            "VALUES (?, ?, ?, ?) "
            "ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, "
            "indexed_at=excluded.indexed_at",
            (path, hash_, kind, now),
        )
    conn.commit()


def _index_single_doc(
    conn: sqlite3.Connection,
    md_path: Path,
    docs_dir: Path,
    ref_map: dict[str, str],
) -> tuple[int, int]:
    """Index one doc file. Returns ``(docs_count, chunks_count)``."""
    content = md_path.read_text(encoding="utf-8")
    rel_path = str(md_path.relative_to(docs_dir))
    file_hash = hashlib.sha256(content.encode()).hexdigest()
    ref_id = ref_map.get(rel_path)

    conn.execute(
        "INSERT INTO docs (path, kind, ref_id, hash) VALUES (?, ?, ?, ?)",
        (rel_path, "other", ref_id, file_hash),
    )
    doc_id = conn.execute("SELECT id FROM docs WHERE path = ?", (rel_path,)).fetchone()[0]

    chunks = chunk_markdown(content)
    for chunk in chunks:
        conn.execute(
            "INSERT INTO chunks (doc_id, chunk_index, heading, section, "
            "content, node_ref_id) VALUES (?, ?, ?, ?, ?, ?)",
            (
                doc_id,
                chunk["chunk_index"],
                chunk["heading"],
                chunk["section"],
                chunk["content"],
                ref_id,
            ),
        )
    conn.commit()
    return 1, len(chunks)


def _index_single_code_file(
    conn: sqlite3.Connection,
    file_path: Path,
    project_root: Path,
    seen_ref_ids: set[str],
) -> int:
    """Index one code file. Returns symbol count."""
    symbols = extract_symbols(file_path)
    rel_path = str(file_path.relative_to(project_root))
    count = 0

    for sym in symbols:
        conn.execute(
            "INSERT INTO code_symbols (file_path, symbol_name, kind, "
            "line_start, line_end, annotations, file_hash) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (
                rel_path,
                sym["symbol_name"],
                sym["kind"],
                sym["line_start"],
                sym["line_end"],
                json.dumps(sym["annotations"], ensure_ascii=False),
                sym["file_hash"],
            ),
        )
        count += 1

        annotations: dict[str, Any] = sym["annotations"]
        for _key, ref_id in annotations.items():
            if ref_id in seen_ref_ids:
                conn.execute(
                    "INSERT OR IGNORE INTO edges (src_ref_id, dst_ref_id, kind) VALUES (?, ?, ?)",
                    (ref_id, ref_id, "touches_code"),
                )

    conn.commit()
    return count


def incremental_reindex(
    project_root: Path,
    *,
    docs_dir: Path | None = None,
) -> ReindexResult:
    """Incremental reindex: only process changed files.

    Falls back to full reindex when:
    - ``file_index`` is empty (first run after upgrade)
    - Any graph YAML file changed (safest: full reload)

    Parameters
    ----------
    project_root:
        Root of the project.
    docs_dir:
        Optional explicit docs directory.

    Returns
    -------
    ReindexResult
        Summary with counts for re-indexed items.
    """
    result = ReindexResult()

    db_path = project_root / ".beadloom" / "beadloom.db"
    db_path.parent.mkdir(parents=True, exist_ok=True)

    conn = open_db(db_path)
    create_schema(conn)

    if docs_dir is None:
        docs_dir = _resolve_docs_dir(project_root)

    # Scan current files on disk.
    current_files = _scan_project_files(project_root, docs_dir)

    # Read stored hashes from previous run.
    stored_files = _get_stored_file_index(conn)

    if not stored_files:
        # First run — fall back to full reindex.
        conn.close()
        return reindex(project_root, docs_dir=docs_dir)

    # Check if parser availability changed (e.g. new tree-sitter grammar installed).
    current_fingerprint = _compute_parser_fingerprint()
    stored_fingerprint = _get_stored_parser_fingerprint(conn)
    if stored_fingerprint is not None and current_fingerprint != stored_fingerprint:
        conn.close()
        return reindex(project_root, docs_dir=docs_dir)

    # Belt-and-suspenders: always check graph YAML files directly.
    # This catches changes even if file_index got out of sync with the DB
    # (e.g. interrupted reindex, partial writes, or upgrade edge cases).
    graph_affected = _graph_yaml_changed(current_files, stored_files)
    if graph_affected:
        conn.close()
        return reindex(project_root, docs_dir=docs_dir)

    changed, added, deleted = _diff_files(current_files, stored_files)

    if not changed and not added and not deleted:
        # Nothing changed — just update timestamp.
        now = datetime.now(tz=timezone.utc).isoformat()
        set_meta(conn, "last_reindex_at", now)
        take_snapshot(conn)
        conn.close()
        result.nothing_changed = True
        return result

    # --- Only docs / code changed — true incremental path ---

    docs_dir_rel = docs_dir.relative_to(project_root)

    # Known ref_ids for edge creation.
    seen_ref_ids: set[str] = {
        row[0] for row in conn.execute("SELECT ref_id FROM nodes").fetchall()
    }

    # Doc → ref_id mapping (from graph YAML).
    graph_dir = project_root / ".beadloom" / "_graph"
    if graph_dir.is_dir():
        ref_map, doc_ref_warns = _build_doc_ref_map(
            graph_dir,
            project_root,
            docs_dir,
        )
        result.warnings.extend(doc_ref_warns)
    else:
        ref_map = {}

    # Snapshot symbols_hash and two-phase data BEFORE deleting/re-indexing
    # files so we can preserve baselines for drift detection.
    old_symbols: dict[str, str] = {}
    old_pairs: dict[tuple[str, str], _SyncPairSnapshot] = {}
    for row in conn.execute("SELECT * FROM sync_state").fetchall():
        if row["symbols_hash"]:
            old_symbols[row["ref_id"]] = row["symbols_hash"]
        # sqlite3.Row `in` checks values not keys; use .keys()
        has_edit_col = "doc_hash_at_last_edit" in row.keys()  # noqa: SIM118
        edit_hash: str = row["doc_hash_at_last_edit"] if has_edit_col else ""
        if edit_hash:
            old_pairs[(row["doc_path"], row["code_path"])] = _SyncPairSnapshot(
                doc_hash_at_last_edit=edit_hash,
                code_hash_at_sync=row["code_hash_at_sync"],
            )

    # Process deleted files.
    for path in deleted:
        kind = stored_files[path][1]
        if kind == "doc":
            doc_rel = str(type(docs_dir_rel)(path).relative_to(docs_dir_rel))
            conn.execute(
                "DELETE FROM sync_state WHERE doc_path = ?",
                (doc_rel,),
            )
            conn.execute("DELETE FROM docs WHERE path = ?", (doc_rel,))
        elif kind == "code":
            conn.execute(
                "DELETE FROM code_symbols WHERE file_path = ?",
                (path,),
            )
            conn.execute(
                "DELETE FROM sync_state WHERE code_path = ?",
                (path,),
            )

    # Process changed files (delete old data, re-index).
    for path in changed:
        kind = current_files[path][1]
        if kind == "doc":
            doc_rel = str(type(docs_dir_rel)(path).relative_to(docs_dir_rel))
            conn.execute("DELETE FROM docs WHERE path = ?", (doc_rel,))
            conn.execute(
                "DELETE FROM sync_state WHERE doc_path = ?",
                (doc_rel,),
            )
            abs_path = project_root / path
            d, c = _index_single_doc(conn, abs_path, docs_dir, ref_map)
            result.docs_indexed += d
            result.chunks_indexed += c
        elif kind == "code":
            conn.execute(
                "DELETE FROM code_symbols WHERE file_path = ?",
                (path,),
            )
            conn.execute(
                "DELETE FROM sync_state WHERE code_path = ?",
                (path,),
            )
            abs_path = project_root / path
            result.symbols_indexed += _index_single_code_file(
                conn,
                abs_path,
                project_root,
                seen_ref_ids,
            )

    # Process added files.
    for path in added:
        kind = current_files[path][1]
        if kind == "doc":
            abs_path = project_root / path
            d, c = _index_single_doc(conn, abs_path, docs_dir, ref_map)
            result.docs_indexed += d
            result.chunks_indexed += c
        elif kind == "code":
            abs_path = project_root / path
            result.symbols_indexed += _index_single_code_file(
                conn,
                abs_path,
                project_root,
                seen_ref_ids,
            )

    # Re-extract routes after code changes and update nodes.extra.
    _extract_and_store_routes(project_root, conn)

    # Rebuild sync_state (cheap full rebuild) using preserved baselines.
    conn.execute("DELETE FROM sync_state")
    _build_initial_sync_state(
        conn,
        preserved_symbols=old_symbols or None,
        preserved_pairs=old_pairs or None,
    )

    # Rebuild FTS5 search index.
    from beadloom.context_oracle.search import populate_search_index

    populate_search_index(conn)

    # Clear persistent bundle cache (conservative invalidation).
    conn.execute("DELETE FROM bundle_cache")
    conn.commit()

    # Update file_index.
    _update_file_index(conn, current_files, changed, added, deleted)

    # Update meta.
    now = datetime.now(tz=timezone.utc).isoformat()
    set_meta(conn, "last_reindex_at", now)
    set_meta(conn, "beadloom_version", __version__)

    # Health snapshot.
    take_snapshot(conn)

    conn.close()
    return result
