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

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

from hashlib import sha256
import logging
from threading import Lock, Thread
import time
from typing import Optional

from iotas.attachment import Attachment
from iotas.attachment_helpers import write_attachment_to_disk
from iotas.category_manager import CategoryManager
from iotas.config_manager import ConfigManager
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 ContentPushSyncResult, FailedPush
from iotas.ui_utils import idle_add_wait


class SyncWorkLock:

    def __init__(self) -> None:
        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__ = {
        # bool: Whether a new account
        "ready": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
        "started": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "have-changes": (GObject.SignalFlags.RUN_FIRST, None, ()),
        # int: Number of changes
        "finished": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
        # Note: Note in conflict
        "note-conflict": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
        # Note: Remotely deleted note
        "note-deleted": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
        "secret-service-failure": (GObject.SignalFlags.RUN_FIRST, None, ()),
        # str: Previous category, str: Remote category
        "remote-category-sanitised": (GObject.SignalFlags.RUN_FIRST, None, (str, str)),
        "missing-password": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "notes-capability-missing": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "attachment-downloaded": (GObject.SignalFlags.RUN_FIRST, None, (Attachment,)),
    }

    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) -> None:
        super().__init__()
        self.__db = db
        self.__config_manager = ConfigManager.get_default()
        self.__sync_worker = NextcloudSyncWorker(self.__config_manager)
        self.__lock = SyncWorkLock()

        self.__note_manager: NoteManager
        self.__category_manager: CategoryManager

        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

        self.__config_manager.connect_changed(
            ConfigManager.SYNC_INTERVAL,
            self.__set_sync_interval,
        )
        self.__set_sync_interval()

        self.__config_manager.bind(
            ConfigManager.NETWORK_TIMEOUT,
            self.__sync_worker,
            "timeout",
            Gio.SettingsBindFlags.DEFAULT,
        )

        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", lambda _o: GLib.idle_add(self.emit, "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)
        self.__sync_worker.connect(
            "notes-capability-missing", lambda _o: self.emit("notes-capability-missing")
        )

    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", lambda _o: 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 = True
            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."""
        self.__config_manager.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)

    def download_attachment(self, attachment: Attachment) -> bool:
        """Fetch attachment from server and store.

        :param Attachment attachment: Attachment to transfer
        :return: Success
        :rtype: bool
        """
        self.__check_and_warn_for_other_note_attachment(attachment)
        result = self.__sync_worker.get_attachment(attachment)
        if not result.data:
            note = self.__note_manager.fetch_note_by_id(attachment.note_id)
            title = note.title if note else "?"  # mypy
            if result.status_code == 404:
                logging.warning(
                    f"Failed to download missing attachment '{attachment.path}' for note "
                    f"'{title}' ({result.status_code})"
                )
            else:
                logging.warning(
                    f"Failed to download attachment '{attachment.path}' for note "
                    f"'{title}' ({result.status_code})"
                )

            return False

        if not self.__db.get_note_by_remote_id(attachment.note_remote_id):
            logging.warning("Discarding attachment for removed note")
            # Returning true as this isn't a failure
            return True

        if not write_attachment_to_disk(attachment, result.data):
            return False

        GLib.idle_add(self.emit, "attachment-downloaded", attachment)
        return True

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

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

    @GObject.Property(type=bool, default=False)
    def configured_but_no_password(self) -> bool:
        return (
            self.__config_manager.nextcloud_endpoint != ""
            and self.__config_manager.nextcloud_username != ""
            and not self.authenticated
        )

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

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

    def __on_initial_note_load_complete(self) -> None:
        if self.__config_manager.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 = True
            return

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

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

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

        start_time = time.time()
        fetching_all = self.__sync_worker.prune_threshold == 0
        sync_result = self.__sync_worker.get_notes()
        if sync_result.success:
            logging.debug("Server GET took {:.2f}s".format(time.time() - start_time))
        else:
            logging.info(f"Refresh failed with status code {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:
            logging.info("Recovered connectivity")
            self.offline = False

        notes = sync_result.data
        assert notes is not None

        remote_ids_from_db = self.__db.get_all_notes_remote_ids()
        # logging.debug("Pulled {} notes".format(len(notes)))

        # Determine remote creations and updates
        remote_new, remote_upd = self.__determine_remote_new_and_updated(
            notes, remote_ids_from_db, fetching_all
        )

        # Remove duplicate updates caused by eg. ignored No-Cache headers
        remote_upd = self.__filter_out_false_updates(remote_upd)

        # Pre-processing for remote updates to notes which have local changes
        remote_upd = self.__preprocess_remote_updates_for_locally_modified(remote_upd)

        # 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:
            idle_add_wait(self.emit, "have-changes")

            # 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 = []

        # 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_updated(
        self, notes: list[dict], remote_ids_from_db: list[int], force_refresh_all: bool
    ) -> tuple[list[dict], list[dict]]:
        create = []
        update = []
        for sync_note in notes:
            # Notes before the prune cutoff will only have an "id" item
            if len(sync_note) == 1:
                continue

            if sync_note["id"] not in remote_ids_from_db:
                create.append(sync_note)
            elif force_refresh_all or self.__db.note_needs_update(
                sync_note["id"], sync_note["etag"]
            ):
                update.append(sync_note)

        return (create, update)

    def __filter_out_false_updates(self, updates_in: list[dict]) -> list[dict]:
        updates_out = []
        for remote_note_dict in updates_in:
            # Fetch note
            local_note = self.__note_manager.fetch_note_by_remote_id(remote_note_dict["id"])
            if local_note is None:
                logging.warning(
                    f"Failed to find local note with remote id '{remote_note_dict['id']}'"
                )
                continue

            # Handle possible false updates from misbehaving proxies etc
            remote_note = Note.from_nextcloud(remote_note_dict)
            if local_note.etag_is_recent(remote_note.etag):
                if local_note.identical_non_sync_meta(remote_note):
                    # The update from the server is an ETag from a recent push with no metadata
                    # changes, we're likely receiving cached or otherwise old content. Ignore the
                    # update.
                    logging.debug(
                        f"Remote note appears to be a past push, etag '{remote_note.etag}'"
                    )
                    # Skip adding for further processing
                    continue
                else:
                    if not local_note.content_loaded:
                        self.__db.populate_note_content(local_note)
                    if local_note.dirty and local_note.content == remote_note.content:
                        # If our local note has changed for something other than content we
                        # possibly have a real conflict. Allow falling through to that.
                        logging.debug(
                            "Note update received for locally modified note with no etag change "
                            "and no content change"
                        )
                    else:
                        # Possibly just a remote metadata change, but let's ignore the
                        # content to be safe
                        logging.debug("Note received with past etag but meta update")
                        remote_note_dict["ignore-content"] = True

            updates_out.append(remote_note_dict)

        return updates_out

    def __preprocess_remote_updates_for_locally_modified(
        self, updates_in: list[dict]
    ) -> list[dict]:
        updates_out = []
        for remote_note_dict in updates_in:
            # Fetch note
            local_note = self.__note_manager.fetch_note_by_remote_id(remote_note_dict["id"])
            if local_note is None:
                logging.warning(
                    f"Failed to find local note with remote id '{remote_note_dict['id']}'"
                )
                continue

            # Duplicate filtering can flag notes for which content changes should be ignored
            ignore_content_change = "ignore-content" in remote_note_dict

            # Handle local note with changes
            if local_note.dirty and not ignore_content_change:
                remote_note = Note.from_nextcloud(remote_note_dict)
                new_dirty = False
                if not local_note.content_loaded:
                    self.__db.populate_note_content(local_note)
                if local_note.identical_excepting_sync_meta(remote_note):
                    logging.info("Applying remote sync meta to otherwise identical note")
                elif self.__conflict_is_recent_failed_push(local_note, remote_note):
                    # The remote note is an older version of the local note. We attempted to sync
                    # it but didn't receive an ack and as a result our local etag is out of date.
                    # So we update the etag and ignore everything else.
                    logging.info(
                        "Avoiding conflict as remote note with updated etag appears to be "
                        "a failed past push"
                    )
                    local_note.etag = remote_note.etag
                    self.__db.persist_note_etag(local_note)
                    # Skip adding for further processing
                    continue
                else:
                    logging.info(
                        f"Sync. conflict, note '{local_note.title}' was modified locally and "
                        "updated remotely"
                    )
                    GLib.idle_add(self.emit, "note-conflict", local_note)
                    # Duplicate existing note
                    GLib.idle_add(self.__duplicate_note_for_sync_conflict, local_note)

                local_note.dirty = new_dirty
                self.__db.persist_note_dirty(local_note)

            updates_out.append(remote_note_dict)

        return updates_out

    def __check_for_remote_delete_conflicts(self, remote_del: list[int]) -> 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:
            deleted_note = self.__note_manager.fetch_note_by_remote_id(remote_id)
            if deleted_note is not None:
                self.emit("note-deleted", deleted_note)
                remove_notes.append(deleted_note)
        if len(remove_notes) > 0:
            self.__note_manager.delete_notes(remove_notes, provide_undo=False)

    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: ContentPushSyncResult
    ) -> 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(f"Note push failed (with status code {sync_result.status_code})")

            if sync_result.sent_content is not None:
                # Track this failed push so that it can be used to avoid possible conflicts if the
                # push actually did make it to the server but we haven't received the tag to update.
                self.__track_failed_push(note, sync_result.sent_content)
            else:
                logging.warning("Wanted to track failed push with null sent content")

        note.saving = False
        if not sync_result.success or note.dirty_while_saving:
            note.flag_changed(update_last_modified=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:
        if self.configured_but_no_password:
            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 self.__config_manager.backup_note_extension != file_suffix:
            logging.info(
                f"Changed backup note filename extension to '{file_suffix}' "
                "as received from server"
            )
            self.__config_manager.backup_note_extension = file_suffix

    def __set_sync_interval(self) -> None:
        self.__sync_interval = self.__config_manager.sync_interval

    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()

    def __track_failed_push(self, note: Note, sent_content: str) -> None:
        hash = sha256(sent_content.encode("utf-8")).hexdigest()
        length = len(sent_content)
        timestamp = int(time.time())
        push = FailedPush(hash, length, timestamp)
        self.__note_manager.add_failed_push(note, push)

    def __conflict_is_recent_failed_push(self, note: Note, remote_note: Note) -> bool:
        pushes = self.__note_manager.get_note_failed_pushes(note)
        if not pushes:
            return False

        hash = sha256(remote_note.content.encode("utf-8")).hexdigest()
        length = len(remote_note.content)
        matched = False

        for push in pushes:
            if push.hash == hash and push.length == length:
                logging.info("Changed etag matches failed push, conflict avoided")
                matched = True
                break

        return matched

    def __check_and_warn_for_other_note_attachment(self, attachment: Attachment) -> None:
        path = attachment.path
        this_note_prefix = f".attachments.{attachment.note_remote_id}/"
        if path.startswith(".attachments.") and not path.startswith(this_note_prefix):
            logging.warning(
                f"Attachment '{attachment.filename}' appears to belong to another note. "
                "This is unsupported; unpredictable behaviour ahead."
            )
