import logging
import os
import re

import duckdb

from mcard.config.settings import settings
from mcard.engine.base import DatabaseConnection, StorageEngine
from mcard.model.card import MCard, MCardFromData
from mcard.model.pagination import Page

DEFAULT_PAGE_SIZE = settings.pagination.default_page_size

logger = logging.getLogger(__name__)


class DuckDBConnection(DatabaseConnection):
    """Database connection for DuckDB -- an embedded columnar OLAP database.

    This mirrors SQLiteConnection but targets DuckDB, which excels at
    analytical queries, Parquet I/O, and columnar scans.  It provides
    a drop-in alternative engine behind the same StorageEngine interface.
    """

    def __init__(self, db_path: str):
        if db_path == ":memory:":
            self.db_path = ":memory:"
        else:
            self.db_path = os.path.abspath(db_path)
        self.conn: duckdb.DuckDBPyConnection | None = None
        self.setup_database()

    def setup_database(self):
        """Resolve the database path and ensure the parent directory exists."""
        try:
            if self.db_path == ":memory:":
                return
            if not os.path.isabs(self.db_path):
                base_dir = os.path.dirname(
                    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
                )
                self.db_path = os.path.normpath(os.path.join(base_dir, self.db_path))
            os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
        except PermissionError as e:
            logger.error(f"Permission error: {e}", exc_info=True)
            raise
        except OSError as e:
            logger.error(f"Unexpected error setting up database: {e}", exc_info=True)
            raise

    def connect(self):
        """Open the DuckDB connection and initialise schema."""
        logger.debug(f"Connecting to DuckDB at {self.db_path}")
        try:
            self.conn = duckdb.connect(self.db_path)
            logger.debug(f"Connection established to {self.db_path}")

            # Check if the card table exists
            result = self.conn.execute(
                "SELECT COUNT(*) FROM information_schema.tables "
                "WHERE table_name = 'card'"
            ).fetchone()

            if result[0] == 0:
                logger.debug(f"Creating new database schema at {self.db_path}")
                self._create_schema()
                logger.debug("Database schema created successfully")
            else:
                logger.debug(f"Using existing database at {self.db_path}")

        except duckdb.Error as e:
            logger.error(
                f"Database error connecting to {self.db_path}: {e}", exc_info=True
            )
            raise

    # =====================================================================
    # Single Source of Truth: Schema files
    # =====================================================================
    # The DuckDB engine does NOT define its own schema.  It loads all
    # table / index definitions from the canonical SQL files via the
    # MCardSchema singleton and applies only dialect-level transforms.
    #
    # Canonical source files:
    #   schema/mcard_schema.sql         (REQUIRED -- core tables)
    #   schema/mcard_vector_schema.sql  (optional -- vector/graph tables)
    #
    # If a new table is added to either file, this engine picks it up
    # automatically.  No hardcoded CREATE TABLE statements should ever
    # appear in this file.
    # =====================================================================

    SCHEMA_SOURCE_FILES = (
        "schema/mcard_schema.sql",         # Required -- core tables
        "schema/mcard_vector_schema.sql",  # Optional -- vector/graph tables
    )

    def _create_schema(self):
        """Create the MCard schema by loading from the canonical SQL files
        and applying DuckDB-specific transformations.

        The schema is loaded via ``MCardSchema`` (which reads
        ``schema/mcard_schema.sql`` and ``schema/mcard_vector_schema.sql``)
        so that this engine stays in sync with the single source of truth.

        DuckDB-specific overrides applied by ``_adapt_statement_for_duckdb``:
        1. Skip ``CREATE VIRTUAL TABLE ... USING fts5(...)`` (unsupported)
        2. ``INTEGER PRIMARY KEY AUTOINCREMENT`` -> sequence + ``nextval()``
        3. ``TEXT`` -> ``VARCHAR``  (DuckDB preference)
        4. Remove FK constraints from ``handle_history`` (DuckDB enforces
           FKs on UPDATE which blocks handle pointer updates)
        5. ``datetime('now')`` -> ``CURRENT_TIMESTAMP``

        Raises:
            FileNotFoundError: if the required core schema SQL file is missing.
            RuntimeError: if no CREATE statements are loaded from the schema.
        """
        from mcard.schema import MCardSchema, _find_schema_path

        # Validate that the canonical schema file exists (raises FileNotFoundError)
        schema_path = _find_schema_path()
        logger.debug(
            f"DuckDB schema loading from canonical source: {schema_path}"
        )

        schema = MCardSchema.get_instance()
        statements = schema.get_all_statements()

        if not statements:
            raise RuntimeError(
                "No CREATE statements loaded from MCardSchema. "
                f"Ensure {self.SCHEMA_SOURCE_FILES[0]} contains valid SQL."
            )
        logger.debug(
            f"Loaded {len(statements)} schema statements from canonical SQL files"
        )

        sequences_created: set[str] = set()

        for stmt in statements:
            result = self._adapt_statement_for_duckdb(stmt, sequences_created)
            if result is None:
                # Statement should be skipped (e.g. FTS5 virtual table)
                continue
            seq_name, adapted_sql = result
            # Create the sequence if this table needs one (AUTOINCREMENT)
            if seq_name and seq_name not in sequences_created:
                self.conn.execute(
                    f"CREATE SEQUENCE IF NOT EXISTS {seq_name} START 1"
                )
                sequences_created.add(seq_name)
            self.conn.execute(adapted_sql)

        # Seed schema_version if not already present
        self.conn.execute("""
            INSERT INTO schema_version (version, applied_at, description)
            SELECT '3.0.0', CURRENT_TIMESTAMP, 'DuckDB Monadic Core Schema'
            WHERE NOT EXISTS (
                SELECT 1 FROM schema_version WHERE version = '3.0.0'
            )
        """)

    @staticmethod
    def _adapt_statement_for_duckdb(stmt, sequences_created):
        """Transform a single SQLite CREATE statement for DuckDB.

        Returns:
            ``None`` if the statement should be skipped entirely.
            A tuple ``(seq_name, sql)`` where *seq_name* is the sequence
            name to create (or ``None`` if no sequence is needed) and
            *sql* is the adapted DuckDB-compatible statement.
        """
        upper = stmt.upper()

        # -- Skip unsupported constructs ---------------------------------
        # FTS5 / vec0 virtual tables are SQLite-specific extensions
        if "USING FTS5" in upper or "USING VEC0" in upper:
            return None
        # INSERT statements are already filtered by MCardSchema, but guard
        if upper.strip().startswith("INSERT"):
            return None

        seq_name = None

        # -- AUTOINCREMENT -> sequence + nextval -------------------------
        autoincrement_re = re.compile(
            r"(\w+)\s+INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT",
            re.IGNORECASE,
        )
        match = autoincrement_re.search(stmt)
        if match:
            col_name = match.group(1)
            # Derive sequence name from the table name
            table_match = re.search(
                r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)",
                stmt, re.IGNORECASE,
            )
            table_name = table_match.group(1) if table_match else "unnamed"
            seq_name = f"{table_name}_seq"
            replacement = (
                f"{col_name} INTEGER PRIMARY KEY DEFAULT nextval('{seq_name}')"
            )
            stmt = autoincrement_re.sub(replacement, stmt)

        # -- TEXT -> VARCHAR ---------------------------------------------
        stmt = re.sub(r"\bTEXT\b", "VARCHAR", stmt, flags=re.IGNORECASE)

        # -- Remove FK constraints from handle_history -------------------
        # DuckDB enforces FK constraints on UPDATE (not just DELETE),
        # which blocks updating handle_registry.current_hash when
        # handle_history references the handle.  Integrity is
        # maintained at the application layer instead.
        if re.search(
            r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?handle_history\b",
            stmt, re.IGNORECASE,
        ):
            stmt = re.sub(
                r",\s*FOREIGN\s+KEY\s*\([^)]+\)\s*REFERENCES\s+\w+\s*\([^)]+\)",
                "", stmt, flags=re.IGNORECASE,
            )

        # -- datetime('now') -> CURRENT_TIMESTAMP ------------------------
        stmt = stmt.replace("datetime('now')", "CURRENT_TIMESTAMP")

        return (seq_name, stmt)

    # =====================================================================
    # Connection lifecycle
    # =====================================================================

    def disconnect(self):
        if self.conn:
            self.conn.close()
            self.conn = None

    def commit(self):
        # DuckDB auto-commits by default; explicit commit for consistency
        if self.conn:
            self.conn.commit()

    def rollback(self):
        if self.conn:
            self.conn.rollback()


class DuckDBEngine(StorageEngine):
    """Storage engine implementation using DuckDB.

    Provides the same StorageEngine interface as SQLiteEngine but uses
    DuckDB as the underlying database.  DuckDB excels at columnar scans,
    analytical queries, and cross-format I/O (Parquet, CSV, JSON).
    """

    def __init__(self, connection: DuckDBConnection):
        self.connection = connection
        self.connection.connect()

    @staticmethod
    def _row_to_card(content, hash_value: str, g_time: str) -> MCard:
        """Convert a database row into an MCard instance."""
        if not isinstance(content, bytes):
            logger.warning(
                f"Content from database is not bytes but {type(content)}: "
                f"{str(content)[:20]}..."
            )
            content_bytes = (
                content.encode("utf-8") if isinstance(content, str) else bytes(content)
            )
        else:
            content_bytes = content
        return MCardFromData(content_bytes, hash_value, g_time)

    def __del__(self):
        self.connection.disconnect()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.connection.disconnect()
        return False

    # ========== Card Operations ==========

    def add(self, card: MCard) -> str:
        hash_value = str(card.hash)
        try:
            content_bytes = (
                card.content
                if isinstance(card.content, bytes)
                else card.content.encode("utf-8")
            )
            self.connection.conn.execute(
                "INSERT INTO card (hash, content, g_time) VALUES ($1, $2, $3)",
                [hash_value, content_bytes, str(card.g_time)],
            )
            logger.debug(f"Added card with hash {hash_value}")
            return hash_value
        except duckdb.ConstraintException:
            raise ValueError(
                f"Card with hash {hash_value} and {str(card.g_time)} already exists, "
                f"\n Content: {card.content[:20]}"
            )

    def get(self, hash_value: str) -> MCard | None:
        result = self.connection.conn.execute(
            "SELECT content, g_time, hash FROM card WHERE hash = $1",
            [str(hash_value)],
        ).fetchone()

        if not result:
            return None

        content, g_time, hash_val = result
        return self._row_to_card(content, hash_val, g_time)

    def delete(self, hash_value: str) -> bool:
        # DuckDB doesn't expose rowcount the same way, so check first
        exists = self.connection.conn.execute(
            "SELECT 1 FROM card WHERE hash = $1", [str(hash_value)]
        ).fetchone()
        if not exists:
            return False
        self.connection.conn.execute(
            "DELETE FROM card WHERE hash = $1", [str(hash_value)]
        )
        return True

    def get_page(
        self, page_number: int = 1, page_size: int = DEFAULT_PAGE_SIZE
    ) -> Page:
        return self.get_cards_by_page(page_number, page_size)

    def search_by_string(
        self,
        search_string: str,
        page_number: int = 1,
        page_size: int = DEFAULT_PAGE_SIZE,
    ) -> Page:
        """Search cards by string in content, hash, or g_time."""
        if page_number < 1:
            raise ValueError("Page number must be >= 1")
        if page_size < 1:
            raise ValueError("Page size must be >= 1")

        offset = (page_number - 1) * page_size
        pattern = f"%{search_string}%"

        total_items = self.connection.conn.execute(
            """
            SELECT COUNT(*) FROM card
            WHERE hash LIKE $1 OR g_time LIKE $2 OR CAST(content AS VARCHAR) LIKE $3
            """,
            [pattern, pattern, pattern],
        ).fetchone()[0]

        rows = self.connection.conn.execute(
            """
            SELECT content, g_time, hash FROM card
            WHERE hash LIKE $1 OR g_time LIKE $2 OR CAST(content AS VARCHAR) LIKE $3
            ORDER BY g_time DESC LIMIT $4 OFFSET $5
            """,
            [pattern, pattern, pattern, page_size, offset],
        ).fetchall()

        items = [
            self._row_to_card(content, hash_val, g_time)
            for content, g_time, hash_val in rows
        ]
        return Page.create(items, total_items, page_number, page_size)

    def search_by_content(
        self,
        search_string: str | bytes,
        page_number: int = 1,
        page_size: int = DEFAULT_PAGE_SIZE,
    ) -> Page:
        """Search cards by content pattern."""
        if page_number < 1:
            raise ValueError("Page number must be >= 1")
        if page_size < 1:
            raise ValueError("Page size must be >= 1")
        if not search_string:
            raise ValueError("Search string cannot be empty")

        offset = (page_number - 1) * page_size

        if isinstance(search_string, str):
            search_text = search_string
        else:
            search_text = search_string.decode("utf-8", errors="replace")

        # DuckDB: position() only works with VARCHAR, so cast BLOB content
        pattern = f"%{search_text}%"
        total_items = self.connection.conn.execute(
            "SELECT COUNT(*) FROM card WHERE CAST(content AS VARCHAR) LIKE $1",
            [pattern],
        ).fetchone()[0]

        rows = self.connection.conn.execute(
            """
            SELECT content, g_time, hash FROM card
            WHERE CAST(content AS VARCHAR) LIKE $1
            ORDER BY g_time DESC
            LIMIT $2 OFFSET $3
            """,
            [pattern, page_size, offset],
        ).fetchall()

        items = [
            self._row_to_card(content, hash_val, g_time)
            for content, g_time, hash_val in rows
        ]
        return Page.create(items, total_items, page_number, page_size)

    def clear(self):
        self.connection.conn.execute("DELETE FROM card")

    def count(self) -> int:
        return self.connection.conn.execute("SELECT COUNT(*) FROM card").fetchone()[0]

    def get_all_cards(self) -> Page:
        """Get all cards in a single page."""
        rows = self.connection.conn.execute(
            "SELECT content, hash, g_time FROM card"
        ).fetchall()

        items = [
            self._row_to_card(content, hash_val, g_time)
            for content, hash_val, g_time in rows
        ]
        return Page(
            items=items,
            total_items=len(items),
            page_number=1,
            page_size=len(items) if items else 1,
            has_next=False,
            has_previous=False,
            total_pages=1,
        )

    def get_cards_by_page(
        self, page_number: int = 1, page_size: int = DEFAULT_PAGE_SIZE
    ) -> Page:
        """Get a page of cards."""
        if page_number < 1:
            raise ValueError("Page number must be >= 1")
        if page_size < 1:
            raise ValueError("Page size must be >= 1")

        total_items = self.connection.conn.execute(
            "SELECT COUNT(*) FROM card"
        ).fetchone()[0]

        offset = (page_number - 1) * page_size
        rows = self.connection.conn.execute(
            "SELECT content, g_time, hash FROM card ORDER BY g_time DESC LIMIT $1 OFFSET $2",
            [page_size, offset],
        ).fetchall()

        items = [
            self._row_to_card(content, hash_val, g_time)
            for content, g_time, hash_val in rows
        ]
        return Page.create(items, total_items, page_number, page_size)

    # ========== Handle Operations ==========

    def ensure_handle_tables(self):
        """Ensure handle tables exist (they're created in _create_schema)."""
        result = self.connection.conn.execute(
            "SELECT COUNT(*) FROM information_schema.tables "
            "WHERE table_name = 'handle_registry'"
        ).fetchone()
        if result[0] == 0:
            self.connection._create_schema()
        logger.debug("Handle tables ensured.")

    def register_handle(self, handle: str, hash_value: str) -> bool:
        from datetime import datetime, timezone

        from mcard.model.handle import validate_handle

        self.ensure_handle_tables()
        validated = validate_handle(handle)
        now = datetime.now(timezone.utc).isoformat()

        try:
            self.connection.conn.execute(
                "INSERT INTO handle_registry (handle, current_hash, created_at, updated_at) "
                "VALUES ($1, $2, $3, $4)",
                [validated, hash_value, now, now],
            )
            logger.info(f"Registered handle '{validated}' -> {hash_value[:8]}...")
            return True
        except duckdb.ConstraintException:
            raise ValueError(f"Handle '{validated}' already exists.")

    def update_handle(self, handle: str, new_hash: str) -> str:
        from datetime import datetime, timezone

        from mcard.model.handle import validate_handle

        self.ensure_handle_tables()
        validated = validate_handle(handle)
        now = datetime.now(timezone.utc).isoformat()

        row = self.connection.conn.execute(
            "SELECT current_hash FROM handle_registry WHERE handle = $1",
            [validated],
        ).fetchone()
        if not row:
            raise ValueError(f"Handle '{validated}' not found.")

        previous_hash = row[0]

        # DuckDB enforces FK constraints strictly.
        # Insert history FIRST (previous_hash FK still valid in card table),
        # then update the registry.
        self.connection.conn.execute(
            "INSERT INTO handle_history (handle, previous_hash, changed_at) "
            "VALUES ($1, $2, $3)",
            [validated, previous_hash, now],
        )
        self.connection.conn.execute(
            "UPDATE handle_registry SET current_hash = $1, updated_at = $2 WHERE handle = $3",
            [new_hash, now, validated],
        )
        logger.info(
            f"Updated handle '{validated}': {previous_hash[:8]}... -> {new_hash[:8]}..."
        )
        return previous_hash

    def resolve_handle(self, handle: str) -> str | None:
        from mcard.model.handle import validate_handle

        self.ensure_handle_tables()
        validated = validate_handle(handle)
        row = self.connection.conn.execute(
            "SELECT current_hash FROM handle_registry WHERE handle = $1",
            [validated],
        ).fetchone()
        return row[0] if row else None

    def get_by_handle(self, handle: str) -> MCard | None:
        hash_value = self.resolve_handle(handle)
        if hash_value:
            return self.get(hash_value)
        return None

    def get_handle_history(self, handle: str) -> list:
        from mcard.model.handle import validate_handle

        self.ensure_handle_tables()
        validated = validate_handle(handle)
        rows = self.connection.conn.execute(
            "SELECT previous_hash, changed_at FROM handle_history "
            "WHERE handle = $1 ORDER BY id DESC",
            [validated],
        ).fetchall()
        return [
            {"previous_hash": row[0], "changed_at": row[1]} for row in rows
        ]

    def prune_handle_history(
        self, handle: str, older_than: str | None = None, delete_all: bool = False
    ) -> int:
        from mcard.model.handle import validate_handle

        self.ensure_handle_tables()
        validated = validate_handle(handle)

        if delete_all:
            count = self.connection.conn.execute(
                "SELECT COUNT(*) FROM handle_history WHERE handle = $1",
                [validated],
            ).fetchone()[0]
            self.connection.conn.execute(
                "DELETE FROM handle_history WHERE handle = $1", [validated]
            )
            return count
        elif older_than:
            count = self.connection.conn.execute(
                "SELECT COUNT(*) FROM handle_history WHERE handle = $1 AND changed_at < $2",
                [validated, older_than],
            ).fetchone()[0]
            self.connection.conn.execute(
                "DELETE FROM handle_history WHERE handle = $1 AND changed_at < $2",
                [validated, older_than],
            )
            return count
        return 0

    # ========== Handle Management Operations ==========

    def get_all_handles(self) -> list:
        self.ensure_handle_tables()
        rows = self.connection.conn.execute(
            "SELECT handle, current_hash FROM handle_registry"
        ).fetchall()
        return [{"handle": row[0], "hash": row[1]} for row in rows]

    def remove_handle(self, handle: str):
        from mcard.model.handle import validate_handle

        self.ensure_handle_tables()
        validated = validate_handle(handle)
        self.connection.conn.execute(
            "DELETE FROM handle_history WHERE handle = $1", [validated]
        )
        self.connection.conn.execute(
            "DELETE FROM handle_registry WHERE handle = $1", [validated]
        )
        logger.info(f"Removed handle '{validated}' and its history.")

    def rename_handle(self, old_handle: str, new_handle: str):
        from mcard.model.handle import validate_handle

        self.ensure_handle_tables()
        old_validated = validate_handle(old_handle)
        new_validated = validate_handle(new_handle)

        if old_validated == new_validated:
            return

        row = self.connection.conn.execute(
            "SELECT handle FROM handle_registry WHERE handle = $1",
            [old_validated],
        ).fetchone()
        if not row:
            raise ValueError(f"Handle '{old_validated}' not found.")

        existing = self.connection.conn.execute(
            "SELECT handle FROM handle_registry WHERE handle = $1",
            [new_validated],
        ).fetchone()
        if existing:
            raise ValueError(f"Handle '{new_validated}' already exists.")

        self.connection.conn.execute(
            "UPDATE handle_registry SET handle = $1 WHERE handle = $2",
            [new_validated, old_validated],
        )
        self.connection.conn.execute(
            "UPDATE handle_history SET handle = $1 WHERE handle = $2",
            [new_validated, old_validated],
        )
        logger.info(f"Renamed handle '{old_validated}' -> '{new_validated}'.")
