# coding=utf-8
"""
In this module the slicing support of OctoPrint is encapsulated.

.. autoclass:: SlicingProfile
   :members:

.. autoclass:: TemporaryProfile
   :members:

.. autoclass:: SlicingManager
   :members:
"""

from __future__ import absolute_import

__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 os
import octoprint.plugin
import octoprint.events
from octoprint.settings import settings

from .exceptions import *


class SlicingProfile(object):
	"""
	A wrapper for slicing profiles, both meta data and actual profile data.

	Arguments:
	    slicer (str): Identifier of the slicer this profile belongs to.
	    name (str): Identifier of this slicing profile.
	    data (object): Profile data, actual structure depends on individual slicer implementation.
	    display_name (str): Displayable name for this slicing profile.
	    description (str): Description of this slicing profile.
	"""

	def __init__(self, slicer, name, data, display_name=None, description=None):
		self.slicer = slicer
		self.name = name
		self.data = data
		self.display_name = display_name
		self.description = description


class TemporaryProfile(object):
	"""
	A wrapper for a temporary slicing profile to be used for a slicing job, based on a :class:`SlicingProfile` with
	optional ``overrides`` applied through the supplied ``save_profile`` method.

	Usage example:

	.. code-block:: python

	   temporary = TemporaryProfile(my_slicer.save_slicer_profile, my_default_profile,
	                                overrides=my_overrides)
	   with (temporary) as profile_path:
	       my_slicer.do_slice(..., profile_path=profile_path, ...)

	Arguments:
	    save_profile (callable): Method to use for saving the temporary profile, also responsible for applying the
	        supplied ``overrides``. This will be called according to the method signature of
	        :meth:`~octoprint.plugin.SlicerPlugin.save_slicer_profile`.
	    profile (SlicingProfile): The profile from which to derive the temporary profile.
	    overrides (dict): Optional overrides to apply to the ``profile`` for creation of the temporary profile.
	"""

	def __init__(self, save_profile, profile, overrides=None):
		self.save_profile = save_profile
		self.profile = profile
		self.overrides = overrides

	def __enter__(self):
		import tempfile
		temp_profile = tempfile.NamedTemporaryFile(prefix="slicing-profile-temp-", suffix=".profile", delete=False)
		temp_profile.close()

		self.temp_path = temp_profile.name
		self.save_profile(self.temp_path, self.profile, overrides=self.overrides)
		return self.temp_path

	def __exit__(self, type, value, traceback):
		import os
		try:
			os.remove(self.temp_path)
		except:
			pass


class SlicingManager(object):
	"""
	The :class:`SlicingManager` is responsible for managing available slicers and slicing profiles.

	Arguments:
	    profile_path (str): Absolute path to the base folder where all slicing profiles are stored.
	    printer_profile_manager (~octoprint.printer.profile.PrinterProfileManager): :class:`~octoprint.printer.profile.PrinterProfileManager`
	       instance to use for accessing available printer profiles, most importantly the currently selected one.
	"""

	def __init__(self, profile_path, printer_profile_manager):
		self._profile_path = profile_path
		self._printer_profile_manager = printer_profile_manager

		self._slicers = dict()
		self._slicer_names = dict()

	def initialize(self):
		"""
		Initializes the slicing manager by loading and initializing all available
		:class:`~octoprint.plugin.SlicerPlugin` implementations.
		"""
		self.reload_slicers()

	def reload_slicers(self):
		"""
		Retrieves all registered :class:`~octoprint.plugin.SlicerPlugin` implementations and registers them as
		available slicers.
		"""
		plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SlicerPlugin)
		slicers = dict()
		for plugin in plugins:
			slicers[plugin.get_slicer_properties()["type"]] = plugin
		self._slicers = slicers

	@property
	def slicing_enabled(self):
		"""
		Returns:
		    boolean: True if there is at least one configured slicer available, False otherwise.
		"""
		return len(self.configured_slicers) > 0

	@property
	def registered_slicers(self):
		"""
		Returns:
		    list of str: Identifiers of all available slicers.
		"""
		return self._slicers.keys()

	@property
	def configured_slicers(self):
		"""
		Returns:
		    list of str: Identifiers of all available configured slicers.
		"""
		return map(lambda slicer: slicer.get_slicer_properties()["type"], filter(lambda slicer: slicer.is_slicer_configured(), self._slicers.values()))

	@property
	def default_slicer(self):
		"""
		Retrieves the default slicer.

		Returns:
		    str: The identifier of the default slicer or ``None`` if the default slicer is not registered in the
		        system.
		"""
		slicer_name = settings().get(["slicing", "defaultSlicer"])
		if slicer_name in self.registered_slicers:
			return slicer_name
		else:
			return None

	def get_slicer(self, slicer, require_configured=True):
		"""
		Retrieves the slicer named ``slicer``. If ``require_configured`` is set to True (the default) an exception
		will be raised if the slicer is not yet configured.

		Arguments:
		    slicer (str): Identifier of the slicer to return
		    require_configured (boolean): Whether to raise an exception if the slicer has not been configured yet (True,
		        the default), or also return an unconfigured slicer (False).

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The ``slicer`` is unknown.
		    ~octoprint.slicing.exceptions.SlicerNotConfigured: The ``slicer`` is not yet configured and ``require_configured`` was set to True.
		"""

		if not slicer in self._slicers:
			raise UnknownSlicer(slicer)

		if require_configured and not self._slicers[slicer].is_slicer_configured():
			raise SlicerNotConfigured(slicer)

		return self._slicers[slicer]

	def slice(self, slicer_name, source_path, dest_path, profile_name, callback,
	          callback_args=None, callback_kwargs=None, overrides=None,
	          on_progress=None, on_progress_args=None, on_progress_kwargs=None, printer_profile_id=None, position=None):
		"""
		Slices ``source_path`` to ``dest_path`` using slicer ``slicer_name`` and slicing profile ``profile_name``.
		Since slicing happens asynchronously, ``callback`` will be called when slicing has finished (either successfully
		or not), with ``callback_args`` and ``callback_kwargs`` supplied.

		If ``callback_args`` is left out, an empty argument list will be assumed for the callback. If ``callback_kwargs``
		is left out, likewise an empty keyword argument list will be assumed for the callback. Note that in any case
		the callback *must* support being called with the following optional keyword arguments:

		_analysis
		    If the slicer returned analysis data of the created machine code as part of its slicing result, this keyword
		    argument will contain that data.
		_error
		    If there was an error while slicing this keyword argument will contain the error message as returned from
		    the slicer.
		_cancelled
		    If the slicing job was cancelled this keyword argument will be set to True.

		Additionally callees may specify ``overrides`` for the specified slicing profile, e.g. a different extrusion
		temperature than defined in the profile or a different layer height.

		With ``on_progress``, ``on_progress_args`` and ``on_progress_kwargs``, callees may specify a callback plus
		arguments and keyword arguments to call upon progress reports from the slicing job. The progress callback will
		be called with a keyword argument ``_progress`` containing the current slicing progress as a value between 0
		and 1 plus all additionally specified args and kwargs.

		If a different printer profile than the currently selected one is to be used for slicing, its id can be provided
		via the keyword argument ``printer_profile_id``.

		If the ``source_path`` is to be a sliced at a different position than the print bed center, this ``position`` can
		be supplied as a dictionary defining the ``x`` and ``y`` coordinate in print bed coordinates of the model's center.

		Arguments:
		    slicer_name (str): The identifier of the slicer to use for slicing.
		    source_path (str): The absolute path to the source file to slice.
		    dest_path (str): The absolute path to the destination file to slice to.
		    profile_name (str): The name of the slicing profile to use.
		    callback (callable): A callback to call after slicing has finished.
		    callback_args (list or tuple): Arguments of the callback to call after slicing has finished. Defaults to
		        an empty list.
		    callback_kwargs (dict): Keyword arguments for the callback to call after slicing has finished, will be
		        extended by ``_analysis``, ``_error`` or ``_cancelled`` as described above! Defaults to an empty
		        dictionary.
		    overrides (dict): Overrides for the printer profile to apply.
		    on_progress (callable): Callback to call upon slicing progress.
		    on_progress_args (list or tuple): Arguments of the progress callback. Defaults to an empty list.
		    on_progress_kwargs (dict): Keyword arguments of the progress callback, will be extended by ``_progress``
		        as described above! Defaults to an empty dictionary.
		    printer_profile_id (str): Identifier of the printer profile for which to slice, if another than the
		        one currently selected is to be used.
		    position (dict): Dictionary containing the ``x`` and ``y`` coordinate in the print bed's coordinate system
		        of the sliced model's center. If not provided the model will be positioned at the print bed's center.
		        Example: ``dict(x=10,y=20)``.

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer specified via ``slicer_name`` is unknown.
		    ~octoprint.slicing.exceptions.SlicerNotConfigured: The slice specified via ``slicer_name`` is not configured yet.
		"""

		if callback_args is None:
			callback_args = ()
		if callback_kwargs is None:
			callback_kwargs = dict()

		if not slicer_name in self.configured_slicers:
			if not slicer_name in self.registered_slicers:
				error = "No such slicer: {slicer_name}".format(**locals())
				exc = UnknownSlicer(slicer_name)
			else:
				error = "Slicer not configured: {slicer_name}".format(**locals())
				exc = SlicerNotConfigured(slicer_name)
			callback_kwargs.update(dict(_error=error, _exc=exc))
			callback(*callback_args, **callback_kwargs)
			raise exc

		slicer = self.get_slicer(slicer_name)

		printer_profile = None
		if printer_profile_id is not None:
			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()

		def slicer_worker(slicer, model_path, machinecode_path, profile_name, overrides, printer_profile, position, callback, callback_args, callback_kwargs):
			try:
				slicer_name = slicer.get_slicer_properties()["type"]
				with self._temporary_profile(slicer_name, name=profile_name, overrides=overrides) as profile_path:
					ok, result = slicer.do_slice(
						model_path,
						printer_profile,
						machinecode_path=machinecode_path,
						profile_path=profile_path,
						position=position,
						on_progress=on_progress,
						on_progress_args=on_progress_args,
						on_progress_kwargs=on_progress_kwargs
					)
				if not ok:
					callback_kwargs.update(dict(_error=result))
				elif result is not None and isinstance(result, dict) and "analysis" in result:
					callback_kwargs.update(dict(_analysis=result["analysis"]))
			except SlicingCancelled:
				callback_kwargs.update(dict(_cancelled=True))
			finally:
				callback(*callback_args, **callback_kwargs)

		import threading
		slicer_worker_thread = threading.Thread(target=slicer_worker,
		                                        args=(slicer, source_path, dest_path, profile_name, overrides, printer_profile, position, callback, callback_args, callback_kwargs))
		slicer_worker_thread.daemon = True
		slicer_worker_thread.start()

	def cancel_slicing(self, slicer_name, source_path, dest_path):
		"""
		Cancels the slicing job on slicer ``slicer_name`` from ``source_path`` to ``dest_path``.

		Arguments:
		    slicer_name (str): Identifier of the slicer on which to cancel the job.
		    source_path (str): The absolute path to the source file being sliced.
		    dest_path (str): The absolute path to the destination file being sliced to.

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer specified via ``slicer_name`` is unknown.
		"""

		slicer = self.get_slicer(slicer_name)
		slicer.cancel_slicing(dest_path)

	def load_profile(self, slicer, name, require_configured=True):
		"""
		Loads the slicing profile for ``slicer`` with the given profile ``name`` and returns it. If it can't be loaded
		due to an :class:`IOError` ``None`` will be returned instead.

		If ``require_configured`` is True (the default) a :class:`SlicerNotConfigured` exception will be raised
		if the indicated ``slicer`` has not yet been configured.

		Returns:
		    SlicingProfile: The requested slicing profile or None if it could not be loaded.

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer specified via ``slicer`` is unknown.
		    ~octoprint.slicing.exceptions.SlicerNotConfigured: The slicer specified via ``slicer`` has not yet been configured and
		        ``require_configured`` was True.
		    ~octoprint.slicing.exceptions.UnknownProfile: The profile for slicer ``slicer`` named ``name`` does not exist.
		"""

		if not slicer in self.registered_slicers:
			raise UnknownSlicer(slicer)

		try:
			path = self.get_profile_path(slicer, name, must_exist=True)
		except IOError:
			return None
		return self._load_profile_from_path(slicer, path, require_configured=require_configured)

	def save_profile(self, slicer, name, profile, overrides=None, allow_overwrite=True, display_name=None, description=None):
		"""
		Saves the slicer profile ``profile`` for slicer ``slicer`` under name ``name``.

		``profile`` may be either a :class:`SlicingProfile` or a :class:`dict`.

		If it's a :class:`SlicingProfile`, its :attr:`~SlicingProfile.slicer``, :attr:`~SlicingProfile.name` and - if
		provided - :attr:`~SlicingProfile.display_name` and :attr:`~SlicingProfile.description` attributes will be
		overwritten with the supplied values.

		If it's a :class:`dict`, a new :class:`SlicingProfile` instance will be created with the supplied meta data and
		the profile data as the :attr:`~SlicingProfile.data` attribute.

		Arguments:
		    slicer (str): Identifier of the slicer for which to save the ``profile``.
		    name (str): Identifier under which to save the ``profile``.
		    profile (SlicingProfile or dict): The :class:`SlicingProfile` or a :class:`dict` containing the profile
		        data of the profile the save.
		    overrides (dict): Overrides to apply to the ``profile`` before saving it.
		    allow_overwrite (boolean): If True (default) if a profile for the same ``slicer`` of the same ``name``
		        already exists, it will be overwritten. Otherwise an exception will be thrown.
		    display_name (str): The name to display to the user for the profile.
		    description (str): A description of the profile.

		Returns:
		    SlicingProfile: The saved profile (including the applied overrides).

		Raises:
		    ValueError: The supplied ``profile`` is neither a :class:`SlicingProfile` nor a :class:`dict`.
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown.
		    ~octoprint.slicing.exceptions.ProfileAlreadyExists: A profile with name ``name`` already exists for ``slicer`` and ``allow_overwrite`` is
		        False.
		"""
		if not slicer in self.registered_slicers:
			raise UnknownSlicer(slicer)

		if not isinstance(profile, SlicingProfile):
			if isinstance(profile, dict):
				profile = SlicingProfile(slicer, name, profile, display_name=display_name, description=description)
			else:
				raise ValueError("profile must be a SlicingProfile or a dict")
		else:
			profile.slicer = slicer
			profile.name = name
			if display_name is not None:
				profile.display_name = display_name
			if description is not None:
				profile.description = description

		path = self.get_profile_path(slicer, name)
		self._save_profile_to_path(slicer, path, profile, overrides=overrides, allow_overwrite=allow_overwrite)
		return profile

	def _temporary_profile(self, slicer, name=None, overrides=None):
		if not slicer in self.registered_slicers:
			raise UnknownSlicer(slicer)

		profile = self._get_default_profile(slicer)
		if name:
			try:
				profile = self.load_profile(slicer, name)
			except (UnknownProfile, IOError):
				# in that case we'll use the default profile
				pass

		return TemporaryProfile(self.get_slicer(slicer).save_slicer_profile, profile, overrides=overrides)

	def delete_profile(self, slicer, name):
		"""
		Deletes the profile ``name`` for the specified ``slicer``.

		If the profile does not exist, nothing will happen.

		Arguments:
		    slicer (str): Identifier of the slicer for which to delete the profile.
		    name (str): Identifier of the profile to delete.

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown.
		"""

		if not slicer in self.registered_slicers:
			raise UnknownSlicer(slicer)

		if not name:
			raise ValueError("name must be set")

		try:
			path = self.get_profile_path(slicer, name, must_exist=True)
		except UnknownProfile:
			return
		os.remove(path)

	def all_profiles(self, slicer, require_configured=False):
		"""
		Retrieves all profiles for slicer ``slicer``.

		If ``require_configured`` is set to True (default is False), only will return the profiles if the ``slicer``
		is already configured, otherwise a :class:`SlicerNotConfigured` exception will be raised.

		Arguments:
		    slicer (str): Identifier of the slicer for which to retrieve all slicer profiles
		    require_configured (boolean): Whether to require the slicer ``slicer`` to be already configured (True)
		        or not (False, default). If False and the slicer is not yet configured, a :class:`~octoprint.slicing.exceptions.SlicerNotConfigured`
		        exception will be raised.

		Returns:
		    list of SlicingProfile: A list of all :class:`SlicingProfile` instances available for the slicer ``slicer``.

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown.
		    ~octoprint.slicing.exceptions.SlicerNotConfigured: The slicer ``slicer`` is not configured and ``require_configured`` was True.
		"""

		if not slicer in self.registered_slicers:
			raise UnknownSlicer(slicer)
		if require_configured and not slicer in self.configured_slicers:
			raise SlicerNotConfigured(slicer)

		profiles = dict()
		slicer_profile_path = self.get_slicer_profile_path(slicer)
		for entry in os.listdir(slicer_profile_path):
			if not entry.endswith(".profile") or entry.startswith("."):
				# we are only interested in profiles and no hidden files
				continue

			path = os.path.join(slicer_profile_path, entry)
			profile_name = entry[:-len(".profile")]

			profiles[profile_name] = self._load_profile_from_path(slicer, path, require_configured=require_configured)
		return profiles

	def get_slicer_profile_path(self, slicer):
		"""
		Retrieves the path where the profiles for slicer ``slicer`` are stored.

		Arguments:
		    slicer (str): Identifier of the slicer for which to retrieve the path.

		Returns:
		    str: The absolute path to the folder where the slicer's profiles are stored.

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown.
		"""

		if not slicer in self.registered_slicers:
			raise UnknownSlicer(slicer)

		path = os.path.join(self._profile_path, slicer)
		if not os.path.exists(path):
			os.makedirs(path)
		return path

	def get_profile_path(self, slicer, name, must_exist=False):
		"""
		Retrieves the path to the profile named ``name`` for slicer ``slicer``.

		If ``must_exist`` is set to True (defaults to False) a :class:`UnknownProfile` exception will be raised if the
		profile doesn't exist yet.

		Arguments:
		    slicer (str): Identifier of the slicer to which the profile belongs to.
		    name (str): Identifier of the profile for which to retrieve the path.
		    must_exist (boolean): Whether the path must exist (True) or not (False, default).

		Returns:
		    str: The absolute path to the profile identified by ``name`` for slicer ``slicer``.

		Raises:
		    ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown.
		    ~octoprint.slicing.exceptions.UnknownProfile: The profile named ``name`` doesn't exist and ``must_exist`` was True.
		"""

		if not slicer in self.registered_slicers:
			raise UnknownSlicer(slicer)

		if not name:
			raise ValueError("name must be set")

		name = self._sanitize(name)

		path = os.path.join(self.get_slicer_profile_path(slicer), "{name}.profile".format(name=name))
		if not os.path.realpath(path).startswith(self._profile_path):
			raise IOError("Path to profile {name} tried to break out of allows sub path".format(**locals()))
		if must_exist and not (os.path.exists(path) and os.path.isfile(path)):
			raise UnknownProfile(slicer, name)
		return path

	def _sanitize(self, name):
		if name is None:
			return None

		if "/" in name or "\\" in name:
			raise ValueError("name must not contain / or \\")

		import string
		valid_chars = "-_.() {ascii}{digits}".format(ascii=string.ascii_letters, digits=string.digits)
		sanitized_name = ''.join(c for c in name if c in valid_chars)
		sanitized_name = sanitized_name.replace(" ", "_")
		return sanitized_name

	def _load_profile_from_path(self, slicer, path, require_configured=False):
		return self.get_slicer(slicer, require_configured=require_configured).get_slicer_profile(path)

	def _save_profile_to_path(self, slicer, path, profile, allow_overwrite=True, overrides=None, require_configured=False):
		print('_save_profile_to_path', overrides)
		self.get_slicer(slicer, require_configured=require_configured).save_slicer_profile(path, profile, allow_overwrite=allow_overwrite, overrides=overrides)

	def _get_default_profile(self, slicer):
		default_profiles = settings().get(["slicing", "defaultProfiles"])
		if default_profiles and slicer in default_profiles:
			try:
				return self.load_profile(slicer, default_profiles[slicer])
			except (UnknownProfile, IOError):
				# in that case we'll use the slicers predefined default profile
				pass

		return self.get_slicer(slicer).get_slicer_default_profile()


