__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License"

import logging
import os
import time
from collections import namedtuple

import octoprint.plugin
import octoprint.util
from octoprint.events import Events, eventManager
from octoprint.util import get_fully_qualified_classname as fqcn
from octoprint.util import yaml

from .analysis import AnalysisQueue, QueueEntry  # noqa: F401
from .destinations import FileDestinations  # noqa: F401
from .storage import LocalFileStorage  # noqa: F401
from .util import AbstractFileWrapper, DiskFileWrapper, StreamWrapper  # noqa: F401

ContentTypeMapping = namedtuple("ContentTypeMapping", "extensions, content_type")
ContentTypeDetector = namedtuple("ContentTypeDetector", "extensions, detector")

extensions = {}


def full_extension_tree():
    result = {
        "machinecode": {"gcode": ContentTypeMapping(["gcode", "gco", "g"], "text/plain")}
    }

    def leaf_merger(a, b):
        supported_leaf_types = (ContentTypeMapping, ContentTypeDetector, list)
        if not isinstance(a, supported_leaf_types) or not isinstance(
            b, supported_leaf_types
        ):
            raise ValueError()

        if isinstance(a, ContentTypeDetector) and isinstance(b, ContentTypeMapping):
            raise ValueError()

        if isinstance(a, ContentTypeMapping) and isinstance(b, ContentTypeDetector):
            raise ValueError()

        a_list = a if isinstance(a, list) else a.extensions
        b_list = b if isinstance(b, list) else b.extensions
        merged = a_list + b_list

        content_type = None
        if isinstance(b, ContentTypeMapping):
            content_type = b.content_type
        elif isinstance(a, ContentTypeMapping):
            content_type = a.content_type

        detector = None
        if isinstance(b, ContentTypeDetector):
            detector = b.detector
        elif isinstance(a, ContentTypeDetector):
            detector = a.detector

        if content_type is not None:
            return ContentTypeMapping(merged, content_type)
        elif detector is not None:
            return ContentTypeDetector(merged, detector)
        else:
            return merged

    slicer_plugins = octoprint.plugin.plugin_manager().get_implementations(
        octoprint.plugin.SlicerPlugin
    )
    for plugin in slicer_plugins:
        try:
            plugin_result = plugin.get_slicer_extension_tree()
            if plugin_result is None or not isinstance(plugin_result, dict):
                continue
            octoprint.util.dict_merge(
                result, plugin_result, leaf_merger=leaf_merger, in_place=True
            )
        except Exception:
            logging.getLogger(__name__).exception(
                "Exception while retrieving additional extension "
                "tree entries from SlicerPlugin {name}".format(name=plugin._identifier),
                extra={"plugin": plugin._identifier},
            )

    extension_tree_hooks = octoprint.plugin.plugin_manager().get_hooks(
        "octoprint.filemanager.extension_tree"
    )
    for name, hook in extension_tree_hooks.items():
        try:
            hook_result = hook()
            if hook_result is None or not isinstance(hook_result, dict):
                continue
            result = octoprint.util.dict_merge(
                result, hook_result, leaf_merger=leaf_merger, in_place=True
            )
        except Exception:
            logging.getLogger(__name__).exception(
                "Exception while retrieving additional extension "
                "tree entries from hook {name}".format(name=name),
                extra={"plugin": name},
            )

    return result


def get_extensions(type, subtree=None):
    if subtree is None:
        subtree = full_extension_tree()

    for key, value in subtree.items():
        if key == type:
            return get_all_extensions(subtree=value)
        elif isinstance(value, dict):
            sub_extensions = get_extensions(type, subtree=value)
            if sub_extensions:
                return sub_extensions

    return None


def get_all_extensions(subtree=None):
    if subtree is None:
        subtree = full_extension_tree()

    result = []
    if isinstance(subtree, dict):
        for value in subtree.values():
            if isinstance(value, dict):
                result += get_all_extensions(value)
            elif isinstance(value, (ContentTypeMapping, ContentTypeDetector)):
                result += value.extensions
            elif isinstance(value, (list, tuple)):
                result += value
    elif isinstance(subtree, (ContentTypeMapping, ContentTypeDetector)):
        result = subtree.extensions
    elif isinstance(subtree, (list, tuple)):
        result = subtree
    return result


def get_path_for_extension(extension, subtree=None):
    if subtree is None:
        subtree = full_extension_tree()

    for key, value in subtree.items():
        if (
            isinstance(value, (ContentTypeMapping, ContentTypeDetector))
            and extension in value.extensions
        ):
            return [key]
        elif isinstance(value, (list, tuple)) and extension in value:
            return [key]
        elif isinstance(value, dict):
            path = get_path_for_extension(extension, subtree=value)
            if path:
                return [key] + path

    return None


def get_content_type_mapping_for_extension(extension, subtree=None):
    if subtree is None:
        subtree = full_extension_tree()

    for value in subtree.values():
        content_extension_matches = (
            isinstance(value, (ContentTypeMapping, ContentTypeDetector))
            and extension in value.extensions
        )
        list_extension_matches = isinstance(value, (list, tuple)) and extension in value

        if content_extension_matches or list_extension_matches:
            return value
        elif isinstance(value, dict):
            result = get_content_type_mapping_for_extension(extension, subtree=value)
            if result is not None:
                return result

    return None


def valid_extension(extension, type=None, tree=None):
    if not type:
        return extension in get_all_extensions(subtree=tree)
    else:
        extensions = get_extensions(type, subtree=tree)
        if extensions:
            return extension in extensions


def valid_file_type(filename, type=None, tree=None):
    _, extension = os.path.splitext(filename)
    extension = extension[1:].lower()
    return valid_extension(extension, type=type, tree=tree)


def get_file_type(filename):
    _, extension = os.path.splitext(filename)
    extension = extension[1:].lower()
    return get_path_for_extension(extension)


def get_mime_type(filename):
    _, extension = os.path.splitext(filename)
    extension = extension[1:].lower()
    mapping = get_content_type_mapping_for_extension(extension)
    if mapping:
        if isinstance(mapping, ContentTypeMapping) and mapping.content_type is not None:
            return mapping.content_type
        elif isinstance(mapping, ContentTypeDetector) and callable(mapping.detector):
            result = mapping.detector(filename)
            if result is not None:
                return result
    return "application/octet-stream"


class NoSuchStorage(Exception):
    pass


class FileManager:
    def __init__(
        self,
        analysis_queue,
        slicing_manager,
        printer_profile_manager,
        initial_storage_managers=None,
    ):
        self._logger = logging.getLogger(__name__)
        self._analysis_queue = analysis_queue
        self._analysis_queue.register_finish_callback(self._on_analysis_finished)

        self._storage_managers = {}
        if initial_storage_managers:
            self._storage_managers.update(initial_storage_managers)

        self._slicing_manager = slicing_manager
        self._printer_profile_manager = printer_profile_manager

        import threading

        self._slicing_jobs = {}
        self._slicing_jobs_mutex = threading.Lock()

        self._slicing_progress_callbacks = []
        self._last_slicing_progress = None

        self._progress_plugins = []
        self._preprocessor_hooks = {}

        import octoprint.settings

        self._recovery_file = os.path.join(
            octoprint.settings.settings().getBaseFolder("data"),
            "print_recovery_data.yaml",
        )
        self._analyzeGcode = octoprint.settings.settings().get(["gcodeAnalysis", "runAt"])

    def initialize(self, process_backlog=False):
        self.reload_plugins()
        if process_backlog:
            self.process_backlog()

    def process_backlog(self):
        # only check for a backlog if gcodeAnalysis is 'idle' or 'always'
        if self._analyzeGcode == "never":
            return

        def worker():
            self._logger.info(
                "Adding backlog items from all storage types to analysis queue..."
            )
            for storage_type, storage_manager in self._storage_managers.items():
                self._determine_analysis_backlog(storage_type, storage_manager)

        import threading

        thread = threading.Thread(target=worker)
        thread.daemon = True
        thread.start()

    def reload_plugins(self):
        self._progress_plugins = octoprint.plugin.plugin_manager().get_implementations(
            octoprint.plugin.ProgressPlugin
        )
        self._preprocessor_hooks = octoprint.plugin.plugin_manager().get_hooks(
            "octoprint.filemanager.preprocessor"
        )

    def register_slicingprogress_callback(self, callback):
        self._slicing_progress_callbacks.append(callback)

    def unregister_slicingprogress_callback(self, callback):
        try:
            self._slicing_progress_callbacks.remove(callback)
        except ValueError:
            # callback was not registered
            pass

    def _determine_analysis_backlog(
        self, storage_type, storage_manager, root=None, high_priority=False
    ):
        counter = 0

        backlog_generator = storage_manager.analysis_backlog
        if root is not None:
            backlog_generator = storage_manager.analysis_backlog_for_path(path=root)

        for entry, path, _ in backlog_generator:
            file_type = get_file_type(path)[-1]
            file_name = storage_manager.split_path(path)

            # we'll use the default printer profile for the backlog since we don't know better
            queue_entry = QueueEntry(
                file_name,
                entry,
                file_type,
                storage_type,
                path,
                self._printer_profile_manager.get_default(),
                None,
            )
            if self._analysis_queue.enqueue(queue_entry, high_priority=high_priority):
                counter += 1

        if root:
            self._logger.info(
                f'Added {counter} items from storage type "{storage_type}" and root "{root}" to analysis queue'
            )
        else:
            self._logger.info(
                f'Added {counter} items from storage type "{storage_type}" to analysis queue'
            )

    def add_storage(self, storage_type, storage_manager):
        self._storage_managers[storage_type] = storage_manager
        self._determine_analysis_backlog(storage_type, storage_manager)

    def remove_storage(self, type):
        if type not in self._storage_managers:
            return
        del self._storage_managers[type]

    @property
    def registered_storages(self):
        return list(self._storage_managers.keys())

    @property
    def slicing_enabled(self):
        return self._slicing_manager.slicing_enabled

    @property
    def registered_slicers(self):
        return self._slicing_manager.registered_slicers

    @property
    def default_slicer(self):
        return self._slicing_manager.default_slicer

    def analyse(self, destination, path, printer_profile_id=None):
        if not self.file_exists(destination, path):
            return

        if printer_profile_id is None:
            printer_profile = self._printer_profile_manager.get_current_or_default()
        else:
            printer_profile = self._printer_profile_manager.get(printer_profile_id)
            if printer_profile is None:
                printer_profile = self._printer_profile_manager.get_current_or_default()

        queue_entry = self._analysis_queue_entry(destination, path)
        self._analysis_queue.dequeue(queue_entry)

        queue_entry = self._analysis_queue_entry(
            destination, path, printer_profile=printer_profile
        )
        if queue_entry:
            return self._analysis_queue.enqueue(queue_entry, high_priority=True)

        return False

    def slice(
        self,
        slicer_name,
        source_location,
        source_path,
        dest_location,
        dest_path,
        position=None,
        profile=None,
        printer_profile_id=None,
        overrides=None,
        display=None,
        callback=None,
        callback_args=None,
    ):
        absolute_source_path = self.path_on_disk(source_location, source_path)

        def stlProcessed(
            source_location,
            source_path,
            tmp_path,
            dest_location,
            dest_path,
            start_time,
            printer_profile_id,
            callback,
            callback_args,
            _error=None,
            _cancelled=False,
            _analysis=None,
        ):
            try:
                if _error:
                    eventManager().fire(
                        Events.SLICING_FAILED,
                        {
                            "slicer": slicer_name,
                            "stl": source_path,
                            "stl_location": source_location,
                            "gcode": dest_path,
                            "gcode_location": dest_location,
                            "reason": _error,
                        },
                    )
                elif _cancelled:
                    eventManager().fire(
                        Events.SLICING_CANCELLED,
                        {
                            "slicer": slicer_name,
                            "stl": source_path,
                            "stl_location": source_location,
                            "gcode": dest_path,
                            "gcode_location": dest_location,
                        },
                    )
                else:
                    source_meta = self.get_metadata(source_location, source_path)
                    hash = source_meta.get("hash", "n/a")

                    import io

                    links = [("model", {"name": source_path})]
                    _, stl_name = self.split_path(source_location, source_path)
                    file_obj = StreamWrapper(
                        os.path.basename(dest_path),
                        io.BytesIO(
                            f";Generated from {stl_name} (hash: {hash})\n".encode(
                                "ascii", "replace"
                            )
                        ),
                        io.FileIO(tmp_path, "rb"),
                    )

                    printer_profile = self._printer_profile_manager.get(
                        printer_profile_id
                    )
                    self.add_file(
                        dest_location,
                        dest_path,
                        file_obj,
                        display=display,
                        links=links,
                        allow_overwrite=True,
                        printer_profile=printer_profile,
                        analysis=_analysis,
                    )

                    end_time = time.monotonic()
                    eventManager().fire(
                        Events.SLICING_DONE,
                        {
                            "slicer": slicer_name,
                            "stl": source_path,
                            "stl_location": source_location,
                            "gcode": dest_path,
                            "gcode_location": dest_location,
                            "time": end_time - start_time,
                        },
                    )

                    if callback is not None:
                        if callback_args is None:
                            callback_args = ()
                        callback(*callback_args)
            finally:
                os.remove(tmp_path)

                source_job_key = (source_location, source_path)
                dest_job_key = (dest_location, dest_path)

                with self._slicing_jobs_mutex:
                    if source_job_key in self._slicing_jobs:
                        del self._slicing_jobs[source_job_key]
                    if dest_job_key in self._slicing_jobs:
                        del self._slicing_jobs[dest_job_key]

        slicer = self._slicing_manager.get_slicer(slicer_name)

        start_time = time.monotonic()
        eventManager().fire(
            Events.SLICING_STARTED,
            {
                "slicer": slicer_name,
                "stl": source_path,
                "stl_location": source_location,
                "gcode": dest_path,
                "gcode_location": dest_location,
                "progressAvailable": slicer.get_slicer_properties().get(
                    "progress_report", False
                )
                if slicer
                else False,
            },
        )

        import tempfile

        f = tempfile.NamedTemporaryFile(suffix=".gco", delete=False)
        temp_path = f.name
        f.close()

        with self._slicing_jobs_mutex:
            source_job_key = (source_location, source_path)
            dest_job_key = (dest_location, dest_path)
            if dest_job_key in self._slicing_jobs:
                (
                    job_slicer_name,
                    job_absolute_source_path,
                    job_temp_path,
                ) = self._slicing_jobs[dest_job_key]

                self._slicing_manager.cancel_slicing(
                    job_slicer_name, job_absolute_source_path, job_temp_path
                )
                del self._slicing_jobs[dest_job_key]

            self._slicing_jobs[dest_job_key] = self._slicing_jobs[source_job_key] = (
                slicer_name,
                absolute_source_path,
                temp_path,
            )

        args = (
            source_location,
            source_path,
            temp_path,
            dest_location,
            dest_path,
            start_time,
            printer_profile_id,
            callback,
            callback_args,
        )
        self._slicing_manager.slice(
            slicer_name,
            absolute_source_path,
            temp_path,
            profile,
            stlProcessed,
            position=position,
            callback_args=args,
            overrides=overrides,
            printer_profile_id=printer_profile_id,
            on_progress=self.on_slicing_progress,
            on_progress_args=(
                slicer_name,
                source_location,
                source_path,
                dest_location,
                dest_path,
            ),
        )

    def on_slicing_progress(
        self,
        slicer,
        source_location,
        source_path,
        dest_location,
        dest_path,
        _progress=None,
    ):
        if not _progress:
            return

        progress_int = int(_progress * 100)
        if self._last_slicing_progress != progress_int:
            self._last_slicing_progress = progress_int
            for callback in self._slicing_progress_callbacks:
                try:
                    callback.sendSlicingProgress(
                        slicer,
                        source_location,
                        source_path,
                        dest_location,
                        dest_path,
                        progress_int,
                    )
                except Exception:
                    self._logger.exception(
                        "Exception while pushing slicing progress",
                        extra={"callback": fqcn(callback)},
                    )

            if progress_int:

                def call_plugins(
                    slicer,
                    source_location,
                    source_path,
                    dest_location,
                    dest_path,
                    progress,
                ):
                    for plugin in self._progress_plugins:
                        try:
                            plugin.on_slicing_progress(
                                slicer,
                                source_location,
                                source_path,
                                dest_location,
                                dest_path,
                                progress,
                            )
                        except Exception:
                            self._logger.exception(
                                "Exception while sending slicing progress to plugin %s"
                                % plugin._identifier,
                                extra={"plugin": plugin._identifier},
                            )

                import threading

                thread = threading.Thread(
                    target=call_plugins,
                    args=(
                        slicer,
                        source_location,
                        source_path,
                        dest_location,
                        dest_path,
                        progress_int,
                    ),
                )
                thread.daemon = False
                thread.start()

    def get_busy_files(self):
        return self._slicing_jobs.keys()

    def file_in_path(self, destination, path, file):
        return self._storage(destination).file_in_path(path, file)

    def file_exists(self, destination, path):
        return self._storage(destination).file_exists(path)

    def folder_exists(self, destination, path):
        return self._storage(destination).folder_exists(path)

    def list_files(
        self,
        destinations=None,
        path=None,
        filter=None,
        recursive=None,
        level=0,
        force_refresh=False,
    ):
        if not destinations:
            destinations = list(self._storage_managers.keys())
        if isinstance(destinations, str):
            destinations = [destinations]

        result = {}
        for dst in destinations:
            result[dst] = self._storage_managers[dst].list_files(
                path=path,
                filter=filter,
                recursive=recursive,
                level=level,
                force_refresh=force_refresh,
            )
        return result

    def add_file(
        self,
        destination,
        path,
        file_object,
        links=None,
        allow_overwrite=False,
        printer_profile=None,
        analysis=None,
        display=None,
    ):
        if printer_profile is None:
            printer_profile = self._printer_profile_manager.get_current_or_default()

        for name, hook in self._preprocessor_hooks.items():
            try:
                hook_file_object = hook(
                    path,
                    file_object,
                    links=links,
                    printer_profile=printer_profile,
                    allow_overwrite=allow_overwrite,
                )
            except Exception:
                self._logger.exception(
                    "Error when calling preprocessor hook for plugin {}, ignoring".format(
                        name
                    ),
                    extra={"plugin": name},
                )
                continue

            if hook_file_object is not None:
                file_object = hook_file_object

        queue_entry = self._analysis_queue_entry(destination, path)

        if queue_entry is not None:
            self._analysis_queue.dequeue(queue_entry)

        path_in_storage = self._storage(destination).add_file(
            path,
            file_object,
            links=links,
            printer_profile=printer_profile,
            allow_overwrite=allow_overwrite,
            display=display,
        )

        queue_entry = self._analysis_queue_entry(
            destination,
            path_in_storage,
            printer_profile=printer_profile,
            analysis=analysis,
        )
        if queue_entry:
            self._analysis_queue.enqueue(queue_entry, high_priority=True)

        _, name = self._storage(destination).split_path(path_in_storage)
        eventManager().fire(
            Events.FILE_ADDED,
            {
                "storage": destination,
                "path": path_in_storage,
                "name": name,
                "type": get_file_type(name),
                "operation": "add",
            },
        )
        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})
        return path_in_storage

    def remove_file(self, destination, path):
        queue_entry = self._analysis_queue_entry(destination, path)
        self._analysis_queue.dequeue(queue_entry)
        self._storage(destination).remove_file(path)

        _, name = self._storage(destination).split_path(path)
        eventManager().fire(
            Events.FILE_REMOVED,
            {
                "storage": destination,
                "path": path,
                "name": name,
                "type": get_file_type(name),
            },
        )
        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})

    def copy_file(self, destination, source, dst):
        path_in_storage = self._storage(destination).copy_file(source, dst)
        if not self.has_analysis(destination, path_in_storage):
            queue_entry = self._analysis_queue_entry(destination, path_in_storage)
            if queue_entry:
                self._analysis_queue.enqueue(queue_entry)

        _, name = self._storage(destination).split_path(path_in_storage)
        eventManager().fire(
            Events.FILE_ADDED,
            {
                "storage": destination,
                "path": path_in_storage,
                "name": name,
                "type": get_file_type(name),
                "operation": "copy",
            },
        )
        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})

    def move_file(self, destination, source, dst):
        queue_entry = self._analysis_queue_entry(destination, source)
        self._analysis_queue.dequeue(queue_entry)
        path = self._storage(destination).move_file(source, dst)
        if not self.has_analysis(destination, path):
            queue_entry = self._analysis_queue_entry(destination, path)
            if queue_entry:
                self._analysis_queue.enqueue(queue_entry)

        source_path_in_storage = self._storage(destination).path_in_storage(source)
        _, source_name = self._storage(destination).split_path(source_path_in_storage)
        dst_path_in_storage = self._storage(destination).path_in_storage(dst)
        _, dst_name = self._storage(destination).split_path(dst_path_in_storage)

        eventManager().fire(
            Events.FILE_REMOVED,
            {
                "storage": destination,
                "path": source_path_in_storage,
                "name": source_name,
                "type": get_file_type(source_name),
            },
        )
        eventManager().fire(
            Events.FILE_ADDED,
            {
                "storage": destination,
                "path": dst_path_in_storage,
                "name": dst_name,
                "type": get_file_type(dst_name),
                "operation": "move",
            },
        )
        eventManager().fire(
            Events.FILE_MOVED,
            {
                "storage": destination,
                "source_path": source_path_in_storage,
                "source_name": source_name,
                "source_type": get_file_type(source_name),
                "destination_path": dst_path_in_storage,
                "destination_name": dst_name,
                "destination_type": get_file_type(dst_name),
            },
        )

        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})

    def add_folder(self, destination, path, ignore_existing=True, display=None):
        path_in_storage = self._storage(destination).add_folder(
            path, ignore_existing=ignore_existing, display=display
        )

        _, name = self._storage(destination).split_path(path_in_storage)
        eventManager().fire(
            Events.FOLDER_ADDED,
            {"storage": destination, "path": path_in_storage, "name": name},
        )
        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})
        return path_in_storage

    def remove_folder(self, destination, path, recursive=True):
        self._analysis_queue.dequeue_folder(destination, path)
        self._analysis_queue.pause()
        self._storage(destination).remove_folder(path, recursive=recursive)
        self._analysis_queue.resume()

        _, name = self._storage(destination).split_path(path)
        eventManager().fire(
            Events.FOLDER_REMOVED, {"storage": destination, "path": path, "name": name}
        )
        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})

    def copy_folder(self, destination, source, dst):
        path_in_storage = self._storage(destination).copy_folder(source, dst)
        self._determine_analysis_backlog(
            destination, self._storage(destination), root=path_in_storage
        )

        _, name = self._storage(destination).split_path(path_in_storage)
        eventManager().fire(
            Events.FOLDER_ADDED,
            {"storage": destination, "path": path_in_storage, "name": name},
        )
        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})

    def move_folder(self, destination, source, dst):
        self._analysis_queue.dequeue_folder(destination, source)
        self._analysis_queue.pause()
        dst_path_in_storage = self._storage(destination).move_folder(source, dst)
        self._determine_analysis_backlog(
            destination, self._storage(destination), root=dst_path_in_storage
        )
        self._analysis_queue.resume()

        source_path_in_storage = self._storage(destination).path_in_storage(source)
        _, source_name = self._storage(destination).split_path(source_path_in_storage)
        _, dst_name = self._storage(destination).split_path(dst_path_in_storage)

        eventManager().fire(
            Events.FOLDER_REMOVED,
            {"storage": destination, "path": source_path_in_storage, "name": source_name},
        )
        eventManager().fire(
            Events.FOLDER_ADDED,
            {"storage": destination, "path": dst_path_in_storage, "name": dst_name},
        )
        eventManager().fire(
            Events.FOLDER_MOVED,
            {
                "storage": destination,
                "source_path": source_path_in_storage,
                "source_name": source_name,
                "destination_path": dst_path_in_storage,
                "destination_name": dst_name,
            },
        )

        eventManager().fire(Events.UPDATED_FILES, {"type": "printables"})

    def has_analysis(self, destination, path):
        return self._storage(destination).has_analysis(path)

    def get_metadata(self, destination, path):
        return self._storage(destination).get_metadata(path)

    def add_link(self, destination, path, rel, data):
        self._storage(destination).add_link(path, rel, data)

    def remove_link(self, destination, path, rel, data):
        self._storage(destination).remove_link(path, rel, data)

    def log_print(
        self, destination, path, timestamp, print_time, success, printer_profile
    ):
        try:
            if success:
                self._storage(destination).add_history(
                    path,
                    {
                        "timestamp": timestamp,
                        "printTime": print_time,
                        "success": success,
                        "printerProfile": printer_profile,
                    },
                )
            else:
                self._storage(destination).add_history(
                    path,
                    {
                        "timestamp": timestamp,
                        "success": success,
                        "printerProfile": printer_profile,
                    },
                )
            eventManager().fire(
                Events.METADATA_STATISTICS_UPDATED, {"storage": destination, "path": path}
            )
        except NoSuchStorage:
            # if there's no storage configured where to log the print, we'll just not log it
            pass

    def save_recovery_data(self, origin, path, pos):
        import time

        from octoprint.util import atomic_write

        data = {
            "origin": origin,
            "path": self.path_in_storage(origin, path),
            "pos": pos,
            "date": time.time(),
        }
        try:
            with atomic_write(self._recovery_file, mode="wt", max_permissions=0o666) as f:
                yaml.save_to_file(data, file=f, pretty=True)
        except Exception:
            self._logger.exception(
                f"Could not write recovery data to file {self._recovery_file}"
            )

    def delete_recovery_data(self):
        if not os.path.isfile(self._recovery_file):
            return

        try:
            os.remove(self._recovery_file)
        except Exception:
            self._logger.exception(
                f"Error deleting recovery data file {self._recovery_file}"
            )

    def get_recovery_data(self):
        if not os.path.isfile(self._recovery_file):
            return None

        try:
            data = yaml.load_from_file(path=self._recovery_file)

            if not isinstance(data, dict) or not all(
                map(lambda x: x in data, ("origin", "path", "pos", "date"))
            ):
                raise ValueError("Invalid recovery data structure")
            return data
        except Exception:
            self._logger.exception(
                f"Could not read recovery data from file {self._recovery_file}"
            )
            self.delete_recovery_data()

    def get_additional_metadata(self, destination, path, key):
        self._storage(destination).get_additional_metadata(path, key)

    def set_additional_metadata(
        self, destination, path, key, data, overwrite=False, merge=False
    ):
        self._storage(destination).set_additional_metadata(
            path, key, data, overwrite=overwrite, merge=merge
        )

    def remove_additional_metadata(self, destination, path, key):
        self._storage(destination).remove_additional_metadata(path, key)

    def path_on_disk(self, destination, path):
        return self._storage(destination).path_on_disk(path)

    def canonicalize(self, destination, path):
        return self._storage(destination).canonicalize(path)

    def sanitize(self, destination, path):
        return self._storage(destination).sanitize(path)

    def sanitize_name(self, destination, name):
        return self._storage(destination).sanitize_name(name)

    def sanitize_path(self, destination, path):
        return self._storage(destination).sanitize_path(path)

    def split_path(self, destination, path):
        return self._storage(destination).split_path(path)

    def join_path(self, destination, *path):
        return self._storage(destination).join_path(*path)

    def path_in_storage(self, destination, path):
        return self._storage(destination).path_in_storage(path)

    def last_modified(self, destination, path=None, recursive=False):
        return self._storage(destination).last_modified(path=path, recursive=recursive)

    def _storage(self, destination):
        if destination not in self._storage_managers:
            raise NoSuchStorage(f"No storage configured for destination {destination}")
        return self._storage_managers[destination]

    def _add_analysis_result(self, destination, path, result):
        if destination not in self._storage_managers:
            return
        if not result:
            return

        storage_manager = self._storage_managers[destination]
        storage_manager.set_additional_metadata(path, "analysis", result, overwrite=True)

    def _on_analysis_finished(self, entry, result):
        self._add_analysis_result(entry.location, entry.path, result)

    def _analysis_queue_entry(
        self, destination, path, printer_profile=None, analysis=None
    ):
        if printer_profile is None:
            printer_profile = self._printer_profile_manager.get_current_or_default()

        absolute_path = self._storage(destination).path_on_disk(path)
        _, file_name = self._storage(destination).split_path(path)
        file_type = get_file_type(absolute_path)

        if file_type:
            return QueueEntry(
                file_name,
                path,
                file_type[-1],
                destination,
                absolute_path,
                printer_profile,
                analysis,
            )
        else:
            return None
