"""SQLite storage with FTS5 text search and sqlite-vec vector search.

Each indexed repository gets its own SQLite database file stored in
~/.tokennuke/repos/. Supports incremental indexing via SHA-256 file
hash comparison.
"""

import json
import logging
import os
import sqlite3
from pathlib import Path
from typing import Optional

import sqlite_vec

from tokennuke.parser.symbols import Symbol, CallEdge

log = logging.getLogger(__name__)

SCHEMA_VERSION = 1

SCHEMA_SQL = """
-- Metadata
CREATE TABLE IF NOT EXISTS meta (
    key TEXT PRIMARY KEY,
    value TEXT
);

-- Indexed files with content hash for incremental updates
CREATE TABLE IF NOT EXISTS files (
    id INTEGER PRIMARY KEY,
    path TEXT UNIQUE NOT NULL,
    sha256 TEXT NOT NULL,
    language TEXT,
    size_bytes INTEGER,
    indexed_at TEXT DEFAULT (datetime('now'))
);

-- Extracted symbols
CREATE TABLE IF NOT EXISTS symbols (
    id INTEGER PRIMARY KEY,
    symbol_id TEXT UNIQUE NOT NULL,
    file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    qualified_name TEXT NOT NULL,
    kind TEXT NOT NULL,
    language TEXT NOT NULL,
    signature TEXT,
    docstring TEXT,
    decorators TEXT,
    parent_symbol_id TEXT,
    line INTEGER NOT NULL,
    end_line INTEGER NOT NULL,
    byte_offset INTEGER NOT NULL,
    byte_length INTEGER NOT NULL,
    content_hash TEXT
);

-- Call graph edges
CREATE TABLE IF NOT EXISTS call_edges (
    id INTEGER PRIMARY KEY,
    caller_symbol_id TEXT NOT NULL,
    callee_name TEXT NOT NULL,
    callee_symbol_id TEXT,
    line INTEGER,
    UNIQUE(caller_symbol_id, callee_name, line)
);

-- Indexes
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
CREATE INDEX IF NOT EXISTS idx_symbols_qualified ON symbols(qualified_name);
CREATE INDEX IF NOT EXISTS idx_call_callee ON call_edges(callee_name);
CREATE INDEX IF NOT EXISTS idx_call_callee_id ON call_edges(callee_symbol_id);
CREATE INDEX IF NOT EXISTS idx_call_caller ON call_edges(caller_symbol_id);
"""

FTS_SCHEMA_SQL = """
-- FTS5 for symbol search
CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
    name, qualified_name, signature, docstring,
    content=symbols, content_rowid=id
);

-- FTS5 triggers to keep in sync
CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
    INSERT INTO symbols_fts(rowid, name, qualified_name, signature, docstring)
    VALUES (new.id, new.name, new.qualified_name, new.signature, new.docstring);
END;

CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
    INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, signature, docstring)
    VALUES ('delete', old.id, old.name, old.qualified_name, old.signature, old.docstring);
END;

CREATE TRIGGER IF NOT EXISTS symbols_au AFTER UPDATE ON symbols BEGIN
    INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name, signature, docstring)
    VALUES ('delete', old.id, old.name, old.qualified_name, old.signature, old.docstring);
    INSERT INTO symbols_fts(rowid, name, qualified_name, signature, docstring)
    VALUES (new.id, new.name, new.qualified_name, new.signature, new.docstring);
END;

-- FTS5 for raw file content search
CREATE VIRTUAL TABLE IF NOT EXISTS file_content_fts USING fts5(
    path, content
);
"""

VEC_SCHEMA_SQL = """
-- sqlite-vec for semantic vector search (384-dim, bge-small-en-v1.5)
CREATE VIRTUAL TABLE IF NOT EXISTS symbols_vec USING vec0(
    id INTEGER PRIMARY KEY,
    embedding float[384]
);
"""


class Database:
    """Per-repository SQLite database with FTS5 and vector search."""

    def __init__(self, db_path: str | Path):
        self.db_path = Path(db_path)
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._conn: Optional[sqlite3.Connection] = None

    @property
    def conn(self) -> sqlite3.Connection:
        if self._conn is None:
            self._conn = self._open()
        return self._conn

    def _open(self) -> sqlite3.Connection:
        conn = sqlite3.connect(str(self.db_path))
        conn.row_factory = sqlite3.Row
        conn.execute('PRAGMA journal_mode=WAL')
        conn.execute('PRAGMA foreign_keys=ON')
        conn.execute('PRAGMA synchronous=NORMAL')

        # Load sqlite-vec extension
        conn.enable_load_extension(True)
        sqlite_vec.load(conn)
        conn.enable_load_extension(False)

        # Create schema
        conn.executescript(SCHEMA_SQL)
        conn.executescript(FTS_SCHEMA_SQL)
        conn.executescript(VEC_SCHEMA_SQL)

        # Set schema version
        conn.execute(
            'INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)',
            ('schema_version', str(SCHEMA_VERSION)),
        )
        conn.commit()
        return conn

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

    # --- File operations ---

    def get_file_hashes(self) -> dict[str, str]:
        """Get {path: sha256} for all indexed files."""
        rows = self.conn.execute('SELECT path, sha256 FROM files').fetchall()
        return {row['path']: row['sha256'] for row in rows}

    def upsert_file(
        self,
        path: str,
        sha256: str,
        language: str | None,
        size_bytes: int,
        content: str,
        symbols: list[Symbol],
        call_edges: dict[str, list[CallEdge]],
    ) -> int:
        """Insert or update a file and its symbols.

        Returns the file_id.
        """
        cur = self.conn.cursor()

        # Check if file exists
        row = cur.execute(
            'SELECT id FROM files WHERE path = ?', (path,),
        ).fetchone()

        if row:
            file_id = row['id']
            # Delete old symbols (cascade will handle call_edges via symbol_id)
            cur.execute('DELETE FROM symbols WHERE file_id = ?', (file_id,))
            cur.execute(
                'DELETE FROM call_edges WHERE caller_symbol_id IN '
                '(SELECT symbol_id FROM symbols WHERE file_id = ?)',
                (file_id,),
            )
            # Update file record
            cur.execute(
                'UPDATE files SET sha256=?, language=?, size_bytes=?, indexed_at=datetime("now") '
                'WHERE id=?',
                (sha256, language, size_bytes, file_id),
            )
        else:
            cur.execute(
                'INSERT INTO files (path, sha256, language, size_bytes) VALUES (?, ?, ?, ?)',
                (path, sha256, language, size_bytes),
            )
            file_id = cur.lastrowid

        # Update file content FTS
        cur.execute('DELETE FROM file_content_fts WHERE path = ?', (path,))
        cur.execute(
            'INSERT INTO file_content_fts (path, content) VALUES (?, ?)',
            (path, content),
        )

        # Insert symbols
        symbol_id_map: dict[str, int] = {}
        for sym in symbols:
            cur.execute(
                'INSERT INTO symbols '
                '(symbol_id, file_id, name, qualified_name, kind, language, '
                'signature, docstring, decorators, parent_symbol_id, '
                'line, end_line, byte_offset, byte_length, content_hash) '
                'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
                (
                    sym.id, file_id, sym.name, sym.qualified_name, sym.kind,
                    sym.language, sym.signature, sym.docstring,
                    json.dumps(sym.decorators) if sym.decorators else None,
                    sym.parent, sym.line, sym.end_line,
                    sym.byte_offset, sym.byte_length, sym.content_hash,
                ),
            )
            symbol_id_map[sym.id] = cur.lastrowid

        # Insert call edges
        for caller_id, edges in call_edges.items():
            for edge in edges:
                try:
                    cur.execute(
                        'INSERT OR IGNORE INTO call_edges '
                        '(caller_symbol_id, callee_name, line) VALUES (?, ?, ?)',
                        (caller_id, edge.callee_name, edge.line),
                    )
                except sqlite3.IntegrityError:
                    pass

        self.conn.commit()
        return file_id

    def delete_file(self, path: str) -> None:
        """Delete a file and all its symbols."""
        cur = self.conn.cursor()
        row = cur.execute('SELECT id FROM files WHERE path = ?', (path,)).fetchone()
        if row:
            file_id = row['id']
            # Delete call edges for symbols in this file
            cur.execute(
                'DELETE FROM call_edges WHERE caller_symbol_id IN '
                '(SELECT symbol_id FROM symbols WHERE file_id = ?)',
                (file_id,),
            )
            cur.execute('DELETE FROM symbols WHERE file_id = ?', (file_id,))
            cur.execute('DELETE FROM files WHERE id = ?', (file_id,))
            cur.execute('DELETE FROM file_content_fts WHERE path = ?', (path,))
            self.conn.commit()

    def delete_files(self, paths: list[str]) -> None:
        """Batch delete multiple files."""
        for path in paths:
            self.delete_file(path)

    # --- Symbol queries ---

    def get_symbol(self, symbol_id: str) -> Optional[dict]:
        """Get a single symbol by its ID."""
        row = self.conn.execute(
            'SELECT s.*, f.path as file_path FROM symbols s '
            'JOIN files f ON s.file_id = f.id '
            'WHERE s.symbol_id = ?',
            (symbol_id,),
        ).fetchone()
        return dict(row) if row else None

    def get_symbol_by_qualified_name(
        self,
        qualified_name: str,
        file_path: str | None = None,
    ) -> Optional[dict]:
        """Get a symbol by qualified name, optionally scoped to a file."""
        if file_path:
            row = self.conn.execute(
                'SELECT s.*, f.path as file_path FROM symbols s '
                'JOIN files f ON s.file_id = f.id '
                'WHERE s.qualified_name = ? AND f.path = ?',
                (qualified_name, file_path),
            ).fetchone()
        else:
            row = self.conn.execute(
                'SELECT s.*, f.path as file_path FROM symbols s '
                'JOIN files f ON s.file_id = f.id '
                'WHERE s.qualified_name = ? LIMIT 1',
                (qualified_name,),
            ).fetchone()
        return dict(row) if row else None

    def get_symbols_batch(self, symbol_ids: list[str]) -> list[dict]:
        """Get multiple symbols by their IDs."""
        if not symbol_ids:
            return []
        placeholders = ','.join('?' for _ in symbol_ids)
        rows = self.conn.execute(
            f'SELECT s.*, f.path as file_path FROM symbols s '
            f'JOIN files f ON s.file_id = f.id '
            f'WHERE s.symbol_id IN ({placeholders})',
            symbol_ids,
        ).fetchall()
        return [dict(r) for r in rows]

    def file_outline(self, file_path: str) -> list[dict]:
        """List all symbols in a single file."""
        rows = self.conn.execute(
            'SELECT s.* FROM symbols s '
            'JOIN files f ON s.file_id = f.id '
            'WHERE f.path = ? ORDER BY s.line',
            (file_path,),
        ).fetchall()
        return [dict(r) for r in rows]

    def repo_outline(
        self,
        kind_filter: str | None = None,
        limit: int = 500,
    ) -> list[dict]:
        """List all symbols in the repo, optionally filtered by kind."""
        if kind_filter:
            rows = self.conn.execute(
                'SELECT s.symbol_id, s.name, s.qualified_name, s.kind, '
                's.language, s.signature, s.line, f.path as file_path '
                'FROM symbols s JOIN files f ON s.file_id = f.id '
                'WHERE s.kind = ? ORDER BY f.path, s.line LIMIT ?',
                (kind_filter, limit),
            ).fetchall()
        else:
            rows = self.conn.execute(
                'SELECT s.symbol_id, s.name, s.qualified_name, s.kind, '
                's.language, s.signature, s.line, f.path as file_path '
                'FROM symbols s JOIN files f ON s.file_id = f.id '
                'ORDER BY f.path, s.line LIMIT ?',
                (limit,),
            ).fetchall()
        return [dict(r) for r in rows]

    # --- FTS5 search ---

    def search_symbols_fts(
        self,
        query: str,
        kind: str | None = None,
        limit: int = 20,
    ) -> list[dict]:
        """Full-text search on symbol names, signatures, docstrings."""
        # Escape special FTS5 characters
        safe_query = query.replace('"', '""')

        if kind:
            rows = self.conn.execute(
                'SELECT s.*, f.path as file_path, '
                'rank as fts_rank '
                'FROM symbols_fts fts '
                'JOIN symbols s ON s.id = fts.rowid '
                'JOIN files f ON s.file_id = f.id '
                'WHERE symbols_fts MATCH ? AND s.kind = ? '
                'ORDER BY rank LIMIT ?',
                (safe_query, kind, limit),
            ).fetchall()
        else:
            rows = self.conn.execute(
                'SELECT s.*, f.path as file_path, '
                'rank as fts_rank '
                'FROM symbols_fts fts '
                'JOIN symbols s ON s.id = fts.rowid '
                'JOIN files f ON s.file_id = f.id '
                'WHERE symbols_fts MATCH ? '
                'ORDER BY rank LIMIT ?',
                (safe_query, limit),
            ).fetchall()
        return [dict(r) for r in rows]

    def search_text(
        self,
        query: str,
        glob_pattern: str | None = None,
        limit: int = 20,
    ) -> list[dict]:
        """Full-text search in raw file contents."""
        safe_query = query.replace('"', '""')

        if glob_pattern:
            rows = self.conn.execute(
                'SELECT path, snippet(file_content_fts, 1, ">>>", "<<<", "...", 40) '
                'as snippet FROM file_content_fts '
                'WHERE file_content_fts MATCH ? AND path GLOB ? '
                'ORDER BY rank LIMIT ?',
                (safe_query, glob_pattern, limit),
            ).fetchall()
        else:
            rows = self.conn.execute(
                'SELECT path, snippet(file_content_fts, 1, ">>>", "<<<", "...", 40) '
                'as snippet FROM file_content_fts '
                'WHERE file_content_fts MATCH ? '
                'ORDER BY rank LIMIT ?',
                (safe_query, limit),
            ).fetchall()
        return [dict(r) for r in rows]

    # --- Vector search ---

    def upsert_embedding(self, symbol_rowid: int, embedding: list[float]) -> None:
        """Insert or replace a symbol's embedding vector."""
        self.conn.execute(
            'INSERT OR REPLACE INTO symbols_vec (id, embedding) VALUES (?, ?)',
            (symbol_rowid, sqlite_vec.serialize_float32(embedding)),
        )

    def upsert_embeddings_batch(
        self,
        pairs: list[tuple[int, list[float]]],
    ) -> None:
        """Batch insert embeddings."""
        for rowid, emb in pairs:
            self.conn.execute(
                'INSERT OR REPLACE INTO symbols_vec (id, embedding) VALUES (?, ?)',
                (rowid, sqlite_vec.serialize_float32(emb)),
            )
        self.conn.commit()

    def search_symbols_vec(
        self,
        query_embedding: list[float],
        limit: int = 20,
    ) -> list[dict]:
        """Vector similarity search using sqlite-vec."""
        rows = self.conn.execute(
            'SELECT sv.id, sv.distance, s.symbol_id, s.name, s.qualified_name, '
            's.kind, s.language, s.signature, s.line, f.path as file_path '
            'FROM symbols_vec sv '
            'JOIN symbols s ON s.id = sv.id '
            'JOIN files f ON s.file_id = f.id '
            'WHERE sv.embedding MATCH ? '
            'AND k = ? '
            'ORDER BY sv.distance',
            (sqlite_vec.serialize_float32(query_embedding), limit),
        ).fetchall()
        return [dict(r) for r in rows]

    # --- Call graph ---

    def get_callees(
        self,
        symbol_id: str,
        depth: int = 1,
    ) -> list[dict]:
        """Get functions called by a symbol (outgoing edges)."""
        visited: set[str] = set()
        result: list[dict] = []
        self._traverse_callees(symbol_id, depth, 0, visited, result)
        return result

    def _traverse_callees(
        self,
        symbol_id: str,
        max_depth: int,
        current_depth: int,
        visited: set[str],
        result: list[dict],
    ) -> None:
        if current_depth >= max_depth or symbol_id in visited:
            return
        visited.add(symbol_id)

        rows = self.conn.execute(
            'SELECT ce.callee_name, ce.callee_symbol_id, ce.line, '
            's.qualified_name, s.kind, f.path as file_path '
            'FROM call_edges ce '
            'LEFT JOIN symbols s ON s.symbol_id = ce.callee_symbol_id '
            'LEFT JOIN files f ON s.file_id = f.id '
            'WHERE ce.caller_symbol_id = ?',
            (symbol_id,),
        ).fetchall()

        for row in rows:
            entry = dict(row)
            entry['depth'] = current_depth + 1
            result.append(entry)
            if row['callee_symbol_id'] and current_depth + 1 < max_depth:
                self._traverse_callees(
                    row['callee_symbol_id'], max_depth,
                    current_depth + 1, visited, result,
                )

    def get_callers(
        self,
        symbol_id: str,
        depth: int = 1,
    ) -> list[dict]:
        """Get functions that call a symbol (incoming edges)."""
        visited: set[str] = set()
        result: list[dict] = []
        self._traverse_callers(symbol_id, depth, 0, visited, result)
        return result

    def _traverse_callers(
        self,
        symbol_id: str,
        max_depth: int,
        current_depth: int,
        visited: set[str],
        result: list[dict],
    ) -> None:
        if current_depth >= max_depth or symbol_id in visited:
            return
        visited.add(symbol_id)

        # Find callers by matching the symbol's name in callee_name
        # or its symbol_id in callee_symbol_id
        rows = self.conn.execute(
            'SELECT ce.caller_symbol_id, ce.line, '
            's.qualified_name, s.kind, f.path as file_path '
            'FROM call_edges ce '
            'JOIN symbols s ON s.symbol_id = ce.caller_symbol_id '
            'JOIN files f ON s.file_id = f.id '
            'WHERE ce.callee_symbol_id = ?',
            (symbol_id,),
        ).fetchall()

        for row in rows:
            entry = dict(row)
            entry['depth'] = current_depth + 1
            result.append(entry)
            if current_depth + 1 < max_depth:
                self._traverse_callers(
                    row['caller_symbol_id'], max_depth,
                    current_depth + 1, visited, result,
                )

    def resolve_call_edges(self) -> int:
        """Resolve callee_name -> callee_symbol_id for all unresolved edges.

        Called after a full indexing pass. Returns count of resolved edges.
        """
        cur = self.conn.cursor()
        cur.execute(
            'UPDATE call_edges SET callee_symbol_id = ('
            '  SELECT s.symbol_id FROM symbols s '
            '  WHERE s.name = call_edges.callee_name '
            '  OR s.qualified_name = call_edges.callee_name '
            '  LIMIT 1'
            ') WHERE callee_symbol_id IS NULL',
        )
        resolved = cur.rowcount
        self.conn.commit()
        return resolved

    # --- Stats ---

    def get_stats(self) -> dict:
        """Get index statistics."""
        files = self.conn.execute('SELECT COUNT(*) as n FROM files').fetchone()['n']
        symbols = self.conn.execute('SELECT COUNT(*) as n FROM symbols').fetchone()['n']
        edges = self.conn.execute('SELECT COUNT(*) as n FROM call_edges').fetchone()['n']

        langs = self.conn.execute(
            'SELECT language, COUNT(*) as n FROM files '
            'WHERE language IS NOT NULL GROUP BY language ORDER BY n DESC',
        ).fetchall()

        kinds = self.conn.execute(
            'SELECT kind, COUNT(*) as n FROM symbols GROUP BY kind ORDER BY n DESC',
        ).fetchall()

        return {
            'files': files,
            'symbols': symbols,
            'call_edges': edges,
            'languages': {r['language']: r['n'] for r in langs},
            'symbol_kinds': {r['kind']: r['n'] for r in kinds},
        }

    # --- File tree ---

    def file_tree(
        self,
        path_prefix: str = '',
        depth: int | None = None,
    ) -> list[dict]:
        """Get directory tree of indexed files."""
        if path_prefix:
            rows = self.conn.execute(
                'SELECT path, language, size_bytes FROM files '
                'WHERE path LIKE ? ORDER BY path',
                (f'{path_prefix}%',),
            ).fetchall()
        else:
            rows = self.conn.execute(
                'SELECT path, language, size_bytes FROM files ORDER BY path',
            ).fetchall()

        # Build tree structure
        tree: dict = {}
        for row in rows:
            parts = row['path'].split('/')
            if depth and len(parts) > depth:
                parts = parts[:depth]

            current = tree
            for part in parts[:-1]:
                if part not in current:
                    current[part] = {}
                current = current[part]
            # Leaf node
            filename = parts[-1]
            current[filename] = {
                'language': row['language'],
                'size': row['size_bytes'],
            }

        return self._flatten_tree(tree)

    def _flatten_tree(
        self,
        tree: dict,
        prefix: str = '',
    ) -> list[dict]:
        """Flatten tree dict into list of entries."""
        entries: list[dict] = []
        for name, value in sorted(tree.items()):
            path = f'{prefix}/{name}' if prefix else name
            if isinstance(value, dict) and 'language' in value:
                entries.append({
                    'path': path,
                    'type': 'file',
                    'language': value['language'],
                    'size': value.get('size'),
                })
            else:
                entries.append({
                    'path': path,
                    'type': 'directory',
                    'children': len(value) if isinstance(value, dict) else 0,
                })
                if isinstance(value, dict):
                    entries.extend(self._flatten_tree(value, path))
        return entries
