"""Vault full-text search using SQLite FTS5."""

from __future__ import annotations

import hashlib
import logging
import re
from datetime import UTC, datetime
from pathlib import PurePosixPath

import aiosqlite

from agntspace.vault.reader import VaultReader

log = logging.getLogger(__name__)


class _ResultMeta:
    """Resolved route, action, and content type for a search result."""

    __slots__ = ("route", "action", "content_type")

    def __init__(self, route: str | None, action: str, content_type: str) -> None:
        self.route = route
        self.action = action          # "navigate" | "open_file"
        self.content_type = content_type

    def to_dict(self) -> dict:
        return {
            "route": self.route,
            "action": self.action,
            "content_type": self.content_type,
        }


# Identity file → route mapping
_IDENTITY_ROUTES: dict[str, str] = {  # arch:deterministic — file-to-route mapping for UI navigation
    "CONTRACT": "/control/contract",
    "SOUL": "/control/agent",
    "USER": "/control/agent",
    "PROFILE": "/control/profile",
    "TRUST": "/control/trust",
    "RELATIONSHIP": "/control/relationship",
}


def _classify_result(logical_path: str, slug: str | None = None) -> _ResultMeta:
    """Classify a vault path into content type, action, and route.

    *slug* is the frontmatter slug stored at index time (wiki articles only).
    """
    parts = PurePosixPath(logical_path)
    pp = parts.parts

    # --- Knowledge base ---
    if logical_path.startswith("knowledge/"):
        # Wiki articles: knowledge/{kb}/wiki/**/{file}.md → /wiki?article={slug}
        if len(pp) >= 4 and "wiki" in pp:
            # Prefer frontmatter slug; fall back to filename-derived
            if not slug:
                slug = re.sub(r"[^a-z0-9]+", "-", parts.stem.lower()).strip("-")
            if slug:
                return _ResultMeta(f"/wiki?article={slug}", "navigate", "Wiki Article")
            return _ResultMeta(None, "open_file", "Wiki Article")

        # Library sources — open with OS handler
        if "/library/" in logical_path:
            return _ResultMeta(None, "open_file", "Library Source")

        # Inbox files
        if "/inbox/" in logical_path:
            return _ResultMeta(None, "open_file", "Inbox File")

        # KB schema, log, index, etc.
        return _ResultMeta(None, "open_file", "KB File")

    # --- Journal ---
    if logical_path.startswith("journal/"):
        if "/user/" in logical_path:
            return _ResultMeta("/journal", "navigate", "User Journal")
        if "morning-briefing" in logical_path:
            return _ResultMeta("/journal", "navigate", "Morning Briefing")
        if "eod-report" in logical_path:
            return _ResultMeta("/journal", "navigate", "EOD Report")
        if "daily-reflection" in logical_path:
            return _ResultMeta("/journal", "navigate", "Daily Reflection")
        if "weekly-reflection" in logical_path:
            return _ResultMeta("/journal", "navigate", "Weekly Reflection")
        if "observations" in logical_path:
            return _ResultMeta("/journal", "navigate", "Agent Observations")
        return _ResultMeta("/journal", "navigate", "Journal")

    # --- Memory ---
    if logical_path.startswith("memory/"):
        if "/summaries/" in logical_path:
            layer = parts.stem.title()  # week, month, quarter, year, longterm
            return _ResultMeta("/memory", "navigate", f"Memory ({layer})")
        if "/graph/" in logical_path:
            return _ResultMeta("/graph", "navigate", "Knowledge Graph")
        return _ResultMeta("/memory", "navigate", "Memory")

    # --- Projects ---
    if logical_path.startswith("projects/"):
        if "/tasks/" in logical_path:
            return _ResultMeta("/tasks", "navigate", "Task")
        return _ResultMeta("/projects", "navigate", "Project")

    # --- Goals ---
    if logical_path.startswith("goals/"):
        return _ResultMeta("/goals", "navigate", "Goal")

    # --- System ---
    if logical_path.startswith("system/agents/"):
        return _ResultMeta("/agents", "navigate", "Agent")

    if logical_path.startswith("system/skills/"):
        return _ResultMeta("/skills", "navigate", "Skill")

    if logical_path.startswith("system/identity/"):
        name = parts.stem.upper()
        route = _IDENTITY_ROUTES.get(name, "/control/agent")
        return _ResultMeta(route, "navigate", "Identity")

    # Fallback — open with OS handler
    return _ResultMeta(None, "open_file", "File")


class VaultSearch:
    """Full-text search over vault markdown files using FTS5."""

    def __init__(
        self,
        db: aiosqlite.Connection,
        vault: VaultReader,
        exclude_dirs: list[str] | None = None,
    ) -> None:
        self._db = db
        self._vault = vault
        self._exclude_dirs = set(exclude_dirs or [])

    async def _ensure_slug_column(self) -> None:
        """Add slug column to vault_index if missing (idempotent migration).

        Forces a full reindex when the column is first added so existing
        rows pick up their frontmatter slugs.
        """
        try:
            cursor = await self._db.execute("PRAGMA table_info(vault_index)")
            cols = {row[1] for row in await cursor.fetchall()}
            if "slug" not in cols:
                await self._db.execute("ALTER TABLE vault_index ADD COLUMN slug TEXT")
                # Clear hashes to force reindex of every file
                await self._db.execute("UPDATE vault_index SET content_hash = ''")
                await self._db.commit()
                log.info("vault_index: added slug column, forcing full reindex")
        except Exception:
            pass  # table doesn't exist yet — will be created by schema init

    async def index_vault(self) -> int:
        """Scan vault and index all markdown files. Returns count of indexed files."""
        await self._ensure_slug_column()

        files = self._vault.list_files(pattern="**/*.md")
        indexed = 0

        for file_path in files:
            if self._vault.paths:
                relative = self._vault.paths.to_logical(file_path)
            else:
                relative = str(file_path.relative_to(self._vault.vault_dir)).replace("\\", "/")

            # Skip excluded directories
            if any(relative.startswith(f"{d}/") for d in self._exclude_dirs):
                continue
            # Skip memory summary archives (only index current summaries, not dated copies)
            if "memory/summaries/" in relative and relative.count("/") > 2:
                continue

            try:
                doc = self._vault.read(relative)
            except Exception:
                continue

            content_hash = hashlib.md5(doc.raw.encode()).hexdigest()

            # Check if already indexed with same hash
            cursor = await self._db.execute(
                "SELECT content_hash FROM vault_index WHERE path = ?",
                (relative,),
            )
            row = await cursor.fetchone()
            if row and row[0] == content_hash:
                continue

            title = doc.metadata.get("title", file_path.stem)

            # Extract slug from frontmatter (wiki articles store it)
            slug = doc.metadata.get("slug") or ""

            # Upsert index record
            await self._db.execute(
                "INSERT OR REPLACE INTO vault_index (path, title, content_hash, slug, indexed_at) "
                "VALUES (?, ?, ?, ?, ?)",
                (relative, title, content_hash, slug, datetime.now(UTC).isoformat()),
            )

            # Upsert FTS record (delete + insert since FTS5 doesn't support REPLACE)
            await self._db.execute(
                "DELETE FROM vault_fts WHERE path = ?", (relative,)
            )
            await self._db.execute(
                "INSERT INTO vault_fts (path, title, content) VALUES (?, ?, ?)",
                (relative, title, doc.content),
            )
            indexed += 1

        await self._db.commit()
        log.debug("Vault indexed: %d files updated", indexed)
        return indexed

    async def search(self, query: str, limit: int = 10) -> list[dict]:
        """Search vault content. Returns matching files with snippets."""
        if not query.strip():
            return []

        # FTS5 query — escape special chars
        safe_query = self._escape_fts_query(query)
        if safe_query == '""':
            return []  # All tokens were FTS5-meaningful punctuation; nothing to search.

        try:
            cursor = await self._db.execute(
                "SELECT f.path, f.title, "
                "snippet(vault_fts, 2, '**', '**', '...', 40) as snippet, "
                "f.rank, i.slug "
                "FROM vault_fts f "
                "LEFT JOIN vault_index i ON i.path = f.path "
                "WHERE vault_fts MATCH ? ORDER BY f.rank LIMIT ?",
                (safe_query, limit),
            )
            rows = await cursor.fetchall()
        except aiosqlite.OperationalError as exc:
            # Defense-in-depth: any FTS5 syntax that slips past _escape_fts_query
            # degrades to "no results" instead of crashing the agent's tool loop.
            log.warning(
                "FTS5 query rejected (returning empty results): query=%r safe=%r err=%s",
                query, safe_query, exc,
            )
            return []
        results = []
        for row in rows:
            meta = _classify_result(row[0], slug=row[4] or None)
            results.append({
                "path": row[0],
                "title": row[1],
                "snippet": row[2],
                "score": row[3],
                **meta.to_dict(),
            })
        return results

    async def remove_file(self, path: str) -> None:
        """Remove a file from the search index."""
        await self._db.execute("DELETE FROM vault_fts WHERE path = ?", (path,))
        await self._db.execute("DELETE FROM vault_index WHERE path = ?", (path,))
        await self._db.commit()

    @staticmethod
    def _escape_fts_query(query: str) -> str:
        """Build a defensively-escaped FTS5 MATCH expression.

        Two layers of safety:
          1. Tokens are split on Unicode-aware word boundaries via `re.findall`,
             dropping every FTS5-meaningful punctuation char (`:`, `"`, `(`,
             `)`, `*`, `+`, `^`, `?`, etc.) at tokenization time. The remaining
             tokens contain only letters, digits, and underscores.
          2. Each clean token is wrapped in `"..."` so even Unicode-letter
             reserved words (`AND`, `OR`, `NOT`, `NEAR`) are treated as terms.

        Returns `'""'` (empty phrase — caller short-circuits to `[]`) when the
        input contains no usable tokens.
        """
        # \w matches [A-Za-z0-9_] plus Unicode letters in re's default mode.
        tokens = re.findall(r"\w+", query, flags=re.UNICODE)
        if not tokens:
            return '""'
        return " ".join(f'"{tok}"' for tok in tokens)
