"""AST Service — singleton per project for structural code intelligence.

Wraps ``codebase_ast.parse_file`` / ``diff_file_ast`` and
``CodebaseContextManager.discover_files`` to provide:

* Full symbol index (definitions + cross-references)
* Incremental updates on file changes
* Impact analysis (transitive dependents of changed files)
* Conflict detection for parallel task allocation (swarm)
* Related file suggestions
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import os
import re
import sqlite3
import threading
from pathlib import Path
from time import monotonic as _parse_recovery_now
from types import SimpleNamespace
from typing import Any

from attocode_intel._internal.integrations.context.codebase_ast import (
    FileAST,
    diff_file_ast,
    diff_imports,
    parse_file,
)
from attocode_intel._internal.integrations.context.codebase_context import (
    CodebaseContextManager,
)
from attocode_intel._internal.integrations.context.cross_references import (
    CrossRefIndex,
    SymbolLocation,
    SymbolRef,
)
from attocode_intel._internal.integrations.context.hydration import (
    TIER_SMALL,
    HydrationState,
    classify_tier,
    skeleton_budget,
)
from attocode_intel._internal.integrations.context.index_store import (
    AST_SNAPSHOT_KEY,
    SNAPSHOT_KEY,
    IndexStore,
    StoredFile,
)

logger = logging.getLogger(__name__)

# Singleton registry: root_dir -> ASTService
_instances: dict[str, ASTService] = {}

# Default location for the persistent index
_INDEX_DIR = ".attocode/index"
_INDEX_DB = "symbols.db"
_PARSE_RECOVERY_COOLDOWN_SECONDS = 1.0


class ASTService:
    """Singleton per project.  NOT passed to prompts — used programmatically
    by the orchestrator and agent internals.

    Usage::

        svc = ASTService.get_instance("/path/to/repo")
        svc.initialize()              # full scan (once)
        svc.notify_file_changed(path) # after edits
        impact = svc.get_impact(["src/auth.py"])
    """

    # ------------------------------------------------------------------
    # Construction
    # ------------------------------------------------------------------

    def __init__(self, root_dir: str, *, store: IndexStore | None = None) -> None:
        self._root_dir = os.path.realpath(root_dir)
        self._context_mgr = CodebaseContextManager(root_dir=self._root_dir)
        self._index = CrossRefIndex()
        self._ast_cache: dict[str, FileAST] = {}   # rel_path -> FileAST
        self._initialized = False
        self._hydration_state: HydrationState | None = None
        self._reference_indexed_files: set[str] = set()
        self._hydration_thread: threading.Thread | None = None
        self._hydration_stop: threading.Event = threading.Event()
        # Persistent index store
        if store is not None:
            self._store = store
        else:
            db_path = os.path.join(self._root_dir, _INDEX_DIR, _INDEX_DB)
            self._store = IndexStore(db_path=db_path)
        self._index.set_store(self._store)
        self._snapshot_tracker = None
        self._snapshot_start = None
        self._last_snapshot_repair = None
        self._parse_lock = threading.RLock()
        self._parse_recovery_attempts = {}
        self._parse_recovery_retry_after = {}

    @classmethod
    def get_instance(cls, root_dir: str) -> ASTService:
        """Return (or create) the singleton for *root_dir*."""
        key = os.path.realpath(root_dir)
        if key not in _instances:
            _instances[key] = cls(key)
        return _instances[key]

    @classmethod
    def clear_instances(cls) -> None:
        """Reset all singletons (for testing)."""
        _instances.clear()

    @property
    def root_dir(self) -> str:
        return self._root_dir

    @property
    def index(self) -> CrossRefIndex:
        return self._index

    @property
    def initialized(self) -> bool:
        return self._initialized

    def _supported_source_languages(self) -> set[str]:
        _ts_langs: set[str] = set()
        try:
            from attocode_intel._internal.integrations.context.ts_parser import supported_languages

            _ts_langs = set(supported_languages())
        except ImportError:
            pass
        return {"python", "javascript", "typescript", "shell"} | _ts_langs

    def _count_parseable_source_files(self) -> int:
        files = self._context_mgr._files or []
        supported = self._supported_source_languages()
        n = 0
        for fi in files:
            lang = fi.language
            if lang == "shell":
                lang = "bash"
            if lang in supported:
                n += 1
        return n

    def hydration_snapshot(self) -> dict:
        """Return hydration fields for UIs.

        When ``_hydration_state`` is set (skeleton / background hydration), returns
        its ``to_dict()``. Otherwise, if ``initialize()`` ran without ever setting
        state, synthesize approximate metrics from the AST cache so MCP
        ``hydration_status`` is not stuck at unknown/0.
        """
        self._context_mgr.refresh_excluded_checkout_roots()
        if self._hydration_state is not None:
            return {**self._hydration_state.to_dict(),
                    "snapshot_repair": self._last_snapshot_repair,
                    "discovered_files": self._context_mgr.discovered_file_count,
                    "discovery_truncated": self._context_mgr.discovery_truncated,
                    "excluded_checkout_roots": self._context_mgr.excluded_checkout_roots,
                    "excluded_checkout_roots_truncated": self._context_mgr.excluded_checkout_roots_truncated}

        base: dict = {
            "tier": "unknown",
            "phase": "unknown",
            "total_files": 0,
            "parsed_files": 0,
            "parse_coverage": 0.0,
            "reference_indexed_files": 0,
            "reference_coverage": 0.0,
            "dep_graph_files": 0,
            "embedding_coverage": 0.0,
            "elapsed_ms": 0.0,
        }
        if not self._initialized:
            return base

        parseable_total = self._count_parseable_source_files()
        parsed = len(self._ast_cache)
        ref_n = len(self._reference_indexed_files)
        total = parseable_total if parseable_total > 0 else max(parsed, 1)
        tier = classify_tier(parseable_total if parseable_total > 0 else parsed)
        if parseable_total and parsed >= parseable_total:
            phase = "ready"
        elif parsed:
            phase = "hydrating"
        else:
            phase = "skeleton"

        st = HydrationState(
            tier=tier,
            total_files=total,
            parsed_files=parsed,
            reference_indexed_files=ref_n,
            phase=phase,
        )
        return {**st.to_dict(),
                "excluded_checkout_roots": self._context_mgr.excluded_checkout_roots,
                "excluded_checkout_roots_truncated": self._context_mgr.excluded_checkout_roots_truncated}

    def _ensure_initialized(self) -> None:
        """Auto-initialize if not yet done.  Called by all query methods."""
        if not self._initialized:
            self.initialize()

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

    def initialize(self, *, force: bool = False) -> None:
        """Discover files, parse ASTs, build cross-ref index.

        When a persistent ``IndexStore`` is available and *force* is False,
        loads cached symbols/refs from SQLite and only re-parses files whose
        mtime has changed (incremental mode).  Otherwise does a full scan.
        """
        self._store.set_meta(SNAPSHOT_KEY, "")
        self._snapshot_start = None
        from attocode_intel.telemetry import measure
        with measure("discovery"):
            files = self._context_mgr.discover_files()
        self._index = CrossRefIndex()
        self._index.set_store(self._store)
        self._ast_cache.clear()

        # Supported languages
        _ts_langs: set[str] = set()
        try:
            from attocode_intel._internal.integrations.context.ts_parser import supported_languages
            _ts_langs = set(supported_languages())
        except ImportError:
            pass
        _supported = {"python", "javascript", "typescript", "shell"} | _ts_langs

        # Build mtime map for parseable files
        parseable: list[Any] = []
        mtime_map: dict[str, float] = {}
        for fi in files:
            lang = fi.language
            if lang == "shell":
                lang = "bash"
            if lang not in _supported:
                continue
            parseable.append(fi)
            try:
                mtime_map[fi.relative_path] = os.path.getmtime(fi.path)
            except OSError:
                mtime_map[fi.relative_path] = 0.0

        # Try incremental load from store
        last_scan = self._store.get_last_scan_time() if not force else None
        if last_scan is not None and not force:
            try:
                loaded = self._index.load_from_store()
                if loaded > 0:
                    stale = self._store.get_stale_files(mtime_map)
                    deleted = self._store.get_deleted_files(set(mtime_map.keys()))

                    for d in deleted:
                        self._index.remove_file(d)

                    stale_set = set(stale)
                    for fi in parseable:
                        rel = fi.relative_path
                        if rel in stale_set:
                            try:
                                ast = parse_file(fi.path)
                            except Exception:
                                continue
                            self._index.remove_file(rel)
                            self._ast_cache[rel] = ast
                            self._index_definitions(rel, ast)

                    dep_graph = self._context_mgr.dependency_graph
                    if dep_graph:
                        for src, targets in dep_graph.forward.items():
                            for tgt in targets:
                                self._index.add_file_dependency(src, tgt)

                    for rel in stale_set:
                        ast = self._ast_cache.get(rel)
                        if ast:
                            self._index_references(rel, ast)
                            self._persist_indexed_file(rel, ast, mtime_map.get(rel, 0.0))

                    self._initialized = True
                    self._store.record_scan_time()
                    logger.debug(
                        "ASTService incremental init: %d cached, %d stale, %d deleted",
                        loaded, len(stale), len(deleted),
                    )
                    return
            except sqlite3.IntegrityError:
                if force:
                    raise
                logger.warning(
                    "ASTService incremental init hit sqlite integrity error; rebuilding index",
                    exc_info=True,
                )
                self._store.clear_all()
                self.initialize(force=True)
                return

        # Full scan fallback
        self._store.clear_all()
        for fi in parseable:
            try:
                ast = parse_file(fi.path)
            except Exception:
                continue
            rel = fi.relative_path
            self._ast_cache[rel] = ast
            self._index_definitions(rel, ast)

        # Phase 2: index references
        for rel, ast in self._ast_cache.items():
            self._index_references(rel, ast)

        # Copy dependency graph edges
        dep_graph = self._context_mgr.dependency_graph
        if dep_graph:
            edges: list[tuple[str, str]] = []
            for src, targets in dep_graph.forward.items():
                for tgt in targets:
                    self._index.add_file_dependency(src, tgt)
                    edges.append((src, tgt))
            if edges:
                self._store.save_dependencies_batch(edges)

        # Persist all to store — files must be saved BEFORE symbols/refs
        # (foreign key: symbols.file_path REFERENCES files(path))
        stored_files: list[StoredFile] = []
        for rel, ast in self._ast_cache.items():
            stored_files.append(self._build_stored_file(rel, ast, mtime_map.get(rel, 0.0)))
        if stored_files:
            self._store.save_files_batch(stored_files)
        self._index.persist_files(set(self._ast_cache))

        self._store.record_scan_time()
        self._initialized = True
        logger.debug(
            "ASTService full init: %d files, %d definitions",
            len(self._ast_cache),
            sum(len(v) for v in self._index.definitions.values()),
        )

    def force_reindex(self) -> None:
        """Force a full re-scan, ignoring cached data."""
        self.initialize(force=True)

    def initialize_skeleton(self, *, indexing_depth: str = "auto") -> HydrationState:
        """Phase 1: Fast skeleton initialization.

        Discovers files, classifies tier, and parses only the top-N most
        important files.  For small repos this is equivalent to full
        ``initialize()``.  For larger repos, remaining files are left for
        background hydration or on-demand parsing.
        """
        from attocode_intel.telemetry import measure
        self._snapshot_start = self._snapshot_identity()
        # Discovery builds imports for smaller repositories before _restore_snapshot.
        # Give it the same source-validated summaries so that restart does not parse
        # every file there and then discard that work before restoring the index.
        from .ast_snapshot import restore_summaries

        snapshot = self._store.get_meta(SNAPSHOT_KEY)
        summaries = restore_summaries(self._store, snapshot, (
            SimpleNamespace(relative_path=rel, path=os.path.join(self._root_dir, rel))
            for rel in self._snapshot_start["files"]
        )) if snapshot else None
        with measure("discovery"):
            files = self._context_mgr.discover_files(ast_cache=summaries)
        self._index = CrossRefIndex()
        self._index.set_store(self._store)
        self._ast_cache.clear()
        self._reference_indexed_files.clear()

        _ts_langs: set[str] = set()
        try:
            from attocode_intel._internal.integrations.context.ts_parser import supported_languages
            _ts_langs = set(supported_languages())
        except ImportError:
            pass
        _supported = {"python", "javascript", "typescript", "shell"} | _ts_langs

        parseable = []
        for fi in files:
            lang = fi.language
            if lang == "shell":
                lang = "bash"
            if lang in _supported:
                parseable.append(fi)

        source_count = len(parseable)
        tier = classify_tier(source_count)

        if indexing_depth == "eager":
            tier = TIER_SMALL
        elif indexing_depth == "minimal":
            self._hydration_state = HydrationState(
                tier=tier, total_files=source_count, phase="skeleton",
            )
            self._initialized = True
            return self._hydration_state

        budget = skeleton_budget(tier, source_count)
        is_full_parse = budget >= source_count

        state = HydrationState(
            tier=tier,
            total_files=source_count,
            phase="ready" if is_full_parse else "skeleton",
        )
        self._hydration_state = state

        if self._restore_snapshot(files, parseable, state, summaries=summaries, snapshot=snapshot):
            self._initialized = True
            return state
        # A partial or interrupted rebuild must never be restored as complete.
        self._store.set_meta(SNAPSHOT_KEY, "")
        self._store.clear_all()

        to_parse = parseable[:budget]

        for fi in to_parse:
            try:
                ast = parse_file(fi.path)
            except Exception:
                continue
            rel = fi.relative_path
            self._ast_cache[rel] = ast
            self._index_definitions(rel, ast)
            state.parsed_files += 1

        if is_full_parse:
            for rel, ast in self._ast_cache.items():
                self._index_references(rel, ast)
                self._reference_indexed_files.add(rel)
                state.reference_indexed_files += 1

            dep_graph = self._context_mgr.dependency_graph
            if dep_graph:
                for src, targets in dep_graph.forward.items():
                    for tgt in targets:
                        self._index.add_file_dependency(src, tgt)

            if self._store:
                stored_files: list[StoredFile] = []
                for rel, ast_item in self._ast_cache.items():
                    try:
                        mtime = os.path.getmtime(os.path.join(self._root_dir, rel))
                    except OSError:
                        continue
                    stored_files.append(self._build_stored_file(rel, ast_item, mtime))
                if stored_files:
                    self._store.save_files_batch(stored_files)
                self._index.persist_files(set(self._ast_cache))
                self._store.record_scan_time()
                self._save_snapshot()

        dep_graph = self._context_mgr.dependency_graph
        if dep_graph:
            for src, targets in dep_graph.forward.items():
                for target in targets:
                    self._index.add_file_dependency(src, target)
            state.dep_graph_files = len(dep_graph.forward)
        if is_full_parse and state.parsed_files == state.reference_indexed_files == source_count:
            # A failed restore can reset the phase before the full rebuild.
            state.phase = "ready"
        self._initialized = True
        return state

    def _snapshot_identity(self) -> dict:
        from attocode_intel.freshness import FreshnessTracker

        if self._snapshot_tracker is None:
            self._snapshot_tracker = FreshnessTracker(self._root_dir)
        manifest = self._snapshot_tracker.scan_manifest()
        return {
            "files": {path: list(identity) for path, identity in manifest.items()},
            "ignore": list(self._snapshot_tracker._ignore_identity or ()),
        }

    def _restore_snapshot(self, files, parseable, state, *, summaries=None, snapshot=None) -> bool:
        """Restore a completed index and repair offline edits when necessary.

        Bounded summaries reuse unchanged ASTs with exact source/parser identity;
        missing or invalid entries are reparsed. References remain persisted. Older caches
        without the completion record are rebuilt once.
        """
        raw = self._store.get_meta(SNAPSHOT_KEY)
        if not raw:
            return False
        try:
            saved = json.loads(raw)
            if saved["counts"] != self._store.stats():
                return False
            from .ast_snapshot import (
                navigation_identity,
                restore_summaries,
            )

            if summaries is None or snapshot != raw:
                summaries = restore_summaries(self._store, raw, parseable)
            else:
                eligible = {fi.relative_path for fi in parseable}
                summaries = {path: ast for path, ast in summaries.items() if path in eligible}
            cached = summaries if summaries is not None else {}
            reparsed = set()
            for fi in parseable:
                if fi.relative_path not in cached:
                    cached[fi.relative_path] = parse_file(fi.path)
                    reparsed.add(fi.relative_path)
            if self._snapshot_identity() != self._snapshot_start:
                return False
            self._index.load_from_store()
            verified_navigation = saved.get('navigation_identity') == navigation_identity()
            # A missing summary can follow a transient grammar failure or a
            # parser upgrade. Unchanged source does not prove that the freshly
            # parsed definitions/references match the persisted index.
            if (reparsed or not verified_navigation or saved["identity"] != self._snapshot_start
                    or saved["discovered"] != sorted(fi.relative_path for fi in files)
                    or saved["parsed"] != sorted(cached)):
                from .index_repair import changed_paths, repair_index

                changed = changed_paths(saved["identity"], self._snapshot_start)
                changed |= set(saved["parsed"]) ^ cached.keys()
                changed |= reparsed
                if not verified_navigation:
                    changed |= cached.keys()
                self._last_snapshot_repair = repair_index(self, cached, self._snapshot_start, changed)
                return True
            from attocode_intel._internal.integrations.context.codebase_context import (
                DependencyGraph,
            )

            graph = DependencyGraph(unresolved=saved["unresolved"])
            for source, targets in self._index.file_dependencies.items():
                for target in targets:
                    graph.add_edge(source, target)
            if summaries is None:
                from .ast_snapshot import encode_summaries

                # Retain a completion summary even for an empty parseable set.
                # Nonempty cache misses were reconciled by repair_index above.
                self._store.set_meta(AST_SNAPSHOT_KEY, encode_summaries(cached, raw))
            self._context_mgr._dep_graph = graph
            self._ast_cache = cached
            self._reference_indexed_files = set(cached)
            state.parsed_files = len(cached)
            state.reference_indexed_files = len(cached)
            state.dep_graph_files = len(graph.forward)
            state.phase = "ready"
            return True
        except (OSError, ValueError, KeyError, TypeError, sqlite3.Error):
            logger.debug("Cannot restore structural snapshot", exc_info=True)
            self._index = CrossRefIndex()
            self._index.set_store(self._store)
            self._ast_cache.clear()
            self._reference_indexed_files.clear()
            state.parsed_files = state.reference_indexed_files = 0
            state.phase = "skeleton"
            return False

    def _save_snapshot(self, *, asts=None, index=None, identity=None, graph=None) -> bool:
        """Save a complete live or staged index before publishing its completion.

        A staged AST map represents a fully reference-indexed replacement, kept
        private until persistence and the final workspace checks succeed.
        """
        reference_files = self._reference_indexed_files if asts is None else set(asts)
        asts = self._ast_cache if asts is None else asts
        index = self._index if index is None else index
        identity = self._snapshot_start if identity is None else identity
        if identity is None:
            return False
        if (len(asts) != self._count_parseable_source_files()
                or set(asts) != reference_files
                or self._snapshot_identity() != identity):
            return False
        graph = self._context_mgr.dependency_graph if graph is None else graph
        self._store.clear_dependencies()
        self._store.save_dependencies_batch([
            (source, target) for source, targets in index.file_dependencies.items()
            for target in targets
        ])
        from .ast_snapshot import encode_summaries, navigation_identity

        snapshot = json.dumps({
            "identity": identity,
            "discovered": sorted(fi.relative_path for fi in self._context_mgr._files),
            "parsed": sorted(asts),
            "counts": self._store.stats(),
            "unresolved": graph.unresolved if graph else {},
            "navigation_identity": navigation_identity(),
        }, separators=(",", ":"))

        # Bind the disposable payload to the completion record; publish it first.
        # A partial/interleaved write can only cause a parse fallback on restart.
        self._store.set_meta(AST_SNAPSHOT_KEY, encode_summaries(asts, snapshot))
        if self._snapshot_identity() != identity:
            return False
        self._store.set_meta(SNAPSHOT_KEY, snapshot)
        if self._snapshot_identity() != identity:
            self._store.set_meta(SNAPSHOT_KEY, '')
            return False
        return True

    def repair_changed_files(self, *, expected_manifest=None) -> bool:
        """Checkpoint edits against a complete in-memory snapshot."""
        state = self._hydration_state
        if state is None or state.phase != "ready" or self._snapshot_start is None:
            return False
        from .index_repair import changed_paths, repair_index

        self.stop_hydration()
        identity = self._snapshot_identity()
        if expected_manifest is not None and identity["files"] != {
            path: list(value) for path, value in expected_manifest.items()
        }:
            raise ValueError("Workspace changed during freshness checks; retry")
        changed = changed_paths(self._snapshot_start, identity)
        if (self._snapshot_start["files"].keys() != identity["files"].keys()
                or self._snapshot_start["ignore"] != identity["ignore"]):
            self._context_mgr.discover_files()
        supported = self._supported_source_languages()
        parseable = {fi.relative_path: fi for fi in self._context_mgr._files
                     if ("bash" if fi.language == "shell" else fi.language) in supported}
        changed |= self._ast_cache.keys() ^ parseable.keys()
        asts = {path: ast for path, ast in self._ast_cache.items() if path in parseable}
        for path in changed & parseable.keys():
            asts[path] = parse_file(parseable[path].path)
        self._last_snapshot_repair = repair_index(self, asts, identity, changed)
        return True

    def ensure_file_parsed(self, rel_path: str) -> bool:
        """Parse a single file on-demand if not already in cache.

        Returns True if the file was newly parsed, False if already cached.
        """
        with self._parse_lock:
            if rel_path in self._ast_cache:
                return False
            abs_path = os.path.join(self._root_dir, rel_path)
            if not os.path.isfile(abs_path):
                return False
            try:
                ast = parse_file(abs_path)
            except Exception:
                return False
            self._index_definitions(rel_path, ast)
            # Publish only after definitions are visible to concurrent queries.
            self._ast_cache[rel_path] = ast
            if self._hydration_state:
                self._hydration_state.parsed_files += 1
            return True

    def recover_failed_file(self, rel_path: str) -> bool:
        """Retry a degraded selected file on a later explicit inspection.

        Reuse the completed-index repair path so callers and persisted navigation
        are reconciled before publishing the replacement AST. Partial hydration
        keeps its normal path; a later request may recover after it completes.
        Repeated requests share a cooldown for the same source/parser identity;
        a transient second parse failure must not prevent recovery indefinitely.
        """
        from .ast_snapshot import parser_identity
        from .index_repair import repair_index

        with self._parse_lock:
            previous = self._ast_cache.get(rel_path)
            if (previous is None or not previous.parse_failures
                    or previous.parsing_tier == 'tree_sitter'
                    or not self._hydration_state or self._hydration_state.phase != 'ready'
                    or self._snapshot_start is None):
                return False
            path = Path(self._root_dir, rel_path)
            if not path.resolve().is_relative_to(Path(self._root_dir)):
                return False
            try:
                identity = self._snapshot_start
                if self._snapshot_identity() != identity:
                    return False
                digest = hashlib.sha256(path.read_text(encoding='utf-8', errors='replace').encode()).hexdigest()
                if digest != previous.content_sha256:
                    return False
                key = (digest, parser_identity(previous.language))
                if (self._parse_recovery_attempts.get(rel_path) == key
                        and _parse_recovery_now() < self._parse_recovery_retry_after.get(rel_path, 0)):
                    return False
                self._parse_recovery_attempts[rel_path] = key
                try:
                    parsed = parse_file(str(path))
                    if parsed.parse_failures or parsed.parsing_tier == 'minimal':
                        return False
                    if hashlib.sha256(path.read_text(encoding='utf-8', errors='replace').encode()).hexdigest() != digest:
                        return False
                    if self._snapshot_identity() != identity:
                        return False
                    asts = dict(self._ast_cache)
                    asts[rel_path] = parsed
                    self._last_snapshot_repair = repair_index(self, asts, identity, {rel_path})
                    return True
                finally:
                    self._parse_recovery_retry_after[rel_path] = (
                        _parse_recovery_now() + _PARSE_RECOVERY_COOLDOWN_SECONDS)
            except (OSError, ValueError, sqlite3.Error):
                logger.debug('Cannot recover selected parse for %s', rel_path, exc_info=True)
                return False

    def ensure_references_indexed(self, rel_path: str) -> bool:
        """Index references for a file on-demand if not already done."""
        if rel_path in self._reference_indexed_files:
            return False
        self.ensure_file_parsed(rel_path)
        ast = self._ast_cache.get(rel_path)
        if ast is None:
            return False
        self._index_references(rel_path, ast)
        self._reference_indexed_files.add(rel_path)
        if self._hydration_state:
            self._hydration_state.reference_indexed_files += 1
        return True

    def start_hydration(self) -> None:
        """Phase 2: Start background hydration of remaining files."""
        state = self._hydration_state
        if state is None or state.phase == "ready":
            return
        if self._hydration_thread is not None and self._hydration_thread.is_alive():
            return

        self._hydration_stop.clear()
        state.phase = "hydrating"

        def _worker():
            try:
                self._run_hydration()
            except Exception:
                logger.error("Hydration worker crashed", exc_info=True)

        self._hydration_thread = threading.Thread(
            target=_worker, daemon=True, name="ast-hydration",
        )
        self._hydration_thread.start()

    def stop_hydration(self) -> None:
        """Stop background hydration if running."""
        self._hydration_stop.set()
        t = self._hydration_thread
        if t is not None and t.is_alive():
            t.join(timeout=5.0)
        self._hydration_thread = None

    def _run_hydration(self) -> None:
        """Background hydration loop: parse remaining files, index refs."""
        state = self._hydration_state
        if state is None:
            return

        ctx_files = self._context_mgr._files
        _ts_langs: set[str] = set()
        try:
            from attocode_intel._internal.integrations.context.ts_parser import supported_languages
            _ts_langs = set(supported_languages())
        except ImportError:
            pass
        _supported = {"python", "javascript", "typescript", "shell"} | _ts_langs

        remaining = []
        for fi in ctx_files:
            lang = fi.language
            if lang == "shell":
                lang = "bash"
            if lang in _supported and fi.relative_path not in self._ast_cache:
                remaining.append(fi)

        # Phase 2a: Parse remaining files and index definitions
        batch_size = 50
        for i in range(0, len(remaining), batch_size):
            if self._hydration_stop.is_set():
                return
            batch = remaining[i:i + batch_size]
            for fi in batch:
                if self._hydration_stop.is_set():
                    return
                self.ensure_file_parsed(fi.relative_path)

        # Phase 2b: Index references for all parsed files
        # Snapshot keys to avoid dict-changed-size during concurrent on-demand
        try:
            cache_snapshot = list(self._ast_cache.items())
        except RuntimeError:
            cache_snapshot = list(self._ast_cache.items())
        for rel, ast in cache_snapshot:
            if self._hydration_stop.is_set():
                return
            if rel not in self._reference_indexed_files:
                self._index_references(rel, ast)
                self._reference_indexed_files.add(rel)
                state.reference_indexed_files += 1

        # Discovery defers expensive graphs for large repositories. Build them now.
        from attocode_intel._internal.integrations.context.codebase_context import (
            build_dependency_graph,
        )
        dep_graph = self._context_mgr.dependency_graph
        if dep_graph is None or not dep_graph.forward:
            self._context_mgr._dep_graph = build_dependency_graph(
                ctx_files, self._root_dir, ast_cache=dict(cache_snapshot),
            )
        dep_graph = self._context_mgr.dependency_graph
        if dep_graph:
            for src, targets in dep_graph.forward.items():
                for tgt in targets:
                    self._index.add_file_dependency(src, tgt)

        # Persist using the same pattern as initialize_skeleton
        if self._store:
            try:
                for offset in range(0, len(cache_snapshot), 100):
                    if self._hydration_stop.is_set():
                        return
                    batch = []
                    for rel, ast in cache_snapshot[offset:offset + 100]:
                        try:
                            mtime = os.path.getmtime(os.path.join(self._root_dir, rel))
                        except OSError:
                            continue
                        batch.append(self._build_stored_file(rel, ast, mtime))
                    self._store.save_files_batch(batch)
                    self._index.persist_files({stored.path for stored in batch})
                self._store.record_scan_time()
                self._save_snapshot()
            except Exception:
                logger.debug("Failed to persist after hydration", exc_info=True)

        state.dep_graph_files = len(dep_graph.forward) if dep_graph else 0
        state.phase = "ready"

    async def async_initialize(self, batch_size: int = 50) -> None:
        """Async version of initialize that parses files in batches.

        Uses ``asyncio.to_thread`` to avoid blocking the event loop
        on large repositories.  Files are parsed in batches of
        *batch_size* concurrently.
        """
        self._store.set_meta(SNAPSHOT_KEY, "")
        self._snapshot_start = None
        from attocode_intel.telemetry import measure
        with measure("discovery"):
            files = self._context_mgr.discover_files()
        self._index = CrossRefIndex()
        self._ast_cache.clear()

        _ts_langs: set[str] = set()
        try:
            from attocode_intel._internal.integrations.context.ts_parser import supported_languages
            _ts_langs = set(supported_languages())
        except ImportError:
            pass
        _supported = {"python", "javascript", "typescript"} | _ts_langs

        parseable = [fi for fi in files if fi.language in _supported]

        def _parse_one(fi_path: str) -> tuple[str, FileAST | None]:
            try:
                return fi_path, parse_file(fi_path)
            except Exception:
                return fi_path, None

        # Phase 1: Parse all files and index definitions
        for i in range(0, len(parseable), batch_size):
            batch = parseable[i:i + batch_size]
            tasks = [
                asyncio.to_thread(_parse_one, fi.path)
                for fi in batch
            ]
            results = await asyncio.gather(*tasks)
            for fi, (_, ast) in zip(batch, results, strict=False):
                if ast is not None:
                    rel = fi.relative_path
                    self._ast_cache[rel] = ast
                    self._index_definitions(rel, ast)

        # Phase 2: Index references (now known_symbols is complete)
        for rel, ast in self._ast_cache.items():
            self._index_references(rel, ast)

        dep_graph = self._context_mgr.dependency_graph
        if dep_graph:
            for src, targets in dep_graph.forward.items():
                for tgt in targets:
                    self._index.add_file_dependency(src, tgt)

        self._initialized = True
        logger.debug(
            "ASTService async_initialized: %d files, %d definitions",
            len(self._ast_cache),
            sum(len(v) for v in self._index.definitions.values()),
        )

    def notify_file_changed(self, path: str) -> list[Any]:
        """Incrementally update AST and index for a changed file.

        Returns a list of ``SymbolChange`` objects (from ``diff_file_ast``).
        """
        from attocode_intel._internal.integrations.context.codebase_ast import (
            SymbolChange,
        )

        rel = self._to_rel(path)
        abs_path = os.path.join(self._root_dir, rel)
        self._store.set_meta(SNAPSHOT_KEY, "")
        self._snapshot_start = None

        # Handle deletion
        if not Path(abs_path).exists():
            self._parse_recovery_attempts.pop(rel, None)
            self._parse_recovery_retry_after.pop(rel, None)
            self._index.remove_file(rel)
            old_ast = self._ast_cache.pop(rel, None)
            if old_ast is None:
                return []
            changes: list[SymbolChange] = []
            for f in old_ast.functions:
                changes.append(SymbolChange(
                    kind="removed", symbol_name=f.name,
                    symbol_kind="function", file_path=rel, previous=f,
                ))
            for c in old_ast.classes:
                changes.append(SymbolChange(
                    kind="removed", symbol_name=c.name,
                    symbol_kind="class", file_path=rel, previous=c,
                ))
            return changes

        # Parse new content
        try:
            new_ast = parse_file(abs_path)
        except Exception:
            return []

        old_ast = self._ast_cache.get(rel)

        # Compute diffs
        if old_ast is not None:
            symbol_changes = diff_file_ast(old_ast, new_ast)
            _dep_changes = diff_imports(old_ast, new_ast)
        else:
            symbol_changes = []
            _dep_changes = None

        try:
            # Update index: remove old entries, re-index
            self._index.remove_file(rel)
            self._ast_cache[rel] = new_ast
            self._index_file(rel, new_ast)

            # Update dependency edges from dependency graph
            self._context_mgr.mark_file_dirty(path)
            self._context_mgr.update_dirty_files()
            dep_graph = self._context_mgr.dependency_graph
            if dep_graph:
                for tgt in dep_graph.forward.get(rel, set()):
                    self._index.add_file_dependency(rel, tgt)

            try:
                mtime = os.path.getmtime(abs_path)
            except OSError:
                mtime = 0.0
            self._persist_indexed_file(rel, new_ast, mtime)
        except sqlite3.IntegrityError as exc:
            logger.warning(
                "ASTService notify_file_changed hit sqlite integrity error for %s; rebuilding index",
                rel,
                exc_info=True,
            )
            self._recover_from_store_error(exc, rel)

        return symbol_changes

    def refresh(self) -> list[Any]:
        """Detect changed files (mtime) and update incrementally.

        Returns aggregated list of ``SymbolChange`` from all changed files.
        """
        all_changes: list[Any] = []
        for rel, cached_ast in list(self._ast_cache.items()):
            abs_path = os.path.join(self._root_dir, rel)
            if not Path(abs_path).exists():
                all_changes.extend(self.notify_file_changed(abs_path))
                continue
            try:
                new_ast = parse_file(abs_path)
            except Exception:
                continue
            changes = diff_file_ast(cached_ast, new_ast)
            if changes:
                all_changes.extend(self.notify_file_changed(abs_path))
        return all_changes

    # ------------------------------------------------------------------
    # Symbol queries
    # ------------------------------------------------------------------

    def get_file_symbols(self, path: str) -> list[SymbolLocation]:
        """Return all symbols defined in *path*."""
        self._ensure_initialized()
        rel = self._to_rel(path)
        return self._index.get_file_definitions(rel)

    def find_symbol(self, name: str, *, include_shadowed: bool = False) -> list[SymbolLocation]:
        """Find definitions for *name* (exact or suffix match)."""
        self._ensure_initialized()
        return self._index.get_definitions(name, include_shadowed=include_shadowed)

    def search_symbol(
        self,
        name: str,
        *,
        limit: int = 50,
        kind_filter: str = "",
    ) -> list[tuple[SymbolLocation, float]]:
        """Enhanced symbol search with multi-strategy matching and scoring.

        Searches by exact match, bare name, case-insensitive, prefix,
        substring, and camelCase/snake_case token overlap.  Results are
        ranked by match quality and symbol importance.
        """
        self._ensure_initialized()
        self._parse_symbol_candidates(name)
        return self._index.search_definitions(
            name, limit=limit, kind_filter=kind_filter,
        )

    def _parse_symbol_candidates(self, name: str) -> None:
        """Discover literal name candidates while the index is still partial.

        Source text only shortlists files; the parser and normal symbol ranking
        determine results. Comments and call sites cannot become definitions.
        Fuzzy matches and references still have progressive coverage.
        """
        state = self._hydration_state
        if state is None or state.phase == "ready" or not name.strip():
            return
        # Qualified names need not appear contiguously in source (Class.method).
        needle = name.rsplit(".", 1)[-1].lower()
        if not needle:
            return
        supported = self._supported_source_languages()
        for fi in self._context_mgr._files:
            if state.phase == "ready":
                break
            language = "bash" if fi.language == "shell" else fi.language
            if language not in supported or fi.relative_path in self._ast_cache:
                continue
            try:
                content = Path(fi.path).read_text(encoding="utf-8", errors="replace")
            except OSError:
                continue
            if needle in content.lower():
                self.ensure_file_parsed(fi.relative_path)

    def get_callers(self, symbol: str) -> list[SymbolRef]:
        """Return all call sites / references for *symbol*."""
        self._ensure_initialized()
        return self._index.get_references(symbol)

    def get_callees_of(self, caller: str, *, depth: int = 1) -> set[str]:
        """Function-level call-graph traversal: callees up to ``depth`` hops.

        Use ``ensure_references_indexed`` for files containing ``caller``
        first if you need on-demand population.
        """
        self._ensure_initialized()
        return self._index.get_callees(caller, depth=depth)

    def get_callers_of(self, callee: str, *, depth: int = 1) -> set[str]:
        """Function-level call-graph traversal: callers up to ``depth`` hops back."""
        self._ensure_initialized()
        return self._index.get_callers(callee, depth=depth)

    def get_dependencies(self, path: str) -> set[str]:
        """Files that *path* imports from."""
        self._ensure_initialized()
        rel = self._to_rel(path)
        return self._index.get_dependencies(rel)

    def get_dependents(self, path: str) -> set[str]:
        """Files that import *path*."""
        self._ensure_initialized()
        rel = self._to_rel(path)
        return self._index.get_dependents(rel)

    def get_impact(self, changed: list[str]) -> set[str]:
        """Compute transitive impact set — all files affected by changes.

        Uses BFS on the reverse dependency graph starting from *changed*.
        """
        self._ensure_initialized()
        visited: set[str] = set()
        queue = [self._to_rel(p) for p in changed]
        while queue:
            current = queue.pop(0)
            if current in visited:
                continue
            visited.add(current)
            for dep in self._index.get_dependents(current):
                if dep not in visited:
                    queue.append(dep)
        # Remove the seed files themselves (they're the *cause*, not the impact)
        for p in changed:
            visited.discard(self._to_rel(p))
        return visited

    # ------------------------------------------------------------------
    # LSP enrichment
    # ------------------------------------------------------------------

    def ingest_lsp_results(
        self,
        tool_name: str,
        file_path: str,
        results: list[Any],
        query: dict | None = None,
    ) -> int:
        """Ingest LSP results into the cross-reference index.

        Called by LSPManager's on_result_callback. Converts LSP locations
        to SymbolLocation/SymbolRef with ``source="lsp"`` and merges them
        into the index.

        ``query`` carries the original request position
        (``{"line": int, "col": int}``, both 0-indexed) so that for
        ``references`` results we can resolve the *queried* symbol — that
        symbol is the callee, while each result's enclosing function is
        the caller. Together they feed high-confidence call-graph edges
        with ``source="lsp"`` attribution.

        Returns the number of new entries added.
        """
        if not self._initialized:
            return 0

        rel = self._to_rel(file_path)
        definitions: list[SymbolLocation] = []
        references: list[SymbolRef] = []

        # For ``references`` results, look up the queried symbol once —
        # that's the callee for every edge we're about to record.
        callee_name = ""
        if tool_name == "references" and query is not None:
            q_line = int(query.get("line", 0)) + 1  # LSP is 0-indexed
            q_name, _q_qname, _q_kind = self._resolve_symbol_at_line(rel, q_line)
            callee_name = query.get("symbol_name", q_name)

        for item in results:
            # LSPLocation has .uri, .range (.start.line, .start.character)
            if not hasattr(item, "range"):
                continue

            item_file = file_path
            if hasattr(item, "uri"):
                uri = item.uri
                if uri.startswith("file://"):
                    from urllib.parse import unquote, urlparse
                    item_file = unquote(urlparse(uri).path)
                item_rel = self._to_rel(item_file)
            else:
                item_rel = rel

            if not Path(item_file).resolve().is_relative_to(Path(self._root_dir).resolve()):
                continue
            line = item.range.start.line + 1  # LSP is 0-indexed

            # Look up the symbol whose body contains this line — for
            # ``definition`` results it identifies the defined symbol;
            # for ``references`` results it's the *enclosing* function
            # (the caller of ``callee_name``).
            name, qname, kind = self._resolve_symbol_at_line(item_rel, line)
            if not name and not callee_name:
                continue

            if tool_name == "definition":
                loc = SymbolLocation(
                    name=name,
                    qualified_name=qname,
                    kind=kind,
                    file_path=item_rel,
                    start_line=line,
                    end_line=item.range.end.line + 1,
                    source="lsp",
                )
                definitions.append(loc)
            elif tool_name == "references":
                # When we know the queried (callee) symbol, store the ref
                # against it and attribute the *enclosing* function as the
                # caller — that's the call-graph edge ``qname → callee``.
                # When ``query`` is missing (legacy callers), fall back to
                # the prior behaviour (symbol_name = enclosing function,
                # no caller) so we don't regress.
                if callee_name:
                    ref = SymbolRef(
                        symbol_name=callee_name,
                        ref_kind=("call" if any(
                            r.file_path == item_rel and r.line == line and r.ref_kind == "call"
                            for r in self._index.get_references(callee_name)
                        ) else "reference"),
                        file_path=item_rel,
                        line=line,
                        source="lsp",
                        caller_qualified_name=qname,
                        definition_key=query.get("definition_key", "") if query else "",
                    )
                else:
                    ref = SymbolRef(
                        symbol_name=name,
                        ref_kind="call",
                        file_path=item_rel,
                        line=line,
                        source="lsp",
                    )
                references.append(ref)

        if definitions or references:
            return self._index.merge_lsp_results(rel, definitions, references, verified_symbol=bool(query and query.get("symbol_name")))
        return 0

    # ------------------------------------------------------------------
    # Task allocation (swarm-specific)
    # ------------------------------------------------------------------

    def detect_conflicts(
        self,
        a_files: list[str],
        b_files: list[str],
    ) -> list[dict[str, Any]]:
        """Detect potential conflicts between two parallel task file sets.

        Returns a list of conflict descriptors::

            [{"file": "src/auth.py", "kind": "direct", "symbols": [...]}]

        Conflict kinds:
        - ``direct``: both tasks touch the same file
        - ``symbol``: tasks modify different files but share a symbol
        - ``dependency``: one task's output file is imported by the other's
        """
        self._ensure_initialized()
        a_set = {self._to_rel(p) for p in a_files}
        b_set = {self._to_rel(p) for p in b_files}
        conflicts: list[dict[str, Any]] = []

        # Direct overlap
        overlap = a_set & b_set
        for f in sorted(overlap):
            syms = sorted(self._index.file_symbols.get(f, set()))
            conflicts.append({
                "file": f,
                "kind": "direct",
                "symbols": syms,
            })

        # Dependency conflicts: a_file imports from b_file or vice versa
        for af in a_set - overlap:
            a_deps = self._index.get_dependencies(af)
            for bf in b_set - overlap:
                if bf in a_deps:
                    conflicts.append({
                        "file": af,
                        "depends_on": bf,
                        "kind": "dependency",
                    })
                b_deps = self._index.get_dependencies(bf)
                if af in b_deps:
                    conflicts.append({
                        "file": bf,
                        "depends_on": af,
                        "kind": "dependency",
                    })

        # Symbol-level overlap (same symbol modified in different files)
        a_symbols: set[str] = set()
        for af in a_set:
            a_symbols.update(self._index.file_symbols.get(af, set()))
        b_symbols: set[str] = set()
        for bf in b_set:
            b_symbols.update(self._index.file_symbols.get(bf, set()))
        shared_symbols = a_symbols & b_symbols
        if shared_symbols and not overlap:
            # Only report if it's NOT already a direct overlap
            conflicts.append({
                "kind": "symbol",
                "shared_symbols": sorted(shared_symbols),
            })

        return conflicts

    def suggest_related_files(self, target: list[str]) -> list[str]:
        """Suggest files related to *target* that a worker may also need.

        Uses the dependency graph (imports + imported-by) up to 1 hop.
        """
        self._ensure_initialized()
        related: set[str] = set()
        target_set = {self._to_rel(p) for p in target}

        for t in target_set:
            related.update(self._index.get_dependencies(t))
            related.update(self._index.get_dependents(t))

        # Remove the target files themselves
        related -= target_set
        return sorted(related)

    # ------------------------------------------------------------------
    def to_rel(self, path: str) -> str:
        """Normalize *path* to a relative path from root (public API)."""
        return self._to_rel(path)

    def get_symbol_names(self, file_path: str) -> list[str]:
        """Return all symbol names defined in the cached AST for *file_path*."""
        rel = self._to_rel(file_path)
        ast = self._ast_cache.get(rel)
        if ast is None:
            return []
        return ast.get_symbols()

    def cached_file_ast(self, rel_path: str, content_sha256: str) -> FileAST | None:
        """Read an already-published AST only when its source digest matches.

        No initialization, parsing or waiting for hydration. Published ASTs are
        replaced on edits; callers must treat the returned snapshot as read-only.
        """
        ast = self._ast_cache.get(rel_path)
        return ast if ast is not None and content_sha256 and ast.content_sha256 == content_sha256 else None

    # Internals
    # ------------------------------------------------------------------

    def _resolve_symbol_at_line(
        self, rel_path: str, line: int,
    ) -> tuple[str, str, str]:
        """Find the symbol name at a given line from the AST cache.

        Returns (name, qualified_name, kind) or ("", "", "") if not found.
        """
        ast = self._ast_cache.get(rel_path)
        if ast is None:
            return ("", "", "")

        candidates = []
        for func in ast.functions:
            if func.start_line <= line <= func.end_line:
                candidates.append((func.end_line - func.start_line, func.name,
                                   func.qualified_name or func.name,
                                   "method" if func.is_method else "function"))

        # Check classes and their methods
        for cls in ast.classes:
            if cls.start_line <= line <= cls.end_line:
                candidates.append((cls.end_line - cls.start_line, cls.name, cls.name, "class"))
                for method in cls.methods:
                    if method.start_line <= line <= method.end_line:
                        candidates.append((method.end_line - method.start_line, method.name,
                                           f"{cls.name}.{method.name}", "method"))

        if candidates:
            return min(candidates, key=lambda c: (c[0], c[3] == "class"))[1:]

        return ("", "", "")

    def _to_rel(self, path: str) -> str:
        """Normalize *path* to a relative path from root."""
        if os.path.isabs(path):
            try:
                return os.path.relpath(path, self._root_dir)
            except ValueError:
                return path
        # Already relative — treat as relative to root_dir
        return path

    def _index_file(self, rel_path: str, ast: FileAST) -> None:
        """Index all symbols and references from a parsed FileAST.

        Convenience wrapper that calls both phases.  Used by
        ``notify_file_changed`` for incremental updates where most
        symbols are already known.
        """
        self._index_definitions(rel_path, ast)
        self._index_references(rel_path, ast)

    def _build_stored_file(self, rel_path: str, ast: FileAST, mtime: float) -> StoredFile:
        """Build the persisted metadata row for one indexed file."""
        return StoredFile(
            path=rel_path,
            mtime=mtime,
            size=0,
            language="",
            line_count=ast.line_count if hasattr(ast, "line_count") else 0,
            content_hash="",
        )

    def _persist_indexed_file(self, rel_path: str, ast: FileAST, mtime: float) -> None:
        """Persist one file's metadata, symbols, refs, and dependency edges in FK-safe order."""
        self._store.save_file(self._build_stored_file(rel_path, ast, mtime))
        self._index.persist_file(rel_path)
        self._store.save_dependencies(rel_path, self._index.get_dependencies(rel_path))

    def _recover_from_store_error(self, exc: Exception, rel_path: str) -> None:
        """Clear the local store and rebuild once after a persistence integrity error."""
        self._store.clear_all()
        try:
            self.initialize(force=True)
        except Exception as rebuild_exc:  # pragma: no cover - defensive escalation
            raise RuntimeError(
                f"AST index rebuild failed after store integrity error for {rel_path}"
            ) from rebuild_exc

    def _index_definitions(self, rel_path: str, ast: FileAST, *, index=None) -> None:
        """Phase 1: Index all *definitions* (functions, classes, methods)."""
        index = self._index if index is None else index
        # Top-level functions
        for func in ast.functions:
            loc = SymbolLocation(
                name=func.name,
                qualified_name=func.qualified_name or func.name,
                kind="method" if func.is_method else "function",
                file_path=rel_path,
                start_line=func.start_line,
                end_line=func.end_line,
            )
            index.add_definition(loc)

        # Classes and their methods
        for cls in ast.classes:
            cls_loc = SymbolLocation(
                name=cls.name,
                qualified_name=cls.name,
                kind="class",
                file_path=rel_path,
                start_line=cls.start_line,
                end_line=cls.end_line,
            )
            index.add_definition(cls_loc)

            for method in cls.methods:
                method_loc = SymbolLocation(
                    name=method.name,
                    qualified_name=f"{cls.name}.{method.name}",
                    kind="method",
                    file_path=rel_path,
                    start_line=method.start_line,
                    end_line=method.end_line,
                )
                index.add_definition(method_loc)

        # Top-level variables/constants
        for var_name in ast.top_level_vars:
            var_loc = SymbolLocation(
                name=var_name,
                qualified_name=var_name,
                kind="variable",
                file_path=rel_path,
                start_line=0,
                end_line=0,
            )
            index.add_definition(var_loc)

    def _index_references(self, rel_path: str, ast: FileAST, *, index=None) -> None:
        """Phase 2: Index import references and call-site references.

        Should be called after all definitions are indexed so that
        ``known_symbols`` is complete.
        """
        index = self._index if index is None else index
        from .parse_diagnostics import ParseFailure, collect_parse_failures

        ast.parse_failures[:] = [failure for failure in ast.parse_failures if failure.stage != 'references']
        # Import references
        for imp in ast.imports:
            for name in imp.names:
                ref = SymbolRef(
                    symbol_name=name,
                    ref_kind="import",
                    file_path=rel_path,
                    line=imp.line,
                    syntax_name=imp.alias or name,
                )
                index.add_reference(ref)

        # Extract call-site references from source (lightweight regex scan)
        abs_path = os.path.join(self._root_dir, rel_path)
        try:
            content = Path(abs_path).read_text(encoding="utf-8", errors="replace")
        except OSError as exc:
            ast.parse_failures.append(ParseFailure('source_unreadable', 0, 0, type(exc).__name__, 'references'))
            return

        # Collect known symbol names for targeted scanning
        known_symbols = index.known_symbol_names()

        if not known_symbols:
            return

        # Build a list of (qualified_name, start_line, end_line) for every
        # function/method in this file so we can attribute each call site to
        # its enclosing scope and feed the call-graph edges. Innermost wins
        # via smallest span on tie.
        scopes: list[tuple[str, int, int]] = []
        for fn in ast.functions:
            scopes.append((fn.qualified_name or fn.name, fn.start_line, fn.end_line))
        for cls in ast.classes:
            for method in cls.methods:
                scopes.append(
                    (f"{cls.name}.{method.name}", method.start_line, method.end_line)
                )

        def _enclosing_qname(line_no: int) -> str:
            best_q = ""
            best_span = -1
            for qname, s, e in scopes:
                if s <= line_no <= e:
                    span = e - s
                    if best_span < 0 or span < best_span:
                        best_q = qname
                        best_span = span
            return best_q

        from .syntax_evidence import reference_candidates
        with collect_parse_failures() as failures:
            candidates = reference_candidates(abs_path, content, ast.language)
        for failure in failures:
            failure.stage = 'references'
        ast.parse_failures.extend(failures[:max(0, 8 - len(ast.parse_failures))])
        if candidates is not None:
            aliases = {imp.alias: imp.names[-1] for imp in ast.imports if imp.alias and imp.names}
            seen = set()
            for name, kind, line in candidates:
                parts = name.split(".")
                parts[0] = aliases.get(parts[0], parts[0])
                qualified = ".".join(parts)
                bare = parts[-1]
                symbol = qualified if qualified in index.definitions else bare
                if bare not in known_symbols or (symbol, kind, line) in seen:
                    continue
                seen.add((symbol, kind, line))
                index.add_reference(SymbolRef(
                    symbol_name=symbol, ref_kind=kind, file_path=rel_path, line=line,
                    source="tree-sitter", syntax_name=name,
                    caller_qualified_name=_enclosing_qname(line),
                ))
            return

        # Build a regex pattern for call sites: symbol_name(
        # Only scan for symbols that are actually defined somewhere.
        # Skip comment lines and string literals to reduce false positives.
        in_multiline_string = False
        for i, line in enumerate(content.split("\n"), 1):
            stripped = line.lstrip()

            # Track triple-quote multiline strings
            triple_count = line.count('"""') + line.count("'''")
            if in_multiline_string:
                if triple_count % 2 == 1:
                    in_multiline_string = False
                continue
            if triple_count % 2 == 1:
                in_multiline_string = True
                continue

            # Skip single-line comments
            if stripped.startswith("#"):
                continue

            # Skip definition lines — the symbol's own name on its def line
            # is not a call site even though the regex matches "name(".
            if re.match(
                r"^\s*(?:async\s+)?(?:def|class|function|interface|type)\s",
                line,
            ):
                continue

            # Strip inline comments and string literals for matching
            # Remove quoted strings to avoid matching inside them
            clean_line = re.sub(r'(["\'])(?:(?!\1).)*\1', '""', line)
            caller = _enclosing_qname(i)

            # Find function/method calls: name(
            for m in re.finditer(r"(?<!\.)\b(\w+)\s*\(", clean_line):
                name = m.group(1)
                if name in known_symbols and name not in ("if", "for", "while", "return", "print"):
                    ref = SymbolRef(
                        symbol_name=name,
                        ref_kind="call",
                        file_path=rel_path,
                        line=i,
                        syntax_name=name,
                        caller_qualified_name=caller,
                    )
                    index.add_reference(ref)

            # Find attribute access: obj.method(
            for m in re.finditer(r"\b(\w+)\.(\w+)\s*\(", clean_line):
                attr_name = m.group(2)
                if attr_name in known_symbols:
                    ref = SymbolRef(
                        symbol_name=attr_name,
                        ref_kind="attribute",
                        file_path=rel_path,
                        line=i,
                        syntax_name=m.group(0).rsplit("(", 1)[0].strip(),
                        caller_qualified_name=caller,
                    )
                    index.add_reference(ref)
