from __future__ import annotations

import os
import socket
import sqlite3
import subprocess
import sys
from pathlib import Path
from typing import Any

try:
    import fcntl
except ImportError:  # pragma: no cover - non-Unix platforms
    fcntl = None  # type: ignore

import threading

from brawny.logging import get_logger
from brawny.model.errors import DatabaseError

logger = get_logger("brawny.db.sqlite")


def connect(db: Any) -> None:
    if db._closed:
        raise DatabaseError("Database is closed")
    if db._connected:
        return

    lock_acquired = False
    conn = None
    try:
        if db._database_path != ":memory:":
            path = Path(db._database_path)
            path.parent.mkdir(parents=True, exist_ok=True)
            _assert_local_filesystem(path)
            _acquire_db_lock(db, path)
            lock_acquired = True

        db._closed = False
        conn = _open_connection(db)
        db._connected = True
        journal_mode_row = conn.execute("PRAGMA journal_mode").fetchone()
        journal_mode = journal_mode_row[0] if journal_mode_row else "unknown"
        logger.info("sqlite.journal_mode", mode=str(journal_mode).lower())
    except Exception:
        if conn is not None:
            try:
                conn.close()
            except Exception:
                pass
            with db._conns_lock:
                db._conns.discard(conn)
            _clear_thread_local_conn(db)
        db._connected = False
        if lock_acquired and db._lock_handle is not None:
            try:
                if fcntl is not None:
                    fcntl.flock(db._lock_handle.fileno(), fcntl.LOCK_UN)
            finally:
                db._lock_handle.close()
                db._lock_handle = None
                if db._lock_path is not None:
                    try:
                        db._lock_path.unlink(missing_ok=True)
                    except OSError:
                        pass
                    db._lock_path = None
        raise


def close(db: Any) -> None:
    db._closed = True
    db._connected = False
    with db._conns_lock:
        for conn in list(db._conns):
            try:
                conn.close()
            except Exception:
                pass
        db._conns.clear()
        db._conn_generation += 1
        db._memory_owner_thread_id = None
    _clear_thread_local_conn(db)

    if db._lock_handle is not None:
        try:
            if fcntl is not None:
                fcntl.flock(db._lock_handle.fileno(), fcntl.LOCK_UN)
        finally:
            db._lock_handle.close()
            db._lock_handle = None
            if db._lock_path is not None:
                try:
                    db._lock_path.unlink(missing_ok=True)
                except OSError:
                    pass
                db._lock_path = None


def is_connected(db: Any) -> bool:
    return db._connected and not db._closed


def ensure_connected(db: Any) -> sqlite3.Connection:
    if db._closed:
        raise DatabaseError("Database is closed")
    if not db._connected:
        raise DatabaseError("Database not connected. Call connect() first.")
    conn = getattr(db._thread_local, "conn", None)
    generation = getattr(db._thread_local, "generation", None)
    if conn is not None and generation == db._conn_generation:
        return conn
    return _open_connection(db)


def _open_connection(db: Any) -> sqlite3.Connection:
    if db._database_path == ":memory:":
        current_thread_id = threading.get_ident()
        owner_thread_id = db._memory_owner_thread_id
        if owner_thread_id is None:
            db._memory_owner_thread_id = current_thread_id
        elif owner_thread_id != current_thread_id:
            raise DatabaseError(
                "SQLite in-memory databases cannot be shared across threads. "
                f"path={db._database_path} owner_thread_id={owner_thread_id} "
                f"current_thread_id={current_thread_id}"
            )
    conn = sqlite3.connect(
        db._database_path,
        detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
        check_same_thread=True,
        timeout=30.0,
    )
    conn.row_factory = sqlite3.Row
    _assert_minimum_sqlite_version(db)
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("PRAGMA synchronous = NORMAL")
    conn.execute("PRAGMA busy_timeout = 5000")
    conn.execute("PRAGMA temp_store = MEMORY")
    db._thread_local.conn = conn
    db._thread_local.generation = db._conn_generation
    with db._conns_lock:
        db._conns.add(conn)
    return conn


def _clear_thread_local_conn(db: Any) -> None:
    if isinstance(db._thread_local, threading.local):
        if hasattr(db._thread_local, "conn"):
            try:
                del db._thread_local.conn
            except Exception:
                pass
        if hasattr(db._thread_local, "generation"):
            try:
                del db._thread_local.generation
            except Exception:
                pass


def _assert_minimum_sqlite_version(db: Any) -> None:
    if db._version_checked:
        return
    minimum = (3, 35, 0)
    current = sqlite3.sqlite_version_info
    if current < minimum:
        raise DatabaseError(
            "SQLite >= 3.35 is required (for RETURNING support); "
            f"found {sqlite3.sqlite_version}"
        )
    db._version_checked = True


def _acquire_db_lock(db: Any, db_path: Path) -> None:
    if fcntl is None:
        raise DatabaseError("SQLite runner lock requires fcntl (Unix-only).")

    lock_path = db_path.with_suffix(db_path.suffix + ".lock")
    lock_handle = lock_path.open("a+")
    try:
        fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError as exc:
        lock_handle.seek(0)
        existing = lock_handle.read().strip()
        lock_handle.close()
        detail = f" Existing lock: {existing}" if existing else ""
        raise DatabaseError(
            f"Database lock already held for {db_path}.{detail}"
        ) from exc

    lock_handle.seek(0)
    lock_handle.truncate()
    lock_handle.write(f"{socket.gethostname()}:{os.getpid()}\n")
    lock_handle.flush()
    db._lock_handle = lock_handle
    db._lock_path = lock_path


def _assert_local_filesystem(db_path: Path) -> None:
    fs_type = _detect_fs_type(db_path)
    if fs_type is None:
        logger.warning(
            "sqlite.fs_type_unknown",
            path=str(db_path),
            hint="Ensure the database is on a local filesystem (no NFS/SMB).",
        )
        return

    fs_type_lower = fs_type.lower()
    network_fs = {"nfs", "nfs4", "smbfs", "cifs", "afpfs", "fuse.sshfs", "sshfs"}
    if fs_type_lower in network_fs:
        raise DatabaseError(
            f"SQLite database must be on a local filesystem; detected {fs_type} at {db_path}"
        )


def _detect_fs_type(db_path: Path) -> str | None:
    try:
        if sys.platform == "darwin":
            output = subprocess.check_output(["stat", "-f", "%T", str(db_path)])
            return output.decode().strip()
        if sys.platform.startswith("linux"):
            output = subprocess.check_output(
                ["stat", "-f", "-c", "%T", str(db_path)]
            )
            return output.decode().strip()
    except Exception:
        return None
    return None
