#!/usr/bin/env python3

from collections import namedtuple
from contextlib import contextmanager
from datetime import datetime, timedelta
from hashlib import sha3_256
from importlib.resources import files
from inspect import signature
import logging
import sqlite3
from time import sleep
from types import GeneratorType, new_class
from typing import Optional

from aiosql.aiosql import (
    _make_driver_adapter as get_adapter,
)
from aiosql.queries import Queries
from aiosql.query_loader import QueryLoader


_sqlite = get_adapter("sqlite3")
_loader = QueryLoader(_sqlite, None)
_queries = Queries(_sqlite)
for f in files("tentacles.sql").iterdir():
    if f.is_file() and f.name.endswith(".sql"):
        print("Loading", f)
        with f.open() as fp:
            _queries.load_from_list(
                _loader.load_query_data_from_sql(fp.read(), [], fname=f)
            )


log = logging.getLogger(__name__)


def qfn(name, f):
    def _helper(_self, *args, **kwargs):
        res = f(_self._conn, *args, **kwargs)
        # Force lazy values for convenience
        if isinstance(res, GeneratorType):
            res = list(res)
        log.log(logging.TRACE, "%s (%r) -> %r", name, kwargs, res)
        return res

    _helper.__name__ = f.__name__
    _helper.__doc__ = f.__doc__
    _helper.__signature__ = signature(f)
    return _helper


def make_query_class(cls):
    for k in _queries.available_queries:
        cls[k] = qfn(k, getattr(_queries, k))

    cls["__all__"] = _queries.available_queries


Queries = new_class(
    "Queries",
    (),
    exec_body=make_query_class,
)


def one(it, *args, **kwargs):
    assert not args
    assert not kwargs
    return it


class StoreError(Exception):
    pass


class LoginError(StoreError):
    pass


class Db(Queries):
    def __init__(self, path):
        self._path = path
        self._conn: sqlite3.Connection = None
        self._cursor: sqlite3.Cursor = None

    def _factory(self, cursor, row):
        fields = [column[0] for column in cursor.description]
        cls = namedtuple("Row", fields)
        return cls._make(row)

    def connect(self):
        if not self._conn:
            self._conn = sqlite3.connect(self._path)
            self._conn.row_factory = self._factory
            self._conn.isolation_level = None  # Disable automagical transactions
            self._cursor = self._conn.cursor()

    def migrate(self):
        self.migration_0000_create_migrations()
        existing_migrations = {it.name for it in self.list_migrations()}
        for query in sorted(
            (
                q
                for q in _queries.available_queries
                if q.startswith("migration_") and q not in existing_migrations
            )
        ):
            log.warn("Applying migration %s", query)
            getattr(self, query)()
            digest = sha3_256()
            digest.update(getattr(_queries, query).sql.encode())
            self.record_migration(name=query, fingerprint=digest.hexdigest())

    def begin(self):
        self._conn.execute("BEGIN")

    def commit(self):
        if self._conn.in_transaction:
            self._conn.execute("COMMIT")

    def rollback(self):
        self._conn.execute("ROLLBACK")

    @property
    def cursor(self):
        return self._cursor

    def savepoint(self):
        """Create an explicit transaction context manager."""
        # Inspired by https://discuss.python.org/t/deprecate-the-sqlite3-context-manager-and-or-add-a-savepoint-based-context-manager/16664/9

        @contextmanager
        def _helper():
            try:
                self.begin()
                yield self
                exc = None
                for attempt in range(5):
                    try:
                        self.commit()
                        break
                    except sqlite3.OperationalError as e:
                        exc = e
                        if e.sqlite_errorcode == 6:
                            sleep(0.1 * attempt)
                            continue
                        else:
                            raise e
                else:
                    raise exc

            except sqlite3.Error:
                self.rollback()
                log.exception("Forced to roll back!")

        return _helper()

    def close(self):
        if self._cursor:
            self.commit()
            self._cursor.close()
            self._conn.close()
            self._conn = self._cursor = None

    ################################################################################
    # Wrappers for doing Python type mapping

    def create_key(self, *, uid: int, name: str, ttl: Optional[timedelta]):
        return super().create_key(
            uid=uid,
            name=name,
            expiration=((datetime.now() + ttl).isoformat() if ttl else None),
        )

    def try_login(
        self, *, username: str, password: str, ttl: timedelta
    ) -> Optional[str]:
        """Given a username and an (unsecured) password, attempt to authenticate the named user.

        If successful, return the ID of a new session/key for that user.

        """

        digest = sha3_256()
        digest.update(password.encode("utf-8"))
        res = super().try_login(username=username, hash=digest.hexdigest())
        if not res:
            return None

        return self.create_key(uid=res.id, name="web session", ttl=ttl)

    def try_create_user(
        self, *, username: str, email: str, password: str, gid: int = 1, sid: int = -3
    ):
        digest = sha3_256()
        digest.update(password.encode("utf-8"))
        return super().try_create_user(
            name=username,
            email=email,
            hash=digest.hexdigest(),
            gid=gid,
            sid=sid,
        )

    def refresh_key(self, *, kid: str, ttl: timedelta):
        """Automagically renew an API key which is still in use.

        Mostly intended for dealing with web sessions which should implicitly extend, but which use the same mechanism as API keys.

        """

        super().refresh_key(kid=kid, expiration=(datetime.now() + ttl).isoformat())

    def start_job(self, *, jid: int):
        super().start_job(jid=jid)
        super().update_job_status(jid=jid, status="running")

    def finish_job(self, *, jid: int, state: str, message: Optional[str] = None):
        super().finish_job(jid=jid, state=state, message=message)

    def create_filament(self, *, name: str):
        super().create_filament(name=name)
        return super().fetch_filament(name=name)
