from gi.repository import GObject, GLib

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

from iotas.category import Category
from iotas.category_manager import CategoryManager
import iotas.config_manager
from iotas.note import Note
from iotas.note_database import NoteDatabase
from iotas.note_list_model import (
    NoteListModelBase,
    NoteListModelCategoryFiltered,
    NoteListModelFavourites,
    NoteListModelTimeFiltered,
)
from iotas.sync_result import SyncResult


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

    STARTUP_LOAD_ALL_NOTES_TOTAL_THRESHOLD = 30
    STARTUP_LOAD_ALL_NOTES_LAST_TWO_MONTHS_THRESHOLD = 6
    EDITING_NOTE_SAVE_INTERVAL = 1000

    def __init__(
        self,
        db: NoteDatabase,
        category_manager: CategoryManager,
    ):
        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 = None

        self.__save_timeout_id = None
        self.__deleted_notes = []

    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)

            older_notes_loaded = False
            if self.__should_display_older_notes_at_startup():
                older_notes_loaded = True
                self.initiate_older_notes_model()
                self.filter_older_notes_by_date()

            self.emit("initial-load-complete", older_notes_loaded)

        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) -> None:
        """Initiate the older notes model."""
        self.__older_notes_model = NoteListModelTimeFiltered(self.__category_filtered_model)
        return self.__older_notes_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
        if iotas.config_manager.get_first_start():
            iotas.config_manager.set_first_start(False)
        self.__category_manager.note_added(note.category)
        return note

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

        :param List[Note] notes: The notes
        """
        self.__deleted_notes = []
        for note in notes:
            if not note.content_loaded:
                self.__db.populate_note_content(note)
            note.locally_deleted = True
            # If no sync server
            if note.remote_id >= 0:
                self.__db.persist_note_locally_deleted(note, True)
            else:
                self.__db.delete_note(note.id)
                self.remove_notes_from_model([note])
            self.__deleted_notes.append(note)
            self.__category_manager.note_deleted(note.category)

    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: SyncResult) -> None:
        """Update note after remote sync.

        :param Note note: The note
        :param SyncResult 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."""
        for note in self.__deleted_notes:
            if note.has_remote_id:
                self.emit("sync-requested")
                break
        self.__deleted_notes = []

    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 persist_note_while_editing(self, note: Note) -> None:
        """Persist note to database on separate thread while editing.

        :param Note note: The note
        """
        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,
            )

    def persist_note_after_closing(self, note: Note) -> None:
        """Persist note to database on main thread after editor closure.

        :param Note note: The note
        """
        # 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
        if note.dirty and not note.handling_conflict and 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)
        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(False)
            self.__db.persist_note_favourite(note)
        return updates

    def search_notes(self, term: str) -> List[int]:
        """Search database for notes with provided term.

        :param str term: The search term
        :return: A list of ids for any matching notes
        :rtype: List[int]
        """
        return self.__db.search_notes(term)

    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
        """
        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)
        if include_older_notes and self.__older_notes_model:
            count += len(self.__older_notes_model)
        return count

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

        :param Category category: Current category
        """
        self.__category_filtered_model.invalidate_filter(category)
        self.__favourites_model.invalidate_filter()

        now = datetime.now()
        today_dt = now.replace(hour=0, minute=0, second=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 = NoteManager.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)
        self.__last_month_model.invalidate_filter(
            time_min=start_of_last_month, time_max=start_of_month
        )

        if self.__older_notes_model is not None:
            self.__older_notes_model.invalidate_filter(time_max=start_of_last_month)

    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 = NoteManager.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

    @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) -> NoteListModelTimeFiltered:
        return self.__older_notes_model

    def __persist_note_on_thread(self, note: Note) -> None:
        # Updated here (instead of in Note content setter) to reduce occurrence
        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_display_older_notes_at_startup(self) -> bool:
        total_note_count = len(self.__base_model)
        last_two_month_count = self.get_filtered_note_count(False)

        if total_note_count == 0 or total_note_count == last_two_month_count:
            return True

        few_total_notes = total_note_count < self.STARTUP_LOAD_ALL_NOTES_TOTAL_THRESHOLD
        few_notes_in_last_two_months = (
            last_two_month_count < self.STARTUP_LOAD_ALL_NOTES_LAST_TWO_MONTHS_THRESHOLD
        )
        return few_total_notes or few_notes_in_last_two_months

    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)

    @staticmethod
    def get_start_of_last_month() -> int:
        now = datetime.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
