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

import glob
import json
import logging
import os
import shutil
from typing import Optional, Tuple

import iotas.config_manager
from iotas import const
from iotas.note import Note, DirtyFields
from iotas.note_database import NoteDatabase
from iotas.string_utils import sanitise_path


class BackupManager(GObject.Object):
    PRIMARY_BACKUP_PATH = os.path.join(GLib.get_user_data_dir(), "iotas", "backup")
    ARCHIVE_BACKUP_PATH = os.path.join(GLib.get_user_data_dir(), "iotas", "backup-previous")
    METADATA_EXTENSION = "iota"
    EXPORT_SCHEMA_VERSION = "1.0"
    MAXIMUM_CONTENT_SIZE_MB = 100

    def __init__(self, db: NoteDatabase):
        super().__init__()
        self.__db = db

    def create_backup(self) -> bool:
        """Create a backup.

        :return: Whether successful
        :rtype: bool
        """
        if not os.path.exists(self.PRIMARY_BACKUP_PATH):
            try:
                os.mkdir(self.PRIMARY_BACKUP_PATH)
            except OSError as e:
                # Translators: Log message, {0} is a filesystem path, {1} an error message
                logging.error(
                    _("Failed to create backup directory at {0}: {1}").format(
                        self.PRIMARY_BACKUP_PATH, e
                    )
                )
                return False

        if not self.__switch_old_backup_to_archive():
            # Translators: Log message
            logging.error(_("Failed to move previous backup to archive path"))
            return False

        success = True
        notes = self.__db.get_all_notes(load_content=True)
        for note in notes:
            success, content_filename = self.__write_note_content(note)
            if not success:
                break

            meta_filename = f"{content_filename}.{self.METADATA_EXTENSION}"
            success = self.__write_note_metadata(note, meta_filename)
            if not success:
                break

        if success:
            # Translators: Log message, {} is a filesystem path
            logging.info(_("Backup created at {}").format(self.PRIMARY_BACKUP_PATH))
            if iotas.config_manager.nextcloud_sync_configured():
                # Translators: Log message
                msg = _("Backup restoration isn't possible with Nextcloud Notes sync configured")
                logging.warning(msg)
        else:
            # Translators: Log message
            logging.error(_("Backup failed"))
        return success

    def restore_backup(self) -> bool:
        """Restore a backup.

        :return: Whether successful
        :rtype: bool
        """

        # A fairly light touch has been taken here. Presuming that existing backups are
        # unmodified/sane restoring to an empty session should be fairly straight forward.
        #
        # Some work has been done towards merging into an existing collection and restoring to a
        # remotely connect session but as they haven't been thoroughly tested yet they're not
        # exposed.

        if not os.path.exists(self.PRIMARY_BACKUP_PATH):
            # Translators: Log message, {} is a filesystem path
            logging.error(_("No backup exists at {}").format(self.PRIMARY_BACKUP_PATH))
            return False

        file_list = glob.glob(
            os.path.join(self.PRIMARY_BACKUP_PATH, f"*.{self.METADATA_EXTENSION}")
        )
        if len(file_list) == 0:
            # Translators: Log message, {} is a filesystem path
            logging.error(_("No backup exists at {}").format(self.PRIMARY_BACKUP_PATH))
            return False

        # Notify and quit if attempting to merge a backup into existing notes
        if self.__db.get_all_notes_count() > 0:
            logging.error(
                # Translators: Log message
                _("Backup restoration can only be run when there are no existing notes")
            )
            return False

        # Notify and quit if attempting to restore a backup into a synced session
        if iotas.config_manager.nextcloud_sync_configured():
            # Translators: Log message
            msg = _("Backup restoration isn't possible with Nextcloud Notes sync configured")
            logging.error(msg)
            return False

        # Load all notes first, to avoid dealing with transactions
        notes = []
        success = True
        for meta_full_path in file_list:
            note = self.__load_note(meta_full_path)
            if note is None:
                success = False
                break
            notes.append(note)

        if not success:
            # Translators: Log message
            logging.error(_("Backup restoration failed"))
            return False

        for note in notes:
            self.__restore_note(note)

        # Translators: Log message
        logging.info(_("Backup restoration completed"))
        return True

    def __write_note_metadata(self, note: Note, meta_filename: str) -> bool:
        content = {
            "SchemaVersion": self.EXPORT_SCHEMA_VERSION,
            "IotasVersion": const.VERSION,
            "Title": note.title,
            "Category": note.category,
            "LastModified": note.last_modified,
            "Favourite": note.favourite,
            "Dirty": note.dirty,
            "LocallyDeleted": note.locally_deleted,
            "RemoteId": note.remote_id,
            "ETag": note.etag,
        }

        filename = os.path.join(self.PRIMARY_BACKUP_PATH, meta_filename)
        success = False
        try:
            with open(filename, "w") as f:
                f.write(json.dumps(content, indent=2))
                success = True
        except OSError as e:
            logging.warning(
                # Translators: Log message, {0} is a filename and {1} an error message
                _("Failed to write metadata to {0}: {1}").format(filename, e)
            )
        return success

    def __write_note_content(self, note: Note) -> Tuple[bool, str]:
        title = sanitise_path(note.title)

        ext = iotas.config_manager.get_backup_note_extension()
        content_filename = f"{title}.{ext}"
        filename = os.path.join(self.PRIMARY_BACKUP_PATH, content_filename)
        success = False
        try:
            with open(filename, "w") as f:
                f.write(note.content)
                success = True
        except OSError as e:
            logging.warning(
                # Translators: Log message, {0} is a filename and {1} an error message
                _("Failed to write metadata to {0}: {1}").format(filename, e)
            )
        return (success, content_filename)

    def __restore_note(self, note: Note) -> None:
        # Check for existing remote id
        remote_id_note = None
        if iotas.config_manager.nextcloud_sync_configured() and note.remote_id > -1:
            remote_id_note = self.__db.get_note_by_remote_id(note.remote_id)
        if remote_id_note is not None:
            self.__restore_note_with_matching_remote_id(note, remote_id_note)
        else:
            existing_note = self.__db.get_note_by_title(note.title)
            if existing_note is not None:
                self.__restore_note_with_matching_title(note, existing_note)
            else:
                # Create new
                # Translators: Log message, {} is a note title
                logging.debug(_("Creating {}").format(note.title))
                self.__db.add_note(note)

    def __restore_note_with_matching_remote_id(self, note: Note, remote_id_note: Note) -> None:
        if note.identical_to(remote_id_note):
            logging.debug(
                # Translators: Log message, {} is a note title
                _('Skipping restoration of existing identical note "{}"').format(note.title)
            )
        else:
            # Translators: Description, prefixes note title on backup restoration clash
            duplicate_reason = _("RESTORATION REMOTE ID CLASH")
            self.__db.create_duplicate_note(note, duplicate_reason)
            logging.info(
                # Translators: Log message, {} is a note title
                _('Duplicating note "{}" due to matching remote id').format(note.title)
            )

    def __restore_note_with_matching_title(self, note: Note, existing_note: Note):
        if note.identical_to(existing_note):
            logging.debug(
                # Translators: Log message, {} is a note title
                _('Skipping restoration of existing identical note "{}"').format(note.title)
            )
        elif note.content == existing_note.content:
            if note.last_modified > existing_note.last_modified:
                # Update metadata if last modified newer and content matches
                self.__update_note_metadata(existing_note, note)
                # Translators: Log message, {} is a note title
                logging.info(_('Updating metadata for note "{}"').format(note.title))
            else:
                msg = _(
                    # Translators: Log message, {} is a note title
                    'Skipping note "{}" with matching title, contents and a newer timestamp'
                )
                logging.info(msg.format(note.title))
        else:
            # Duplicate note
            # Translators: Description, prefixes note title on backup restoration clash
            duplicate_reason = _("RESTORATION TITLE CLASH")
            self.__db.create_duplicate_note(note, duplicate_reason)
            logging.info(
                _(
                    # Translators: Log message, {} is a note title
                    'Duplicating note "{}" due to matching title but different content'
                ).format(note.title)
            )

    def __update_note_metadata(self, dest: Note, source: Note) -> None:
        # Update metadata if last modified newer and content matches
        changed_fields = DirtyFields()
        if dest.favourite != source.favourite:
            dest.favourite = source.favourite
            changed_fields.favourite = True
        if dest.last_modified != source.last_modified:
            dest.last_modified = source.last_modified
            changed_fields.last_modified = True
        if dest.category != source.category:
            dest.category = source.category
            changed_fields.category = True
        if dest.locally_deleted != source.locally_deleted:
            dest.locally_deleted = source.locally_deleted
            changed_fields.deleted_locally = True
        if dest.etag != source.etag:
            dest.etag = source.etag
            changed_fields.etag = True
        if dest.remote_id != source.remote_id:
            dest.remote_id = source.remote_id
            changed_fields.remote_id = True
        self.__db.persist_note_selective(dest, changed_fields)

    def __switch_old_backup_to_archive(self) -> bool:
        file_list = glob.glob(
            os.path.join(self.PRIMARY_BACKUP_PATH, f"*.{self.METADATA_EXTENSION}")
        )
        if len(file_list) == 0:
            return True

        archive_path = self.ARCHIVE_BACKUP_PATH
        if os.path.exists(archive_path):
            try:
                shutil.rmtree(archive_path)
            except OSError as e:
                logging.error(
                    # Translators: Log message, {0} is a filesystem path, {1} an error message
                    _("Failed to remove backup archive directory at {0}: {1}").format(
                        archive_path, e
                    )
                )
                return False

        try:
            os.mkdir(archive_path)
        except OSError as e:
            # Translators: Log message, {0} is a filesystem path, {1} an error message
            msg = _("Failed to create backup archive directory at {0}: {1}")
            logging.error(msg.format(archive_path, e))
            return False

        for meta_file in file_list:
            content_file = ".".join(meta_file.split(".")[:-1])
            if os.path.exists(content_file):
                try:
                    shutil.move(content_file, archive_path)
                except OSError as e:
                    logging.error(
                        # Translators: Log message, {0} is a file, {1} is its destination path,
                        # and {2} an error message
                        _("Failed to move {0} into {1}: {2}").format(content_file, archive_path, e)
                    )
                    return False
            try:
                shutil.move(meta_file, archive_path)
            except OSError as e:
                logging.error(
                    # Translators: Log message, {0} is a file, {1} is its destination path,
                    # and {2} an error message
                    _("Failed to move {0} into {1}: {2}").format(meta_file, archive_path, e)
                )
                return False

        return True

    def __load_note(self, meta_full_path) -> Optional[Note]:
        meta_filename = os.path.basename(meta_full_path)
        content_full_path = ".".join(meta_full_path.split(".")[:-1])
        content_filename = os.path.basename(content_full_path)
        if not os.path.exists(content_full_path):
            logging.warning(
                # Translators: Log message, {} is a filename
                _('Skipping "{}" due to missing content').format(content_filename)
            )
            return None

        # Skip restoring massive files
        if os.path.getsize(content_full_path) > self.MAXIMUM_CONTENT_SIZE_MB * 1000 * 1000:
            logging.error(
                # Translators: Log message, {0} is a filename and {1} is a is a number (eg. 100)
                _('Bailing "{0}" due to exceeding {1}MB').format(
                    content_filename, self.MAXIMUM_CONTENT_SIZE_MB
                )
            )
            return None

        try:
            with open(content_full_path, "r") as f:
                content = f.read()
        except OSError as e:
            logging.error(
                # Translators: Log message, {0} is a filename and {1} an error message
                _("Failed to read note content from {0}: {1}").format(content_full_path, e)
            )
            return None

        try:
            with open(meta_full_path, "r") as f:
                meta_str = f.read()
                meta = json.loads(meta_str)
        except (OSError, json.decoder.JSONDecodeError) as e:
            logging.error(
                # Translators: Log message, {0} is a filename and {1} an error message
                _("Failed to read note metadata from {0}: {1}").format(meta_filename, e)
            )
            return None

        note = Note.from_backup(meta, content)
        if note is None:
            logging.error(
                # Translators: Log message, {} is a filename
                _('Failed to parse note metadata from "{}"').format(meta_filename)
            )
            return None

        return note
