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

from __future__ import annotations

from gi.repository import GObject

import logging
import re
import time
from typing import Optional, Self

from iotas.text_utils import sanitise_path


class DirtyFields:
    title = False
    content = False
    excerpt = False
    favourite = False
    category = False
    etag = False
    remote_id = False
    last_modified = False
    locally_deleted = False
    read_only = False

    def empty(self) -> bool:
        """Whether no fields have been modified.

        :return: Whether there are modifications
        :rtype: bool
        """
        return (
            not self.title
            and not self.content
            and not self.excerpt
            and not self.favourite
            and not self.category
            and not self.etag
            and not self.remote_id
            and not self.last_modified
            and not self.locally_deleted
            and not self.read_only
        )


class _RecentETagList:
    """
    List of recent known ETags.
    """

    SIZE = 50

    def __init__(self) -> None:
        self.__tags: list[str] = []

    def add(self, tag: str) -> None:
        """Add an ETag.

        :param str tag: ETag
        """
        if not tag.strip():
            return
        self.__trim()
        self.__tags.append(tag)

    def contains(self, tag: str) -> bool:
        """Check if ETag is in list.

        :param str tag: ETag
        :return: Whether in list
        :rtype: bool
        """
        return tag in self.__tags

    def __trim(self) -> None:
        while len(self.__tags) >= self.SIZE:
            del self.__tags[0]


class Note(GObject.Object):
    __gsignals__ = {
        "remote-content-update": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    EXCERPT_LENGTH = 200
    EXCERPT_LINE_SEPARATOR = "   "

    def __init__(self, new_note: bool = False) -> None:
        super().__init__()
        self.__title = ""
        self.__excerpt = ""
        self.__content: Optional[str] = None
        self.__category = ""
        self.__favourite = False
        self.__id = -1
        self.__last_modified = 0
        self.__read_only = False
        self.__dirty = False
        self.__dirty_while_saving = False
        self.__server_sanitised_title: Optional[str] = None
        self.__etag = ""
        self.__remote_id = -1
        self.__title_is_top_line = new_note
        self.__saving = False
        self.__locally_deleted = False
        self.__handling_conflict = False
        self.__recent_tags = _RecentETagList()

    def duplicate(self) -> Self:
        """Duplicate note.

        :return: Duplicate note
        :rtype: Self
        """
        n = Note()
        n.title = self.title
        n.excerpt = self.excerpt
        n.content = self.content
        n.favourite = self.favourite
        n.category = self.category
        return n

    def flag_changed(self, update_last_modified: bool = True) -> None:
        """Flag that the note has changed.

        dirty, last_modified and dirty_while_saving will be updated as applicable.
        """
        if not self.dirty:
            self.dirty = True
        if update_last_modified:
            self.last_modified = int(time.time())
        if self.saving:
            self.dirty_while_saving = True

    def regenerate_excerpt(self) -> bool:
        """Regenerates the excerpt from the content.

        :return: Whether the excerpt has changed
        :rtype: bool
        """
        if self.__content is None:
            logging.warning(
                "Can't generate excerpt without content populated ({})".format(self.__title)
            )
            return False
        new_excerpt = self.__content.strip()
        if new_excerpt != "":
            # Remove any heading markdown before first line
            new_excerpt = re.sub(r"^#* ", "", new_excerpt).strip()

            title = self.__title.strip()
            if len(title) and new_excerpt.startswith(title):
                # Remove title from start of content
                new_excerpt = new_excerpt[len(title) :]

            new_excerpt = re.sub(r"^\s*[-*+] \[ \]", "☐", new_excerpt, flags=re.MULTILINE)
            new_excerpt = re.sub(r"^\s*[-*+] \[x\]", "☑", new_excerpt, flags=re.MULTILINE)
            new_excerpt = re.sub(r"^\s*[-*+] (?![-*+[])", "• ", new_excerpt, flags=re.MULTILINE)

            new_excerpt = new_excerpt.replace("\n", self.EXCERPT_LINE_SEPARATOR).strip()
            if len(new_excerpt) > self.EXCERPT_LENGTH:
                new_excerpt = new_excerpt[: self.EXCERPT_LENGTH]

        changed = new_excerpt != self.excerpt
        if changed:
            self.excerpt = new_excerpt
        return changed

    def repopulate_meta_from_sync_update(self, update_dict: dict) -> None:
        """Repopulate note metadata from update over REST API JSON.

        :param dict update_dict: Nextcloud Notes API JSON
        """
        self.__last_modified = update_dict["modified"]
        self.etag = update_dict["etag"]
        self.__remote_id = update_dict["id"]

        # Handle title and category updates from sanitisation on server side
        if self.title != update_dict["title"]:
            if self.title_is_top_line:
                self.server_sanitised_title = update_dict["title"]
            else:
                self.title = update_dict["title"]
        if self.category != update_dict["category"]:
            self.category = update_dict["category"]

    def update_from_remote_sync(self, note_dict: dict) -> DirtyFields:
        """Update from remote sync JSON dict and flag which fields are updated.

        :param dict note_dict: Nextcloud Notes API JSON
        :return: Which fields were updated
        :rtype: DirtyFields
        """
        f = DirtyFields()
        if self.__last_modified != note_dict["modified"]:
            self.last_modified = note_dict["modified"]
            f.last_modified = True
        if self.__etag != note_dict["etag"]:
            self.etag = note_dict["etag"]
            f.etag = True
        if self.__read_only != note_dict["readonly"]:
            self.read_only = note_dict["readonly"]
            f.read_only = True
        if self.__favourite != note_dict["favorite"]:
            self.favourite = note_dict["favorite"]
            f.favourite = True
        if self.__category != note_dict["category"]:
            self.category = note_dict["category"]
            f.category = True
        if self.__title != note_dict["title"]:
            self.title = note_dict["title"]
            f.title = True
        if "ignore-content" not in note_dict and self.__content != note_dict["content"]:
            self.content = note_dict["content"]
            f.content = True
            # Always called on UI thread
            self.emit("remote-content-update")
        if (f.title or f.content) and self.content:
            self.regenerate_excerpt()
            f.excerpt = True
        return f

    def update_title_from_top_line(self) -> None:
        """Update the title from the top line of the content."""
        if not self.__title_is_top_line:
            return
        if self.__content is not None and self.__content.strip() != "":
            lines = self.__content.strip().split("\n")
            title = lines[0].strip()
            title = title.strip("#")
            title = title.strip()
            title = sanitise_path(title)
            if len(title) > 200:
                title = title[:200]
            self.title = title

    def identical_excepting_sync_meta(self, note2: Note) -> bool:
        """Check whether notes are identical, apart from sync meta fields.

        :param Note note2: Note to compare to
        :return: Whether matching
        :rtype: bool
        """
        return self.identical_non_sync_meta(note2) and self.content == note2.content

    def identical_to(self, note2: Note) -> bool:
        """Check whether notes are identical.

        :param Note note2: Note to compare to
        :return: Whether identical
        :rtype: bool
        """
        return (
            self.identical_excepting_sync_meta(note2)
            and self.last_modified == note2.last_modified
            and self.dirty == note2.dirty
            and self.etag == note2.etag
        )

    def identical_non_sync_meta(self, note2: Note) -> bool:
        """Check whether notes have identical meta fields, excluding sync meta.

        :param Note note2: Note to compare to
        :return: Whether matching
        :rtype: bool
        """
        return (
            self.title == note2.title
            and self.favourite == note2.favourite
            and self.category == note2.category
            and self.locally_deleted == note2.locally_deleted
            and self.remote_id == note2.remote_id
        )

    def etag_is_recent(self, etag: str) -> bool:
        """Check whether etag matches a known recent push.

        :param str etag: ETag to compare
        :return: Whether known
        :rtype: bool
        """
        return self.__recent_tags.contains(etag)

    def to_nextcloud_dict(self) -> dict:
        """Create an API dict object for the note, for debugging.

        :return: Note as API results dict
        :rtype: dict
        """
        return {
            "id": self.remote_id,
            "title": self.title,
            "content": self.content,
            "category": self.category,
            "favorite": self.favourite,
            "modified": self.last_modified,
            "etag": self.etag,
            "readonly": self.read_only,
        }

    @staticmethod
    def from_backup(meta: dict, content: str, include_sync_attributes: bool) -> Optional[Note]:
        """Create note from backup dict.

        :param dict meta: Backup metadata
        :param str content: Content
        :param bool include_sync_attributes: Whether to include sync attributes
        :return: The note or None on failure
        :rtype: Optional[Self]
        """
        note = Note()
        try:
            note.title = meta["Title"]
            note.content = content
            note.regenerate_excerpt()
            note.category = meta["Category"]
            note.favourite = meta["Favourite"]
            note.last_modified = meta["LastModified"]
            note.locally_deleted = meta["LocallyDeleted"]
            note.dirty = meta["Dirty"]
            if include_sync_attributes:
                note.etag = meta["ETag"]
                note.remote_id = meta["RemoteId"]
        except KeyError:
            return None
        return note

    @staticmethod
    def from_nextcloud(note_dict: dict) -> Note:
        """Create note from dict originating from REST API JSON.

        :param dict note_dict: Nextcloud Notes API JSON
        :return: The note
        :rtype: Self
        """
        note = Note()
        note.title = note_dict["title"]
        note.content = note_dict["content"]
        note.regenerate_excerpt()
        note.category = note_dict["category"]
        note.favourite = note_dict["favorite"]
        note.last_modified = note_dict["modified"]
        note.etag = note_dict["etag"]
        note.remote_id = note_dict["id"]
        note.read_only = note_dict["readonly"]
        return note

    @GObject.Property(type=str, default="")
    def title(self) -> str:
        return self.__title

    @title.setter
    def set_title(self, value: str) -> None:
        self.__title = value
        self.__server_sanitised_title = None

    @GObject.Property(type=str, default="")
    def excerpt(self) -> str:
        return self.__excerpt

    @excerpt.setter
    def set_excerpt(self, value: str) -> None:
        self.__excerpt = value

    @GObject.Property(type=str)
    def content(self) -> Optional[str]:
        return self.__content

    @content.setter
    def set_content(self, value: str) -> None:
        self.__content = value

    @GObject.Property(type=bool, default=False)
    def content_loaded(self) -> bool:
        """Whether the note's contents are populated.

        :return: Whether populated
        :rtype: bool
        """
        return self.__content is not None

    @GObject.Property(type=bool, default=False)
    def favourite(self) -> bool:
        return self.__favourite

    @favourite.setter
    def set_favourite(self, value: bool) -> None:
        self.__favourite = value

    @GObject.Property(type=str)
    def category(self) -> str:
        return self.__category

    @category.setter
    def set_category(self, value: str) -> None:
        self.__category = value

    @GObject.Property(type=bool, default=False)
    def has_id(self) -> bool:
        """Whether the note has an id (as a result of being persisted to the database).

        :return: Whether the note has been persisted to the database
        :rtype: bool
        """
        return self.__id != -1

    @GObject.Property(type=int, default=-1)
    def id(self) -> int:
        return self.__id

    @id.setter
    def set_id(self, value: int) -> None:
        self.__id = value

    @GObject.Property(type=int, default=0)
    def last_modified(self) -> int:
        return self.__last_modified

    @last_modified.setter
    def set_last_modified(self, value: int) -> None:
        self.__last_modified = value

    @GObject.Property(type=bool, default=False)
    def read_only(self) -> bool:
        return self.__read_only

    @read_only.setter
    def set_read_only(self, value: bool) -> None:
        self.__read_only = value

    @GObject.Property(type=bool, default=False)
    def dirty(self) -> bool:
        return self.__dirty

    @dirty.setter
    def set_dirty(self, value: bool) -> None:
        self.__dirty = value

    @GObject.Property(type=bool, default=False)
    def dirty_while_saving(self) -> bool:
        return self.__dirty_while_saving

    @dirty_while_saving.setter
    def set_dirty_while_saving(self, value: bool) -> None:
        self.__dirty_while_saving = value

    @GObject.Property(type=bool, default=False)
    def saving(self) -> bool:
        return self.__saving

    @saving.setter
    def set_saving(self, value: bool) -> None:
        self.__saving = value

    @GObject.Property(type=bool, default=False)
    def locally_deleted(self) -> bool:
        return self.__locally_deleted

    @locally_deleted.setter
    def set_locally_deleted(self, value: bool) -> None:
        self.__locally_deleted = value

    @GObject.Property(type=bool, default=False)
    def handling_conflict(self) -> bool:
        return self.__handling_conflict

    @handling_conflict.setter
    def set_handling_conflict(self, value: bool) -> None:
        self.__handling_conflict = value

    @GObject.Property(type=str, default="")
    def server_sanitised_title(self) -> Optional[str]:
        return self.__server_sanitised_title

    @server_sanitised_title.setter
    def set_server_sanitised_title(self, value: str) -> None:
        self.__server_sanitised_title = value

    @GObject.Property(type=str, default="")
    def etag(self) -> str:
        return self.__etag

    @etag.setter
    def set_etag(self, value: str) -> None:
        self.__etag = value
        self.__recent_tags.add(value)

    @GObject.Property(type=int, default=-1)
    def remote_id(self) -> int:
        return self.__remote_id

    @remote_id.setter
    def set_remote_id(self, value: int) -> None:
        self.__remote_id = value

    @GObject.Property(type=bool, default=False)
    def has_remote_id(self) -> bool:
        """Whether the note has a remote id (as a result of being synced to a remote server).

        :return: Whether the note has, at some point, been synced to a remote server
        :rtype: bool
        """
        return self.__remote_id >= 0

    @GObject.Property(type=bool, default=False)
    def title_is_top_line(self) -> bool:
        return self.__title_is_top_line

    @title_is_top_line.setter
    def set_title_is_top_line(self, value: bool) -> None:
        self.__title_is_top_line = value

    @GObject.Property(type=bool, default=False)
    def new_and_empty(self) -> bool:
        """Check if the note is new and has no content or title.

        :return: Whether new and empty
        :rtype: bool
        """
        return (
            self.id == -1
            and self.title.strip() == ""
            and self.content is not None
            and self.content.strip() == ""
        )
