"""
RBSP Runtime Engine.

Singleton orchestrator that manages all RBSP components: indexing,
querying, configuration, plugins, and background watchers.  Components
are lazily initialized on first use.
"""

from __future__ import annotations

import hashlib
import heapq
import json
import os
import re
import struct
import time
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

from rbsp.runtime.config import RBSPConfig
from rbsp.runtime.plugins import PluginManager, PluginResult
from rbsp.runtime.platform import FileSystem, ProcessManager, PLATFORM
from rbsp.indexer.inverted import InvertedIndex
from rbsp.indexer.mmap_store import MMapStore
from rbsp.core.lsh import LSHIndex, ngram_shingles
from rbsp.core.signature import SignatureGenerator
from rbsp.core.bm25 import BM25Scorer, compute_term_freqs
from rbsp.core.stemmer import stem_tokens
from rbsp.query.signal import SignalEncoder


# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------

@dataclass
class Result:
    """A single RBSP search/resolve result."""

    path: str
    score: float
    snippet: str = ""
    line: int = 0
    metadata: Dict[str, Any] = field(default_factory=dict)

    def __repr__(self) -> str:
        return f"Result(path={self.path!r}, score={self.score:.4f}, line={self.line})"


@dataclass
class IndexStats:
    """Statistics about an indexing operation."""

    files_indexed: int = 0
    files_skipped: int = 0
    total_size: int = 0
    duration: float = 0.0
    index_path: str = ""

    def __repr__(self) -> str:
        return (
            f"IndexStats(files={self.files_indexed}, "
            f"skipped={self.files_skipped}, "
            f"size={self.total_size}, "
            f"duration={self.duration:.3f}s)"
        )


# ---------------------------------------------------------------------------
# Internal index entry
# ---------------------------------------------------------------------------

@dataclass(slots=True)
class _IndexEntry:
    """Internal representation of an indexed file."""

    path: str
    relative_path: str
    content_hash: str
    size: int
    modified: float
    tokens: List[str] = field(default_factory=list)
    trigrams: Set[str] = field(default_factory=set)
    token_set: frozenset = field(default_factory=frozenset)
    term_freqs: Dict[str, int] = field(default_factory=dict)
    doc_len: int = 0


# ---------------------------------------------------------------------------
# Tokenizer / signal helpers
# ---------------------------------------------------------------------------

_TOKEN_RE = re.compile(r'[a-zA-Z0-9_]+')


def _tokenize(text: str) -> List[str]:
    """Split text into lowercase, stemmed tokens (words, identifiers)."""
    raw = [m.lower() for m in _TOKEN_RE.findall(text)]
    return stem_tokens(raw)


def _trigrams(text: str) -> Set[str]:
    """Generate character-level trigrams from text."""
    lower = text.lower()
    return {lower[i : i + 3] for i in range(len(lower) - 2)}


def _content_hash(content: str) -> str:
    """Fast content hash for change detection."""
    return hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()


def _query_score(
    query_tokens: List[str],
    query_trigrams: Set[str],
    entry: _IndexEntry,
    bm25: Optional[BM25Scorer] = None,
) -> float:
    """Score an index entry against a query.

    Uses BM25 when available (SOTA), falls back to token overlap + trigram.
    Returns a float in [0, 1].
    """
    if not query_tokens:
        return 0.0

    entry_token_set = entry.token_set
    if not entry_token_set:
        return 0.0

    # BM25 scoring (SOTA) with trigram fuzzy fallback
    if bm25 is not None and bm25.num_docs > 0 and entry.term_freqs:
        bm25_score = bm25.score_normalized(
            query_tokens, entry.term_freqs, entry.doc_len
        )
        # Blend with trigram for fuzzy matching on typos/substrings
        if query_trigrams and entry.trigrams:
            common = len(query_trigrams & entry.trigrams)
            trigram_score = common / len(query_trigrams)
        else:
            trigram_score = 0.0
        return 0.8 * bm25_score + 0.2 * trigram_score

    # Fallback: token overlap + trigram (pre-BM25 path)
    token_matches = sum(1 for t in query_tokens if t in entry_token_set)
    token_score = token_matches / len(query_tokens)

    if query_trigrams and entry.trigrams:
        common = len(query_trigrams & entry.trigrams)
        trigram_score = common / len(query_trigrams)
    else:
        trigram_score = 0.0

    return 0.7 * token_score + 0.3 * trigram_score


# ---------------------------------------------------------------------------
# MMapStore binary serialization helpers
# ---------------------------------------------------------------------------

_ENTRY_META_FMT = "<IIIdI"  # size, token_count, doc_len, modified, hash_len
_ENTRY_META_SIZE = struct.calcsize(_ENTRY_META_FMT)


def _serialize_entry_meta(entry: _IndexEntry) -> bytes:
    """Serialize an _IndexEntry's metadata to binary for MMapStore."""
    chash_bytes = entry.content_hash.encode("utf-8")
    rel_bytes = entry.relative_path.encode("utf-8")
    path_bytes = entry.path.encode("utf-8")
    header = struct.pack(
        _ENTRY_META_FMT,
        entry.size,
        len(entry.tokens),
        entry.doc_len,
        entry.modified,
        len(chash_bytes),
    )
    # Layout: header + hash + rel_len(4) + rel + path_len(4) + path
    return (
        header
        + chash_bytes
        + struct.pack("<I", len(rel_bytes))
        + rel_bytes
        + struct.pack("<I", len(path_bytes))
        + path_bytes
    )


def _deserialize_entry_meta(data: bytes) -> Dict[str, Any]:
    """Deserialize binary metadata back to a dict."""
    size, token_count, doc_len, modified, hash_len = struct.unpack_from(
        _ENTRY_META_FMT, data, 0
    )
    off = _ENTRY_META_SIZE
    chash = data[off : off + hash_len].decode("utf-8")
    off += hash_len
    (rel_len,) = struct.unpack_from("<I", data, off)
    off += 4
    rel = data[off : off + rel_len].decode("utf-8")
    off += rel_len
    (path_len,) = struct.unpack_from("<I", data, off)
    off += 4
    path = data[off : off + path_len].decode("utf-8")
    return {
        "path": path,
        "relative_path": rel,
        "content_hash": chash,
        "size": size,
        "modified": modified,
        "token_count": token_count,
        "doc_len": doc_len,
    }


# ---------------------------------------------------------------------------
# Engine singleton
# ---------------------------------------------------------------------------

class RBSPEngine:
    """Core RBSP runtime engine.

    Manages the full lifecycle: configuration, indexing, querying, plugins,
    and background watchers.  Uses lazy initialization so that heavy
    resources are only allocated when actually needed.

    Usage::

        engine = RBSPEngine.get_instance()
        engine.init("/my/project")
        results = engine.resolve("authentication logic")
    """

    _instance: Optional["RBSPEngine"] = None
    _lock = threading.Lock()

    def __init__(self) -> None:
        self._config: Optional[RBSPConfig] = None
        self._plugins: Optional[PluginManager] = None
        self._fs = FileSystem()
        self._processes = ProcessManager()
        self._index: Dict[str, _IndexEntry] = {}
        self._relpath_to_key: Dict[str, str] = {}
        self._inverted = InvertedIndex()
        self._lsh = LSHIndex(num_hashes=128, bands=16, rows=8)
        self._sig_gen = SignatureGenerator(num_hashes=128)
        self._signal_encoder = SignalEncoder(num_hashes=128)
        self._bm25 = BM25Scorer(k1=1.2, b=0.75)
        self._mmap_store: Optional[MMapStore] = None
        self._initialized = False
        self._watcher_stop = threading.Event()
        self._stats: Dict[str, Any] = {
            "queries": 0,
            "index_operations": 0,
            "files_indexed": 0,
            "last_index_time": 0.0,
            "last_query_time": 0.0,
        }

    @classmethod
    def get_instance(cls) -> "RBSPEngine":
        """Return the global singleton engine instance."""
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = cls()
        return cls._instance

    @classmethod
    def reset(cls) -> None:
        """Discard the singleton (primarily for testing)."""
        with cls._lock:
            if cls._instance is not None:
                cls._instance.stop_watcher()
                if cls._instance._mmap_store is not None:
                    try:
                        cls._instance._mmap_store.close()
                    except Exception:
                        pass
                    cls._instance._mmap_store = None
            cls._instance = None

    # --- Lazy property access ----------------------------------------------

    @property
    def config(self) -> RBSPConfig:
        """Lazily loaded configuration."""
        if self._config is None:
            self._config = RBSPConfig.load()
        return self._config

    @property
    def plugins(self) -> PluginManager:
        """Lazily initialized plugin manager."""
        if self._plugins is None:
            self._plugins = PluginManager()
            plugins_dir = self.config.resolve_path("plugins_path")
            self._plugins.discover(plugins_dir)
        return self._plugins

    @property
    def is_initialized(self) -> bool:
        """Whether the engine has been initialized with a project root."""
        return self._initialized

    # --- Initialization ----------------------------------------------------

    def init(self, root: str = ".", overrides: Optional[Dict[str, Any]] = None) -> None:
        """Initialize RBSP in *root*.

        Creates the ``.rbsp/`` directory structure and a default
        configuration file if one doesn't exist.
        """
        root_path = Path(root).resolve()

        # Create directory structure
        rbsp_dir = root_path / ".rbsp"
        self._fs.ensure_dir(rbsp_dir)
        self._fs.ensure_dir(rbsp_dir / "index")
        self._fs.ensure_dir(rbsp_dir / "plugins")

        # Write default config if absent
        config_file = rbsp_dir / "config.json"
        if not config_file.exists():
            self._config = RBSPConfig.load(str(root_path), overrides)
            self._config.save_json()
        else:
            self._config = RBSPConfig.load(str(root_path), overrides)

        # Load plugins
        self._plugins = PluginManager()
        self._plugins.discover(str(rbsp_dir / "plugins"))

        self._initialized = True

    # --- Indexing -----------------------------------------------------------

    def _update_bm25_stats(self) -> None:
        """Recompute BM25 corpus statistics from the current index."""
        num_docs = len(self._index)
        if num_docs == 0:
            return
        total_len = sum(e.doc_len for e in self._index.values())
        avg_len = total_len / num_docs
        doc_freqs = self._inverted.doc_freqs()
        self._bm25.update_stats(num_docs, avg_len, doc_freqs)

    def index(self, path: str = ".", force: bool = False) -> IndexStats:
        """Index all files under *path*.

        Parameters
        ----------
        path:
            Directory to index (default: current directory).
        force:
            If True, re-index even if the file hasn't changed.
        """
        start = time.monotonic()
        target = Path(path).resolve()
        stats = IndexStats(index_path=str(self.config.resolve_path("index_path")))

        # Run pre_index hooks
        self.plugins.run_hook("pre_index", path=str(target))

        extensions = set(self.config.file_extensions) or None
        ignored = set(self.config.ignored_dirs) or None
        max_size = self.config.max_file_size

        for filepath in self._fs.walk_files(target, extensions, ignored):
            try:
                info = self._fs.file_info(filepath)
                if info is None:
                    stats.files_skipped += 1
                    continue

                size = int(info["size"])  # type: ignore[arg-type]
                if size > max_size:
                    stats.files_skipped += 1
                    continue

                content = self._fs.read_text(filepath)
                chash = _content_hash(content)

                key = str(filepath)
                existing = self._index.get(key)
                if not force and existing and existing.content_hash == chash:
                    stats.files_skipped += 1
                    continue

                tokens = _tokenize(content)
                tgrams = _trigrams(content)
                tfreqs = compute_term_freqs(tokens)

                rel = self._fs.relative_to(filepath, target)

                entry = _IndexEntry(
                    path=key,
                    relative_path=rel,
                    content_hash=chash,
                    size=size,
                    modified=float(info["modified"]),  # type: ignore[arg-type]
                    tokens=tokens,
                    trigrams=tgrams,
                    token_set=frozenset(tokens),
                    term_freqs=tfreqs,
                    doc_len=len(tokens),
                )

                # Let indexer plugins augment the entry
                for indexer in self.plugins.indexers:
                    if indexer.can_handle(key):
                        try:
                            extra = indexer.index_file(key, content)
                            entry.metadata = {**getattr(entry, "metadata", {}), **extra}  # type: ignore[attr-defined]
                        except Exception:
                            pass

                self._index[key] = entry
                self._relpath_to_key[rel] = key

                # Feed O(1) lookup structures
                self._inverted.add_document(key, tokens)
                shingles = ngram_shingles(content)
                if shingles:
                    self._lsh.insert(key, shingles)

                stats.files_indexed += 1
                stats.total_size += size

            except Exception:
                stats.files_skipped += 1

        stats.duration = time.monotonic() - start

        # Update BM25 corpus statistics from inverted index
        self._update_bm25_stats()

        # Persist index to disk
        self._save_index()

        # Update engine stats
        self._stats["index_operations"] += 1
        self._stats["files_indexed"] = len(self._index)
        self._stats["last_index_time"] = time.time()

        # Run post_index hooks
        self.plugins.run_hook("post_index", stats=stats)

        return stats

    # --- Querying ----------------------------------------------------------

    def resolve(self, query: str, scope: str = ".", **kwargs: Any) -> List[Result]:
        """Execute the full query pipeline: parse -> score -> rank -> return.

        Uses BM25 scoring with WAND-style early termination for efficiency.

        Parameters
        ----------
        query:
            Natural-language or keyword search query.
        scope:
            Directory scope for results.
        """
        start = time.monotonic()

        # Run pre_query hooks
        self.plugins.run_hook("pre_query", query=query, scope=scope)

        # If index is empty, try loading from disk
        if not self._index:
            self._load_index()

        # If still empty, do a quick index of scope
        if not self._index:
            self.index(scope)

        # Parse query into tokens and trigrams (stemmed)
        q_tokens = _tokenize(query)
        q_trigrams = _trigrams(query)

        # Try plugin resolvers first
        plugin_results: List[Result] = []
        for resolver in self.plugins.resolvers:
            if resolver.can_handle(query):
                try:
                    presults = resolver.resolve(query, scope, **kwargs)
                    for pr in presults:
                        plugin_results.append(Result(
                            path=pr.path,
                            score=pr.score,
                            snippet=pr.snippet,
                            line=pr.line,
                            metadata=pr.metadata,
                        ))
                except Exception:
                    pass

        # Collect candidates via O(1) lookups instead of brute-force scan
        candidate_keys: Set[str] = set()
        threshold = self.config.resonance_threshold

        # 1. Inverted index: find docs matching query tokens
        for token in q_tokens:
            candidate_keys.update(self._inverted.lookup(token))

        # 2. LSH: find semantically similar docs
        q_shingles = ngram_shingles(query)
        if q_shingles and len(self._lsh) > 0:
            lsh_hits = self._lsh.query(q_shingles, top_k=200, min_similarity=0.0)
            for item_id, _sim in lsh_hits:
                candidate_keys.add(str(item_id))

        # 3. Fallback: if no candidates found, scan all (safety net)
        if not candidate_keys:
            candidate_keys = set(self._index.keys())

        # Score candidates with BM25 + WAND-style early termination
        max_results = kwargs.get("max_results", self.config.max_results)
        candidates: List[Result] = list(plugin_results)

        # WAND: track k-th best score for pruning
        top_k_heap: List[float] = []  # min-heap of top-K scores

        for key in candidate_keys:
            entry = self._index.get(key)
            if entry is None:
                continue

            # WAND pruning: if we have enough results, skip entries whose
            # upper-bound BM25 score can't beat the current k-th best
            if len(top_k_heap) >= max_results and self._bm25.num_docs > 0 and entry.doc_len > 0:
                upper = self._bm25.score_upper_bound(q_tokens, entry.doc_len)
                # Upper bound of blended score: 0.8 * normalized_upper + 0.2 * 1.0
                blended_upper = 0.8 + 0.2  # max possible = 1.0
                if upper > 0:
                    blended_upper = 0.8 * min(upper / upper, 1.0) + 0.2
                if blended_upper < top_k_heap[0]:
                    continue

            score = _query_score(q_tokens, q_trigrams, entry, self._bm25)
            if score >= threshold:
                candidates.append(Result(
                    path=entry.relative_path,
                    score=score,
                ))
                # Maintain top-K heap for WAND threshold
                if len(top_k_heap) < max_results:
                    heapq.heappush(top_k_heap, score)
                elif score > top_k_heap[0]:
                    heapq.heapreplace(top_k_heap, score)

        # Let ranker plugins re-order
        for ranker in self.plugins.rankers:
            try:
                ranked = ranker.rank(query, [
                    PluginResult(
                        path=r.path, score=r.score, snippet=r.snippet,
                        line=r.line, metadata=r.metadata,
                    )
                    for r in candidates
                ])
                candidates = [
                    Result(
                        path=pr.path, score=pr.score, snippet=pr.snippet,
                        line=pr.line, metadata=pr.metadata,
                    )
                    for pr in ranked
                ]
            except Exception:
                pass

        # Apply limit - select top-K FIRST, then extract snippets only for those
        results = heapq.nlargest(max_results, candidates, key=lambda r: r.score)

        # Defer snippet extraction to final top-K only
        for r in results:
            entry = self._find_entry_by_relpath(r.path)
            if entry is not None:
                snippet, line = _extract_snippet_and_line(entry, q_tokens)
                r.snippet = snippet
                r.line = line

        # Post-query hooks
        self.plugins.run_hook("post_query", query=query, results=results)

        # Stats
        self._stats["queries"] += 1
        self._stats["last_query_time"] = time.time()

        return results

    def search(self, query: str, **kwargs: Any) -> List[Result]:
        """Search alias with extended options. Delegates to :meth:`resolve`."""
        scope = kwargs.pop("scope", ".")
        return self.resolve(query, scope=scope, **kwargs)

    # --- Watcher -----------------------------------------------------------

    def start_watcher(self, path: str = ".") -> bool:
        """Start a background file watcher that re-indexes on changes.

        Returns True if the watcher was started, False if already running.
        """
        self._watcher_stop.clear()
        target = str(Path(path).resolve())

        def _watch_loop() -> None:
            interval = self.config.watch_interval
            last_mtimes: Dict[str, float] = {}
            extensions = set(self.config.file_extensions) or None
            ignored = set(self.config.ignored_dirs) or None

            while not self._watcher_stop.is_set():
                changed = False
                for fp in self._fs.walk_files(target, extensions, ignored):
                    key = str(fp)
                    try:
                        mtime = fp.stat().st_mtime
                    except OSError:
                        continue
                    if key not in last_mtimes or last_mtimes[key] < mtime:
                        last_mtimes[key] = mtime
                        changed = True

                if changed:
                    self.index(target)

                self._watcher_stop.wait(interval)

        return self._processes.start_thread("rbsp-watcher", _watch_loop)

    def stop_watcher(self) -> None:
        """Stop the background file watcher."""
        self._watcher_stop.set()
        self._processes.stop_thread("rbsp-watcher", timeout=5.0)

    def is_watching(self) -> bool:
        """Return True if the file watcher is running."""
        return self._processes.is_running("rbsp-watcher")

    # --- Status / metrics --------------------------------------------------

    def status(self) -> Dict[str, Any]:
        """Return engine status and statistics."""
        return {
            "initialized": self._initialized,
            "platform": PLATFORM.system,
            "index_entries": len(self._index),
            "watching": self.is_watching(),
            "plugins": self.plugins.summary(),
            "stats": dict(self._stats),
            "config_root": self.config.root,
        }

    def _find_entry_by_relpath(self, relpath: str) -> Optional[_IndexEntry]:
        """O(1) lookup of an index entry by its relative_path."""
        key = self._relpath_to_key.get(relpath)
        if key is not None:
            return self._index.get(key)
        return None

    # --- Index persistence -------------------------------------------------

    def _get_mmap_store(self) -> MMapStore:
        """Get or create the MMapStore for binary persistence."""
        if self._mmap_store is None:
            index_dir = self.config.resolve_path("index_path")
            index_dir.mkdir(parents=True, exist_ok=True)
            store_path = index_dir / "index.rbsp"
            self._mmap_store = MMapStore(store_path)
        return self._mmap_store

    def _save_index(self) -> None:
        """Persist the in-memory index to disk.

        Primary: binary MMapStore for fast loading.
        Fallback: JSON for migration compatibility.
        """
        # Primary: binary MMapStore persistence
        try:
            store = self._get_mmap_store()
            for key, entry in self._index.items():
                store.put(key, _serialize_entry_meta(entry))
            store.flush()
        except Exception:
            pass

        # Fallback: JSON persistence for migration
        index_dir = self.config.resolve_path("index_path")
        index_dir.mkdir(parents=True, exist_ok=True)
        index_file = index_dir / "index.json"

        data: Dict[str, Any] = {}
        for key, entry in self._index.items():
            data[key] = {
                "path": entry.path,
                "relative_path": entry.relative_path,
                "content_hash": entry.content_hash,
                "size": entry.size,
                "modified": entry.modified,
                "token_count": len(entry.tokens),
            }

        try:
            with index_file.open("w", encoding="utf-8") as fh:
                json.dump(
                    {"version": 1, "entries": data, "updated": time.time()},
                    fh,
                    indent=2,
                )
                fh.write("\n")
        except OSError:
            pass

    def _load_index(self) -> None:
        """Load a previously persisted index from disk.

        Tries MMapStore (binary) first, falls back to JSON.
        """
        # Try binary MMapStore first
        loaded_from_mmap = False
        try:
            index_dir = self.config.resolve_path("index_path")
            store_path = index_dir / "index.rbsp"
            if store_path.exists():
                store = MMapStore(store_path, readonly=True)
                for key, val in store.items():
                    meta = _deserialize_entry_meta(val)
                    filepath = Path(meta["path"])
                    if filepath.is_file():
                        try:
                            content = self._fs.read_text(filepath)
                            tokens = _tokenize(content)
                            tgrams = _trigrams(content)
                            tfreqs = compute_term_freqs(tokens)
                        except OSError:
                            tokens = []
                            tgrams = set()
                            tfreqs = {}

                        rel = meta.get("relative_path", "")
                        self._index[key] = _IndexEntry(
                            path=meta["path"],
                            relative_path=rel,
                            content_hash=meta.get("content_hash", ""),
                            size=meta.get("size", 0),
                            modified=meta.get("modified", 0.0),
                            tokens=tokens,
                            trigrams=tgrams,
                            token_set=frozenset(tokens),
                            term_freqs=tfreqs,
                            doc_len=len(tokens),
                        )
                        self._relpath_to_key[rel] = key

                        if tokens:
                            self._inverted.add_document(key, tokens)
                        try:
                            shingles = ngram_shingles(content)
                            if shingles:
                                self._lsh.insert(key, shingles)
                        except Exception:
                            pass
                        loaded_from_mmap = True
                store.close()
        except Exception:
            pass

        if loaded_from_mmap:
            self._update_bm25_stats()
            return

        # Fallback: JSON persistence
        index_file = self.config.resolve_path("index_path") / "index.json"
        if not index_file.is_file():
            return

        try:
            with index_file.open("r", encoding="utf-8") as fh:
                data = json.load(fh)
        except (OSError, json.JSONDecodeError):
            return

        entries = data.get("entries", {})
        for key, meta in entries.items():
            filepath = Path(meta["path"])
            if filepath.is_file():
                try:
                    content = self._fs.read_text(filepath)
                    tokens = _tokenize(content)
                    tgrams = _trigrams(content)
                    tfreqs = compute_term_freqs(tokens)
                except OSError:
                    tokens = []
                    tgrams = set()
                    tfreqs = {}

                rel = meta.get("relative_path", "")
                self._index[key] = _IndexEntry(
                    path=meta["path"],
                    relative_path=rel,
                    content_hash=meta.get("content_hash", ""),
                    size=meta.get("size", 0),
                    modified=meta.get("modified", 0.0),
                    tokens=tokens,
                    trigrams=tgrams,
                    token_set=frozenset(tokens),
                    term_freqs=tfreqs,
                    doc_len=len(tokens),
                )
                self._relpath_to_key[rel] = key

                if tokens:
                    self._inverted.add_document(key, tokens)
                try:
                    shingles = ngram_shingles(content)
                    if shingles:
                        self._lsh.insert(key, shingles)
                except Exception:
                    pass

        # Update BM25 stats after loading all entries
        if self._index:
            self._update_bm25_stats()


# ---------------------------------------------------------------------------
# Snippet / line helpers
# ---------------------------------------------------------------------------

def _extract_snippet_and_line(
    entry: _IndexEntry, query_tokens: List[str]
) -> tuple[str, int]:
    """Extract snippet and best line number in a single file read + scan.

    Returns (snippet, 1-based line number).
    """
    try:
        content = FileSystem.read_text(entry.path)
    except Exception:
        return "", 0

    lines = content.splitlines()
    if not lines:
        return "", 0

    best_line = 0
    best_score = -1

    for i, line in enumerate(lines):
        line_lower = line.lower()
        score = sum(1 for t in query_tokens if t in line_lower)
        if score > best_score:
            best_score = score
            best_line = i

    start = max(0, best_line - 1)
    end = min(len(lines), best_line + 2)
    snippet = "\n".join(lines[start:end])

    return snippet, best_line + 1  # 1-based
