# SPDX-FileCopyrightText: 2022 Chris Heywood
#
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

from gi.repository import Gio, GLib

import itertools
import logging
import os
import sqlite3
from threading import current_thread, Lock
from typing import Optional

from iotas.config_manager import ConfigManager


class DbCursor:
    """
    Database cursor.
    """

    def __init__(self, obj: Database, commit: bool = False) -> None:
        self.__obj = obj
        self.__commit = commit
        self.__cursor: Optional[sqlite3.Connection] = None

    def __enter__(self) -> sqlite3.Connection:
        thread_id = current_thread().name
        app = Gio.Application.get_default()
        # App can be None for unit tests
        if app and thread_id in app.cursors.keys():
            cursor = app.cursors[thread_id]
            return cursor
        else:
            cursor = self.__obj.get_cursor()
            self.__cursor = cursor
            return cursor

    def __exit__(self, type, value, traceback) -> None:
        if self.__cursor is not None:
            if self.__commit:
                self.__obj.acquire_lock()
                self.__cursor.commit()
                self.__obj.release_lock()
            self.__cursor.close()
        self.__cursor = None


class DbLock:
    """
    Database lock.
    """

    def __init__(self) -> None:
        self.__lock = Lock()
        self.__locked = False

    def acquire(self) -> None:
        """Acquire lock."""
        self.__locked = True
        self.__lock.acquire()

    def release(self) -> None:
        """Release lock."""
        self.__locked = False
        self.__lock.release()

    @property
    def locked(self) -> bool:
        return self.__locked


class Database:
    """
    Base database object.
    """

    DATA_SUBPATH = "iotas"
    FILENAME = "notes.db"
    VERSION = 3

    __fts_creation = """
    CREATE VIRTUAL TABLE note_fts_idx
    USING fts5(title, note_content, content='note', content_rowid='id');

    CREATE TRIGGER note_ai AFTER INSERT ON note BEGIN
      INSERT INTO note_fts_idx(rowid, title, note_content)
      VALUES (new.id, new.title, new.content);
    END;
    CREATE TRIGGER note_ad AFTER DELETE ON note BEGIN
      INSERT INTO note_fts_idx(note_fts_idx, rowid, title, note_content)
      VALUES('delete', old.id, old.title, old.content);
    END;
    CREATE TRIGGER note_au AFTER UPDATE ON note BEGIN
      INSERT INTO note_fts_idx(note_fts_idx, rowid, title, note_content)
      VALUES('delete', old.id, old.title, old.content);
      INSERT INTO note_fts_idx(rowid, title, note_content)
      VALUES (new.id, new.title, new.content);
    END;
    """

    __create_script = f"""
    CREATE TABLE note (id INTEGER PRIMARY KEY,
                       title TEXT NOT NULL,
                       content TEXT NOT NULL,
                       excerpt TEXT NOT NULL,
                       last_modified INT NOT NULL,
                       favourite BOOLEAN NOT NULL,
                       category TEXT NOT NULL,
                       remote_id INT NOT NULL,
                       dirty BOOLEAN NOT NULL,
                       locally_deleted BOOLEAN NOT NULL,
                       etag TEXT,
                       read_only BOOLEAN NOT NULL DEFAULT false,
                       failed_pushes TEXT);
    CREATE index idx_note_last_modified ON note(last_modified);
    CREATE index idx_note_remote_id ON note(remote_id);

    {__fts_creation}
    """

    # A migration is a tuple comprising the query and whether we want to resync from the server
    __migrations = {}
    __migrations[1] = ("ALTER TABLE note ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT false", True)
    __second_migration = f"""{__fts_creation}
    INSERT INTO note_fts_idx (rowid, title, note_content)
    SELECT id, title, content FROM note;
    """
    __migrations[2] = (__second_migration, True)
    __migrations[3] = ("ALTER TABLE note ADD COLUMN failed_pushes TEXT", False)

    def __init__(self, custom_directory: Optional[str] = None) -> None:
        self.__thread_lock = DbLock()

        # Allow for custom directory for testing
        if custom_directory:
            self.__db_directory = custom_directory
        else:
            self.__db_directory = os.path.join(GLib.get_user_data_dir(), self.DATA_SUBPATH)
        self.__db_path = os.path.join(self.__db_directory, self.FILENAME)

        f = Gio.File.new_for_path(self.__db_path)
        if not f.query_exists():
            try:
                d = Gio.File.new_for_path(self.__db_directory)
                if not d.query_exists():
                    d.make_directory_with_parents()
                with DbCursor(self, True) as sql:
                    sql.executescript(self.__create_script)
                    sql.execute("PRAGMA user_version=%s" % self.VERSION)
            except Exception as e:
                logging.error("DB init error: {}".format(e))
                raise (e)
        else:
            current_version = self.__get_version()
            if current_version < self.VERSION:
                self.__run_migrations(current_version)
            elif current_version > self.VERSION:
                versions = f"v{current_version} > v{self.VERSION}"
                logging.warning(f"Database schema version is newer than expected ({versions})")

    def acquire_lock(self):
        self.__thread_lock.acquire()

    def release_lock(self):
        self.__thread_lock.release()

    def execute(self, request: str) -> list:
        """Execute a query

        :param str request: The request
        :return: A list of ids, if applicable
        :rtype: list
        """
        try:
            ids = []
            db = Gio.Application.get_default().db
            with DbCursor(db) as sql:
                result = sql.execute(request)
                ids += list(itertools.chain(*result))
                return ids
        except Exception as e:
            logging.error("DB execute error: {} for {}".format(e, request))
        return []

    def get_cursor(self) -> sqlite3.Connection:
        """Get a cursor.

        :return: The cursor
        :rtype: sqlite3.Connection
        """
        return sqlite3.connect(self.__db_path)

    def trash(self) -> None:
        """Trash the database, remove the file."""
        os.remove(self.__db_path)

    def __get_version(self) -> int:
        request = "PRAGMA user_version"
        try:
            version = None
            with DbCursor(self) as sql:
                result = sql.execute(request)
                row = result.fetchone()
                if isinstance(row, tuple) and len(row) == 1:
                    version = row[0]
        except Exception as e:
            logging.error("DB execute error: {} for {}".format(e, request))
        if not version:
            logging.warning("Didn't get version from DB, using 1")
            version = 1
        return version

    def __set_version(self, version: int) -> None:
        if not isinstance(version, int):
            return
        request = f"PRAGMA user_version = {version}"
        try:
            with DbCursor(self) as sql:
                sql.execute(request)
        except Exception as e:
            logging.error("DB execute error: {} for {}".format(e, request))

    def __run_migrations(self, current_version: int) -> None:
        logging.info(
            "Running database schema migration from v{} to v{}".format(
                current_version, self.VERSION
            )
        )
        resync_requested = False
        in_error = False
        while current_version != self.VERSION:
            request, resync = self.__migrations[current_version + 1]
            try:
                with DbCursor(self) as sql:
                    sql.executescript(request)
            except Exception as e:
                logging.error("DB execute error: {} for {}".format(e, request))
                in_error = True
                break
            current_version += 1
            if resync:
                resync_requested = True

        if resync_requested:
            ConfigManager.get_default().nextcloud_prune_threshold = 0

        if not in_error:
            self.__set_version(current_version)
