from contextlib import closing
from functools import wraps
from pathlib import Path
import sqlite3 as sql3
from string.templatelib import Template
from typing import Any, Literal, overload
import typing
from urllib.parse import parse_qs, urlsplit, urlunsplit
from weakref import WeakValueDictionary

import cattrs

from coolqlite._results import QueryResults, Row
from coolqlite._util import UNDEFINED, _Undef, _last
from coolqlite.from_sql import configure_converter
from coolqlite.to_sql import ToSqlFnRegistry
from coolqlite._common import (
    _DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK,
    RowFactory,
    SqliteType,
)
from coolqlite.experimental.transactions import AdvancedSavepoint
from coolqlite.query import BuiltQuery, analyze_query, _build_query
from coolqlite.query._common import AnalyzedQuery
from coolqlite.transactions import (
    _SavepointStatus,
    IncorrectTransactionControlError,
    Savepoint,
)


def _conv_factory[T](converter: cattrs.BaseConverter, target: type[T]) -> RowFactory[T]:
    hook = converter.get_structure_hook(target)

    @wraps(hook)
    def hook_as_row_factory(cursor: sql3.Cursor, data: tuple[Any, ...]) -> T:
        row = Row(cursor, data)
        return hook(row, target)

    return hook_as_row_factory


def _column_conv_factory[T](
    converter: cattrs.BaseConverter, target: type[T]
) -> RowFactory[T]:
    hook = converter.get_structure_hook(target)

    @wraps(hook)
    def hook_as_column_row_factory(_cursor: sql3.Cursor, data: tuple[Any, ...]) -> T:
        return hook(data[0], target)

    return hook_as_column_row_factory


class Connection[RowType = Row]:
    """A connection to a sqlite database.

    Connect with :func:`connect`, not the constructor.

    It is generic over the RowType configured with :func:`connect`,
    which defaults to :class:`Row`.

    .. TODO:: More comprehensive Connection docs?
       Or should that be at the module level / in prose?
    """

    def __init__(
        self,
        private,
        connec: sql3.Connection,
        to_sql_fns: ToSqlFnRegistry,
        converter: cattrs.BaseConverter,
    ):
        assert private is _DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK
        self._connec = connec
        self._to_sql_fns = to_sql_fns
        # self._savepoint_stack: list[str] = []
        self._savepoints: WeakValueDictionary[str, Savepoint] = WeakValueDictionary()
        self._conv = converter

    # TODO: actually, no row factories yet.
    # Let someone ask for it.

    # If type is specified, that's what you get.
    @overload
    def query[T](
        self, query: Template | AnalyzedQuery | BuiltQuery, type_: type[T], /
    ) -> QueryResults[T]: ...
    @overload
    def query(
        self, query: Template | AnalyzedQuery | BuiltQuery, /
    ) -> QueryResults[RowType]: ...

    def query[T](
        self,
        query: Template | AnalyzedQuery | BuiltQuery,
        type_: type[T] | Literal[_Undef.UNDEFINED] = _Undef.UNDEFINED,
        /,
    ) -> QueryResults[Any]:
        """Run the given query."""

        row = None if type_ is _Undef.UNDEFINED else _conv_factory(self._conv, type_)

        return self._inner_query(query, row)

    # TODO: can we just expose this instead???
    # so if you wanna customize a row factory you can,
    # but in its own method.
    # TODO: _maybe_ we can get typing guarantees here.
    def _inner_query(
        self,
        query: Template | AnalyzedQuery | BuiltQuery,
        row: RowFactory | None = None,
    ) -> QueryResults[Any]:
        # note: row = None is not the same as in sqlite3,
        # it means we ignore it instead of making it return a tuple.

        qb = query if isinstance(query, BuiltQuery) else self.build(query)

        cursor = self._connec.cursor()

        if row is not None:
            # pyright is pywrong: it thinks the second arg to a row factory is always a Row
            cursor.row_factory = row  # pyright: ignore[reportAttributeAccessIssue]

        cursor.execute(qb.query_str, qb._converted_dict())

        return QueryResults(_DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK, cursor)

    @overload
    def colquery(
        self, query: Template | AnalyzedQuery | BuiltQuery, /
    ) -> QueryResults[SqliteType]: ...
    @overload
    def colquery[T](
        self,
        query: Template | AnalyzedQuery | BuiltQuery,
        type_: type[T],
        /,
    ) -> QueryResults[T]: ...

    def colquery(
        self,
        query: Template | AnalyzedQuery | BuiltQuery,
        type_: Any = UNDEFINED,
        /,
    ) -> QueryResults[Any]:
        match type_:
            case _Undef.UNDEFINED:
                target_type = SqliteType
            case other:
                target_type = other
        # for some reason, pyright doesn't like the below
        # target_type = SqliteType if type_ is UNDEFINED else type_
        # TODO: shared "inner query" function so we don't have to double-check
        return self._inner_query(
            query, row=_column_conv_factory(self._conv, target_type)
        )

    # TODO: return rowcount? Maybe lastrowid or both?
    def run(self, query: Template | AnalyzedQuery | BuiltQuery):
        """
        Run a sql statement without looking at the results.
        """
        qb = self.build(query) if not isinstance(query, BuiltQuery) else query
        with closing(self._connec.cursor()) as cursor:
            cursor.row_factory = None
            cursor.execute(
                qb.query_str,
                {key: value.converted_value for key, value in qb.params.items()},
            )

    @property
    def user_version(self) -> int:
        """Access to the ``user_version`` sqlite pragma.

        You can use this to manage migrations in a simple fashion:

        .. code-block:: python

           MIGRATIONS = [file.read_text() for file in MIGRATIONS_DIR.iterdir()]
           with db:
               for migration in MIGRATIONS[db.user_version:]:
                   db.run_script(migration)

               db.user_version = len(MIGRATIONS)

        """
        with closing(self._connec.execute("PRAGMA user_version")) as q:
            return int(q.fetchone()[0])

    @user_version.setter
    def user_version(self, version: int):
        # we don't allow subclasses either. could be str() shenanigans.
        if type(version) is not int:
            raise TypeError("version must be an int")

        # TODO: this wouldn't work with autocommit=False.
        # The _config_ version would.
        # But we dont'support autocommit=False anyway?
        self._connec.execute(f"PRAGMA user_version = {version}").close()

    def close(self):
        """Close the connection to the database.
        This will roll back any currently active transaction.

        Note that using the Connection as a context manager *will not*
        close it at the end, since context management is used
        for transactions.

        Like the stdlib suggests, you can instead use :func:`contextlib.closing` :

        .. code-block:: python

           from contextlib import closing
           from coolqlite import connect

           with closing(connect(":memory:")) as db:
               with db: # transaction!
                   ... # do stuff here

        """
        self._connec.close()

    # TODO: we should force _all_ to be templates for safety,
    # even this. Just disallow any actual substitutions.
    # TODO: when do you want the cursor?
    def RUN_SCRIPT_DONT_USE_TEMPLATES(self, script: str):
        """Execute multiple statements.
        Does not give access to the cursor.

        .. warning:: This may be removed or reworked in the future;
           I'm unsure about its design.
        """
        self._connec.executescript(script).close()

    def build(self, query: Template | AnalyzedQuery) -> BuiltQuery:
        """Build the query, converting its values.

        See :mod:`coolqlite.query` for terminology,
        and :func:`coolqlite.query.build_query` for details.
        """
        analyzed = query if isinstance(query, AnalyzedQuery) else analyze_query(query)
        return _build_query(analyzed, self._to_sql_fns)

    def with_savepoint(self) -> Savepoint:
        """Create a new savepoint and a context manager for it.

        See the :mod:`coolqlite.transactions` module docs
        and :class:`~coolqlite.transactions.Savepoint` for details.
        """
        savepoint_number = len(self._savepoints)
        savepoint_id = f"_CQL_save_{savepoint_number}"
        self._connec.execute(f"savepoint {savepoint_id}")
        latest_savepoint = _last(self._savepoints.values(), None)
        self._savepoints[savepoint_id] = savepoint = Savepoint(
            _DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK,
            savepoint_id,
            self,
            latest_savepoint,
        )
        return savepoint

    def with_EXPERIMENTAL_SAVEPOINT(self) -> AdvancedSavepoint:
        savepoint_number = len(self._savepoints)
        savepoint_id = f"_CQL_save_{savepoint_number}"
        self._connec.execute(f"savepoint {savepoint_id}")
        latest_savepoint = _last(self._savepoints.values(), None)
        self._savepoints[savepoint_id] = savepoint = AdvancedSavepoint(  # type: ignore
            _DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK,
            savepoint_id,
            self,
            # TODO: intermingling regular and advanced?
            latest_savepoint,  # type: ignore
        )
        return savepoint

    def _invalidate_all_savepoints_due_to_on_conflict_rollback(self):
        if self._connec.in_transaction:
            raise IncorrectTransactionControlError(
                "Tried to invalidate all savepoints due to conflict "
                "but we're still in a transaction"
            )
        for savepoint in self._savepoints.values():
            savepoint._status = _SavepointStatus.DEAD

    def _release(self, savepoint_id: str):
        self._connec.execute(f"release {savepoint_id}").close()
        self._savepoints.pop(savepoint_id)

    def _rollback_to_before(self, savepoint_id: str):
        with closing(self._connec.cursor()) as c:
            c.execute(f"rollback to {savepoint_id}")
            c.execute(f"release {savepoint_id}")
        self._savepoints.pop(savepoint_id)

    def _rollback_to_after(self, savepoint_id: str):
        latest_txn_id = _last(self._savepoints.keys())
        if latest_txn_id != savepoint_id:
            raise IncorrectTransactionControlError(
                f"Tried to rollback to after {savepoint_id} but the"
                f"innermost savepoint id was {latest_txn_id}"
            )
        self._connec.execute(f"rollback to {savepoint_id}").close()

    # TODO: hide until we can force a template string and insist on no params.
    # TODO: well, except for DANGER passthroughs.
    # def query_posn(
    #     self,
    #     query: str,
    #     # technically not sequence but I can't get ty to behave
    #     params: Sequence = tuple(),
    # ) -> Cursor[RowType]:
    #     """
    #     Query using positional params. Does not do
    #     """
    #     return Cursor(self._connec.execute(query, params))

    # TODO: need to figure out conversion strategy
    # def query_many(
    #     self, query: str, params: Iterable[Mapping[str, Any]] | Iterable[Sequence]
    # ):
    #     return Cursor(self._connec.executemany(query, params))


@overload
def connect[RowType = Row](
    database: str,
    *,
    uri: bool = False,
    readonly: Literal[True] | None = None,
    row_factory: RowFactory[RowType] = Row,
    to_sql_fns: ToSqlFnRegistry | None = None,
    converter: cattrs.BaseConverter | None = None,
) -> Connection[RowType]: ...
@overload
def connect[RowType = Row](
    database: Path,
    *,
    readonly: Literal[True] | None = None,
    row_factory: RowFactory[RowType] = Row,
    to_sql_fns: ToSqlFnRegistry | None = None,
    converter: cattrs.BaseConverter | None = None,
) -> Connection[RowType]: ...


def connect[RowType = Row](
    database: str | Path,
    *,
    uri: bool = False,
    readonly: Literal[True] | None = None,
    row_factory: RowFactory[RowType] = Row,
    to_sql_fns: ToSqlFnRegistry | None = None,
    converter: cattrs.BaseConverter | None = None,
) -> Connection[RowType]:
    """
    Connect to a sqlite database.

    Works similarly to :func:`sqlite3.connect`,
    but has fewer flags exposed for the sake of good-practices defaults.

    See package docs for details.

    :param database: The path to the sqlite db file,
                     or you can use ``":memory:"`` for an in-memory db.
    :type database: str | Path
    :param row_factory: A custom :type:`RowFactory`, defaults to :class:`Row`.
    :type row_factory: RowFactory
    :param to_sql_fns: The registry of sql serialization functions.
                       If unspecified / None, uses the defaults for
                       :class:`~coolqlite.to_sql.ToSqlConfig` .
    :type to_sql_fns: ToSqlFnRegistry | None
    :return: The connection to the database,
             with the correct Row type as per ``row_factory`` .
    :rtype: Connection
    """
    return Connection(
        _DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK,
        _sql3_connect(database, uri=uri, readonly=readonly, row_factory=row_factory),
        to_sql_fns=ToSqlFnRegistry() if to_sql_fns is None else to_sql_fns,
        converter=converter if converter is not None else configure_converter(),
    )


def _sql3_connect(
    database: str | Path,
    *,
    uri: bool,
    readonly: Literal[True] | None,
    row_factory: RowFactory,
) -> sql3.Connection:
    """Set up a connection with autocommit=True, foreign keys on,
    and the row factory set up.
    """
    match (database, uri, readonly):
        case (Path(), True, _):
            raise TypeError("Cannot specify uri=True for a Path")
        case (Path(), False, ro):
            uri_str = f"file:{database.as_posix()}" + "?mode=ro" if ro else ""
        case (str(), False, ro):
            uri_str = f"file:{database}" + "?mode=ro" if ro else ""
        case (str(), True, True):
            parsed = urlsplit(database)
            qs = parse_qs(parsed.query)
            if "mode" in qs:
                raise ValueError(
                    "Cannot specify readonly=True when mode is already in the URI"
                )
            qs["mode"] = ["ro"]
            uri_str = urlunsplit(parsed._replace(query=qs))
        case (str(), True, None):
            uri_str = database
        case unreachable:
            typing.assert_never(unreachable)
    connection = sql3.connect(uri_str, uri=True, autocommit=True)
    connection.execute("PRAGMA journal_mode=WAL").close()
    connection.execute("PRAGMA foreign_keys = ON").close()
    connection.row_factory = row_factory  # pyright: ignore[reportAttributeAccessIssue]
    return connection
