import sqlite3
import threading
from collections.abc import Sequence
from types import TracebackType
from typing import Any, TypeAlias, cast

# Prevent multiple access to the database (SQLite limitation). This must cover the entire
# connection lifetime, not just individual statements: SQLite holds a transaction's locks
# from its first statement all the way through the commit, so guarding only execute() would
# leave the commit in db_cursor.__exit__ free to race with another connection and fail with
# "database is locked".
DB_LOCK = threading.RLock()

# How long to wait for DB_LOCK before concluding something is deadlocked. The lock is held
# for a whole transaction, and a thread holding it can be delayed by an unrelated CPU-bound
# thread hogging the GIL, so this is deliberately generous.
DB_LOCK_TIMEOUT_SECONDS = 30

DBResult: TypeAlias = dict[str, Any]
DBResults: TypeAlias = list[DBResult]
DBCondition: TypeAlias = tuple[str, Any]
DBConditionsDict: TypeAlias = dict[str, Any]
DBUpdateDict: TypeAlias = dict[str, Any]
DBParams: TypeAlias = Sequence[Any]


class db_cursor(object):
    """Context manager providing a cursor for a single database transaction.

    DB_LOCK is held for the whole block, from connecting through the commit, so that one
    transaction's SQLite locks can never overlap another connection's.

    Since that lock is global and guards every database access in the process, code inside the
    block must not suspend. In particular, never yield from inside one of these blocks: the lock
    would stay held until the generator is resumed or garbage collected, and if the caller
    abandons the generator part way through, every other database access blocks until then.

    For the same reason, keep the block short and do not let the cursor outlive it; the
    connection is closed on exit.
    """

    def __init__(self, db_path: str):
        self.db_path = db_path
        self.db_conn: sqlite3.Connection = None

    def __enter__(self) -> sqlite3.Cursor:
        if not DB_LOCK.acquire(timeout=DB_LOCK_TIMEOUT_SECONDS):  # pylint: disable=consider-using-with
            raise RuntimeError(f"Database is busy. Not opening {self.db_path}")

        try:
            self.db_conn = sqlite3.connect(self.db_path)
            return self.db_conn.cursor()
        except BaseException:
            # __exit__ is not called when __enter__ raises, so the lock must be released here
            # or it would be held forever.
            DB_LOCK.release()
            raise

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        try:
            try:
                if exc_type is None:
                    self.db_conn.commit()
                else:
                    self.db_conn.rollback()
            finally:
                self.db_conn.close()
        finally:
            DB_LOCK.release()


def cursor_execute(cursor: sqlite3.Cursor, query: str, params: DBParams | None = None) -> sqlite3.Cursor:
    """Execute a SQL query. The cursor must come from db_cursor, which holds DB_LOCK for the
    whole transaction and so serializes all database access."""
    return cursor.execute(query, params or ())


def db_insert(db_path: str, table: str, fields: DBUpdateDict) -> int:
    columns = ", ".join(list(fields.keys()))
    placeholders = ("?, " * len(fields))[:-2]
    field_values = tuple(fields.values())
    with db_cursor(db_path) as cursor:
        cursor_execute(
            cursor,
            "insert into {0}({1}) values ({2})".format(table, columns, placeholders),
            field_values,
        )
        inserted_id = cursor.lastrowid
    return cast(int, inserted_id)


def db_update(db_path: str, table: str, updated_fields: DBUpdateDict, conditions: DBConditionsDict) -> sqlite3.Cursor:
    """Update `table` with the values given in the dict `values` on the
    condition given with the `row` tuple.
    """
    columns = "=?, ".join(list(updated_fields.keys())) + "=?"
    field_values = tuple(updated_fields.values())

    condition_field = " AND ".join(["%s=?" % field for field in conditions])
    condition_value = tuple(conditions.values())

    with db_cursor(db_path) as cursor:
        query = "UPDATE {0} SET {1} WHERE {2}".format(table, columns, condition_field)
        result = cursor_execute(cursor, query, field_values + condition_value)
    return result


def db_delete(db_path: str, table: str, field: str, value: Any) -> None:
    with db_cursor(db_path) as cursor:
        cursor_execute(cursor, "delete from {0} where {1}=?".format(table, field), (value,))


def db_select(
    db_path: str, table: str, fields: Sequence[str] | None = None, condition: DBCondition | None = None
) -> DBResults:
    if fields:
        columns = ", ".join(fields)
    else:
        columns = "*"
    with db_cursor(db_path) as cursor:
        query = "SELECT {} FROM {}"
        if condition:
            condition_field, condition_value = condition
            if isinstance(condition_value, (list, tuple, set)):
                condition_value = tuple(condition_value)
                placeholders = ", ".join("?" * len(condition_value))
                where_condition = " where {} in (" + placeholders + ")"
            else:
                condition_value = (condition_value,)
                where_condition = " where {}=?"
            query = query + where_condition
            query = query.format(columns, table, condition_field)
            params = condition_value
        else:
            query = query.format(columns, table)
            params = ()
        cursor_execute(cursor, query, params)
        rows = cursor.fetchall()
        column_names = [column[0] for column in cursor.description]
    results = []
    for row in rows:
        row_data = {}
        for index, column in enumerate(column_names):
            row_data[column] = row[index]
        results.append(row_data)
    return results


def db_query(db_path: str, query: str, params: DBParams = ()) -> DBResults:
    with db_cursor(db_path) as cursor:
        cursor_execute(cursor, query, params)
        rows = cursor.fetchall()
        column_names = [column[0] for column in cursor.description]
    results = []
    for row in rows:
        row_data = {}
        for index, column in enumerate(column_names):
            row_data[column] = row[index]
        results.append(row_data)
    return results


def add_field(db_path: str, tablename: str, field: dict[str, str]) -> None:
    query = "ALTER TABLE %s ADD COLUMN %s %s" % (
        tablename,
        field["name"],
        field["type"],
    )
    with db_cursor(db_path) as cursor:
        cursor.execute(query)


def _create_filter(field: str, value: Any, params: list[Any], negate: bool = False) -> str:
    """Creates a filter to match a field to a value, or to a list of
    values. None can be used as well, to make NULL."""
    also_null = False
    if hasattr(value, "__iter__") and not isinstance(value, str):
        values = list(value)

        if None in values:
            values.remove(None)
            also_null = True
    elif value is None:
        also_null = True
        values = []
    else:
        values = [value]

    if len(values) == 0:
        if negate:
            if also_null:
                return f"{field} IS NOT NULL"
            else:
                return "1 = 1"
        else:
            if also_null:
                return f"{field} IS NULL"
            else:
                return "1 = 0"

    if len(values) == 1:
        params.append(values[0])
        sql = f"{field} != ?" if negate else f"{field} = ?"
    else:
        sql = f"{field} NOT IN (" if negate else f"{field} IN ("
        for i, v in enumerate(values):
            params.append(v)
            if i > 0:
                sql += ", "
            sql += "?"
        sql += ")"

    if also_null:
        if negate:
            return f"({field} IS NOT NULL AND {sql})"
        else:
            return f"({field} IS NULL OR {sql})"
    else:
        if negate:
            return f"({field} IS NULL OR {sql})"
        return sql


def filtered_query(
    db_path: str,
    table: str,
    searches: dict[str, str] | None = None,
    filters: DBConditionsDict | None = None,
    excludes: DBConditionsDict | None = None,
    sorts: Sequence[str] | None = None,
) -> DBResults:
    searches = searches or {}
    filters = filters or {}
    excludes = excludes or {}
    query = "select * from %s" % table
    params = []
    sql_filters = []
    for field in searches:
        sql_filters.append("%s LIKE ?" % field)
        params.append("%" + searches[field] + "%")
    for field in filters:
        sql_filters.append(_create_filter(field, filters[field], params))
    for field in excludes or {}:
        sql_filters.append(_create_filter(field, excludes[field], params, negate=True))
    if sql_filters:
        query += " WHERE " + " AND ".join(sql_filters)
    if sorts:
        query += " ORDER BY %s" % ", ".join(["%s %s" % (sort[0], sort[1]) for sort in sorts])
    else:
        query += " ORDER BY slug ASC"
    return db_query(db_path, query, tuple(params))
