"""SQLite FTS5-based full-text search index for recipes.

This module provides fast recipe search using SQLite's FTS5 extension,
enabling sub-100ms search times even with 20k+ recipes.
"""

from __future__ import annotations

import asyncio
import hashlib
import logging
import os
import re
import sqlite3
import threading
import time
from typing import Any, Dict, List, Optional, Set, Tuple

from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration

logger = logging.getLogger(__name__)


class RecipeFTSIndex:
    """SQLite FTS5-based full-text search index for recipes.

    Provides fast prefix-based search across multiple recipe fields:
    - title
    - tags
    - lora_names (file names)
    - lora_models (model names)
    - prompt
    - negative_prompt
    """

    _DEFAULT_FILENAME = "recipe_fts.sqlite"

    # Map of search option keys to FTS column names
    FIELD_MAP = {
        'title': ['title'],
        'tags': ['tags'],
        'lora_name': ['lora_names'],
        'lora_model': ['lora_models'],
        'prompt': ['prompt', 'negative_prompt'],
    }

    def __init__(self, db_path: Optional[str] = None) -> None:
        """Initialize the FTS index.

        Args:
            db_path: Optional path to the SQLite database file.
                     If not provided, uses the default location in settings directory.
        """
        self._db_path = db_path or self._resolve_default_path()
        self._lock = threading.Lock()
        self._ready = threading.Event()
        self._indexing_in_progress = False
        self._schema_initialized = False
        self._warned_not_ready = False

        # Ensure directory exists
        directory = os.path.dirname(self._db_path)
        try:
            if directory:
                os.makedirs(directory, exist_ok=True)
        except Exception as exc:
            logger.warning("Could not create FTS index directory %s: %s", directory, exc)

    def _resolve_default_path(self) -> str:
        """Resolve the default database path."""
        env_override = os.environ.get("LORA_MANAGER_RECIPE_FTS_DB")
        return resolve_cache_path_with_migration(
            CacheType.RECIPE_FTS,
            env_override=env_override,
        )

    def get_database_path(self) -> str:
        """Return the resolved database path."""
        return self._db_path

    def is_ready(self) -> bool:
        """Check if the FTS index is ready for queries."""
        return self._ready.is_set()

    def is_indexing(self) -> bool:
        """Check if indexing is currently in progress."""
        return self._indexing_in_progress

    def initialize(self) -> None:
        """Initialize the database schema."""
        if self._schema_initialized:
            return

        with self._lock:
            if self._schema_initialized:
                return

            try:
                conn = self._connect()
                try:
                    conn.execute("PRAGMA journal_mode=WAL")
                    conn.executescript("""
                        -- FTS5 virtual table for full-text search
                        -- Note: We use a regular FTS5 table (not contentless) so we can retrieve recipe_id
                        CREATE VIRTUAL TABLE IF NOT EXISTS recipe_fts USING fts5(
                            recipe_id,
                            title,
                            tags,
                            lora_names,
                            lora_models,
                            prompt,
                            negative_prompt,
                            tokenize='unicode61 remove_diacritics 2'
                        );

                        -- Recipe ID to rowid mapping for fast lookups and deletions
                        CREATE TABLE IF NOT EXISTS recipe_rowid (
                            recipe_id TEXT PRIMARY KEY,
                            fts_rowid INTEGER UNIQUE
                        );

                        -- Index version tracking
                        CREATE TABLE IF NOT EXISTS fts_metadata (
                            key TEXT PRIMARY KEY,
                            value TEXT
                        );
                    """)
                    conn.commit()
                    self._schema_initialized = True
                    logger.debug("FTS index schema initialized at %s", self._db_path)
                finally:
                    conn.close()
            except Exception as exc:
                logger.error("Failed to initialize FTS schema: %s", exc)

    def build_index(self, recipes: List[Dict[str, Any]]) -> None:
        """Build or rebuild the entire FTS index from recipe data.

        Args:
            recipes: List of recipe dictionaries to index.
        """
        if self._indexing_in_progress:
            logger.warning("FTS indexing already in progress, skipping")
            return

        self._indexing_in_progress = True
        self._ready.clear()
        start_time = time.time()

        try:
            self.initialize()
            if not self._schema_initialized:
                logger.error("Cannot build FTS index: schema not initialized")
                return

            with self._lock:
                conn = self._connect()
                try:
                    conn.execute("BEGIN")

                    # Clear existing data
                    conn.execute("DELETE FROM recipe_fts")
                    conn.execute("DELETE FROM recipe_rowid")

                    # Batch insert for performance
                    batch_size = 500
                    total = len(recipes)
                    inserted = 0
                    indexed_ids: Set[str] = set()

                    for i in range(0, total, batch_size):
                        batch = recipes[i:i + batch_size]
                        rows = []
                        rowid_mappings = []

                        for recipe in batch:
                            recipe_id = str(recipe.get('id', ''))
                            if not recipe_id:
                                continue

                            row = self._prepare_fts_row(recipe)
                            rows.append(row)
                            inserted += 1
                            indexed_ids.add(recipe_id)

                        if rows:
                            # Insert into FTS table
                            conn.executemany(
                                """INSERT INTO recipe_fts (recipe_id, title, tags, lora_names,
                                   lora_models, prompt, negative_prompt)
                                   VALUES (?, ?, ?, ?, ?, ?, ?)""",
                                rows
                            )

                            # Build rowid mappings
                            for row in rows:
                                recipe_id = row[0]
                                cursor = conn.execute(
                                    "SELECT rowid FROM recipe_fts WHERE recipe_id = ?",
                                    (recipe_id,)
                                )
                                result = cursor.fetchone()
                                if result:
                                    rowid_mappings.append((recipe_id, result[0]))

                            if rowid_mappings:
                                conn.executemany(
                                    "INSERT OR REPLACE INTO recipe_rowid (recipe_id, fts_rowid) VALUES (?, ?)",
                                    rowid_mappings
                                )

                    # Update metadata
                    conn.execute(
                        "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                        ('last_build_time', str(time.time()))
                    )
                    conn.execute(
                        "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                        (self._COUNT_METADATA_KEY, str(inserted))
                    )
                    conn.execute(
                        "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                        (self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(indexed_ids))
                    )

                    conn.commit()
                    elapsed = time.time() - start_time
                    logger.info("FTS index built: %d recipes indexed in %.2fs", inserted, elapsed)
                finally:
                    conn.close()

            self._ready.set()

        except Exception as exc:
            logger.error("Failed to build FTS index: %s", exc, exc_info=True)
        finally:
            self._indexing_in_progress = False

    def search(self, query: str, fields: Optional[Set[str]] = None) -> Set[str]:
        """Search recipes using FTS5 with prefix matching.

        Args:
            query: The search query string.
            fields: Optional set of field names to search. If None, searches all fields.
                    Valid fields: 'title', 'tags', 'lora_name', 'lora_model', 'prompt'

        Returns:
            Set of matching recipe IDs.
        """
        if not self.is_ready():
            if not self._warned_not_ready:
                logger.debug("FTS index not ready, returning empty results")
                self._warned_not_ready = True
            return set()

        if not query or not query.strip():
            return set()

        fts_query = self._build_fts_query(query, fields)
        if not fts_query:
            return set()

        try:
            with self._lock:
                conn = self._connect(readonly=True)
                try:
                    cursor = conn.execute(
                        "SELECT recipe_id FROM recipe_fts WHERE recipe_fts MATCH ?",
                        (fts_query,)
                    )
                    return {row[0] for row in cursor.fetchall()}
                finally:
                    conn.close()
        except Exception as exc:
            logger.debug("FTS search error for query '%s': %s", query, exc)
            return set()

    def add_recipe(self, recipe: Dict[str, Any]) -> bool:
        """Add a single recipe to the FTS index.

        Args:
            recipe: The recipe dictionary to add.

        Returns:
            True if successful, False otherwise.
        """
        if not self.is_ready():
            return False

        recipe_id = str(recipe.get('id', ''))
        if not recipe_id:
            return False

        try:
            with self._lock:
                conn = self._connect()
                try:
                    # Check existence via the rowid mapping (fast PK lookup)
                    existed = conn.execute(
                        "SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
                        (recipe_id,)
                    ).fetchone() is not None

                    # Remove existing entry if present
                    self._remove_recipe_locked(conn, recipe_id)

                    # Insert new entry
                    row = self._prepare_fts_row(recipe)
                    conn.execute(
                        """INSERT INTO recipe_fts (recipe_id, title, tags, lora_names,
                           lora_models, prompt, negative_prompt)
                           VALUES (?, ?, ?, ?, ?, ?, ?)""",
                        row
                    )

                    # Update rowid mapping
                    cursor = conn.execute(
                        "SELECT rowid FROM recipe_fts WHERE recipe_id = ?",
                        (recipe_id,)
                    )
                    result = cursor.fetchone()
                    if result:
                        conn.execute(
                            "INSERT OR REPLACE INTO recipe_rowid (recipe_id, fts_rowid) VALUES (?, ?)",
                            (recipe_id, result[0])
                        )

                    # Keep validation metadata in sync (only a new id changes it)
                    if not existed:
                        self._update_mutation_metadata_locked(conn, recipe_id, delta=1)

                    conn.commit()
                    return True
                finally:
                    conn.close()
        except Exception as exc:
            logger.debug("Failed to add recipe %s to FTS index: %s", recipe_id, exc)
            return False

    def remove_recipe(self, recipe_id: str) -> bool:
        """Remove a recipe from the FTS index.

        Args:
            recipe_id: The ID of the recipe to remove.

        Returns:
            True if successful, False otherwise.
        """
        if not self.is_ready():
            return False

        if not recipe_id:
            return False

        try:
            with self._lock:
                conn = self._connect()
                try:
                    existed = conn.execute(
                        "SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
                        (recipe_id,)
                    ).fetchone() is not None
                    self._remove_recipe_locked(conn, recipe_id)
                    if existed:
                        self._update_mutation_metadata_locked(conn, recipe_id, delta=-1)
                    conn.commit()
                    return True
                finally:
                    conn.close()
        except Exception as exc:
            logger.debug("Failed to remove recipe %s from FTS index: %s", recipe_id, exc)
            return False

    def update_recipe(self, recipe: Dict[str, Any]) -> bool:
        """Update a recipe in the FTS index.

        Args:
            recipe: The updated recipe dictionary.

        Returns:
            True if successful, False otherwise.
        """
        return self.add_recipe(recipe)  # add_recipe handles removal and re-insertion

    def clear(self) -> bool:
        """Clear all data from the FTS index.

        Returns:
            True if successful, False otherwise.
        """
        try:
            with self._lock:
                conn = self._connect()
                try:
                    conn.execute("DELETE FROM recipe_fts")
                    conn.execute("DELETE FROM recipe_rowid")
                    # Reset validation metadata to the empty index state
                    conn.execute(
                        "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                        (self._COUNT_METADATA_KEY, '0')
                    )
                    conn.execute(
                        "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                        (self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(set()))
                    )
                    conn.commit()
                    self._ready.clear()
                    return True
                finally:
                    conn.close()
        except Exception as exc:
            logger.error("Failed to clear FTS index: %s", exc)
            return False

    def get_indexed_count(self) -> int:
        """Return the number of recipes currently indexed."""
        if not self._schema_initialized:
            return 0

        try:
            with self._lock:
                conn = self._connect(readonly=True)
                try:
                    cursor = conn.execute("SELECT COUNT(*) FROM recipe_fts")
                    result = cursor.fetchone()
                    return result[0] if result else 0
                finally:
                    conn.close()
        except Exception:
            return 0

    def get_indexed_recipe_ids(self) -> Set[str]:
        """Return all recipe IDs currently in the index.

        Returns:
            Set of recipe ID strings.
        """
        if not self._schema_initialized:
            self.initialize()

        if not self._schema_initialized:
            return set()

        try:
            with self._lock:
                conn = self._connect(readonly=True)
                try:
                    cursor = conn.execute("SELECT recipe_id FROM recipe_fts")
                    return {row[0] for row in cursor.fetchall() if row[0]}
                finally:
                    conn.close()
        except FileNotFoundError:
            return set()
        except Exception as exc:
            logger.debug("Failed to get indexed recipe IDs: %s", exc)
            return set()

    def validate_index(self, recipe_count: int, recipe_ids: Set[str]) -> bool:
        """Check if the FTS index matches the expected recipes.

        This method validates whether the existing FTS index can be reused
        without a full rebuild. It compares the expected count and recipe ID
        fingerprint against metadata recorded when the index was (re)built,
        so it does not scan the FTS content table. Indexes built by older
        versions lack this metadata; for those the validation falls back to
        a one-time scan of the content table and records the metadata so
        subsequent startups are cheap.

        Args:
            recipe_count: Expected number of recipes.
            recipe_ids: Expected set of recipe IDs.

        Returns:
            True if the index is valid and can be reused, False otherwise.
        """
        if not self._schema_initialized:
            self.initialize()

        if not self._schema_initialized:
            return False

        try:
            metadata = self._read_validation_metadata()
            if metadata is not None:
                stored_count, stored_fingerprint = metadata
                if stored_count != recipe_count:
                    logger.debug(
                        "FTS index count mismatch: indexed=%d, expected=%d",
                        stored_count, recipe_count
                    )
                    return False

                if stored_fingerprint != self._compute_ids_fingerprint(recipe_ids):
                    logger.debug("FTS index recipe ID fingerprint mismatch")
                    return False

                return True

            # Legacy fallback: no stored metadata, scan the content table once
            # and persist the metadata so later validations are cheap.
            indexed_count = self.get_indexed_count()
            indexed_ids = self.get_indexed_recipe_ids()
            self._store_validation_metadata(indexed_count, indexed_ids)

            if indexed_count != recipe_count:
                logger.debug(
                    "FTS index count mismatch: indexed=%d, expected=%d",
                    indexed_count, recipe_count
                )
                return False

            if indexed_ids != recipe_ids:
                missing = recipe_ids - indexed_ids
                extra = indexed_ids - recipe_ids
                if missing:
                    logger.debug("FTS index missing %d recipe IDs", len(missing))
                if extra:
                    logger.debug("FTS index has %d extra recipe IDs", len(extra))
                return False

            return True
        except Exception as exc:
            logger.debug("FTS index validation failed: %s", exc)
            return False

    # Internal helpers

    _FINGERPRINT_METADATA_KEY = 'recipe_ids_fingerprint'
    _COUNT_METADATA_KEY = 'recipe_count'

    @staticmethod
    def _fingerprint_recipe_id(recipe_id: str) -> int:
        """Return a stable 64-bit fingerprint contribution for a recipe ID."""
        digest = hashlib.sha256(recipe_id.encode("utf-8")).digest()
        return int.from_bytes(digest[:8], "big")

    @classmethod
    def _compute_ids_fingerprint(cls, recipe_ids: Set[str]) -> str:
        """Order-independent fingerprint of a recipe ID set (XOR of per-id hashes)."""
        fingerprint = 0
        for recipe_id in recipe_ids:
            fingerprint ^= cls._fingerprint_recipe_id(str(recipe_id))
        return f"{fingerprint:016x}"

    def _read_validation_metadata(self) -> Optional[Tuple[int, str]]:
        """Return stored (recipe count, ID fingerprint), or None if absent."""
        try:
            with self._lock:
                conn = self._connect(readonly=True)
                try:
                    rows = conn.execute(
                        "SELECT key, value FROM fts_metadata WHERE key IN (?, ?)",
                        (self._COUNT_METADATA_KEY, self._FINGERPRINT_METADATA_KEY)
                    ).fetchall()
                    values = {row[0]: row[1] for row in rows}
                    fingerprint = values.get(self._FINGERPRINT_METADATA_KEY)
                    if fingerprint is None:
                        return None
                    try:
                        count = int(values.get(self._COUNT_METADATA_KEY) or 0)
                    except (TypeError, ValueError):
                        return None
                    return count, fingerprint
                finally:
                    conn.close()
        except FileNotFoundError:
            return None
        except Exception as exc:
            logger.debug("Failed to read FTS validation metadata: %s", exc)
            return None

    def _store_validation_metadata(self, recipe_count: int, recipe_ids: Set[str]) -> None:
        """Persist recipe count and ID fingerprint for cheap future validation."""
        try:
            with self._lock:
                conn = self._connect()
                try:
                    conn.execute(
                        "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                        (self._COUNT_METADATA_KEY, str(recipe_count))
                    )
                    conn.execute(
                        "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                        (self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(recipe_ids))
                    )
                    conn.commit()
                finally:
                    conn.close()
        except Exception as exc:
            logger.debug("Failed to store FTS validation metadata: %s", exc)

    def _update_mutation_metadata_locked(
        self,
        conn: sqlite3.Connection,
        recipe_id: str,
        delta: int,
    ) -> None:
        """Incrementally maintain validation metadata after add/remove.

        Caller must hold the lock. The fingerprint is only updated when it
        already exists; without it, validation falls back to a one-time scan
        that records fresh metadata.
        """
        fingerprint_row = conn.execute(
            "SELECT value FROM fts_metadata WHERE key = ?",
            (self._FINGERPRINT_METADATA_KEY,)
        ).fetchone()
        if fingerprint_row and fingerprint_row[0]:
            try:
                fingerprint = int(fingerprint_row[0], 16)
            except ValueError:
                fingerprint = None
            if fingerprint is not None:
                fingerprint ^= self._fingerprint_recipe_id(recipe_id)
                conn.execute(
                    "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                    (self._FINGERPRINT_METADATA_KEY, f"{fingerprint & 0xFFFFFFFFFFFFFFFF:016x}")
                )

        count_row = conn.execute(
            "SELECT value FROM fts_metadata WHERE key = ?",
            (self._COUNT_METADATA_KEY,)
        ).fetchone()
        if count_row:
            try:
                count = max(0, int(count_row[0] or 0) + delta)
            except (TypeError, ValueError):
                return
            conn.execute(
                "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
                (self._COUNT_METADATA_KEY, str(count))
            )

    def _connect(self, readonly: bool = False) -> sqlite3.Connection:
        """Create a database connection."""
        uri = False
        path = self._db_path
        if readonly:
            if not os.path.exists(path):
                raise FileNotFoundError(path)
            path = f"file:{path}?mode=ro"
            uri = True
        conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
        conn.row_factory = sqlite3.Row
        return conn

    def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
        """Remove a recipe entry. Caller must hold the lock."""
        # Get the rowid for deletion
        cursor = conn.execute(
            "SELECT fts_rowid FROM recipe_rowid WHERE recipe_id = ?",
            (recipe_id,)
        )
        result = cursor.fetchone()
        if result:
            fts_rowid = result[0]
            # Delete from FTS using rowid
            conn.execute(
                "DELETE FROM recipe_fts WHERE rowid = ?",
                (fts_rowid,)
            )
        # Also try direct delete by recipe_id (handles edge cases)
        conn.execute(
            "DELETE FROM recipe_fts WHERE recipe_id = ?",
            (recipe_id,)
        )
        conn.execute(
            "DELETE FROM recipe_rowid WHERE recipe_id = ?",
            (recipe_id,)
        )

    def _prepare_fts_row(self, recipe: Dict[str, Any]) -> tuple[str, str, str, str, str, str, str]:
        """Prepare a row tuple for FTS insertion."""
        recipe_id = str(recipe.get('id', ''))
        title = str(recipe.get('title', ''))

        # Extract tags as space-separated string
        tags_list = recipe.get('tags', [])
        tags = ' '.join(str(t) for t in tags_list if t) if tags_list else ''

        # Extract LoRA file names and model names
        loras = recipe.get('loras', [])
        lora_names = []
        lora_models = []
        for lora in loras:
            if isinstance(lora, dict):
                file_name = lora.get('file_name', '')
                if file_name:
                    lora_names.append(str(file_name))
                model_name = lora.get('modelName', '')
                if model_name:
                    lora_models.append(str(model_name))

        lora_names_str = ' '.join(lora_names)
        lora_models_str = ' '.join(lora_models)

        # Extract prompts from gen_params
        gen_params = recipe.get('gen_params', {})
        prompt = str(gen_params.get('prompt', '')) if gen_params else ''
        negative_prompt = str(gen_params.get('negative_prompt', '')) if gen_params else ''

        return (recipe_id, title, tags, lora_names_str, lora_models_str, prompt, negative_prompt)

    def _build_fts_query(self, query: str, fields: Optional[Set[str]] = None) -> str:
        """Build an FTS5 query string with prefix matching and field restrictions.

        Args:
            query: The user's search query.
            fields: Optional set of field names to restrict search to.

        Returns:
            FTS5 query string.
        """
        # Split query into words and clean them
        words = query.lower().split()
        if not words:
            return ''

        # Escape and add prefix wildcard to each word
        prefix_terms = []
        for word in words:
            escaped = self._escape_fts_query(word)
            if escaped:
                # Add prefix wildcard for substring-like matching
                # FTS5 prefix queries: word* matches words starting with "word"
                prefix_terms.append(f'{escaped}*')

        if not prefix_terms:
            return ''

        # Combine terms with implicit AND (all words must match)
        term_expr = ' '.join(prefix_terms)

        # If no field restriction, search all indexed fields (not recipe_id)
        if not fields:
            return term_expr

        # Build field-restricted query where ALL words must match within at least one field
        field_clauses = []
        for field in fields:
            if field in self.FIELD_MAP:
                cols = self.FIELD_MAP[field]
                for col in cols:
                    # Create clause where ALL terms must match in this column (implicit AND)
                    col_terms = [f'{col}:{term}' for term in prefix_terms]
                    field_clauses.append('(' + ' '.join(col_terms) + ')')

        if not field_clauses:
            return term_expr

        # Any field matching all terms is acceptable (OR between field clauses)
        return ' OR '.join(field_clauses)

    def _escape_fts_query(self, text: str) -> str:
        """Escape special FTS5 characters.

        FTS5 special characters: " ( ) * : ^ -
        We keep * for prefix matching but escape others.
        """
        if not text:
            return ''

        # Replace FTS5 special characters with space
        # Keep alphanumeric, CJK characters, and common punctuation
        special = ['"', '(', ')', '*', ':', '^', '-', '{', '}', '[', ']']
        result = text
        for char in special:
            result = result.replace(char, ' ')

        # Collapse multiple spaces and strip
        result = re.sub(r'\s+', ' ', result).strip()
        return result
