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

import itertools
from typing import Optional

from iotas.backup_storage import BackupStorage
from iotas.category import Category, CategorySpecialPurpose
from iotas.category_storage import CategoryStorage
from iotas.database import Database, DbCursor
from iotas.note import Note, DirtyFields


class NoteDatabase(BackupStorage, CategoryStorage):

    def __init__(self, db_base: Database) -> None:
        self.__db_base = db_base

    def add_note(self, note: Note) -> None:
        """Add a new note to the database.

        :param Note note: Note to add
        """
        with DbCursor(self.__db_base, True) as sql:
            result = sql.execute(
                "INSERT INTO note (title, content, last_modified, remote_id,"
                "                  dirty, etag, locally_deleted, favourite,"
                "                  category, excerpt, read_only)"
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (
                    note.title,
                    note.content,
                    note.last_modified,
                    note.remote_id,
                    note.dirty,
                    note.etag,
                    False,
                    note.favourite,
                    note.category,
                    note.excerpt,
                    note.read_only,
                ),
            )
            note.id = result.lastrowid

    def populate_note_content(self, note: Note) -> None:
        """Load note content for provided note.

        :param Note note: Note to load for
        """
        if note.content_loaded:
            return
        if note.id < 0:
            return
        with DbCursor(self.__db_base) as sql:
            request = "SELECT content FROM note WHERE rowid=?"
            result = sql.execute(request, (note.id,))
            row = result.fetchone()
            if row is not None:
                note.content = row[0]

    def create_duplicate_note(self, note: Note, reason: str) -> Note:
        """Create a duplicate note with a prefixed title.

        :param Note note: The note to duplicate
        :param str reason: The reason for the duplication, which is prefixed on the title
        """
        new_note = note.duplicate()
        new_note.title = f"{reason} - {note.title}"
        new_note.flag_changed()
        self.add_note(new_note)
        return new_note

    def delete_all_clean_synced_notes(self) -> None:
        """Delete all notes which are on the server and don't have local updates."""
        with DbCursor(self.__db_base, True) as sql:
            sql.execute("DELETE FROM note WHERE remote_id <> -1 AND DIRTY=0")

    def delete_note(self, note_id: int) -> None:
        """Delete a note.

        :param int note_id: The note identifier (row id, not remote id)
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute("DELETE FROM note WHERE rowid=?", (note_id,))

    def get_all_notes(self, load_content: bool = False) -> list[Note]:
        """Fetch all notes from the database.

        :param bool load_content: Whether to load the content for each note
        :return: List of notes
        :rtype: list[Note]
        """
        with DbCursor(self.__db_base) as sql:
            request = (
                "SELECT title, excerpt, last_modified, favourite, category, remote_id,"
                "       dirty, etag, rowid, locally_deleted, read_only"
            )
            if load_content:
                request += ", content"
            request += " FROM note"
            result = sql.execute(request)
            return [NoteDatabase.db_row_to_note(row) for row in result]

    def get_all_categories(self) -> list[Category]:
        """Fetch all categories from the database.

        :return: List of categories
        :rtype: list[Category]
        """
        with DbCursor(self.__db_base) as sql:
            request = (
                "SELECT category, count() AS count "
                "FROM note "
                "WHERE locally_deleted=0 "
                "GROUP BY category "
                "ORDER BY LOWER(category)"
            )
            result = sql.execute(request)
            categories = [NoteDatabase.db_row_to_category(row) for row in result]
            total = 0
            uncategorised_found = False
            for category in categories:
                total += category.note_count
                if category.name == Category.UNCATEGORISED_TITLE:
                    uncategorised_found = True
            all_category = Category(Category.ALL_TITLE, total)
            all_category.special_purpose = CategorySpecialPurpose.ALL
            categories.insert(0, all_category)

            # Create an empty uncategorised entry if we don't have any uncategorised notes
            # (most commonly happening for new users)
            if not uncategorised_found:
                uncategorised_category = Category(Category.UNCATEGORISED_TITLE, 0)
                uncategorised_category.special_purpose = CategorySpecialPurpose.UNCATEGORISED
                categories.insert(1, uncategorised_category)

            return categories

    def get_all_notes_count(self, include_locally_deleted: bool = True) -> int:
        """Fetch the number of notes in the database.

        :param bool include_locally_deleted: Whether to include locally deleted notes in the count
        :return: The count
        :rtype: int
        """
        with DbCursor(self.__db_base) as sql:
            request = "SELECT count() FROM note"
            if not include_locally_deleted:
                request += " locally_deleted=0"
            result = sql.execute(request)
            row = result.fetchone()
            return row[0]

    def get_notes_by_ids(self, db_ids: list[int]) -> list[Note]:
        """Fetch notes from the database by db ids.

        :param list[int] db_ids: Database id of the note
        :return: The notes, in the same order as the provided ids
        :rtype: list[Note]
        """
        if len(db_ids) == 0:
            return []
        with DbCursor(self.__db_base) as sql:
            request = (
                "SELECT title, excerpt, last_modified, favourite, category, remote_id,"
                "       dirty, etag, rowid, locally_deleted, content "
                f"FROM note WHERE id IN ({','.join(['?']*len(db_ids))})"
            )
            result = sql.execute(request, db_ids)
            as_dict = {row[8]: NoteDatabase.db_row_to_note(row) for row in result}
            return [as_dict[note_id] for note_id in db_ids]

    def get_all_notes_remote_ids(self) -> list[int]:
        """Fetch remote ids for all remotely synced notes.

        :return: List of ids
        :rtype: list[int]
        """
        with DbCursor(self.__db_base) as sql:
            request = "SELECT remote_id FROM note WHERE remote_id <> -1"
            result = sql.execute(request)
            return list(itertools.chain(*result))

    def get_note_by_remote_id(self, remote_id: int) -> Optional[Note]:
        """Fetch note from the database by remote id.

        :param int remote_id: Remote id of the note
        :return: The note, or None
        :rtype: Optional[Note]
        """
        with DbCursor(self.__db_base) as sql:
            request = (
                "SELECT title, excerpt, last_modified, favourite, category, remote_id,"
                "       dirty, etag, rowid, locally_deleted, content "
                "FROM note WHERE remote_id=? "
            )
            result = sql.execute(request, (remote_id,))
            row = result.fetchone()
            if row is not None:
                i = NoteDatabase.db_row_to_note(row)
                return i
            return None

    def get_note_by_title(self, title: str) -> Optional[Note]:
        """Fetch note from the database by title.

        :param str title: Search title
        :return: The note, or None
        :rtype: Optional[Note]
        """
        with DbCursor(self.__db_base) as sql:
            request = (
                "SELECT title, excerpt, last_modified, favourite, category, remote_id,"
                "       dirty, etag, rowid, locally_deleted, content "
                "FROM note WHERE title=? "
            )
            result = sql.execute(request, (title,))
            row = result.fetchone()
            if row is not None:
                i = NoteDatabase.db_row_to_note(row)
                return i
            return None

    def get_note_failed_pushes(self, note: Note) -> Optional[str]:
        """Fetch note failed pushes from the database.

        :param Note note: The note
        :return: Failed pushes as JSON string, or None
        :rtype: str, optional
        """
        with DbCursor(self.__db_base) as sql:
            request = "SELECT failed_pushes FROM note WHERE id = ?"
            result = sql.execute(request, (note.id,))
            row = result.fetchone()
            if row is None:
                return None
            else:
                return row[0]

    def note_needs_update(self, remote_id: int, etag: str) -> bool:
        """Whether a note needs updating from a remote update.

        :param int remote_id: The remote id for the note
        :param str etag: The etag in the update
        :return: Whether an update is required
        :rtype: bool
        """
        with DbCursor(self.__db_base) as sql:
            request = "SELECT 1 FROM note WHERE remote_id=? AND etag<>?"
            result = sql.execute(
                request,
                (
                    remote_id,
                    etag,
                ),
            )
            return result.fetchone() is not None

    def persist_note_category(self, note: Note) -> None:
        """Persist category field for note to database.

        :param Note note: Note to update
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute(
                "UPDATE note SET dirty=?, category=? WHERE rowid=?",
                (note.dirty, note.category, note.id),
            )

    def persist_note_dirty(self, note: Note) -> None:
        """Persist dirty field for note to database.

        :param Note note: Note to update
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute(
                "UPDATE note SET dirty=? WHERE rowid=?",
                (note.dirty, note.id),
            )

    def persist_note_editable(self, note: Note) -> None:
        """Save editable fields for provided note to the database.

        Updates title, content, category, excerpt, last_modified, favourite and dirty

        :param Note note: Note to update
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute(
                "UPDATE note "
                "SET title=?, content=?, excerpt=?,"
                "    last_modified=?, favourite=?, dirty=?, "
                "    category=? "
                "WHERE rowid=?",
                (
                    note.title,
                    note.content,
                    note.excerpt,
                    note.last_modified,
                    note.favourite,
                    note.dirty,
                    note.category,
                    note.id,
                ),
            )

    def persist_note_favourite(self, note: Note) -> None:
        """Persist favourite field for note to database.

        :param Note note: Note to update
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute(
                "UPDATE note SET dirty=?, favourite=? WHERE rowid=?",
                (note.dirty, note.favourite, note.id),
            )

    def persist_note_etag(self, note: Note) -> None:
        """Persist etag field for note to database.

        :param Note note: Note to update
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute(
                "UPDATE note SET etag=? WHERE rowid=?",
                (note.etag, note.id),
            )

    def persist_note_locally_deleted(self, note: Note, value: bool) -> None:
        """Persist whether a note is locally deleted.

        :param Note note: The note to update
        :param bool value: The value to set
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute(
                "UPDATE note SET locally_deleted=?, dirty=1 WHERE rowid=?",
                (value, note.id),
            )

    def persist_note_remote_meta_and_sanitisable_fields(
        self, note: Note, clear_dirty: bool
    ) -> None:
        """Persist remote metadata and server sanitisable fields to database for provided note.

        :param Note note: Note to update
        :param bool clear_dirty: Whether to clear the dirty flag
        """
        with DbCursor(self.__db_base, True) as sql:
            request = "UPDATE note SET last_modified=?, remote_id=?, etag=?, title=?, category=?"
            request += ", failed_pushes=NULL"
            if clear_dirty:
                request += ", dirty=0"
            request += " WHERE rowid=?"
            sql.execute(
                request,
                (
                    note.last_modified,
                    note.remote_id,
                    note.etag,
                    note.title,
                    note.category,
                    note.id,
                ),
            )

    def persist_note_selective(self, note: Note, updated_fields: DirtyFields) -> None:
        """Persist local note based on remote changes.

        :param Note note: The note to update
        :param DirtyFields updated_fields: Which fields to update
        """
        if updated_fields.empty():
            return
        with DbCursor(self.__db_base, True) as sql:
            request = "UPDATE note SET"
            fields = []
            if updated_fields.title:
                request += " title=?"
                fields.append(note.title)
            if updated_fields.content:
                if len(fields) > 0:
                    request += ","
                request += " content=?"
                fields.append(note.content)
            if updated_fields.excerpt:
                if len(fields) > 0:
                    request += ","
                request += " excerpt=?"
                fields.append(note.excerpt)
            if updated_fields.favourite:
                if len(fields) > 0:
                    request += ","
                request += " favourite=?"
                fields.append(note.favourite)
            if updated_fields.category:
                if len(fields) > 0:
                    request += ","
                request += " category=?"
                fields.append(note.category)
            if updated_fields.etag:
                if len(fields) > 0:
                    request += ","
                request += " etag=?"
                fields.append(note.etag)
            if updated_fields.last_modified:
                if len(fields) > 0:
                    request += ","
                request += " last_modified=?"
                fields.append(note.last_modified)
            if updated_fields.read_only:
                if len(fields) > 0:
                    request += ","
                request += " read_only=?"
                fields.append(note.read_only)
            if updated_fields.remote_id:
                if len(fields) > 0:
                    request += ","
                request += " remote_id=?"
                fields.append(note.remote_id)
            if updated_fields.locally_deleted:
                if len(fields) > 0:
                    request += ","
                request += " locally_deleted=?"
                fields.append(note.locally_deleted)
            request += " WHERE id=?"
            fields.append(note.id)
            sql.execute(request, fields)

    def persist_note_failed_pushes(self, note: Note, failed_pushes: Optional[str]) -> None:
        """Persist note failed pushes to database.

        :param Note note: Note to update
        :param Optional[str] failed_pushes: Failed pushes, as JSON string
        """
        with DbCursor(self.__db_base, True) as sql:
            sql.execute(
                "UPDATE note SET failed_pushes=? WHERE rowid=?",
                (failed_pushes, note.id),
            )

    def search_notes(self, term: str, restrict_to: list[int] = [], sort: bool = False) -> list[int]:
        """Search the notes.

        :param str term: The search term
        :param list[int] restrict_to: Database ids for notes to restrict to
        :return: A list of ids for any matching notes
        :rtype: list[int]
        """
        with DbCursor(self.__db_base) as sql:
            filters = []
            search = term.replace('"', '""')
            search = search.replace("'", "''")
            request = "SELECT i.rowid FROM note_fts_idx i "
            if sort:
                request += " INNER JOIN note n ON i.rowid = n.id AND n.locally_deleted = 0 "
            request += " WHERE  "
            if len(restrict_to) > 0:
                request += f" id IN ({','.join(['?']*len(restrict_to))}) AND"
                filters.extend(restrict_to)
            request += f" note_fts_idx MATCH '\"{search}\"*' "
            if sort:
                request += " ORDER BY last_modified DESC"

            result = sql.execute(request, filters)
            return list(itertools.chain(*result))

    @staticmethod
    def db_row_to_note(row: tuple) -> Note:
        """Create Note from database row.

        :param tuple row: The database row
        :return: The note
        :rtype: Note
        """
        i = Note()
        i.title = row[0]
        i.excerpt = row[1]
        i.last_modified = row[2]
        i.favourite = row[3]
        i.category = row[4]
        i.remote_id = row[5]
        i.dirty = row[6]
        i.etag = row[7]
        i.id = row[8]
        i.locally_deleted = row[9]
        i.read_only = row[10]
        if len(row) > 11:
            i.content = row[11]
        return i

    @staticmethod
    def db_row_to_category(row: tuple) -> Category:
        """Create Category from database row.

        :param tuple row: The database row
        :return: The category
        :rtype: Category
        """
        i = Category(row[0], row[1])
        if i.name == "":
            i.name = Category.UNCATEGORISED_TITLE
            i.special_purpose = CategorySpecialPurpose.UNCATEGORISED
        return i
