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

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

from iotas.backup_storage import BackupStorage
from iotas.note import Note, DirtyFields
from iotas.text_utils import sanitise_path


class BackupManager(GObject.Object):

    DATA_SUBPATH = "iotas"
    PRIMARY_DIRNAME = "backup"
    ARCHIVE_DIRNAME = "backup-previous"
    METADATA_EXTENSION = "iota"
    EXPORT_SCHEMA_VERSION = "1.0"
    MAXIMUM_CONTENT_SIZE_MB = 100

    def __init__(
        self, db: BackupStorage, iotas_version: str, storage_dir: Optional[str] = None
    ) -> None:
        super().__init__()
        self.__db = db
        self.__iotas_version = iotas_version

        if storage_dir:
            working_directory = storage_dir
        else:
            working_directory = os.path.join(GLib.get_user_data_dir(), self.DATA_SUBPATH)
        self.__primary_backup_path = os.path.join(working_directory, self.PRIMARY_DIRNAME)
        self.__archive_backup_path = os.path.join(working_directory, self.ARCHIVE_DIRNAME)

        self.__file_extension = "md"
        self.__sync_configured = False

    def create_backup(self, sync_configured: bool, file_extension: str) -> bool:
        """Create a backup.

        :param bool sync_configured: Whether sync is configured
        :param str file_extension: File extension to use for notes
        :return: Whether successful
        :rtype: bool
        """
        self.__sync_configured = sync_configured
        self.__file_extension = file_extension

        if not os.path.exists(self.__primary_backup_path):
            try:
                os.mkdir(self.__primary_backup_path)
            except OSError as e:
                logging.error(
                    f"Failed to create backup directory at {self.__primary_backup_path}: {e}"
                )
                return False

        if not self.__switch_old_backup_to_archive():
            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:
            logging.info(f"Backup created at {self.__primary_backup_path}")
            if sync_configured:
                logging.warning(
                    "Backup restoration isn't possible with Nextcloud Notes sync configured"
                )
        else:
            logging.error("Backup failed")
        return success

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

        :param bool sync_configured: Whether sync is configured
        :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.

        self.__sync_configured = sync_configured

        if not os.path.exists(self.__primary_backup_path):
            logging.error(f"No backup exists at {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:
            logging.error(f"No backup exists at {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("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 sync_configured:
            logging.error("Backup restoration isn't possible with Nextcloud Notes sync configured")
            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:
            logging.error("Backup restoration failed")
            return False

        for note in notes:
            self.__restore_note(note)

        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": self.__iotas_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(f"Failed to write metadata to {filename}: {e}")
        return success

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

        content_filename = f"{title}.{self.__file_extension}"
        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(f"Failed to write metadata to {filename}: {e}")
        return (success, content_filename)

    def __restore_note(self, note: Note) -> None:
        # Check for existing remote id
        remote_id_note = None
        if self.__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
                logging.debug(f"Creating {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(f'Skipping restoration of existing identical note "{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(f'Duplicating note "{note.title}" due to matching remote id')

    def __restore_note_with_matching_title(self, note: Note, existing_note: Note):
        if note.identical_to(existing_note):
            logging.debug(f'Skipping restoration of existing identical note "{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)
                logging.info(f'Updating metadata for note "{note.title}"')
            else:
                msg = '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(
                f'Duplicating note "{note.title}" due to matching title but different content'
            )

    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.locally_deleted = 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(f"Failed to remove backup archive directory at {archive_path}: {e}")
                return False

        try:
            os.mkdir(archive_path)
        except OSError as e:
            logging.error(f"Failed to create backup archive directory at {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(f"Failed to move {content_file} into {archive_path}: {e}")
                    return False
            try:
                shutil.move(meta_file, archive_path)
            except OSError as e:
                logging.error(f"Failed to move {meta_file} into {archive_path}: {e}")
                return False

        return True

    def __load_note(self, meta_full_path: str) -> 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(f'Skipping "{content_filename}" due to missing content')
            return None

        # Skip restoring massive files
        if os.path.getsize(content_full_path) > self.MAXIMUM_CONTENT_SIZE_MB * 1000 * 1000:
            logging.error(
                f'Bailing "{content_filename}" due to exceeding {self.MAXIMUM_CONTENT_SIZE_MB}MB'
            )
            return None

        try:
            with open(content_full_path, "r") as f:
                content = f.read()
        except OSError as e:
            logging.error(f"Failed to read note content from {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(f"Failed to read note metadata from {meta_filename}: {e}")
            return None

        note = Note.from_backup(meta, content, self.__sync_configured)
        if note is None:
            logging.error(f'Failed to parse note metadata from "{meta_filename}"')
            return None

        return note

    @staticmethod
    def get_default_primary_path() -> str:
        return os.path.join(
            GLib.get_user_data_dir(), BackupManager.DATA_SUBPATH, BackupManager.PRIMARY_DIRNAME
        )
