"""
DuckDB read-only analytics reader.

Opens a single long-lived DuckDB connection at startup (read_only=True).
All queries run via asyncio.to_thread() to avoid blocking the event loop.

When export_serving.py atomically renames a new analytics.duckdb into place,
_check_and_reopen() detects the inode change and transparently reopens —
no app restart required.

Usage:
    from .analytics import fetch_analytics, execute_user_query

    rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"])

    cols, rows, error, elapsed_ms = await execute_user_query("SELECT city_slug FROM serving.location_profiles LIMIT 5")
"""
import asyncio
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

_conn = None  # duckdb.DuckDBPyConnection | None — lazy import
_conn_inode: int | None = None
_reopen_lock = threading.Lock()
_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")

# DuckDB queries run in the asyncio thread pool. Cap them so a slow scan
# cannot starve the pool and leave all workers busy.
_QUERY_TIMEOUT_SECONDS = 30


def open_analytics_db() -> None:
    """Open the DuckDB connection. Call once at app startup."""
    import duckdb

    global _conn, _conn_inode
    path = Path(_DUCKDB_PATH)
    if not path.exists():
        # Database doesn't exist yet — skip silently. Queries will return empty.
        return
    _conn = duckdb.connect(str(path), read_only=True)
    _conn_inode = path.stat().st_ino


def close_analytics_db() -> None:
    """Close the DuckDB connection. Call at app shutdown."""
    global _conn, _conn_inode
    if _conn is not None:
        _conn.close()
        _conn = None
        _conn_inode = None


def _check_and_reopen() -> None:
    """Reopen the connection if analytics.duckdb was atomically replaced (new inode).

    Called at the start of each query. Requires a directory bind mount (not a file
    bind mount) so that os.stat() inside the container sees the new inode after rename.
    """
    global _conn, _conn_inode
    import duckdb

    path = Path(_DUCKDB_PATH)
    try:
        current_inode = path.stat().st_ino
    except OSError:
        return

    if current_inode == _conn_inode:
        return  # same file — nothing to do

    with _reopen_lock:
        # Double-check under lock to avoid concurrent reopens.
        try:
            current_inode = path.stat().st_ino
        except OSError:
            return
        if current_inode == _conn_inode:
            return

        old_conn = _conn
        try:
            new_conn = duckdb.connect(str(path), read_only=True)
        except Exception:
            logger.exception("Failed to reopen analytics DB after file change")
            return

        _conn = new_conn
        _conn_inode = current_inode
        logger.info("Analytics DB reopened (inode changed to %d)", current_inode)

        if old_conn is not None:
            try:
                old_conn.close()
            except Exception:
                pass


async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str, Any]]:
    """
    Run a read-only DuckDB query and return rows as dicts.

    Returns [] if analytics DB is unavailable (not yet built, or DUCKDB_PATH unset).
    Returns [] on query error after logging — callers treat empty results as "no data yet".
    """
    assert sql, "sql must not be empty"

    if _conn is None:
        return []

    def _run() -> list[dict]:
        _check_and_reopen()
        conn = _conn
        if conn is None:
            return []
        cur = conn.cursor()
        try:
            rel = cur.execute(sql, params or [])
            cols = [d[0] for d in rel.description]
            return [dict(zip(cols, row)) for row in rel.fetchall()]
        finally:
            cur.close()

    try:
        return await asyncio.wait_for(
            asyncio.to_thread(_run),
            timeout=_QUERY_TIMEOUT_SECONDS,
        )
    except TimeoutError:
        logger.error("DuckDB analytics query timed out after %ds: %.200s", _QUERY_TIMEOUT_SECONDS, sql)
        return []
    except Exception:
        logger.exception("DuckDB analytics query failed: %.200s", sql)
        return []


async def execute_user_query(
    sql: str,
    max_rows: int = 1000,
    timeout_seconds: int = 10,
) -> tuple[list[str], list[tuple], str | None, float]:
    """
    Run an admin-submitted SQL query.

    Returns (columns, rows, error, elapsed_ms).
    - columns: list of column name strings (empty on error)
    - rows: list of value tuples, capped at max_rows
    - error: error message string, or None on success
    - elapsed_ms: wall-clock query time in milliseconds
    """
    assert sql, "sql must not be empty"
    assert 1 <= max_rows <= 10_000, f"max_rows must be 1–10000, got {max_rows}"
    assert 1 <= timeout_seconds <= 60, f"timeout_seconds must be 1–60, got {timeout_seconds}"

    if _conn is None:
        return [], [], "Analytics database is not available.", 0.0

    def _run() -> tuple[list[str], list[tuple], str | None, float]:
        _check_and_reopen()
        conn = _conn
        if conn is None:
            return [], [], "Analytics database is not available.", 0.0
        t0 = time.monotonic()
        cur = conn.cursor()
        try:
            rel = cur.execute(sql)
            cols = [d[0] for d in rel.description]
            rows = rel.fetchmany(max_rows)
            elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
            return cols, rows, None, elapsed_ms
        except Exception as exc:
            elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
            return [], [], str(exc), elapsed_ms
        finally:
            cur.close()

    try:
        return await asyncio.wait_for(
            asyncio.to_thread(_run),
            timeout=timeout_seconds,
        )
    except TimeoutError:
        return [], [], f"Query timed out after {timeout_seconds}s.", 0.0
