# coding=utf-8
from __future__ import absolute_import, division, print_function

__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 octoprint.plugin

import copy
import flask
import os
import threading
import time
import logging
import logging.handlers
import hashlib

# noinspection PyCompatibility
from concurrent import futures

from . import version_checks, updaters, exceptions, util, cli

from flask_babel import gettext

from octoprint.server.util.flask import restricted_access, with_revalidation_checking, check_etag
from octoprint.server import admin_permission, VERSION, REVISION, BRANCH
from octoprint.util import dict_merge, to_unicode
from octoprint.util.version import get_comparable_version, get_python_version_string
from octoprint.util.pip import LocalPipCaller
import octoprint.settings


MINIMUM_PYTHON = "2.7.9"
MINIMUM_SETUPTOOLS = "5.5.1"
MINIMUM_PIP = "9.0.1"


##~~ Plugin


class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
                           octoprint.plugin.SettingsPlugin,
                           octoprint.plugin.AssetPlugin,
                           octoprint.plugin.TemplatePlugin,
                           octoprint.plugin.StartupPlugin,
                           octoprint.plugin.WizardPlugin,
                           octoprint.plugin.EventHandlerPlugin):

	COMMIT_TRACKING_TYPES = ("github_commit", "bitbucket_commit")

	DATA_FORMAT_VERSION = "v4"

	# noinspection PyMissingConstructor
	def __init__(self):
		self._update_in_progress = False
		self._configured_checks_mutex = threading.Lock()
		self._configured_checks = None
		self._refresh_configured_checks = False

		self._get_versions_mutex = threading.RLock()
		self._get_versions_data = None
		self._get_versions_data_ready = threading.Event()

		self._version_cache = dict()
		self._version_cache_ttl = 0
		self._version_cache_path = None
		self._version_cache_dirty = False
		self._version_cache_timestamp = None

		self._environment_supported = True
		self._environment_versions = dict()
		self._environment_ready = threading.Event()

		self._console_logger = None

	def initialize(self):
		self._console_logger = logging.getLogger("octoprint.plugins.softwareupdate.console")

		self._version_cache_ttl = self._settings.get_int(["cache_ttl"]) * 60
		self._version_cache_path = os.path.join(self.get_plugin_data_folder(), "versioncache.yaml")
		self._load_version_cache()

		def refresh_checks(name, plugin):
			self._refresh_configured_checks = True
			self._send_client_message("update_versions")

		self._plugin_lifecycle_manager.add_callback("enabled", refresh_checks)
		self._plugin_lifecycle_manager.add_callback("disabled", refresh_checks)

	def on_startup(self, host, port):
		console_logging_handler = logging.handlers.RotatingFileHandler(self._settings.get_plugin_logfile_path(postfix="console"), maxBytes=2*1024*1024)
		console_logging_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
		console_logging_handler.setLevel(logging.DEBUG)

		self._console_logger.addHandler(console_logging_handler)
		self._console_logger.setLevel(logging.DEBUG)
		self._console_logger.propagate = False

	def on_after_startup(self):
		self._check_environment()
		self.get_current_versions()

	def _get_configured_checks(self):
		with self._configured_checks_mutex:
			if self._refresh_configured_checks or self._configured_checks is None:
				self._refresh_configured_checks = False
				self._configured_checks = self._settings.get(["checks"], merged=True)

				update_check_hooks = self._plugin_manager.get_hooks("octoprint.plugin.softwareupdate.check_config")
				check_providers = self._settings.get(["check_providers"], merged=True)
				if not isinstance(check_providers, dict):
					check_providers = dict()

				effective_configs = dict()

				for name, hook in update_check_hooks.items():
					try:
						hook_checks = hook()
					except:
						self._logger.exception("Error while retrieving update information "
						                       "from plugin {name}".format(**locals()),
						                       extra=dict(plugin=name))
					else:
						for key, default_config in hook_checks.items():
							if key in effective_configs or key == "octoprint":
								if key == name:
									self._logger.warn("Software update hook {} provides check for itself but that was already registered by {} - overwriting that third party registration now!".format(name, check_providers.get(key, "unknown hook")))
								else:
									self._logger.warn("Software update hook {} tried to overwrite config for check {} but that was already configured elsewhere".format(name, key))
									continue

							check_providers[key] = name

							yaml_config = dict()
							effective_config = default_config
							if key in self._configured_checks:
								yaml_config = self._configured_checks[key]
								effective_config = dict_merge(default_config, yaml_config)

								# Make sure there's nothing persisted in that check that shouldn't be persisted
								#
								# This used to be part of the settings migration (version 2) due to a bug - it can't
								# stay there though since it interferes with manual entries to the checks not
								# originating from within a plugin. Hence we do that step now here.
								if "type" not in effective_config or effective_config["type"] not in self.COMMIT_TRACKING_TYPES:
									deletables = ["current", "displayVersion"]
								else:
									deletables = []
								self._clean_settings_check(key, yaml_config, default_config, delete=deletables, save=False)

							if effective_config:
								effective_configs[key] = effective_config
							else:
								self._logger.warn("Update for {} is empty or None, ignoring it".format(key))

				# finally set all our internal representations to our processed results
				for key, config in effective_configs.items():
					self._configured_checks[key] = config

				self._settings.set(["check_providers"], check_providers)
				self._settings.save()

				# we only want to process checks that came from plugins for
				# which the plugins are still installed and enabled
				config_checks = self._settings.get(["checks"])
				plugin_and_not_enabled = lambda k: k in check_providers and \
				                                   not check_providers[k] in self._plugin_manager.enabled_plugins
				obsolete_plugin_checks = filter(plugin_and_not_enabled,
				                                config_checks.keys())
				for key in obsolete_plugin_checks:
					self._logger.debug("Check for key {} was provided by plugin {} that's no longer available, ignoring it".format(key, check_providers[key]))
					del self._configured_checks[key]

			return self._configured_checks

	def _check_environment(self):
		import pkg_resources

		local_pip = LocalPipCaller()

		# check python and setuptools version
		versions = dict(python=get_python_version_string(),
		                setuptools=pkg_resources.get_distribution("setuptools").version,
		                pip=local_pip.version_string)
		supported = get_comparable_version(versions["python"]) >= get_comparable_version(MINIMUM_PYTHON) \
		       and get_comparable_version(versions["setuptools"]) >= get_comparable_version(MINIMUM_SETUPTOOLS) \
		       and get_comparable_version(versions["pip"]) >= get_comparable_version(MINIMUM_PIP)

		self._environment_supported = supported
		self._environment_versions = versions
		self._environment_ready.set()

	def _load_version_cache(self):
		if not os.path.isfile(self._version_cache_path):
			return

		import yaml
		try:
			with open(self._version_cache_path) as f:
				data = yaml.safe_load(f)
			timestamp = os.stat(self._version_cache_path).st_mtime
		except:
			self._logger.exception("Error while loading version cache from disk")
		else:
			try:
				if not isinstance(data, dict):
					self._logger.info("Version cache was created in a different format, not using it")
					return

				if "__version" in data:
					data_version = data["__version"]
				else:
					self._logger.info("Can't determine version of OctoPrint version cache was created for, not using it")
					return

				from octoprint._version import get_versions
				octoprint_version = get_versions()["version"]
				if data_version != octoprint_version:
					self._logger.info("Version cache was created for another version of OctoPrint, not using it")
					return

				self._version_cache = data
				self._version_cache_dirty = False
				self._version_cache_timestamp = timestamp
				self._logger.info("Loaded version cache from disk")
			except:
				self._logger.exception("Error parsing in version cache data")

	def _save_version_cache(self):
		import yaml
		from octoprint.util import atomic_write
		from octoprint._version import get_versions

		octoprint_version = get_versions()["version"]
		self._version_cache["__version"] = octoprint_version

		with atomic_write(self._version_cache_path, max_permissions=0o666) as file_obj:
			yaml.safe_dump(self._version_cache, stream=file_obj, default_flow_style=False, indent="  ", allow_unicode=True)

		self._version_cache_dirty = False
		self._version_cache_timestamp = time.time()
		self._logger.info("Saved version cache to disk")

	#~~ SettingsPlugin API

	def get_settings_defaults(self):
		update_script = os.path.join(self._basefolder, "scripts", "update-octoprint.py")
		default_update_script = "{{python}} \"{update_script}\" --branch={{branch}} " \
		                        "--force={{force}} \"{{folder}}\" {{target}}".format(update_script=update_script)

		return {
			"checks": {
				"octoprint": {
					"type": "github_release",
					"user": "foosel",
					"repo": "OctoPrint",
					"method": "pip",
					"pip": "https://github.com/foosel/OctoPrint/archive/{target_version}.zip",
					"update_script": default_update_script,
					"restart": "octoprint",
					"stable_branch": dict(branch="master", commitish=["master"], name="Stable"),
					"prerelease_branches": [dict(branch="rc/maintenance",
					                             commitish=["rc/maintenance"],             # maintenance RCs
					                             name="Maintenance RCs"),
					                        dict(branch="rc/devel",
					                             commitish=["rc/maintenance", "rc/devel"], # devel & maintenance RCs
					                             name="Devel RCs")]
				},
			},
			"pip_command": None,
			"check_providers": {},

			"cache_ttl": 24 * 60,

			"notify_users": True
		}

	def on_settings_load(self):
		data = dict(octoprint.plugin.SettingsPlugin.on_settings_load(self))
		if "checks" in data:
			del data["checks"]

		if "check_providers" in data:
			del data["check_providers"]

		checks = self._get_configured_checks()
		if "octoprint" in checks:
			data["octoprint_checkout_folder"] = self._get_octoprint_checkout_folder(checks=checks)
			data["octoprint_type"] = checks["octoprint"].get("type", None)

			try:
				data["octoprint_method"] = self._get_update_method("octoprint", checks["octoprint"])
			except exceptions.UnknownUpdateType:
				data["octoprint_method"] = "unknown"

			stable_branch = None
			prerelease_branches = []
			branch_mappings = []
			if "stable_branch" in checks["octoprint"]:
				branch_mappings.append(checks["octoprint"]["stable_branch"])
				stable_branch = checks["octoprint"]["stable_branch"]["branch"]
			if "prerelease_branches" in checks["octoprint"]:
				for mapping in checks["octoprint"]["prerelease_branches"]:
					branch_mappings.append(mapping)
					prerelease_branches.append(mapping["branch"])
			data["octoprint_branch_mappings"] = branch_mappings

			data["octoprint_release_channel"] = stable_branch
			if checks["octoprint"].get("prerelease", False):
				channel = checks["octoprint"].get("prerelease_channel", BRANCH)
				if channel in prerelease_branches:
					data["octoprint_release_channel"] = channel

		else:
			data["octoprint_checkout_folder"] = None
			data["octoprint_type"] = None
			data["octoprint_branch_mappings"] = []

		return data

	def on_settings_save(self, data):
		for key in self.get_settings_defaults():
			if key in ("checks", "cache_ttl", "notify_user", "octoprint_checkout_folder", "octoprint_type", "octoprint_release_channel"):
				continue
			if key in data:
				self._settings.set([key], data[key])

		if "cache_ttl" in data:
			self._settings.set_int(["cache_ttl"], data["cache_ttl"])
		self._version_cache_ttl = self._settings.get_int(["cache_ttl"]) * 60

		if "notify_users" in data:
			self._settings.set_boolean(["notify_users"], data["notify_users"])

		defaults = dict(
			plugins=dict(softwareupdate=dict(
				checks=dict(
					octoprint=self.get_settings_defaults()["checks"]["octoprint"]
				)
			))
		)

		updated_octoprint_check_config = False

		if "octoprint_checkout_folder" in data:
			self._settings.set(["checks", "octoprint", "checkout_folder"], data["octoprint_checkout_folder"], defaults=defaults, force=True)
			if self._settings.get(["checks", "octoprint", "update_folder"]) and data["octoprint_checkout_folder"]:
				self._settings.set(["checks", "octoprint", "update_folder"], None, defaults=defaults, force=True)
			updated_octoprint_check_config = True

		if "octoprint_type" in data:
			octoprint_type = data["octoprint_type"]

			if octoprint_type == "github_release":
				self._settings.set(["checks", "octoprint", "type"], octoprint_type, defaults=defaults)
				self._settings.set(["checks", "octoprint", "method"], "pip", defaults=defaults)
				updated_octoprint_check_config = True

			elif octoprint_type == "git_commit":
				self._settings.set(["checks", "octoprint", "type"], octoprint_type, defaults=defaults)
				self._settings.set(["checks", "octoprint", "method"], "update_script", defaults=defaults)
				updated_octoprint_check_config = True

		if "octoprint_release_channel" in data:
			prerelease_branches = self._settings.get(["checks", "octoprint", "prerelease_branches"])
			if prerelease_branches and data["octoprint_release_channel"] in [x["branch"] for x in prerelease_branches]:
				self._settings.set(["checks", "octoprint", "prerelease"], True, defaults=defaults, force=True)
				self._settings.set(["checks", "octoprint", "prerelease_channel"], data["octoprint_release_channel"],
				                   defaults=defaults, force=True)
			else:
				self._settings.set(["checks", "octoprint", "prerelease"], False, defaults=defaults, force=True)
				self._settings.set(["checks", "octoprint", "prerelease_channel"], None, defaults=defaults, force=True)
			updated_octoprint_check_config = True

		if updated_octoprint_check_config:
			self._refresh_configured_checks = True
			try:
				del self._version_cache["octoprint"]
			except KeyError:
				pass
			self._version_cache_dirty = True

	def get_settings_version(self):
		return 6

	def on_settings_migrate(self, target, current=None):

		if current is None or current < 6:
			# up until & including config version 5 we didn't set the method parameter for the octoprint check
			# configuration

			configured_checks = self._settings.get(["checks"], incl_defaults=False)
			if configured_checks is not None and "octoprint" in configured_checks:
				octoprint_check = dict(configured_checks["octoprint"])

				if not "method" in octoprint_check and octoprint_check.get("type") == "git_commit":
					defaults = dict(plugins=dict(softwareupdate=dict(checks=dict(octoprint=dict(method="pip")))))
					self._settings.set(["checks", "octoprint", "method"], "update_script", defaults=defaults)

		if current == 4:
			# config version 4 didn't correctly remove the old settings for octoprint_restart_command
			# and environment_restart_command

			self._settings.set(["environment_restart_command"], None)
			self._settings.set(["octoprint_restart_command"], None)

		if current is None or current < 5:
			# config version 4 and higher moves octoprint_restart_command and
			# environment_restart_command to the core configuration

			# current plugin commands
			configured_octoprint_restart_command = self._settings.get(["octoprint_restart_command"])
			configured_environment_restart_command = self._settings.get(["environment_restart_command"])

			# current global commands
			configured_system_restart_command = self._settings.global_get(["server", "commands", "systemRestartCommand"])
			configured_server_restart_command = self._settings.global_get(["server", "commands", "serverRestartCommand"])

			# only set global commands if they are not yet set
			if configured_system_restart_command is None and configured_environment_restart_command is not None:
				self._settings.global_set(["server", "commands", "systemRestartCommand"], configured_environment_restart_command)
			if configured_server_restart_command is None and configured_octoprint_restart_command is not None:
				self._settings.global_set(["server", "commands", "serverRestartCommand"], configured_octoprint_restart_command)

			# delete current plugin commands from config
			self._settings.set(["environment_restart_command"], None)
			self._settings.set(["octoprint_restart_command"], None)

		if current is None or current == 2:
			# No config version and config version 2 need the same fix, stripping
			# accidentally persisted data off the checks.
			#
			# We used to do the same processing for the plugin entries too here, but that interfered
			# with manual configuration entries. Stuff got deleted that wasn't supposed to be deleted.
			#
			# The problem is that we don't know if an entry we are looking at and which didn't come through
			# a plugin hook is simply an entry from a now uninstalled/unactive plugin, or if it was something
			# manually configured by the user. So instead of just blindly removing anything that doesn't
			# come from a plugin here we instead clean up anything that indeed comes from a plugin
			# during run time and leave everything else as is in the hopes that will not cause trouble.
			#
			# We still handle the "octoprint" entry here though.

			configured_checks = self._settings.get(["checks"], incl_defaults=False)
			if configured_checks is not None and "octoprint" in configured_checks:
				octoprint_check = dict(configured_checks["octoprint"])
				if "type" not in octoprint_check or octoprint_check["type"] not in self.COMMIT_TRACKING_TYPES:
					deletables=["current", "displayName", "displayVersion"]
				else:
					deletables=[]
				self._clean_settings_check("octoprint", octoprint_check, self.get_settings_defaults()["checks"]["octoprint"], delete=deletables, save=False)

		elif current == 1:
			# config version 1 had the error that the octoprint check got accidentally
			# included in checks["octoprint"], leading to recursion and hence to
			# yaml parser errors

			configured_checks = self._settings.get(["checks"], incl_defaults=False)
			if configured_checks is None:
				return

			if "octoprint" in configured_checks and "octoprint" in configured_checks["octoprint"]:
				# that's a circular reference, back to defaults
				dummy_defaults = dict(plugins=dict())
				dummy_defaults["plugins"][self._identifier] = dict(checks=dict())
				dummy_defaults["plugins"][self._identifier]["checks"]["octoprint"] = None
				self._settings.set(["checks", "octoprint"], None, defaults=dummy_defaults)

	def _clean_settings_check(self, key, data, defaults, delete=None, save=True):
		if not data:
			# nothing to do
			return data

		if delete is None:
			delete = []

		for k, v in data.items():
			if k in defaults and defaults[k] == data[k]:
				del data[k]

		for k in delete:
			if k in data:
				del data[k]

		dummy_defaults = dict(plugins=dict())
		dummy_defaults["plugins"][self._identifier] = dict(checks=dict())
		dummy_defaults["plugins"][self._identifier]["checks"][key] = defaults
		if len(data):
			self._settings.set(["checks", key], data, defaults=dummy_defaults)
		else:
			self._settings.set(["checks", key], None, defaults=dummy_defaults)

		if save:
			self._settings.save()

		return data

	#~~ BluePrint API

	@octoprint.plugin.BlueprintPlugin.route("/check", methods=["GET"])
	@restricted_access
	def check_for_update(self):
		if "check" in flask.request.values:
			check_targets = map(lambda x: x.strip(), flask.request.values["check"].split(","))
		else:
			check_targets = None

		force = flask.request.values.get("force", "false") in octoprint.settings.valid_boolean_trues

		def view():
			self._environment_ready.wait(timeout=30.0)

			try:
				information, update_available, update_possible = self.get_current_versions(check_targets=check_targets, force=force)
				return flask.jsonify(dict(status="inProgress" if self._update_in_progress else "updatePossible" if update_available and update_possible and self._environment_supported else "updateAvailable" if update_available else "current",
				                          information=information,
				                          timestamp=self._version_cache_timestamp,
				                          environment=dict(supported=self._environment_supported,
				                                           versions=[dict(name=gettext("Python"), current=self._environment_versions.get("python", "unknown"), minimum=MINIMUM_PYTHON),
				                                                     dict(name=gettext("pip"), current=self._environment_versions.get("pip", "unknown"), minimum=MINIMUM_PIP),
				                                                     dict(name=gettext("setuptools"), current=self._environment_versions.get("setuptools", "unknown"), minimum=MINIMUM_SETUPTOOLS)])))
			except exceptions.ConfigurationInvalid as e:
				return flask.make_response("Update not properly configured, can't proceed: %s" % e.message, 500)

		def etag():
			checks = self._get_configured_checks()

			targets = check_targets
			if targets is None:
				targets = checks.keys()

			import hashlib
			hash = hashlib.sha1()

			targets = sorted(targets)
			for target in targets:
				current_hash = self._get_check_hash(checks.get(target, dict()))
				if target in self._version_cache and not force:
					data = self._version_cache[target]
					hash.update(current_hash)
					hash.update(str(data["timestamp"] + self._version_cache_ttl >= time.time() > data["timestamp"]))
					hash.update(repr(data["information"]))
					hash.update(str(data["available"]))
					hash.update(str(data["possible"]))
					hash.update(str(data.get("online", None)))

			hash.update(",".join(targets))

			hash.update(str(self._environment_supported))
			hash.update(str(self._version_cache_timestamp))
			hash.update(str(self._connectivity_checker.online))
			hash.update(str(self._update_in_progress))
			hash.update(self.DATA_FORMAT_VERSION)
			return hash.hexdigest()

		def condition():
			return check_etag(etag())

		return with_revalidation_checking(etag_factory=lambda *args, **kwargs: etag(),
		                                  condition=lambda *args, **kwargs: condition(),
		                                  unless=lambda: force)(view)()


	@octoprint.plugin.BlueprintPlugin.route("/update", methods=["POST"])
	@restricted_access
	@admin_permission.require(403)
	def perform_update(self):
		if self._printer.is_printing() or self._printer.is_paused():
			# do not update while a print job is running
			return flask.make_response("Printer is currently printing or paused", 409)

		if not self._environment_supported:
			return flask.make_response("Direct updates are not supported in this Python environment", 409)

		if not "application/json" in flask.request.headers["Content-Type"]:
			return flask.make_response("Expected content-type JSON", 400)

		json_data = flask.request.json

		if "targets" in json_data:
			targets = map(lambda x: x.strip(), json_data["targets"])
		else:
			targets = None

		if "force" in json_data and json_data["force"] in octoprint.settings.valid_boolean_trues:
			force = True
		else:
			force = False

		to_be_checked, checks = self.perform_updates(targets=targets, force=force)
		return flask.jsonify(dict(order=to_be_checked, checks=checks))

	#~~ Asset API

	def get_assets(self):
		return dict(
			css=["css/softwareupdate.css"],
			js=["js/softwareupdate.js"],
			clientjs=["clientjs/softwareupdate.js"],
			less=["less/softwareupdate.less"]
		)

	##~~ TemplatePlugin API

	def get_template_configs(self):
		from flask_babel import gettext
		return [
			dict(type="settings", name=gettext("Software Update"))
		]

	##~~ WizardPlugin API

	def is_wizard_required(self):
		checks = self._get_configured_checks()
		check = checks.get("octoprint", None)
		checkout_folder = self._get_octoprint_checkout_folder(checks=checks)
		return check and "method" in check and check["method"] == "update_script" and not checkout_folder

	##~~ EventHandlerPlugin API

	def on_event(self, event, payload):
		from octoprint.events import Events
		if event != Events.CONNECTIVITY_CHANGED or not payload or not payload.get("new", False):
			return

		thread = threading.Thread(target=self.get_current_versions)
		thread.daemon = True
		thread.start()

	#~~ Updater

	def get_current_versions(self, check_targets=None, force=False):
		"""
		Retrieves the current version information for all defined check_targets. Will retrieve information for all
		available targets by default.

		:param check_targets: an iterable defining the targets to check, if not supplied defaults to all targets
		"""

		checks = self._get_configured_checks()
		if check_targets is None:
			check_targets = checks.keys()

		update_available = False
		update_possible = False
		information = dict()

		# we don't want to do the same work twice, so let's use a lock
		if self._get_versions_mutex.acquire(False):
			self._get_versions_data_ready.clear()
			try:
				futures_to_result = dict()
				online = self._connectivity_checker.check_immediately()
				self._logger.debug("Looks like we are {}".format("online" if online else "offline"))

				with futures.ThreadPoolExecutor(max_workers=5) as executor:
					for target, check in checks.items():
						if not target in check_targets:
							continue

						if not check:
							continue

						try:
							populated_check = self._populated_check(target, check)
							future = executor.submit(self._get_current_version, target, populated_check, force=force)
							futures_to_result[future] = (target, populated_check)
						except exceptions.UnknownCheckType:
							self._logger.warn("Unknown update check type for target {}: {}".format(target,
							                                                                       check.get("type",
							                                                                                 "<n/a>")))
							continue
						except:
							self._logger.exception("Could not check {} for updates".format(target))
							continue

					for future in futures.as_completed(futures_to_result):

						target, populated_check = futures_to_result[future]
						if future.exception() is not None:
							self._logger.error("Could not check {} for updates, error: {!r}".format(target,
							                                                                        future.exception()))
							continue

						target_information, target_update_available, target_update_possible, target_online, target_error = future.result()
						target_update_possible = target_update_possible and self._environment_supported

						target_information = dict_merge(dict(local=dict(name="?", value="?"),
						                                     remote=dict(name="?", value="?",
						                                                 release_notes=None),
						                                     needs_online=True), target_information)

						update_available = update_available or target_update_available
						update_possible = update_possible or (target_update_possible and target_update_available)

						local_name = target_information["local"]["name"]
						local_value = target_information["local"]["value"]

						release_notes = None
						if target_information and target_information["remote"] and target_information["remote"][
							"value"]:
							if "release_notes" in populated_check and populated_check["release_notes"]:
								release_notes = populated_check["release_notes"]
							elif "release_notes" in target_information["remote"]:
								release_notes = target_information["remote"]["release_notes"]

							if release_notes:
								release_notes = release_notes.format(octoprint_version=VERSION,
								                                     target_name=target_information["remote"]["name"],
								                                     target_version=target_information["remote"]["value"])

						information[target] = dict(updateAvailable=target_update_available,
						                           updatePossible=target_update_possible,
						                           information=target_information,
						                           displayName=populated_check["displayName"],
						                           displayVersion=populated_check["displayVersion"].format(octoprint_version=VERSION,
						                                                                                   local_name=local_name,
						                                                                                   local_value=local_value),
						                           releaseNotes=release_notes,
						                           online=target_online,
						                           error=target_error)

						if target == "octoprint" and "released_version" in populated_check:
							information[target]["released_version"] = populated_check["released_version"]

				if self._version_cache_dirty:
					self._save_version_cache()

				self._get_versions_data = information, update_available, update_possible
				self._get_versions_data_ready.set()
			finally:
				self._get_versions_mutex.release()

		else: # something's already in progress, let's wait for it to complete and use its result
			self._get_versions_data_ready.wait()
			information, update_available, update_possible = self._get_versions_data

		return information, update_available, update_possible

	def _get_check_hash(self, check):
		def dict_to_sorted_repr(d):
			lines = []
			for key in sorted(d.keys()):
				value = d[key]
				if isinstance(value, dict):
					lines.append("{!r}: {}".format(key, dict_to_sorted_repr(value)))
				else:
					lines.append("{!r}: {!r}".format(key, value))

			return "{" + ", ".join(lines) + "}"

		hash = hashlib.md5()
		hash.update(dict_to_sorted_repr(check))
		return hash.hexdigest()

	def _get_current_version(self, target, check, force=False, online=None):
		"""
		Determines the current version information for one target based on its check configuration.
		"""

		current_hash = self._get_check_hash(check)
		if online is None:
			online = self._connectivity_checker.online
		if target in self._version_cache and not force:
			data = self._version_cache[target]
			if data["hash"] == current_hash \
					and data["timestamp"] + self._version_cache_ttl >= time.time() > data["timestamp"] \
					and data.get("online", None) == online:
				# we also check that timestamp < now to not get confused too much by clock changes
				return data["information"], data["available"], data["possible"], data["online"], data.get("error", None)

		information = dict()
		update_available = False
		error = None

		try:
			version_checker = self._get_version_checker(target, check)
			information, is_current = version_checker.get_latest(target, check, online=online)
			if information is not None and not is_current:
				update_available = True
		except exceptions.CannotCheckOffline:
			update_possible = False
			information["needs_online"] = True
		except exceptions.UnknownCheckType:
			self._logger.warn("Unknown check type %s for %s" % (check["type"], target))
			update_possible = False
			error = "unknown_check"
		except exceptions.NetworkError:
			self._logger.warn("Could not check %s for updates due to a network error" % target)
			update_possible = False
			error = "network"
		except:
			self._logger.exception("Could not check %s for updates" % target)
			update_possible = False
			error = "unknown"
		else:
			try:
				updater = self._get_updater(target, check)
				update_possible = updater.can_perform_update(target, check, online=online)
			except:
				update_possible = False

		self._version_cache[target] = dict(timestamp=time.time(),
		                                   hash=current_hash,
		                                   information=information,
		                                   available=update_available,
		                                   possible=update_possible,
		                                   online=online,
		                                   error=error)
		self._version_cache_dirty = True
		return information, update_available, update_possible, online, error

	def perform_updates(self, force=False, **kwargs):
		"""
		Performs the updates for the given check_targets. Will update all possible targets by default.

		:param targets: an iterable defining the targets to update, if not supplied defaults to all targets
		"""

		if not self._environment_supported:
			self._logger.error("Direct updates are unsupported in this environment")
			return [], dict()

		targets = kwargs.get("targets", kwargs.get("check_targets", None))

		checks = self._get_configured_checks()
		populated_checks = dict()
		for target, check in checks.items():
			try:
				populated_checks[target] = self._populated_check(target, check)
			except exceptions.UnknownCheckType:
				self._logger.debug("Ignoring unknown check type for target {}".format(target))
			except:
				self._logger.exception("Error while populating check prior to update for target {}".format(target))

		if targets is None:
			targets = populated_checks.keys()
		to_be_updated = sorted(set(targets) & set(populated_checks.keys()))
		if "octoprint" in to_be_updated:
			to_be_updated.remove("octoprint")
			tmp = ["octoprint"] + to_be_updated
			to_be_updated = tmp

		updater_thread = threading.Thread(target=self._update_worker, args=(populated_checks, to_be_updated, force))
		updater_thread.daemon = False
		updater_thread.start()

		check_data = dict((key, check["displayName"] if "displayName" in check else key) for key, check in populated_checks.items() if key in to_be_updated)
		return to_be_updated, check_data

	def _update_worker(self, checks, check_targets, force):

		restart_type = None

		try:
			self._update_in_progress = True

			target_results = dict()
			error = False

			### iterate over all configured targets

			for target in check_targets:
				if not target in checks:
					continue
				check = checks[target]

				if "enabled" in check and not check["enabled"]:
					continue

				if not target in check_targets:
					continue

				target_error, target_result = self._perform_update(target, check, force)
				error = error or target_error
				if target_result is not None:
					target_results[target] = target_result

					if "restart" in check:
						target_restart_type = check["restart"]
					elif "pip" in check:
						target_restart_type = "octoprint"
					else:
						target_restart_type = None

					# if our update requires a restart we have to determine which type
					if restart_type is None or (restart_type == "octoprint" and target_restart_type == "environment"):
						restart_type = target_restart_type

		finally:
			# we might have needed to update the config, so we'll save that now
			self._settings.save()

			# also, we are now longer updating
			self._update_in_progress = False

		if error:
			# if there was an unignorable error, we just return error
			self._send_client_message("error", dict(results=target_results))

		else:
			self._save_version_cache()

			# otherwise the update process was a success, but we might still have to restart
			if restart_type is not None and restart_type in ("octoprint", "environment"):
				# one of our updates requires a restart of either type "octoprint" or "environment". Let's see if
				# we can actually perform that

				restart_command = None
				if restart_type == "octoprint":
					restart_command = self._settings.global_get(["server", "commands", "serverRestartCommand"])
				elif restart_type == "environment":
					restart_command = self._settings.global_get(["server", "commands", "systemRestartCommand"])

				if restart_command is not None:
					self._send_client_message("restarting", dict(restart_type=restart_type, results=target_results))
					try:
						self._perform_restart(restart_command)
					except exceptions.RestartFailed:
						self._send_client_message("restart_failed", dict(restart_type=restart_type, results=target_results))
				else:
					# we don't have this restart type configured, we'll have to display a message that a manual
					# restart is needed
					self._send_client_message("restart_manually", dict(restart_type=restart_type, results=target_results))
			else:
				self._send_client_message("success", dict(results=target_results))

	def _perform_update(self, target, check, force):
		online = self._connectivity_checker.online

		information, update_available, update_possible, _, _ = self._get_current_version(target, check, online=online)

		if not update_available and not force:
			return False, None

		if not update_possible:
			self._logger.warn("Cannot perform update for %s, update type is not fully configured" % target)
			return False, None

		# determine the target version to update to
		target_version = information["remote"]["value"]
		target_error = False
		target_result = None

		def trigger_event(success, **additional_payload):
			from octoprint.events import Events

			if success:
				# noinspection PyUnresolvedReferences
				event = Events.PLUGIN_SOFTWAREUPDATE_UPDATE_SUCCEEDED
			else:
				# noinspection PyUnresolvedReferences
				event = Events.PLUGIN_SOFTWAREUPDATE_UPDATE_FAILED

			payload = copy.copy(additional_payload)
			payload.update(dict(target=target,
			                    from_version=information["local"]["value"],
			                    to_version=target_version))

			self._event_bus.fire(event, payload=payload)

		### The actual update procedure starts here...

		populated_check = self._populated_check(target, check)
		try:
			self._logger.info("Starting update of %s to %s..." % (target, target_version))
			self._send_client_message("updating", dict(target=target, version=target_version, name=populated_check["displayName"]))
			updater = self._get_updater(target, check)
			if updater is None:
				raise exceptions.UnknownUpdateType()

			update_result = updater.perform_update(target, populated_check, target_version, log_cb=self._log, online=online)
			target_result = ("success", update_result)
			self._logger.info("Update of %s to %s successful!" % (target, target_version))
			trigger_event(True)

		except exceptions.UnknownUpdateType:
			self._logger.warn("Update of %s can not be performed, unknown update type" % target)
			self._send_client_message("update_failed", dict(target=target, version=target_version, name=populated_check["displayName"], reason="Unknown update type"))
			return False, None

		except exceptions.CannotUpdateOffline:
			self._logger.warn("Update of %s can not be performed, it's not marked as 'offline' capable but we are apparently offline right now" % target)
			self._send_client_message("update_failed", dict(target=target, version=target_version, name=populated_check["displayName"], reason="No internet connection"))

		except Exception as e:
			self._logger.exception("Update of %s can not be performed, please also check plugin_softwareupdate_console.log for possible causes of this" % target)
			trigger_event(False)

			if not "ignorable" in populated_check or not populated_check["ignorable"]:
				target_error = True

			if isinstance(e, exceptions.UpdateError):
				target_result = ("failed", e.data)
				self._send_client_message("update_failed", dict(target=target, version=target_version, name=populated_check["displayName"], reason=e.data))
			else:
				target_result = ("failed", None)
				self._send_client_message("update_failed", dict(target=target, version=target_version, name=populated_check["displayName"], reason="unknown"))

		else:
			# make sure that any external changes to config.yaml are loaded into the system
			self._settings.load()

			# persist the new version if necessary for check type
			if check["type"] in self.COMMIT_TRACKING_TYPES:
				dummy_default = dict(plugins=dict())
				dummy_default["plugins"][self._identifier] = dict(checks=dict())
				dummy_default["plugins"][self._identifier]["checks"][target] = dict(current=None)
				self._settings.set(["checks", target, "current"], target_version, defaults=dummy_default)

				# we have to save here (even though that makes us save quite often) since otherwise the next
				# load will overwrite our changes we just made
				self._settings.save()

			del self._version_cache[target]
			self._version_cache_dirty = True

		return target_error, target_result

	def _perform_restart(self, restart_command):
		"""
		Performs a restart using the supplied restart_command.
		"""

		self._logger.info("Restarting...")
		try:
			util.execute(restart_command, evaluate_returncode=False, do_async=True)
		except exceptions.ScriptError as e:
			self._logger.exception("Error while restarting via command {}".format(restart_command))
			self._logger.warn("Restart stdout:\n{}".format(e.stdout))
			self._logger.warn("Restart stderr:\n{}".format(e.stderr))
			raise exceptions.RestartFailed()

	def _populated_check(self, target, check):
		if not "type" in check:
			raise exceptions.UnknownCheckType()

		result = dict(check)

		if target == "octoprint":
			from flask_babel import gettext

			from octoprint.util.version import is_released_octoprint_version, is_stable_octoprint_version

			result["displayName"] = to_unicode(check.get("displayName"), errors="replace")
			if result["displayName"] is None:
				# displayName missing or set to None
				result["displayName"] = to_unicode(gettext("OctoPrint"), errors="replace")

			result["displayVersion"] = to_unicode(check.get("displayVersion"), errors="replace")
			if result["displayVersion"] is None:
				# displayVersion missing or set to None
				result["displayVersion"] = u"{octoprint_version}"

			stable_branch = "master"
			release_branches = []
			if "stable_branch" in check:
				release_branches.append(check["stable_branch"]["branch"])
				stable_branch = check["stable_branch"]["branch"]
			if "prerelease_branches" in check:
				release_branches += [x["branch"] for x in check["prerelease_branches"]]
			result["released_version"] = is_released_octoprint_version()

			if check["type"] in self.COMMIT_TRACKING_TYPES:
				result["current"] = REVISION if REVISION else "unknown"
			else:
				result["current"] = VERSION

				if check["type"] == "github_release" and (check.get("prerelease", None) or not is_stable_octoprint_version()):
					# we are tracking github releases and are either also tracking prerelease OR are currently running
					# a non stable version => we need to change some parameters

					# we compare versions fully, not just the base so that we see a difference
					# between RCs + stable for the same version release
					result["force_base"] = False

					if check.get("prerelease", None):
						# we are tracking prereleases => we want to be on the correct prerelease channel/branch
						channel = check.get("prerelease_channel", None)
						if channel:
							# if we have a release channel, we also set our update_branch here to our release channel
							# in case it's not already set
							result["update_branch"] = check.get("update_branch", channel)

					else:
						# we are not tracking prereleases, but aren't on the stable branch either => switch back
						# to stable branch on update
						result["update_branch"] = check.get("update_branch", stable_branch)

					if check.get("update_script", None):
						# we force an exact version & python unequality check, to be able to downgrade
						result["force_exact_version"] = True
						result["release_compare"] = "python_unequal"

					elif check.get("pip", None):
						# we force python unequality check for pip installs, to be able to downgrade
						result["release_compare"] = "python_unequal"

		else:
			result["displayName"] = to_unicode(check.get("displayName"), errors="replace")
			if result["displayName"] is None:
				# displayName missing or None
				result["displayName"] = to_unicode(target, errors="replace")

			result["displayVersion"] = to_unicode(check.get("displayVersion", check.get("current")), errors="replace")
			if result["displayVersion"] is None:
				# displayVersion AND current missing or None
				result["displayVersion"] = u"unknown"

			if check["type"] in self.COMMIT_TRACKING_TYPES:
				result["current"] = check.get("current", None)
			else:
				result["current"] = check.get("current", check.get("displayVersion", None))

		if "pip" in result:
			if not "pip_command" in check and self._settings.get(["pip_command"]) is not None:
				result["pip_command"] = self._settings.get(["pip_command"])

		return result

	def _log(self, lines, prefix=None, stream=None, strip=True):
		if strip:
			lines = map(lambda x: x.strip(), lines)

		self._send_client_message("loglines", data=dict(loglines=[dict(line=line, stream=stream) for line in lines]))
		for line in lines:
			self._console_logger.debug(u"{} {}".format(prefix, line))

	def _send_client_message(self, message_type, data=None):
		self._plugin_manager.send_plugin_message(self._identifier, dict(type=message_type, data=data))

	def _get_version_checker(self, target, check):
		"""
		Retrieves the version checker to use for given target and check configuration. Will raise an UnknownCheckType
		if version checker cannot be determined.
		"""

		if not "type" in check:
			raise exceptions.ConfigurationInvalid("no check type defined")

		check_type = check["type"]
		method = getattr(version_checks, check_type)
		if method is None:
			raise exceptions.UnknownCheckType()
		else:
			return method

	def _get_update_method(self, target, check, valid_methods=None):
		"""
		Determines the update method for the given target and check.

		If ``valid_methods`` is provided, determine method must be contained
		therein to be considered valid.

		Raises an ``UnknownUpdateType`` exception if method cannot be determined
		or validated.
		"""

		method = None
		if "method" in check:
			method = check["method"]
		else:
			if "update_script" in check:
				method = "update_script"
			elif "pip" in check:
				method = "pip"
			elif "python_updater" in check:
				method = "python_updater"

		if method is None or (valid_methods and not method in valid_methods):
			raise exceptions.UnknownUpdateType()

		return method

	def _get_updater(self, target, check):
		"""
		Retrieves the updater for the given target and check configuration. Will raise an UnknownUpdateType if updater
		cannot be determined.
		"""

		mapping = dict(update_script=updaters.update_script,
		               pip=updaters.pip,
		               python_updater=updaters.python_updater,
		               sleep_a_bit=updaters.sleep_a_bit)

		method = self._get_update_method(target, check, valid_methods=mapping.keys())
		return mapping[method]

	def _get_octoprint_checkout_folder(self, checks=None):
		if checks is None:
			checks = self._get_configured_checks()

		if not "octoprint" in checks:
			return None

		if "checkout_folder" in checks["octoprint"]:
			return checks["octoprint"]["checkout_folder"]
		elif "update_folder" in checks["octoprint"]:
			return checks["octoprint"]["update_folder"]

		return None


def _register_custom_events(*args, **kwargs):
	return ["update_succeeded", "update_failed"]


__plugin_name__ = "Software Update"
__plugin_author__ = "Gina Häußge"
__plugin_url__ = "http://docs.octoprint.org/en/master/bundledplugins/softwareupdate.html"
__plugin_description__ = "Allows receiving update notifications and performing updates of OctoPrint and plugins"
__plugin_disabling_discouraged__ = gettext("Without this plugin OctoPrint will no longer be able to "
                                           "update itself or any of your installed plugins which might put "
                                           "your system at risk.")
__plugin_license__ = "AGPLv3"
def __plugin_load__():
	global __plugin_implementation__
	__plugin_implementation__ = SoftwareUpdatePlugin()

	global __plugin_helpers__
	__plugin_helpers__ = dict(
		version_checks=version_checks,
		updaters=updaters,
		exceptions=exceptions,
		util=util
	)

	global __plugin_hooks__
	__plugin_hooks__ = {
		"octoprint.cli.commands": cli.commands,
		"octoprint.events.register_custom_events": _register_custom_events
	}


