"""SQLiteSearch — SQLite FTS5 implementation of SearchAPI."""

from __future__ import annotations

import json
import logging
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import yaml

from remotivelabs.cli.search.yaml_parser import parse_yaml_signals

# import yaml  # pyright: ignore[reportUnusedImport]from ..yaml_parser import parse_yaml_signals
from ..base import SearchAPI, SearchOptions, StatusResult
from ..grpc import _fetch_grpc_signals
from ..query import (
    _FTS_COLUMNS,
    Operator,
    ParsedQuery,
    Query,
)
from .query_builder import get_file_path_from_query, get_type_filter_from_query, to_fts_query, to_like_clauses
from .schema import (
    _DOT_SPLIT_FIELDS,
    _INSERT_SQL,
    _INSERT_TOKEN_SQL,
    _MIGRATION_SQL,
    _SCHEMA_FTS,
    _SCHEMA_RAW,
    _SCHEMA_TOKENS,
    _SCHEMA_TOKENS_INDEX,
    _TOKENIZED_SUGGEST_FIELDS,
    _TRIGGERS,
)

log = logging.getLogger(__name__)


def _escape_like(s: str) -> str:
    """Escape ``!``, ``%`` and ``_`` for use in a LIKE pattern with ESCAPE '!'."""
    return s.replace("!", "!!").replace("%", "!%").replace("_", "!_")


def _build_token_rows_for_signal(signal_id: int, sig: dict[str, Any]) -> list[tuple[int, str, str]]:
    """Build (signal_id, field, token) tuples for one row (frame or signal)."""
    seen: set[tuple[str, str]] = set()
    rows: list[tuple[int, str, str]] = []
    for field in _TOKENIZED_SUGGEST_FIELDS:
        value = sig.get(field)
        if not value:
            continue
        token_field = field
        if isinstance(value, dict):
            words = [str(v) for v in value.values() if v]
        elif isinstance(value, str):
            words = value.replace(".", " ").split() if field in _DOT_SPLIT_FIELDS else value.split()
        else:
            continue
        for word in words:
            pair = (token_field, word)
            if pair not in seen:
                seen.add(pair)
                rows.append((signal_id, token_field, word))
    return rows


def _extra_clauses(
    source_filter: str | None,
    file_path_filter: str | None,
    table_alias: str = "",
    filters: dict[str, str] | None = None,
) -> tuple[str, list[Any]]:
    """Build extra WHERE clause fragments and params for source/file/field filtering."""
    prefix = f"{table_alias}." if table_alias else ""
    clauses: list[str] = []
    params: list[Any] = []
    if source_filter is not None:
        clauses.append(f"{prefix}source = ?")
        params.append(source_filter)
    if file_path_filter is not None:
        clauses.append(f"{prefix}file_path = ?")
        params.append(file_path_filter)
    if filters:
        for col, value in filters.items():
            clauses.append(f'{prefix}"{col}" = ?')
            params.append(value)
    sql = (" AND " + " AND ".join(clauses)) if clauses else ""
    return sql, params


def _build_suggest_unions(
    columns: list[str],
    like_pattern: str,
    raw_extra: tuple[str, list[Any]],
    tok_extra: tuple[str, list[Any]],
) -> tuple[list[str], list[Any]]:
    """Build UNION SQL fragments and params for suggest queries."""
    raw_extra_sql, raw_extra_params = raw_extra
    tok_extra_sql, tok_extra_params = tok_extra
    unions: list[str] = []
    params: list[Any] = []
    for col in columns:
        display = col
        if col in _TOKENIZED_SUGGEST_FIELDS:
            unions.append(
                f"SELECT DISTINCT ? AS field, token AS value FROM signals_tokens WHERE field = ? AND token LIKE ? ESCAPE '!'{tok_extra_sql}"
            )
            params.extend([display, col, like_pattern] + tok_extra_params)
        else:
            unions.append(
                f"SELECT DISTINCT '{display}' AS field, \"{col}\" AS value"
                f" FROM signals_raw WHERE \"{col}\" LIKE ? ESCAPE '!'{raw_extra_sql}"
            )
            params.extend([like_pattern] + raw_extra_params)
    return unions, params


_INSERT_COLUMNS = (
    "type",
    "signal_name",
    "frame_name",
    "frame_id",
    "namespace",
    "description",
    "unit",
    "senders",
    "receivers",
    "named_values",
    "named_values_json",
    "min",
    "max",
    "factor",
    "offset",
    "cycle_time",
    "start_value",
    "size",
    "is_raw",
    "source",
    "channel",
    "channel_type",
    "file_path",
    "workspace_path",
    "start_bit",
    "is_big_endian",
    "is_signed",
    "is_ieee_floating_type",
    "multiplex_json",
    "payload_size",
    "opt_empty_payload",
    "is_can_fd",
    "is_extended",
    "can_fd_flags",
    "multiplex_selector",
    "e2e_json",
    "secoc_json",
    "groups_json",
    "channel_cluster",
    "channel_physical_name",
)


def _log_row_error(exc: Exception, sig: dict[str, Any], row: tuple[Any, ...]) -> None:
    """Log which column value caused an insertion error."""
    bad_fields = [
        f"{col}={val!r}" for col, val in zip(_INSERT_COLUMNS, row) if val is not None and not isinstance(val, (str, float, bytes, bool))
    ]
    log.error(
        "Failed to insert signal %r (channel=%r, frame=%r): %s — suspect fields: %s",
        sig.get("signal_name"),
        sig.get("channel"),
        sig.get("frame_name"),
        exc,
        ", ".join(bad_fields) if bad_fields else "<none identified>",
    )


def _signal_to_row(sig: dict[str, Any]) -> tuple[Any, ...]:
    """Convert a signal dict to a tuple matching _INSERT_SQL column order."""
    senders = sig.get("senders") or []
    receivers = sig.get("receivers") or []

    named_values = sig.get("named_values")
    if isinstance(named_values, dict):
        nv_str = " ".join(str(v) for v in named_values.values())
    elif isinstance(named_values, list):
        nv_str = " ".join(str(v) for v in named_values)
    elif named_values:
        nv_str = str(named_values)
    else:
        nv_str = ""

    named_values_json = sig.get("named_values_json")
    if named_values_json is None and isinstance(named_values, dict) and named_values:
        named_values_json = json.dumps(named_values)

    is_raw_val = sig.get("is_raw")
    is_raw_int = None if is_raw_val is None else (1 if is_raw_val else 0)

    def _to_float(v: Any) -> float | None:
        """Cast to float so sqlite3 never attempts INTEGER storage for REAL columns."""
        return float(v) if v is not None else None

    def _to_bool_int(v: Any) -> int | None:
        """Cast to 0/1 for SQLite INTEGER bool columns."""
        return None if v is None else (1 if v else 0)

    return (
        sig.get("type", "signal"),
        sig.get("signal_name"),
        sig.get("frame_name"),
        sig.get("frame_id"),
        sig.get("namespace"),
        sig.get("description", ""),
        sig.get("unit", ""),
        " ".join(senders) if isinstance(senders, list) else str(senders),
        " ".join(receivers) if isinstance(receivers, list) else str(receivers),
        nv_str,
        named_values_json,
        _to_float(sig.get("min")),
        _to_float(sig.get("max")),
        _to_float(sig.get("factor")),
        _to_float(sig.get("offset")),
        _to_float(sig.get("cycle_time")),
        _to_float(sig.get("start_value")),
        sig.get("size"),
        is_raw_int,
        sig.get("source", "grpc"),
        sig.get("channel"),
        sig.get("channel_type"),
        sig.get("file_path"),
        sig.get("workspace_path"),
        sig.get("start_bit"),
        _to_bool_int(sig.get("is_big_endian")),
        _to_bool_int(sig.get("is_signed")),
        _to_bool_int(sig.get("is_ieee_floating_type")),
        sig.get("multiplex_json"),
        sig.get("payload_size"),
        sig.get("opt_empty_payload"),
        _to_bool_int(sig.get("is_can_fd")),
        _to_bool_int(sig.get("is_extended")),
        sig.get("can_fd_flags"),
        sig.get("multiplex_selector"),
        sig.get("e2e_json"),
        sig.get("secoc_json"),
        sig.get("groups_json"),
        sig.get("channel_cluster"),
        sig.get("channel_physical_name"),
    )


def _apply_query_type_filter(query: Query, opts: SearchOptions) -> SearchOptions:
    """Merge an implicit type filter from typed query terms into opts (if any)."""
    type_filter = get_type_filter_from_query(query.parsed)
    if not type_filter or "type" in opts.filters:
        return opts
    return SearchOptions(
        exact=opts.exact,
        substring=opts.substring,
        operator=opts.operator,
        source_filter=opts.source_filter,
        file_path_filter=opts.file_path_filter,
        filters={**opts.filters, "type": type_filter},
    )


class SQLiteSearch(SearchAPI):
    def __init__(self, db_path: str = ":memory:"):
        self._db_path = db_path
        self._lock = threading.Lock()
        self._conn: sqlite3.Connection | None = None
        self._last_indexed: str | None = None

    def connect(self) -> None:
        self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
        self._conn.row_factory = sqlite3.Row
        self._conn.execute("PRAGMA journal_mode=WAL")
        with self._conn:
            self._conn.execute(_SCHEMA_RAW)
            self._conn.execute(_SCHEMA_FTS)
            for trigger in _TRIGGERS:
                self._conn.execute(trigger)
            self._conn.execute(_SCHEMA_TOKENS)
            self._conn.execute(_SCHEMA_TOKENS_INDEX)
            for migration in _MIGRATION_SQL:
                try:
                    self._conn.execute(migration)
                except sqlite3.OperationalError:
                    pass  # Column already exists — safe to ignore

    def close(self) -> None:
        if self._conn:
            self._conn.close()
            self._conn = None

    def clear(self) -> None:
        assert self._conn is not None
        with self._lock, self._conn:
            self._conn.execute("DELETE FROM signals_raw")
            self._conn.execute("DELETE FROM signals_tokens")
        self._last_indexed = None

    def index_signals(self, signals: list[dict[str, Any]]) -> int:
        """Index a list of signal dicts, replacing all existing data."""
        assert self._conn is not None
        with self._lock, self._conn:
            self._conn.execute("DELETE FROM signals_raw")
            self._conn.execute("DELETE FROM signals_tokens")
            for sig in signals:
                cursor = self._conn.execute(_INSERT_SQL, _signal_to_row(sig))
                assert cursor.lastrowid is not None
                token_rows = _build_token_rows_for_signal(cursor.lastrowid, sig)
                if token_rows:
                    self._conn.executemany(_INSERT_TOKEN_SQL, token_rows)
        self._last_indexed = datetime.now(timezone.utc).isoformat()
        return len(signals)

    def index_grpc_signals(self, broker_url: str, api_key: str | None = None) -> int:
        """Fetch all signals from the gRPC broker and index them."""
        signals = _fetch_grpc_signals(broker_url, api_key)
        count = self.index_signals(signals)
        log.info("Indexed %d signals from gRPC broker at %s", count, broker_url)
        return count

    def index_yaml_data(self, file_path: str, workspace_path: str, raw: str) -> int:
        """Index signal data from a YAML string.

        Args:
            file_path: Workspace-relative path stored in the index (client-facing).
            workspace_path: Absolute filesystem path (server-internal).
            raw: Raw YAML text to parse.
        """
        data = yaml.safe_load(raw)
        signals = parse_yaml_signals(file_path, workspace_path, data)

        assert self._conn is not None
        with self._lock, self._conn:
            self._conn.execute("DELETE FROM signals_raw WHERE file_path = ?", (file_path,))
            self._conn.execute("DELETE FROM signals_tokens WHERE signal_id NOT IN (SELECT id FROM signals_raw)")
            indexed = 0
            for sig in signals:
                row = _signal_to_row(sig)
                try:
                    cursor = self._conn.execute(_INSERT_SQL, row)
                except Exception as exc:
                    _log_row_error(exc, sig, row)
                    raise
                assert cursor.lastrowid is not None
                token_rows = _build_token_rows_for_signal(cursor.lastrowid, sig)
                if token_rows:
                    self._conn.executemany(_INSERT_TOKEN_SQL, token_rows)
                indexed += 1

        log.info("Indexed %d entries from YAML file %s", indexed, workspace_path)
        return indexed

    def index_yaml_file(self, workspace_path: str, file_path: str) -> int:
        """Parse a remotive-signal-database YAML file and index its signals.

        Existing rows with the same ``file_path`` are replaced so calling this
        again with the same paths is idempotent. Signals from gRPC or other
        YAML files are left intact.

        Args:
            workspace_path: Absolute path used to read the file from disk.
            file_path: Workspace-relative path stored in the index and used for filtering.
        """
        resolved = str(Path(workspace_path).resolve())
        raw = Path(resolved).read_text(encoding="utf-8")
        return self.index_yaml_data(file_path, resolved, raw)

    def search(
        self,
        query: Query,
        limit: int = 50,
        opts: SearchOptions | None = None,
    ) -> list[dict[str, Any]]:
        opts = _apply_query_type_filter(query, opts or SearchOptions())
        effective_file_path = opts.file_path_filter or get_file_path_from_query(query.parsed)
        if opts.substring:
            where_sql, _ = to_like_clauses(query.parsed, operator=opts.operator)
            if not where_sql and (effective_file_path or opts.source_filter or opts.filters):
                extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
                assert self._conn is not None
                with self._lock:
                    cursor = self._conn.execute(
                        f"SELECT * FROM signals_raw WHERE 1=1{extra_sql} ORDER BY id LIMIT ?",
                        (*extra_params, limit),
                    )
                    return [dict(row) for row in cursor.fetchall()]
            return self._search_like(query, limit=limit, opts=opts, file_path_filter=effective_file_path)
        assert self._conn is not None
        fts_query = to_fts_query(query.parsed, exact=opts.exact, operator=opts.operator)
        if not fts_query:
            extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
            if not extra_sql:
                return []
            assert self._conn is not None
            with self._lock:
                cursor = self._conn.execute(
                    f"SELECT * FROM signals_raw WHERE 1=1{extra_sql} ORDER BY id LIMIT ?",
                    (*extra_params, limit),
                )
                return [dict(row) for row in cursor.fetchall()]
        extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, table_alias="r", filters=opts.filters)
        with self._lock:
            cursor = self._conn.execute(
                f"""SELECT r.*
                   FROM signals_fts f
                   JOIN signals_raw r ON r.id = f.rowid
                   WHERE signals_fts MATCH ?{extra_sql}
                   ORDER BY bm25(signals_fts)
                   LIMIT ?""",
                (fts_query, *extra_params, limit),
            )
            return [dict(row) for row in cursor.fetchall()]

    def search_slim(
        self,
        query: Query,
        opts: SearchOptions | None = None,
    ) -> list[str]:
        opts = _apply_query_type_filter(query, opts or SearchOptions())
        effective_file_path = opts.file_path_filter or get_file_path_from_query(query.parsed)
        if opts.substring:
            where_sql, _ = to_like_clauses(query.parsed, operator=opts.operator)
            if not where_sql and (effective_file_path or opts.source_filter or opts.filters):
                extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
                assert self._conn is not None
                with self._lock:
                    cursor = self._conn.execute(
                        f"""SELECT namespace, channel,
                            COALESCE(signal_name, frame_name) AS display_name FROM signals_raw
                            WHERE 1=1{extra_sql}""",
                        extra_params,
                    )
                    rows = [dict(row) for row in cursor.fetchall()]
            else:
                rows = self._search_slim_like(
                    query,
                    opts=opts,
                    file_path_filter=effective_file_path,
                )
        else:
            fts_query = to_fts_query(query.parsed, exact=opts.exact, operator=opts.operator)
            if not fts_query:
                extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
                if extra_sql:
                    assert self._conn is not None
                    with self._lock:
                        cursor = self._conn.execute(
                            f"""SELECT namespace, channel,
                                COALESCE(signal_name, frame_name) AS display_name FROM signals_raw
                                WHERE 1=1{extra_sql}""",
                            extra_params,
                        )
                        rows = [dict(row) for row in cursor.fetchall()]
                else:
                    rows = []
            else:
                rows = self._search_slim_fts(
                    query,
                    opts=opts,
                    file_path_filter=effective_file_path,
                )
        return [f"{r.get('namespace') or r.get('channel') or ''}:{r['display_name']}" for r in rows]

    def suggest(
        self,
        query: Query,
        selected: list[Query] | None = None,
        limit: int = 20,
        opts: SearchOptions | None = None,
    ) -> tuple[list[dict[str, str]], int]:
        """Return autocomplete suggestions for the term being typed.

        When ``selected`` is given, suggestions are scoped to signals that
        match all of those already-confirmed terms, and the selected values
        themselves are excluded from the returned suggestions.
        """
        opts = opts or SearchOptions()
        assert self._conn is not None
        active = query.parsed.active_term
        if active is None:
            return [], 0

        scope_ids: set[int] | None = None
        exclusions: set[tuple[str, str]] = set()

        if selected:
            # Merge all selected terms into one ParsedQuery for ID lookup.
            merged_terms = [t for sq in selected for t in sq.parsed.terms]
            if merged_terms:
                scope_query = Query(ParsedQuery(terms=merged_terms))
                scope_file_path = opts.file_path_filter or get_file_path_from_query(scope_query.parsed)
                scope_ids = self._search_ids(
                    scope_query,
                    opts=opts,
                    file_path_filter=scope_file_path,
                )
                if not scope_ids:
                    return [], 0

            # Build exclusion set: (display_field, value) pairs already selected.
            for sq in selected:
                for term in sq.parsed.terms:
                    display_fields = [f for f in term.fields] if term.fields else [f for f in _FTS_COLUMNS]
                    for df in display_fields:
                        exclusions.add((df, term.value.lower()))

        suggest_file_path = opts.file_path_filter or get_file_path_from_query(query.parsed)
        cols = sorted(active.fields) if active.fields else sorted(_FTS_COLUMNS)
        scope = (scope_ids, suggest_file_path)
        items, total = self._suggest_from_columns(
            (cols, active.value, limit),
            opts,
            scope,
        )
        if exclusions:
            items = [s for s in items if (s["field"], s["suggestion"].lower()) not in exclusions]
            total = len(items)

        return items, total

    def _count_status_query(self, sql: str, params: list[Any] | tuple[Any, ...]) -> int:
        """Run a single-column COUNT query and return the total."""
        assert self._conn is not None
        with self._lock:
            cursor = self._conn.execute(sql, params)
            row = cursor.fetchone()
        return int(row["total"]) if row else 0

    def status(  # noqa: PLR0913
        self,
        source_filter: str | None = None,
        file_path_filter: str | None = None,
        filters: dict[str, str] | None = None,
        query: Query | None = None,
        exact: bool = False,
        substring: bool = False,
        operator: Operator = Operator.AND,
    ) -> StatusResult:
        """Count rows matching the same criteria as ``search_slim`` (no limit, no parent-frame fetch)."""
        assert self._conn is not None
        last_indexed = self._last_indexed

        if query is None or not query.parsed.terms:
            extra_sql, extra_params = _extra_clauses(source_filter, file_path_filter, filters=filters)
            total = self._count_status_query(
                f"SELECT COUNT(*) AS total FROM signals_raw WHERE 1=1{extra_sql}",
                extra_params,
            )
        else:
            opts = SearchOptions(
                exact=exact,
                substring=substring,
                operator=operator,
                source_filter=source_filter,
                file_path_filter=file_path_filter,
                filters=dict(filters) if filters else {},
            )
            opts = _apply_query_type_filter(query, opts)
            effective_file_path = opts.file_path_filter or get_file_path_from_query(query.parsed)

            if opts.substring:
                where_sql, like_params = to_like_clauses(query.parsed, operator=opts.operator)
                extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
                if not where_sql:
                    total = (
                        0
                        if not extra_sql
                        else self._count_status_query(
                            f"SELECT COUNT(*) AS total FROM signals_raw WHERE 1=1{extra_sql}",
                            extra_params,
                        )
                    )
                else:
                    total = self._count_status_query(
                        f"SELECT COUNT(*) AS total FROM signals_raw WHERE {where_sql}{extra_sql}",
                        like_params + extra_params,
                    )
            else:
                fts_query = to_fts_query(query.parsed, exact=opts.exact, operator=opts.operator)
                if not fts_query:
                    extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
                    total = (
                        0
                        if not extra_sql
                        else self._count_status_query(
                            f"SELECT COUNT(*) AS total FROM signals_raw WHERE 1=1{extra_sql}",
                            extra_params,
                        )
                    )
                else:
                    extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, table_alias="r", filters=opts.filters)
                    total = self._count_status_query(
                        "SELECT COUNT(*) AS total FROM signals_fts f JOIN signals_raw r ON r.id = f.rowid "
                        f"WHERE signals_fts MATCH ?{extra_sql}",
                        (fts_query, *extra_params),
                    )

        return StatusResult(total=total, last_indexed=last_indexed)

    def fetch_frame_row(self, file_path: str, channel: str, frame_name: str) -> dict[str, Any] | None:
        """Fetch the frame row for (file_path, channel, frame_name). Returns None if not found."""
        assert self._conn is not None
        with self._lock:
            cursor = self._conn.execute(
                """SELECT * FROM signals_raw
                   WHERE type = 'frame' AND file_path = ? AND channel = ? AND frame_name = ?""",
                (file_path, channel, frame_name),
            )
            row = cursor.fetchone()
        return dict(row) if row else None

    def fetch_broker_frame_row(self, namespace: str, frame_name: str) -> dict[str, Any] | None:
        """Fetch the broker frame row for (namespace, frame_name). Returns None if not found."""
        assert self._conn is not None
        with self._lock:
            cursor = self._conn.execute(
                """SELECT * FROM signals_raw
                   WHERE type = 'frame' AND source = 'grpc' AND namespace = ? AND frame_name = ?""",
                (namespace, frame_name),
            )
            row = cursor.fetchone()
        return dict(row) if row else None

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _resolve_token_scope_ids(
        self,
        scope_ids: set[int] | None,
        source_filter: str | None,
        file_path_filter: str | None,
    ) -> set[int] | None:
        """Narrow scope_ids to only those matching source/file filter for tokenized columns."""
        assert self._conn is not None
        if source_filter is None and file_path_filter is None:
            return scope_ids
        source_id_clauses: list[str] = []
        source_id_params: list[Any] = []
        if source_filter is not None:
            source_id_clauses.append("source = ?")
            source_id_params.append(source_filter)
        if file_path_filter is not None:
            source_id_clauses.append("file_path = ?")
            source_id_params.append(file_path_filter)
        where = " AND ".join(source_id_clauses)
        if scope_ids:
            placeholders = ",".join("?" * len(scope_ids))
            where += f" AND id IN ({placeholders})"
            source_id_params.extend(scope_ids)
        with self._lock:
            cur = self._conn.execute(f"SELECT id FROM signals_raw WHERE {where}", source_id_params)
            return {row[0] for row in cur.fetchall()} or {-1}

    def _search_ids(
        self,
        query: Query,
        *,
        opts: SearchOptions | None = None,
        file_path_filter: str | None = None,
    ) -> set[int]:
        """Return signal IDs matching *query* (used to scope suggest results)."""
        opts = _apply_query_type_filter(query, opts or SearchOptions())
        assert self._conn is not None
        effective_file_path = file_path_filter or get_file_path_from_query(query.parsed)
        if opts.substring:
            where_sql, params = to_like_clauses(query.parsed, operator=Operator.AND)
            if not where_sql:
                if effective_file_path or opts.source_filter or opts.filters:
                    extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
                    with self._lock:
                        cursor = self._conn.execute(
                            f"SELECT id FROM signals_raw WHERE 1=1{extra_sql}",
                            extra_params,
                        )
                        return {row[0] for row in cursor.fetchall()}
                return set()
            extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
            with self._lock:
                cursor = self._conn.execute(
                    f"SELECT id FROM signals_raw WHERE {where_sql}{extra_sql}",
                    params + extra_params,
                )
                return {row[0] for row in cursor.fetchall()}
        fts_query = to_fts_query(query.parsed, exact=False, operator=Operator.AND)
        if not fts_query:
            if effective_file_path or opts.source_filter or opts.filters:
                extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, filters=opts.filters)
                with self._lock:
                    cursor = self._conn.execute(
                        f"SELECT id FROM signals_raw WHERE 1=1{extra_sql}",
                        extra_params,
                    )
                    return {row[0] for row in cursor.fetchall()}
            return set()
        extra_sql, extra_params = _extra_clauses(opts.source_filter, effective_file_path, table_alias="r", filters=opts.filters)
        with self._lock:
            cursor = self._conn.execute(
                f"""SELECT r.id
                   FROM signals_fts f
                   JOIN signals_raw r ON r.id = f.rowid
                   WHERE signals_fts MATCH ?{extra_sql}""",
                (fts_query, *extra_params),
            )
            return {row[0] for row in cursor.fetchall()}

    def _search_slim_fts(
        self,
        query: Query,
        opts: SearchOptions | None = None,
        file_path_filter: str | None = None,
    ) -> list[dict[str, Any]]:
        opts = opts or SearchOptions()
        assert self._conn is not None
        fts_query = to_fts_query(query.parsed, exact=opts.exact, operator=opts.operator)
        if not fts_query:
            return []
        extra_sql, extra_params = _extra_clauses(opts.source_filter, file_path_filter, table_alias="r", filters=opts.filters)
        with self._lock:
            cursor = self._conn.execute(
                f"""SELECT r.namespace, r.channel,
                   COALESCE(r.signal_name, r.frame_name) AS display_name
                   FROM signals_fts f
                   JOIN signals_raw r ON r.id = f.rowid
                   WHERE signals_fts MATCH ?{extra_sql}""",
                (fts_query, *extra_params),
            )
            return [dict(row) for row in cursor.fetchall()]

    def _search_like(
        self,
        query: Query,
        limit: int = 50,
        opts: SearchOptions | None = None,
        file_path_filter: str | None = None,
    ) -> list[dict[str, Any]]:
        """Substring search using LIKE '%term%' on signals_raw."""
        opts = opts or SearchOptions()
        assert self._conn is not None
        where_sql, params = to_like_clauses(query.parsed, operator=opts.operator)
        if not where_sql:
            return []
        extra_sql, extra_params = _extra_clauses(opts.source_filter, file_path_filter, filters=opts.filters)
        with self._lock:
            cursor = self._conn.execute(
                f"SELECT * FROM signals_raw WHERE {where_sql}{extra_sql} LIMIT ?",
                params + extra_params + [limit],
            )
            return [dict(row) for row in cursor.fetchall()]

    def _search_slim_like(
        self,
        query: Query,
        opts: SearchOptions | None = None,
        file_path_filter: str | None = None,
    ) -> list[dict[str, Any]]:
        """Slim substring search using LIKE '%term%' on signals_raw."""
        opts = opts or SearchOptions()
        assert self._conn is not None
        where_sql, params = to_like_clauses(query.parsed, operator=opts.operator)
        if not where_sql:
            return []
        extra_sql, extra_params = _extra_clauses(opts.source_filter, file_path_filter, filters=opts.filters)
        with self._lock:
            cursor = self._conn.execute(
                f"""SELECT namespace, channel,
                    COALESCE(signal_name, frame_name) AS display_name FROM signals_raw
                    WHERE {where_sql}{extra_sql}""",
                params + extra_params,
            )
            return [dict(row) for row in cursor.fetchall()]

    def _suggest_from_columns(
        self,
        query_params: tuple[list[str], str, int],
        opts: SearchOptions | None,
        scope: tuple[set[int] | None, str | None],
    ) -> tuple[list[dict[str, str]], int]:
        columns, prefix, limit = query_params
        opts = opts or SearchOptions()
        scope_ids, file_path_filter = scope
        assert self._conn is not None
        escaped = _escape_like(prefix)
        like_pattern = f"%{escaped}%" if opts.substring else f"{escaped}%"

        # Build scope (selected terms) and source/file clauses for signals_raw rows.
        raw_extra_clauses: list[str] = []
        raw_extra_params: list[Any] = []
        if scope_ids:
            placeholders = ",".join("?" * len(scope_ids))
            raw_extra_clauses.append(f"id IN ({placeholders})")
            raw_extra_params.extend(scope_ids)
        if opts.source_filter is not None:
            raw_extra_clauses.append("source = ?")
            raw_extra_params.append(opts.source_filter)
        if file_path_filter is not None:
            raw_extra_clauses.append("file_path = ?")
            raw_extra_params.append(file_path_filter)
        raw_extra_sql = (" AND " + " AND ".join(raw_extra_clauses)) if raw_extra_clauses else ""

        # For tokenized columns we must join back to signals_raw to apply source/file filters.
        tok_scope_ids = self._resolve_token_scope_ids(scope_ids, opts.source_filter, file_path_filter)

        tok_extra_sql = ""
        tok_extra_params: list[Any] = []
        if tok_scope_ids:
            placeholders = ",".join("?" * len(tok_scope_ids))
            tok_extra_sql = f" AND signal_id IN ({placeholders})"
            tok_extra_params = list(tok_scope_ids)

        unions, params = _build_suggest_unions(
            columns,
            like_pattern,
            (raw_extra_sql, raw_extra_params),
            (tok_extra_sql, tok_extra_params),
        )

        union_sql = " UNION ".join(unions)
        count_sql = f"SELECT COUNT(*) FROM ({union_sql})"
        data_sql = union_sql + " ORDER BY value LIMIT ?"

        with self._lock:
            total_row = self._conn.execute(count_sql, params).fetchone()
            total = total_row[0] if total_row else 0
            cursor = self._conn.execute(data_sql, params + [limit])
            items = [{"field": row["field"], "suggestion": row["value"]} for row in cursor.fetchall() if row["value"]]

        return items, total


# Backward-compatible alias
SearchIndex = SQLiteSearch
