"""
SQL Adapter — Expose a SQL database as Brynq tools.

Accepts a database source: a local file path for SQLite (e.g.
``/path/to/db.sqlite3`` or ``sqlite:///path/to/db.sqlite3``) or a
PostgreSQL URL (e.g. ``postgresql://user:pass@host/dbname``).

SQLite uses the stdlib ``sqlite3`` module.  PostgreSQL uses ``psycopg2``,
imported lazily so it is only required when a postgres URL is provided.

All stdlib for SQLite — psycopg2 only needed for PostgreSQL.
"""

from __future__ import annotations

import re
import sqlite3
from typing import Any, Dict, List
from urllib.parse import urlparse

from runtime.adapters.base import BaseAdapter

_VERSION = "0.1.0"

# Schemes that map to PostgreSQL.
_POSTGRES_SCHEMES = frozenset({"postgresql", "postgres"})


class SQLAdapter(BaseAdapter):
    """Adapter that exposes a SQL database as callable tools.

    Parameters
    ----------
    source:
        Either a SQLite file path / ``sqlite:///path`` URL, or a
        PostgreSQL URL (``postgresql://user:pass@host/dbname``).
    """

    def __init__(self, source: str) -> None:
        self._source = source
        self._backend = _detect_backend(source)
        self._db_path = _resolve_sqlite_path(source) if self._backend == "sqlite" else None
        self._dsn = source if self._backend == "postgres" else None
        self._tables: List[Dict[str, Any]] | None = None
        self._tools: List[Dict[str, Any]] | None = None

    # ── BaseAdapter interface ────────────────────────────────────────────

    def introspect(self) -> Dict[str, Any]:
        """Discover tables and expose them as tools."""
        self._ensure_loaded()
        return {
            "tools": list(self._tools),  # type: ignore[arg-type]
            "version": _VERSION,
            "source": self._source,
            "backend": self._backend,
        }

    def execute(self, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
        """Execute a SQL tool by name.

        Supported tools (per table):
          - ``{table}_select`` — SELECT with optional WHERE, ORDER BY, LIMIT
          - ``{table}_insert`` — INSERT a row
          - ``{table}_count``  — COUNT rows with optional WHERE

        Parameters
        ----------
        tool_name:
            Name of the tool to invoke.
        args:
            Keyword arguments for the operation.
        """
        self._ensure_loaded()

        tool = self._find_tool(tool_name)
        if tool is None:
            return {"status": "error", "result": f"Unknown tool: {tool_name}"}

        try:
            result = self._run_tool(tool, args)
            return {"status": "ok", "result": result}
        except Exception as exc:  # noqa: BLE001
            return {"status": "error", "result": str(exc)}

    # ── Schema loading ───────────────────────────────────────────────────

    def _ensure_loaded(self) -> None:
        """Load schema lazily (once)."""
        if self._tables is not None:
            return
        self._tables = self._fetch_tables()
        self._tools = self._build_tools(self._tables)

    def _fetch_tables(self) -> List[Dict[str, Any]]:
        """Query the database schema for tables and columns."""
        if self._backend == "sqlite":
            return self._fetch_tables_sqlite()
        return self._fetch_tables_postgres()

    def _fetch_tables_sqlite(self) -> List[Dict[str, Any]]:
        """Fetch table metadata from SQLite."""
        conn = sqlite3.connect(self._db_path)  # type: ignore[arg-type]
        try:
            cursor = conn.execute(
                "SELECT name FROM sqlite_master "
                "WHERE type='table' AND name NOT LIKE 'sqlite_%' "
                "ORDER BY name"
            )
            table_names = [row[0] for row in cursor.fetchall()]

            tables: List[Dict[str, Any]] = []
            for name in table_names:
                cursor = conn.execute(f"PRAGMA table_info({_quote_ident(name)})")
                columns = [
                    {
                        "name": row[1],
                        "type": row[2] or "TEXT",
                        "notnull": bool(row[3]),
                        "primary_key": bool(row[5]),
                    }
                    for row in cursor.fetchall()
                ]
                tables.append({"name": name, "columns": columns})
            return tables
        finally:
            conn.close()

    def _fetch_tables_postgres(self) -> List[Dict[str, Any]]:
        """Fetch table metadata from PostgreSQL."""
        psycopg2 = _import_psycopg2()
        conn = psycopg2.connect(self._dsn)
        try:
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT table_name FROM information_schema.tables "
                    "WHERE table_schema = 'public' "
                    "ORDER BY table_name"
                )
                table_names = [row[0] for row in cur.fetchall()]

                tables: List[Dict[str, Any]] = []
                for name in table_names:
                    cur.execute(
                        "SELECT column_name, data_type, is_nullable, "
                        "       column_default "
                        "FROM information_schema.columns "
                        "WHERE table_schema = 'public' AND table_name = %s "
                        "ORDER BY ordinal_position",
                        (name,),
                    )
                    columns = [
                        {
                            "name": row[0],
                            "type": row[1],
                            "notnull": row[2] == "NO",
                            "primary_key": False,  # simplified
                        }
                        for row in cur.fetchall()
                    ]
                    tables.append({"name": name, "columns": columns})
                return tables
        finally:
            conn.close()

    # ── Tool building ────────────────────────────────────────────────────

    @staticmethod
    def _build_tools(tables: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """Generate tool descriptors from discovered tables."""
        tools: List[Dict[str, Any]] = []

        # General-purpose tools
        tools.append({
            "name": "list_tables",
            "description": "List all tables in the database",
            "operation": "list_tables",
            "parameters": [],
        })
        tools.append({
            "name": "describe_table",
            "description": "Describe the columns of a table",
            "operation": "describe_table",
            "parameters": [
                {"name": "table", "description": "Table name to describe", "required": True},
            ],
        })
        tools.append({
            "name": "query_db",
            "description": "Run a read-only SQL query against the database",
            "operation": "query_db",
            "parameters": [
                {"name": "sql", "description": "SQL SELECT query to execute", "required": True},
            ],
        })

        for table in tables:
            name = table["name"]
            col_names = [c["name"] for c in table["columns"]]
            col_desc = ", ".join(col_names)

            # SELECT tool
            tools.append({
                "name": f"{name}_select",
                "description": f"Select rows from {name} ({col_desc})",
                "table": name,
                "operation": "select",
                "columns": table["columns"],
                "parameters": [
                    {"name": "where", "description": "SQL WHERE clause (without WHERE keyword)", "required": False},
                    {"name": "order_by", "description": "Column to order by", "required": False},
                    {"name": "limit", "description": "Max rows to return", "required": False},
                ],
            })

            # INSERT tool
            tools.append({
                "name": f"{name}_insert",
                "description": f"Insert a row into {name} ({col_desc})",
                "table": name,
                "operation": "insert",
                "columns": table["columns"],
                "parameters": [
                    {"name": c["name"], "description": f"Value for {c['name']} ({c['type']})", "required": c["notnull"]}
                    for c in table["columns"]
                ],
            })

            # COUNT tool
            tools.append({
                "name": f"{name}_count",
                "description": f"Count rows in {name}",
                "table": name,
                "operation": "count",
                "columns": table["columns"],
                "parameters": [
                    {"name": "where", "description": "SQL WHERE clause (without WHERE keyword)", "required": False},
                ],
            })

        return tools

    def _find_tool(self, tool_name: str) -> Dict[str, Any] | None:
        """Look up a tool by name."""
        assert self._tools is not None  # noqa: S101
        for tool in self._tools:
            if tool["name"] == tool_name:
                return tool
        return None

    # ── Execution ────────────────────────────────────────────────────────

    def _run_tool(self, tool: Dict[str, Any], args: Dict[str, Any]) -> Any:
        """Dispatch to the appropriate SQL operation."""
        operation = tool["operation"]

        if operation == "list_tables":
            return self._run_list_tables()
        if operation == "describe_table":
            return self._run_describe_table(args)
        if operation == "query_db":
            return self._run_query_db(args)

        table = tool["table"]

        if operation == "select":
            return self._run_select(table, args)
        if operation == "insert":
            return self._run_insert(table, tool["columns"], args)
        if operation == "count":
            return self._run_count(table, args)

        raise ValueError(f"Unknown operation: {operation}")

    def _run_list_tables(self) -> List[Dict[str, Any]]:
        """Return list of tables with their column names."""
        assert self._tables is not None  # noqa: S101
        return [
            {"name": t["name"], "columns": [c["name"] for c in t["columns"]]}
            for t in self._tables
        ]

    def _run_describe_table(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
        """Return column details for a specific table."""
        assert self._tables is not None  # noqa: S101
        table_name = args.get("table")
        if not table_name:
            raise ValueError("Missing required parameter: table")
        for t in self._tables:
            if t["name"] == table_name:
                return t["columns"]
        raise ValueError(f"Unknown table: {table_name}")

    def _run_query_db(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
        """Execute a read-only SQL query."""
        sql = args.get("sql")
        if not sql:
            raise ValueError("Missing required parameter: sql")
        _validate_clause(sql)
        rows, columns = self._execute_query(sql, [], fetch=True)
        return [dict(zip(columns, row)) for row in rows]

    def _run_select(self, table: str, args: Dict[str, Any]) -> List[Dict[str, Any]]:
        """Execute a SELECT query."""
        sql = f"SELECT * FROM {_quote_ident(table)}"
        params: List[Any] = []

        where = args.get("where")
        if where:
            _validate_clause(where)
            sql += f" WHERE {where}"

        order_by = args.get("order_by")
        if order_by:
            _validate_identifier(order_by)
            sql += f" ORDER BY {_quote_ident(order_by)}"

        limit = args.get("limit")
        if limit is not None:
            sql += f" LIMIT {int(limit)}"

        rows, columns = self._execute_query(sql, params, fetch=True)
        return [dict(zip(columns, row)) for row in rows]

    def _run_insert(
        self, table: str, columns: List[Dict[str, Any]], args: Dict[str, Any]
    ) -> Dict[str, Any]:
        """Execute an INSERT statement."""
        col_names = [c["name"] for c in columns if c["name"] in args]
        if not col_names:
            raise ValueError("No columns provided for insert")

        values = [args[c] for c in col_names]
        placeholders = _placeholders(self._backend, len(col_names))
        quoted_cols = ", ".join(_quote_ident(c) for c in col_names)
        sql = f"INSERT INTO {_quote_ident(table)} ({quoted_cols}) VALUES ({placeholders})"

        self._execute_query(sql, values, fetch=False)
        return {"inserted": 1, "columns": col_names}

    def _run_count(self, table: str, args: Dict[str, Any]) -> int:
        """Execute a COUNT query."""
        sql = f"SELECT COUNT(*) FROM {_quote_ident(table)}"

        where = args.get("where")
        if where:
            _validate_clause(where)
            sql += f" WHERE {where}"

        rows, _ = self._execute_query(sql, [], fetch=True)
        return rows[0][0]

    # ── Database connection ──────────────────────────────────────────────

    def _execute_query(
        self, sql: str, params: List[Any], *, fetch: bool
    ) -> tuple[list, list]:
        """Run a query against the configured backend.

        Returns ``(rows, column_names)``.  For non-fetch queries
        ``rows`` is empty and the connection is committed.
        """
        if self._backend == "sqlite":
            return self._execute_sqlite(sql, params, fetch=fetch)
        return self._execute_postgres(sql, params, fetch=fetch)

    def _execute_sqlite(
        self, sql: str, params: List[Any], *, fetch: bool
    ) -> tuple[list, list]:
        """Execute against SQLite."""
        conn = sqlite3.connect(self._db_path)  # type: ignore[arg-type]
        try:
            cursor = conn.execute(sql, params)
            if fetch:
                columns = [desc[0] for desc in cursor.description or []]
                return cursor.fetchall(), columns
            conn.commit()
            return [], []
        finally:
            conn.close()

    def _execute_postgres(
        self, sql: str, params: List[Any], *, fetch: bool
    ) -> tuple[list, list]:
        """Execute against PostgreSQL."""
        psycopg2 = _import_psycopg2()
        conn = psycopg2.connect(self._dsn)
        try:
            with conn.cursor() as cur:
                cur.execute(sql, params)
                if fetch:
                    columns = [desc[0] for desc in cur.description or []]
                    return cur.fetchall(), columns
                conn.commit()
                return [], []
        finally:
            conn.close()


# ── helpers ──────────────────────────────────────────────────────────────


def _detect_backend(source: str) -> str:
    """Determine whether the source is SQLite or PostgreSQL."""
    parsed = urlparse(source)
    scheme = parsed.scheme.lower().split("+")[0]
    if scheme in _POSTGRES_SCHEMES:
        return "postgres"
    # sqlite:// or bare file path
    return "sqlite"


def _resolve_sqlite_path(source: str) -> str:
    """Extract the file path from a SQLite source string.

    Handles bare paths (``/tmp/db.sqlite3``), ``sqlite:///path`` URLs,
    and ``sqlite:path`` relative URLs.
    """
    parsed = urlparse(source)
    if parsed.scheme == "sqlite":
        # sqlite:///absolute/path or sqlite:relative/path
        path = parsed.path
        if parsed.netloc:
            path = parsed.netloc + path
        return path or source
    # No scheme — treat as a bare file path.
    if not parsed.scheme:
        return source
    # Unknown scheme but detected as sqlite by _detect_backend
    return parsed.path or source


def _import_psycopg2():  # type: ignore[no-untyped-def]
    """Lazily import psycopg2."""
    try:
        import psycopg2  # noqa: E402
        return psycopg2
    except ImportError:
        raise ImportError(
            "psycopg2 is required for PostgreSQL connections. "
            "Install it with: pip install psycopg2-binary"
        ) from None


def _quote_ident(name: str) -> str:
    """Quote a SQL identifier to prevent injection.

    Uses double-quote escaping per SQL standard.
    """
    return '"' + name.replace('"', '""') + '"'


def _placeholders(backend: str, count: int) -> str:
    """Return parameter placeholder string for the backend."""
    if backend == "postgres":
        return ", ".join(["%s"] * count)
    return ", ".join(["?"] * count)


_DANGEROUS_PATTERN = re.compile(
    r"\b(DROP|ALTER|CREATE|TRUNCATE|DELETE|UPDATE|INSERT|EXEC|EXECUTE)\b",
    re.IGNORECASE,
)


def _validate_clause(clause: str) -> None:
    """Basic validation for user-supplied WHERE clauses.

    Rejects clauses containing DDL/DML keywords to prevent
    obvious injection attempts.  This is a defence-in-depth
    measure, not a replacement for parameterised queries.
    """
    if _DANGEROUS_PATTERN.search(clause):
        raise ValueError(
            f"WHERE clause contains forbidden keyword: {clause!r}"
        )


_IDENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


def _validate_identifier(name: str) -> None:
    """Validate that a string looks like a safe SQL identifier."""
    if not _IDENT_PATTERN.match(name):
        raise ValueError(f"Invalid identifier: {name!r}")
