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

from gi.repository import GObject, GLib

from datetime import datetime, timedelta
import json
import logging
from threading import Thread
import time
from typing import Optional

from iotas.attachment_helpers import delete_attachments_for_note
from iotas.category import Category
from iotas.category_manager import CategoryManager
from iotas.config_manager import ConfigManager
from iotas.note import Note, DirtyFields
from iotas.note_database import NoteDatabase
from iotas.note_list_model import (
    NoteListModelBase,
    NoteListModelCategoryFiltered,
    NoteListModelFavourites,
    NoteListModelTimeFiltered,
    NoteListModelSearch,
)
from iotas.sync_result import ContentPushSyncResult, FailedPush


class NoteManager(GObject.Object):
    __gtype_name__ = "NoteManager"
    __gsignals__ = {
        "initial-load-complete": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "new-note-persisted": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "sync-requested": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    LOAD_ALL_NOTES_TOTAL_THRESHOLD = 50
    LOAD_ALL_NOTES_LAST_TWO_MONTHS_THRESHOLD = 6
    EDITING_NOTE_SAVE_INTERVAL = 1000

    def __init__(
        self,
        db: NoteDatabase,
        category_manager: CategoryManager,
    ) -> None:
        super().__init__()
        self.__db = db
        self.__category_manager = category_manager

        self.__base_model = NoteListModelBase()

        self.__category_filtered_model = NoteListModelCategoryFiltered(self.__base_model)
        self.__favourites_model = NoteListModelFavourites(self.__category_filtered_model)
        self.__today_model = NoteListModelTimeFiltered(self.__category_filtered_model)
        self.__yesterday_model = NoteListModelTimeFiltered(self.__category_filtered_model)
        self.__week_model = NoteListModelTimeFiltered(self.__category_filtered_model)
        self.__month_model = NoteListModelTimeFiltered(self.__category_filtered_model)
        self.__last_month_model = NoteListModelTimeFiltered(self.__category_filtered_model)
        self.__older_notes_model: Optional[NoteListModelTimeFiltered] = None
        self.__search_model: Optional[NoteListModelSearch] = None

        self.__save_timeout_id = None
        self.__deleted_notes: list[Note] = []

    def initiate_model_from_db(self) -> None:
        """Populate the models from the database."""

        def add_to_model(notes: list[Note]) -> None:
            start_time = time.time()
            self.add_notes_to_model(notes)
            logging.debug("Add to model took %.2fs", time.time() - start_time)
            self.emit("initial-load-complete")

        def thread_do() -> None:
            start_time = time.time()
            notes = self.__db.get_all_notes()
            GLib.idle_add(add_to_model, notes)
            logging.debug("Get from DB took %.2fs", time.time() - start_time)

        thread = Thread(target=thread_do)
        thread.daemon = True
        thread.start()

    def initiate_older_notes_model(self) -> NoteListModelTimeFiltered:
        """Initiate the older notes model."""
        self.__older_notes_model = NoteListModelTimeFiltered(self.__category_filtered_model)
        return self.__older_notes_model

    def initiate_search_model(self) -> NoteListModelSearch:
        """Initiate the search model."""
        self.__search_model = NoteListModelSearch(self.__base_model)
        return self.__search_model

    def add_notes_to_model(self, notes: list[Note]) -> None:
        """Add new notes to model.

        :param list[Note] notes: The notes to add
        """
        self.__base_model.add_notes(notes)

    def remove_notes_from_model(self, notes: list[Note]) -> None:
        """Remove notes from the model.

        :param list[Note] notes: The notes to remove
        """
        self.__base_model.remove_notes(notes)

    def create_note(self, current_category: Category) -> Note:
        """Create a new note.

        :param Category category: The category currently active in the index
        """
        note = Note(True)
        note.content = ""
        note.flag_changed()
        if current_category.special_purpose is None:
            note.category = current_category.name
        config_manager = ConfigManager.get_default()
        if config_manager.first_start:
            config_manager.first_start = False
        self.__category_manager.note_added(note.category)
        return note

    def delete_notes(self, notes: list[Note], provide_undo: bool) -> None:
        """Delete notes from the database and model.

        :param list[Note] notes: The notes
        :param bool provide_undo: Whether to provide ability to undo
        """
        if provide_undo:
            for note in self.__deleted_notes:
                delete_attachments_for_note(note)
            self.__deleted_notes = []

        for note in notes:
            if provide_undo and not note.content_loaded:
                self.__db.populate_note_content(note)

            note.locally_deleted = True
            if note.remote_id >= 0 and provide_undo:
                self.__db.persist_note_locally_deleted(note, True)
            else:
                self.__db.delete_note(note.id)
                self.remove_notes_from_model([note])

            if provide_undo:
                self.__deleted_notes.append(note)
            self.__category_manager.note_deleted(note.category)

            # Delay attachment removal for local deletions, allowing for the undo period
            if not provide_undo:
                delete_attachments_for_note(note)

    def fetch_note_by_remote_id(self, remote_id: int) -> Optional[Note]:
        """Fetch a note by remote id"

        :param int remote_id: The remote id of the note
        """
        return self.__base_model.fetch_note_by_remote_id(remote_id)

    def fetch_note_by_id(self, db_id: int) -> Optional[Note]:
        """Fetch a note by database id"

        :param int db_id: The database id of the note
        """
        return self.__base_model.fetch_note_by_db_id(db_id)

    def invalidate_sort(self) -> None:
        """Invalidate the sorter."""
        self.__base_model.invalidate_sort()

    def update_note_post_sync(self, note: Note, sync_result: ContentPushSyncResult) -> None:
        """Update note after remote sync.

        :param Note note: The note
        :param ContentPushSyncResult sync_result: The sync result
        """
        note.dirty = False
        note.repopulate_meta_from_sync_update(sync_result.data)
        clear_dirty = not note.dirty_while_saving
        self.__db.persist_note_remote_meta_and_sanitisable_fields(note, clear_dirty)
        self.__base_model.ensure_remote_map_entry(note)

    def on_deletion_undo_elapsed(self) -> None:
        """Push local deletions to server and flush local queue upon undo lapse."""
        had_remote = False
        for note in self.__deleted_notes:
            delete_attachments_for_note(note)
            if note.has_remote_id:
                had_remote = True
        self.__deleted_notes = []
        if had_remote:
            self.emit("sync-requested")

    def undo_deletion(self) -> bool:
        """Undo a pending deletion.

        :return: Whether any notes were restored
        :rtype: bool
        """
        if not self.__deleted_notes:
            return False

        for note in self.__deleted_notes:
            note.locally_deleted = False
            if note.remote_id >= 0:
                self.__db.persist_note_locally_deleted(note, False)
            else:
                note.id = -1
                note.last_modified = int(time.time())
                self.__db.add_note(note)
                self.add_notes_to_model([note])
            self.__category_manager.note_added(note.category)
        self.__deleted_notes = []
        return True

    def apply_deleted_notes_attachment_deletion(self) -> None:
        """Delete attachments for any locally deleted notes awaiting undo timeout.

        Ensures the removal of attachments for deleted notes if exiting before the undo toast has
        lapsed.
        """
        for note in self.__deleted_notes:
            delete_attachments_for_note(note)
        self.__deleted_notes = []

    def persist_note_while_editing(self, note: Note, update_excerpt: bool) -> None:
        """Persist note to database on separate thread while editing.

        :param Note note: The note
        :param bool update_excerpt: Whether to update excerpt
        """
        if self.__save_timeout_id is None:
            if note.new_and_empty:
                return
            self.__save_timeout_id = GLib.timeout_add(
                self.EDITING_NOTE_SAVE_INTERVAL,
                self.__persist_note_on_thread,
                note,
                update_excerpt,
            )

    def persist_note_sync(
        self, note: Note, allow_empty: bool = False, force_update_excerpt: bool = False
    ) -> None:
        """Persist note to database on main thread.

        :param Note note: The note
        :param bool allow_empty: Whether to store even when new and empty
        :param bool force_update_excerpt: Whether to update the excerpt, even if the note isn't
            "dirty"
        """
        # Cancel any queued save
        if self.__save_timeout_id is not None:
            GLib.source_remove(self.__save_timeout_id)
            self.__save_timeout_id = None

        # Persist any changes
        update = note.dirty or force_update_excerpt
        if update and not note.handling_conflict and (allow_empty or not note.new_and_empty):
            note.regenerate_excerpt()
            new_note = not note.has_id
            self.__persist_note_to_db(note)
            if new_note:
                self.add_notes_to_model([note])
        elif note.new_and_empty:
            self.__category_manager.note_deleted(note.category)
        else:
            self.apply_any_server_sanitised_title(note)
        note.handling_conflict = False

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

        :param Note note: The note
        :param str old_category: The old category
        """

        def remote_sync_and_integrate_to_models(is_new: bool) -> None:
            if not is_new:
                self.emit("sync-requested")

        def thread_do() -> None:
            is_new = not note.has_id
            if not is_new:
                self.__db.persist_note_category(note)
            GLib.idle_add(remote_sync_and_integrate_to_models, is_new)

        thread = Thread(target=thread_do)
        thread.daemon = True
        thread.start()
        self.__category_manager.note_category_changed(old_category, note.category)

    def set_and_persist_favourite_for_notes(self, notes: list[Note], value: bool) -> bool:
        """Set and persist the favourite flag on the provided notes.

        :param list[Note] notes: The notes
        :param bool value: The new value
        :return: Whether any notes were updated
        :rtype: bool
        """
        updates = False
        for note in notes:
            if note.favourite == value:
                continue
            updates = True
            note.favourite = value
            note.flag_changed(update_last_modified=False)
            self.__db.persist_note_favourite(note)
        return updates

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

        :param str term: The search term
        :param list[int] restrict_to: List of ids of notes to restrict to
        :param bool sort: Whether the results should be datetime sorted
        :return: A list of ids for any matching notes
        :rtype: list[int]
        """
        return self.__db.search_notes(term, restrict_to, sort)

    def get_filtered_note_count(self, include_older_notes: bool) -> int:
        """Fetch filtered note count.

        :param bool include_older_notes: Whether to include the "older notes" model
        :return: The count
        :rtype: int
        """
        if include_older_notes:
            return len(self.__category_filtered_model)
        else:
            count = 0
            for model in (
                self.__favourites_model,
                self.__today_model,
                self.__yesterday_model,
                self.__week_model,
                self.__month_model,
                self.__last_month_model,
            ):
                count += len(model)
            return count

    def update_filters(
        self, category: Category, older_notes_displayed: bool, check_older_notes: bool
    ) -> bool:
        """Update model filters.

        If told to check whether older notes should be loaded the total number of notes in the
        category and within the last two months are compared to thresholds to determine whether to
        show the section automatically or not.

        :param Category category: Current category
        :param bool older_notes_displayed: Whether the notes older than two months section has
            been displayed
        :param bool check_older_notes: Whether to check if notes older than two months should be
            loaded
        :return: Whether the filter was opened to notes older than two months
        :rtype: bool
        """
        self.__category_filtered_model.invalidate_filter(category)
        self.__favourites_model.invalidate_filter()

        now = get_now()
        today_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
        today = today_dt.timestamp()
        yesterday = (today_dt - timedelta(days=1)).timestamp()
        monday = (today_dt - timedelta(days=now.weekday())).timestamp()
        start_of_month = now.replace(hour=0, minute=0, second=0, day=1).timestamp()
        start_of_last_month = get_start_of_last_month()

        self.__today_model.invalidate_filter(time_min=today)
        self.__yesterday_model.invalidate_filter(time_min=yesterday, time_max=today)
        self.__week_model.invalidate_filter(time_min=monday, time_max=yesterday)

        # If today is the first day of the month, or if the start of the week is earlier than the
        # start of the month don't show the rest of this month. Also ensures last month doesn't
        # have overlap content with the week.
        if today == start_of_month or monday < start_of_month:
            start_of_month = monday
        # Account for yesterday when setting rest of month end point
        month_end = yesterday if yesterday < monday else monday
        self.__month_model.invalidate_filter(time_min=start_of_month, time_max=month_end)

        # Account for yesterday when setting last month end point
        last_month_end = yesterday if yesterday < start_of_month else start_of_month
        self.__last_month_model.invalidate_filter(
            time_min=start_of_last_month, time_max=last_month_end
        )

        older_loaded = False
        if check_older_notes and not older_notes_displayed and self.__should_load_older_notes():
            if self.__older_notes_model is None:
                self.initiate_older_notes_model()
            older_loaded = True

        if older_notes_displayed or older_loaded:
            assert self.__older_notes_model is not None
            self.__older_notes_model.invalidate_filter(time_max=start_of_last_month)

        return older_loaded

    def filter_older_notes_by_date(self) -> None:
        """Filter older notes model to notes older than two months."""
        if self.__older_notes_model is None:
            return

        start_of_last_month = get_start_of_last_month()
        self.__older_notes_model.invalidate_filter(time_max=start_of_last_month)

    def get_deleted_notes_pending_undo(self) -> list[Note]:
        """Fetch any notes queued for deletion.

        :return: The notes
        :rtype: list[Note]
        """
        return self.__deleted_notes

    def set_deleted_notes_pending_undo(self, notes: list[Note]) -> None:
        """Set the notes queued for deletion.

        :param list[Note] notes: The notes
        """
        self.__deleted_notes = notes

    def apply_any_server_sanitised_title(self, note: Note) -> None:
        """Persist to the database any sanitised title received from the server while editing.

        During note creation we ignore sanitised titles returning from the server (to avoid
        replacing a title which is being edited via the first buffer line with something the
        server returns).

        :param Note note: The note to work on
        """
        if not note or note.server_sanitised_title is None:
            return

        logging.info("Applying sanitised title provided by server while creating note")
        note.title = note.server_sanitised_title
        note.server_sanitised_title = None
        changed_fields = DirtyFields()
        changed_fields.title = True
        self.__db.persist_note_selective(note, changed_fields)

    def get_note_failed_pushes(self, note: Note) -> list[FailedPush]:
        """Fetch any failed pushes for a note.

        :return: List of failed pushes
        :rtype: list[FailedPush]
        """
        pushes_as_str = self.__db.get_note_failed_pushes(note)
        if not pushes_as_str:
            return []

        try:
            pushes = json.loads(pushes_as_str)
        except json.JSONDecodeError as e:
            logging.warning(f"Failed to load failed pushes: {e}")
            return []

        if type(pushes) is not list:
            logging.warning("Failed to load failed pushes: not a list")
            return []

        ret = []
        for push_json in pushes:
            if not set(push_json.keys()) == {"hash", "length", "timestamp"}:
                logging.warning(f"Invalid push: {push_json}")
                continue
            ret.append(FailedPush(push_json["hash"], push_json["length"], push_json["timestamp"]))

        return ret

    def add_failed_push(self, note: Note, push: FailedPush) -> None:
        """Add a failed push to the database.

        :param Note note: The note to add for
        :param FailedPush push: The failed push info
        """
        pushes = self.get_note_failed_pushes(note)
        pushes.append(push)
        out = []
        for push in pushes:
            out.append(push._asdict())
        pushes_json = json.dumps(out)
        self.__db.persist_note_failed_pushes(note, pushes_json)

    @GObject.Property(type=NoteListModelBase, default=None)
    def base_model(self) -> NoteListModelBase:
        return self.__base_model

    @GObject.Property(type=NoteListModelFavourites, default=None)
    def favourites_model(self) -> NoteListModelFavourites:
        return self.__favourites_model

    @GObject.Property(type=NoteListModelTimeFiltered, default=None)
    def today_model(self) -> NoteListModelTimeFiltered:
        return self.__today_model

    @GObject.Property(type=NoteListModelTimeFiltered, default=None)
    def yesterday_model(self) -> NoteListModelTimeFiltered:
        return self.__yesterday_model

    @GObject.Property(type=NoteListModelTimeFiltered, default=None)
    def week_model(self) -> NoteListModelTimeFiltered:
        return self.__week_model

    @GObject.Property(type=NoteListModelTimeFiltered, default=None)
    def month_model(self) -> NoteListModelTimeFiltered:
        return self.__month_model

    @GObject.Property(type=NoteListModelTimeFiltered, default=None)
    def last_month_model(self) -> NoteListModelTimeFiltered:
        return self.__last_month_model

    @GObject.Property(type=NoteListModelTimeFiltered, default=None)
    def older_notes_model(self) -> Optional[NoteListModelTimeFiltered]:
        return self.__older_notes_model

    @GObject.Property(type=NoteListModelSearch, default=None)
    def search_model(self) -> Optional[NoteListModelSearch]:
        return self.__search_model

    def __persist_note_on_thread(self, note: Note, update_excerpt: bool) -> None:
        # Updated here (instead of in Note content setter) to reduce occurrence
        if update_excerpt:
            note.regenerate_excerpt()

        def remote_sync_and_integrate_new_note(note: Note, is_new: bool) -> None:
            if is_new:
                self.add_notes_to_model([note])
                self.emit("new-note-persisted")
            self.emit("sync-requested")

        def thread_do() -> None:
            is_new = not note.has_id
            self.__persist_note_to_db(note)

            GLib.idle_add(remote_sync_and_integrate_new_note, note, is_new)
            self.__save_timeout_id = None

        thread = Thread(target=thread_do)
        thread.daemon = True
        thread.start()

    def __should_load_older_notes(self) -> bool:
        total_note_count = len(self.__category_filtered_model)
        last_two_month_count = self.get_filtered_note_count(include_older_notes=False)

        if total_note_count == last_two_month_count:
            return True
        elif len(self.__category_filtered_model) < self.LOAD_ALL_NOTES_TOTAL_THRESHOLD:
            return True
        elif last_two_month_count < self.LOAD_ALL_NOTES_LAST_TWO_MONTHS_THRESHOLD:
            return True
        else:
            return False

    def __persist_note_to_db(self, note: Note) -> None:
        if note.id == -1:
            self.__db.add_note(note)
        else:
            self.__db.persist_note_editable(note)


def get_start_of_last_month() -> float:
    now = get_now()
    start_of_month_dt = now.replace(hour=0, minute=0, second=0, day=1)
    if start_of_month_dt.month > 1:
        start_of_last_month = start_of_month_dt.replace(
            month=start_of_month_dt.month - 1
        ).timestamp()
    else:
        start_of_last_month = start_of_month_dt.replace(
            month=12, year=start_of_month_dt.year - 1
        ).timestamp()
    return start_of_last_month


def get_now() -> datetime:
    return datetime.now()
