from gettext import gettext as _
from gi.repository import GObject, GLib, Gtk

import logging
from threading import Lock, Thread
import time
from typing import List, Optional, Tuple

from iotas.category_manager import CategoryManager
import iotas.config_manager
from iotas.nextcloud_sync_worker import NextcloudSyncWorker, LoginRequestResult
from iotas.note import Note
from iotas.note_database import NoteDatabase
from iotas.note_manager import NoteManager
from iotas.sync_result import SyncResult
from iotas.ui_utils import idle_add_wait


class SyncWorkLock:
    def __init__(self):
        self.__lock = Lock()
        self.__locked = False

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

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

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


class SyncManager(GObject.Object):
    __gsignals__ = {
        "ready": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
        "started": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "finished": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
        "note-conflict": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
        "secret-service-failure": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "remote-category-sanitised": (GObject.SignalFlags.RUN_FIRST, None, (str, str)),
        "missing-password": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    STARTUP_SYNC_INIT_DURATION = 500
    SYNC_FAIL_OFFLINE_THRESHOLD = 2
    MIN_NEW_NOTES_FOR_BATCHING = 30
    SYNC_BATCH_SIZE = 5

    def __init__(self, db: NoteDatabase):
        super().__init__()
        self.__db = db
        self.__sync_worker = NextcloudSyncWorker()
        self.__lock = SyncWorkLock()

        self.__note_manager = None
        self.__category_manager = None

        self.__authenticated = False
        self.__api_check_complete = False
        self.__initial_sync_delay_complete = False
        self.__sync_requested_while_active = False
        self.__waiting_timeout = None
        self.__offline = False
        self.__sync_fail_counter = 0

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.SYNC_INTERVAL}", self.__set_sync_interval
        )
        self.__set_sync_interval()

        self.__sync_worker.connect("ready", self.__on_sync_ready)
        self.__sync_worker.connect("api-check-finished", self.__on_api_check_finished)
        self.__sync_worker.connect("settings-fetched", self.__on_settings_fetched)
        self.__sync_worker.connect("secret-service-failure", self.__on_secret_service_failure)
        self.__sync_worker.bind_property(
            "authenticated", self, "authenticated", GObject.BindingFlags.SYNC_CREATE
        )
        self.__sync_worker.connect("missing-password", self.__on_missing_password)

    def init_auth(self) -> None:
        """Initialise sync authentication details from the Secret Service."""
        self.__sync_worker.init_auth()

    def set_managers(self, note_manager: NoteManager, category_manager: CategoryManager) -> None:
        """Set the models.

        :param NoteManager note_manager: The note manager
        :param CategoryManager category_manager: The category manager
        """
        self.__note_manager = note_manager
        self.__note_manager.connect("initial-load-complete", self.__on_initial_note_load_complete)
        self.__category_manager = category_manager

    def sync_now(self) -> None:
        """Attempt to initiate a sync.

        If the server isn't authenticated the request will be discarded. Another sync already
        underway will result in the sync being run immediately after the current operation.
        """
        if not self.__sync_worker.authenticated:
            return

        if self.__lock.locked:
            self.__sync_requested_while_active = False
            return

        if self.__waiting_timeout:
            GLib.source_remove(self.__waiting_timeout)
            self.__waiting_timeout = None

        self.__sync()

    def close(self) -> None:
        """Close the manager, perform cleanup."""
        iotas.config_manager.set_nextcloud_prune_threshold(self.__sync_worker.prune_threshold)

    def reset_marker(self) -> None:
        """Reset the most recent sync timestamp market."""
        self.__sync_worker.reset_prune_threshold()

    def sign_out(self) -> None:
        """Sign out from the sync server."""
        self.__sync_worker.sign_out()

    def flush_pending_deletions(self) -> None:
        """Flush any notes awaiting their pending undo period."""
        notes_pending_undo = self.__note_manager.get_deleted_notes_pending_undo()
        if notes_pending_undo and self.authenticated:
            for note in notes_pending_undo:
                if note.has_remote_id:
                    self.sync_now()
                    break
        self.__note_manager.set_deleted_notes_pending_undo([])

    def start_login(
        self, server_uri: str, window: Gtk.Window
    ) -> Tuple[LoginRequestResult, Optional[str], Optional[str]]:
        """Initiate a new login.

        :param str server_uri: Server instance URI
        :param Gtk.Window window: Main window
        :return: Tuple with LoginRequestResult before endpoint URI and auth token (or Nones on
            failures)
        :rtype: Tuple[LoginRequestResult, Optional[str], Optional[str]]
        """
        return self.__sync_worker.start_login(server_uri, window)

    def check_for_login_success(self, endpoint: str, token: str) -> Tuple[bool, bool]:
        """Check whether login succeeded.

        :param str endpoint: Endpoint URI
        :param str token: Request token
        :return: Whether authenticated and whether auth. storage succeeded
        :rtype: Tuple[bool, bool]
        """
        return self.__sync_worker.check_endpoint_for_auth_token(endpoint, token)

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

    @authenticated.setter
    def authenticated(self, new_val: bool) -> None:
        self.__authenticated = new_val

    @GObject.Property(type=bool, default=False)
    def configured_but_no_password(self) -> bool:
        return (
            iotas.config_manager.get_nextcloud_endpoint() != ""
            and iotas.config_manager.get_nextcloud_username() != ""
            and not self.authenticated
        )

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

    @offline.setter
    def offline(self, new_val: bool) -> None:
        self.__offline = new_val

    def __on_initial_note_load_complete(
        self, _obj: GObject.Object, _older_notes_loaded: bool
    ) -> None:
        if iotas.config_manager.get_nextcloud_username() != "":
            GLib.timeout_add(self.STARTUP_SYNC_INIT_DURATION, self.__perform_initial_sync)

    def __sync(self) -> None:
        self.__waiting_timeout = None

        if self.__lock.locked:
            self.__sync_requested_while_active = False
            return

        GLib.idle_add(self.emit, "started")
        thread = Thread(target=self.__do_remote_sync)
        thread.daemon = True
        thread.start()

    def __do_remote_sync(self) -> None:
        logging.debug("Refresh....")

        force_refresh_all = iotas.config_manager.get_perform_full_server_refresh()
        if force_refresh_all:
            logging.info("Performing full refresh from server")

        self.__lock.acquire()
        self.__sync_requested_while_active = False

        start_time = time.time()
        sync_result = self.__sync_worker.get_notes(force_refresh_all)
        logging.debug("Server GET took {:.2f}s".format(time.time() - start_time))
        if not sync_result.success:
            logging.info("Refresh failed with status code %s", sync_result.status_code)

            # Ensure our API check gets run, just in case a major API version issue is causing
            # the issue
            self.__api_check_and_settings_sync()

            self.__finish_sync(0)
            self.__sync_fail_counter += 1
            if not self.__offline and self.__sync_fail_counter >= self.SYNC_FAIL_OFFLINE_THRESHOLD:
                logging.info("Flagging offline")
                self.offline = True
            return
        self.__sync_fail_counter = 0
        if self.__offline:
            self.offline = False
        notes = sync_result.data
        remote_ids_from_db = self.__db.get_all_notes_remote_ids()
        # logging.debug("Pulled {} notes".format(len(notes)))

        # Determine remote new and updates
        (remote_new, remote_upd) = self.__determine_remote_new_and_update(
            notes, remote_ids_from_db, force_refresh_all
        )

        # Determine any remote deletions
        sync_server_remote_ids = [v["id"] for v in notes]
        remote_del = list(set(remote_ids_from_db) - set(sync_server_remote_ids))

        # Check for delete conflicts
        self.__check_for_remote_delete_conflicts(remote_del)

        # Determine, sync and locally apply local deletions
        local_del = self.__apply_local_deletions()

        # Determine and sync local updates
        local_upd = self.__apply_local_updates()

        # Determine and sync local creations
        local_new = self.__apply_local_creations()

        changes_out = len(local_new + local_upd + local_del)
        if changes_out > 0:
            logging.debug(
                "Changes OUT: %s new %s upd %s del",
                len(local_new),
                len(local_upd),
                len(local_del),
            )
        changes_in = len(remote_new + remote_upd + remote_del)
        if changes_in > 0:
            logging.debug(
                "Changes IN: %s new %s upd %s del",
                len(remote_new),
                len(remote_upd),
                len(remote_del),
            )
        if changes_in > 0:
            # Integrate remote changes, batching for large initial transfers
            if len(remote_new) > self.MIN_NEW_NOTES_FOR_BATCHING:
                batch_size = self.SYNC_BATCH_SIZE
            else:
                batch_size = self.MIN_NEW_NOTES_FOR_BATCHING
            for i in range(0, max(1, len(remote_new)), batch_size):
                if len(remote_new) > 0:
                    batch = remote_new[i : i + batch_size]
                else:
                    batch = []
                idle_add_wait(self.__integrate_remote_changes, batch, remote_upd, remote_del)
                remote_upd = []
                remote_del = []

        if force_refresh_all and iotas.config_manager.get_perform_full_server_refresh():
            GLib.idle_add(iotas.config_manager.set_perform_full_server_refresh, False)

        # API check and settings sync are slung onto the back of the first request so that first
        # sync isn't as delayed. Justified by the fact that the outcome of the API check isn't
        # currently impacting behaviour; this may move.
        self.__api_check_and_settings_sync()

        self.__finish_sync(changes_in + changes_out)

    def __determine_remote_new_and_update(
        self, notes: List[Note], remote_ids_from_db: List[int], force_refresh_all: bool = False
    ) -> Tuple[List[dict], List[dict]]:
        remote_upd = []
        remote_new = []
        for sync_note in notes:
            if len(sync_note) == 1:
                # Notes before the prune cutoff will only have an "id" field
                continue
            if sync_note["id"] not in remote_ids_from_db:
                remote_new.append(sync_note)
            else:
                if force_refresh_all or self.__db.note_needs_update(
                    sync_note["id"], sync_note["etag"]
                ):
                    note = self.__note_manager.fetch_note_by_remote_id(sync_note["id"])
                    if note is None:
                        continue
                    if note.dirty:
                        update_note = Note.from_nextcloud(sync_note)
                        if not note.content_loaded:
                            self.__db.populate_note_content(note)
                        if not note.identical_excepting_sync_update(update_note):
                            logging.info(
                                "Sync. conflict, note %s was modified locally and updated remotely",
                                note.title,
                            )
                            GLib.idle_add(self.emit, "note-conflict", note)
                            # Duplicate existing note
                            GLib.idle_add(self.__duplicate_note_for_sync_conflict, note)
                        note.dirty = False
                        self.__db.persist_note_dirty(note)

                    remote_upd.append(sync_note)
        return (remote_new, remote_upd)

    def __check_for_remote_delete_conflicts(self, remote_del: List[dict]) -> None:
        for remote_id in remote_del:
            note = self.__note_manager.fetch_note_by_remote_id(remote_id)
            if note is None:
                continue
            if note.dirty and not note.locally_deleted:
                GLib.idle_add(self.emit, "note-conflict", note)
                # Duplicate existing note
                logging.info(
                    "Sync. conflict, note '%s' was modified locally and deleted remotely",
                    note.title,
                )
                GLib.idle_add(self.__duplicate_note_for_sync_conflict, note)
                note.dirty = False
                self.__db.persist_note_dirty(note)

    def __apply_local_updates(self) -> List[Note]:
        local_upd = [
            v
            for v in self.__note_manager.base_model
            if v.dirty and not v.locally_deleted and v.remote_id >= 0
        ]
        for note in local_upd:
            if not note.content_loaded:
                self.__db.populate_note_content(note)
            note.saving = True
            res = self.__sync_worker.update_note(note)
            idle_add_wait(self.__update_note_metadata_post_sync, note, res)
        return local_upd

    def __apply_local_creations(self) -> List[Note]:
        local_new = [
            n
            for n in self.__note_manager.base_model
            if n.has_id and n.dirty and not n.locally_deleted and n.remote_id < 0
        ]
        for note in local_new:
            if not note.content_loaded:
                self.__db.populate_note_content(note)
            note.saving = True
            res = self.__sync_worker.create_note(note)
            idle_add_wait(self.__update_note_metadata_post_sync, note, res)
        return local_new

    def __apply_local_deletions(self) -> List[Note]:
        local_del_queue = [
            n
            for n in self.__note_manager.base_model
            if n.locally_deleted and n not in self.__note_manager.get_deleted_notes_pending_undo()
        ]
        local_del = []
        for note in local_del_queue:
            # logging.debug("Processing {} in local_del_queue".format(len(local_del_queue)))
            if note.remote_id >= 0:
                res = self.__sync_worker.delete_note(note)
                if res.success:
                    local_del.append(note)
            else:
                local_del.append(note)
        if len(local_del) > 0:
            for note in local_del:
                self.__db.delete_note(note.id)
            idle_add_wait(self.__note_manager.remove_notes_from_model, local_del)
        return local_del

    def __finish_sync(self, number_of_changes: int) -> None:
        GLib.idle_add(self.emit, "finished", number_of_changes)
        if self.__sync_requested_while_active:
            self.__lock.release()
            self.__sync()
        else:
            self.__waiting_timeout = GLib.timeout_add_seconds(self.__sync_interval, self.__sync)
            self.__lock.release()

    def __integrate_remote_changes(
        self, remote_new: List[dict], remote_upd: List[dict], remote_del: List[int]
    ) -> None:
        for sync_note in remote_new + remote_upd:
            existing_note = self.__note_manager.fetch_note_by_remote_id(sync_note["id"])
            if existing_note:
                if not existing_note.content_loaded:
                    self.__db.populate_note_content(existing_note)
                old_category = existing_note.category
                updated_fields = existing_note.update_from_remote_sync(sync_note)
                # Updated fields can be empty when doing a schema migration full resync
                if updated_fields.empty():
                    continue
                self.__db.persist_note_selective(existing_note, updated_fields)
                if updated_fields.category:
                    self.__category_manager.note_category_changed(
                        old_category, existing_note.category
                    )
            else:
                note = Note.from_nextcloud(sync_note)
                self.__db.add_note(note)
                self.__note_manager.add_notes_to_model([note])
                self.__category_manager.note_added(note.category)

        remove_notes = []
        for remote_id in remote_del:
            note = self.__note_manager.fetch_note_by_remote_id(remote_id)
            if note is not None:
                remove_notes.append(note)
                self.__db.delete_note(note.id)
        if len(remove_notes) > 0:
            self.__note_manager.remove_notes_from_model(remove_notes)
            for note in remove_notes:
                self.__category_manager.note_deleted(note.category)

    def __duplicate_note_for_sync_conflict(self, note: Note) -> None:
        if not note.content_loaded:
            self.__db.populate_note_content(note)
        # Translators: Description, used as a prefix to the previous title for notes updated both
        # locally and remotely. " - " is placed between this prefix and the title.
        reason = _("SYNC CONFLICT")
        new_note = self.__db.create_duplicate_note(note, reason)
        self.__note_manager.add_notes_to_model([new_note])
        self.__category_manager.note_added(new_note.category)

        new_note.saving = True
        res = self.__sync_worker.create_note(new_note)
        self.__update_note_metadata_post_sync(new_note, res)

    def __update_note_metadata_post_sync(self, note: Note, sync_result: SyncResult) -> None:
        if sync_result.success:
            previous_category = note.category
            remote_category = sync_result.data["category"]
            self.__note_manager.update_note_post_sync(note, sync_result)
            if previous_category != remote_category:
                GLib.idle_add(
                    self.emit, "remote-category-sanitised", previous_category, remote_category
                )
        else:
            logging.info("Note sync failure: %s", sync_result.status_code)
        note.saving = False
        if not sync_result.success or note.dirty_while_saving:
            note.flag_changed(False)
            note.dirty_while_saving = False

    def __on_sync_ready(self, _obj: GObject.Object, new_setup: bool) -> None:
        GLib.idle_add(self.emit, "ready", new_setup)
        if new_setup or self.__initial_sync_delay_complete:
            self.sync_now()

    def __on_missing_password(self, _obj: GObject.Object) -> None:
        logging.info(
            "Sync is configured but password missing. Login to the server to reauthenticate."
        )
        self.emit("missing-password")

    def __on_api_check_finished(self, _obj: GObject.Object) -> None:
        self.__api_check_complete = True
        thread = Thread(target=self.__sync_worker.fetch_settings)
        thread.daemon = True
        thread.start()

    def __on_settings_fetched(self, _obj: GObject.Object, file_suffix: str) -> None:
        if iotas.config_manager.get_backup_note_extension() != file_suffix:
            logging.info(
                f"Changed backup note filename extension to '{file_suffix}' "
                "as received from server"
            )
            iotas.config_manager.set_backup_note_extension(file_suffix)

    def __set_sync_interval(self) -> None:
        self.__sync_interval = iotas.config_manager.get_sync_interval()

    def __on_secret_service_failure(self, _obj: GObject.Object) -> None:
        GLib.idle_add(self.emit, "secret-service-failure")

    def __api_check_and_settings_sync(self) -> None:
        # API check and settings sync are slung onto the back of the first request so that first
        # sync isn't as delayed. Justified by the fact that the outcome of the API check isn't
        # currently impacting behaviour.
        if not self.__api_check_complete:
            thread = Thread(target=self.__sync_worker.check_capabilities)
            thread.daemon = True
            thread.start()

    def __perform_initial_sync(self) -> None:
        self.__initial_sync_delay_complete = True
        self.sync_now()
