# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

__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 io
import logging
import os
from collections import namedtuple

from past.builtins import basestring

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

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):
    if not type:
        return extension in get_all_extensions()
    else:
        extensions = get_extensions(type)
        if extensions:
            return extension in extensions


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


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(object):
    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...".format(
                    **locals()
                )
            )
            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(
                'Added {counter} items from storage type "{storage_type}" and root "{root}" to analysis queue'.format(
                    **locals()
                )
            )
        else:
            self._logger.info(
                'Added {counter} items from storage type "{storage_type}" to analysis queue'.format(
                    **locals()
                )
            )

    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(
                            ";Generated from {stl_name} (hash: {hash})\n".format(
                                **locals()
                            ).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 = octoprint.util.monotonic_time()
                    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 = octoprint.util.monotonic_time()
        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, basestring):
            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),
            },
        )
        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),
            },
        )
        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),
            },
        )
        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.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

        import yaml

        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.safe_dump(
                    data, stream=f, default_flow_style=False, indent=2, allow_unicode=True
                )
        except Exception:
            self._logger.exception(
                "Could not write recovery data to file {}".format(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(
                "Error deleting recovery data file {}".format(self._recovery_file)
            )

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

        import yaml

        try:
            with io.open(self._recovery_file, "rt", encoding="utf-8") as f:
                data = yaml.safe_load(f)
            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(
                "Could not read recovery data from file {}".format(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(
                "No storage configured for destination {destination}".format(**locals())
            )
        return self._storage_managers[destination]

    def _add_analysis_result(self, destination, path, result):
        if destination not in self._storage_managers:
            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
