# -*- coding: utf-8 -*-
#
# 2011-2015 Nico Schottelius (nico-cdist at schottelius.org)
# 2012-2013 Steven Armstrong (steven-cdist at armstrong.cc)
# 2014 Daniel Heule (hda at sfs.biz)
#
# This file is part of cdist.
#
# cdist is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# cdist is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with cdist. If not, see <http://www.gnu.org/licenses/>.
#
#

import argparse
import logging
import os
import sys

import cdist
from cdist import core
from cdist import flock


class MissingRequiredEnvironmentVariableError(cdist.Error):
    def __init__(self, name):
        self.name = name
        self.message = ("Emulator requires the environment variable %s to be "
                        "setup" % self.name)

    def __str__(self):
        return self.message


class DefaultList(list):
    """Helper class to allow default values for optional_multiple parameters.

       @see https://groups.google.com/forum/#!msg/comp.lang.python/sAUvkJEDpRc/RnRymrzJVDYJ
    """
    def __copy__(self):
        return []

    @classmethod
    def create(cls, initial=None):
        if initial:
            return cls(initial.split('\n'))


class Emulator(object):
    def __init__(self, argv, stdin=sys.stdin.buffer, env=os.environ):
        self.argv = argv
        self.stdin = stdin
        self.env = env

        self.object_id = ''

        try:
            self.global_path = self.env['__global']
            self.target_host = (
                self.env['__target_host'],
                self.env['__target_hostname'],
                self.env['__target_fqdn']
            )

            # Internal variables
            self.object_source = self.env['__cdist_manifest']
            self.type_base_path = self.env['__cdist_type_base_path']
            self.object_marker = self.env['__cdist_object_marker']

        except KeyError as e:
            raise MissingRequiredEnvironmentVariableError(e.args[0])

        self.object_base_path = os.path.join(self.global_path, "object")
        self.typeorder_path = os.path.join(self.global_path, "typeorder")

        self.type_name = os.path.basename(argv[0])
        self.cdist_type = core.CdistType(self.type_base_path, self.type_name)

        # If set then object alreay exists and this var holds existing
        # requirements.
        self._existing_reqs = None

        self.__init_log()

    def run(self):
        """Emulate type commands (i.e. __file and co)"""

        self.commandline()
        self.init_object()

        # locking for parallel execution
        with flock.Flock(self.flock_path):
            self.setup_object()
            self.save_stdin()
            self.record_requirements()
            self.record_auto_requirements()
            self.log.trace("Finished %s %s" % (
                self.cdist_object.path, self.parameters))

    def __init_log(self):
        """Setup logging facility"""

        if '__cdist_log_level' in self.env:
            try:
                loglevel = self.env['__cdist_log_level']
                level = int(loglevel)
            except ValueError:
                level = logging.WARNING
        else:
            level = logging.WARNING
        try:
            logging.root.setLevel(level)
        except (ValueError, TypeError):
            # if invalid __cdist_log_level value
            logging.root.setLevel(logging.WARNING)

        self.log = logging.getLogger(self.target_host[0])

    def commandline(self):
        """Parse command line"""

        parser = argparse.ArgumentParser(add_help=False,
                                         argument_default=argparse.SUPPRESS)

        for parameter in self.cdist_type.required_parameters:
            argument = "--" + parameter
            parser.add_argument(argument, dest=parameter, action='store',
                                required=True)
        for parameter in self.cdist_type.required_multiple_parameters:
            argument = "--" + parameter
            parser.add_argument(argument, dest=parameter, action='append',
                                required=True)
        for parameter in self.cdist_type.optional_parameters:
            argument = "--" + parameter
            default = self.cdist_type.parameter_defaults.get(parameter, None)
            parser.add_argument(argument, dest=parameter, action='store',
                                required=False, default=default)
        for parameter in self.cdist_type.optional_multiple_parameters:
            argument = "--" + parameter
            default = DefaultList.create(
                    self.cdist_type.parameter_defaults.get(
                        parameter, None))
            parser.add_argument(argument, dest=parameter, action='append',
                                required=False, default=default)
        for parameter in self.cdist_type.boolean_parameters:
            argument = "--" + parameter
            parser.add_argument(argument, dest=parameter,
                                action='store_const', const='')

        # If not singleton support one positional parameter
        if not self.cdist_type.is_singleton:
            parser.add_argument("object_id", nargs=1)

        # And finally parse/verify parameter
        self.args = parser.parse_args(self.argv[1:])
        self.log.trace('Args: %s' % self.args)

    def init_object(self):
        # Initialize object - and ensure it is not in args
        if self.cdist_type.is_singleton:
            self.object_id = ''
        else:
            self.object_id = self.args.object_id[0]
            del self.args.object_id

        # Instantiate the cdist object we are defining
        self.cdist_object = core.CdistObject(
                self.cdist_type, self.object_base_path, self.object_marker,
                self.object_id)
        lockfname = ('.' + self.cdist_type.name +
                     self.object_id + '_' +
                     self.object_marker + '.lock')
        lockfname = lockfname.replace(os.sep, '_')
        self.flock_path = os.path.join(self.object_base_path, lockfname)

    def _object_params_in_context(self):
        ''' Get cdist_object parameters dict adopted by context.
        Context consists of cdist_type boolean, optional, required,
        optional_multiple and required_multiple parameters. If parameter
        is multiple parameter then its value is a list.
        This adaptation works on cdist_object.parameters which are read from
        directory based dict where it is unknown what kind of data is in
        file. If there is only one line in the file it is unknown if this
        is a value of required/optional parameter or if it is one value of
        multiple values parameter.
        '''
        params = {}
        if self.cdist_object.exists:
            for param in self.cdist_object.parameters:
                value = ('' if param in self.cdist_type.boolean_parameters
                         else self.cdist_object.parameters[param])
                if ((param in self.cdist_type.required_multiple_parameters or
                     param in self.cdist_type.optional_multiple_parameters) and
                        not isinstance(value, list)):
                    value = [value]
                params[param] = value
        return params

    def setup_object(self):
        # Create object with given parameters
        self.parameters = {}
        for key, value in vars(self.args).items():
            if value is not None:
                self.parameters[key] = value

        if self.cdist_object.exists and 'CDIST_OVERRIDE' not in self.env:
            # Make existing requirements a set so that we can compare it
            # later with new requirements.
            self._existing_reqs = set(self.cdist_object.requirements)
            obj_params = self._object_params_in_context()
            if obj_params != self.parameters:
                errmsg = ("Object %s already exists with conflicting "
                          "parameters:\n%s: %s\n%s: %s" % (
                              self.cdist_object.name,
                              " ".join(self.cdist_object.source),
                              obj_params,
                              self.object_source,
                              self.parameters))
                raise cdist.Error(errmsg)
        else:
            if self.cdist_object.exists:
                self.log.debug(('Object %s override forced with '
                                'CDIST_OVERRIDE'), self.cdist_object.name)
                self.cdist_object.create(True)
            else:
                self.cdist_object.create()
            self.cdist_object.parameters = self.parameters
            # record the created object in typeorder file
            with open(self.typeorder_path, 'a') as typeorderfile:
                print(self.cdist_object.name, file=typeorderfile)

        # Record / Append source
        self.cdist_object.source.append(self.object_source)

    chunk_size = 65536

    def _read_stdin(self):
        return self.stdin.read(self.chunk_size)

    def save_stdin(self):
        """If something is written to stdin, save it in the object as
        $__object/stdin so it can be accessed in manifest and gencode-*
        scripts.
        """
        if not self.stdin.isatty():
            try:
                # go directly to file instead of using CdistObject's api
                # as that does not support streaming
                path = os.path.join(self.cdist_object.absolute_path, 'stdin')
                with open(path, 'wb') as fd:
                    chunk = self._read_stdin()
                    while chunk:
                        fd.write(chunk)
                        chunk = self._read_stdin()
            except EnvironmentError as e:
                raise cdist.Error('Failed to read from stdin: %s' % e)

    def record_requirement(self, requirement):
        """record requirement and return recorded requirement"""

        # Raises an error, if object cannot be created
        try:
            cdist_object = self.cdist_object.object_from_name(requirement)
        except core.cdist_type.InvalidTypeError as e:
            self.log.error(("%s requires object %s, but type %s does not"
                            " exist. Defined at %s" % (
                                self.cdist_object.name,
                                requirement, e.name, self.object_source)))
            raise
        except core.cdist_object.MissingObjectIdError:
            self.log.error(("%s requires object %s without object id."
                            " Defined at %s" % (self.cdist_object.name,
                                                requirement,
                                                self.object_source)))
            raise

        self.log.debug("Recording requirement %s for %s",
                       requirement, self.cdist_object.name)

        # Save the sanitised version, not the user supplied one
        # (__file//bar => __file/bar)
        # This ensures pattern matching is done against sanitised list
        self.cdist_object.requirements.append(cdist_object.name)

        return cdist_object.name

    def record_requirements(self):
        """Record requirements."""

        # Inject the predecessor, but not if its an override
        # (this would leed to an circular dependency)
        if ("CDIST_ORDER_DEPENDENCY" in self.env and
                'CDIST_OVERRIDE' not in self.env):
            # load object name created befor this one from typeorder file ...
            with open(self.typeorder_path, 'r') as typecreationfile:
                typecreationorder = typecreationfile.readlines()
                # get the type created before this one ...
                try:
                    lastcreatedtype = typecreationorder[-2].strip()
                    # __object_name is the name of the object whose type
                    # manifest is currently executed
                    __object_name = self.env.get('__object_name', None)
                    if lastcreatedtype == __object_name:
                        self.log.debug(("Not injecting require for "
                                        "CDIST_ORDER_DEPENDENCY: %s for %s,"
                                        " %s's type manifest is currently"
                                        " being executed"),
                                       lastcreatedtype,
                                       self.cdist_object.name,
                                       lastcreatedtype)
                    else:
                        if 'require' in self.env:
                            appendix = " " + lastcreatedtype
                            if appendix not in self.env['require']:
                                self.env['require'] += appendix
                        else:
                            self.env['require'] = lastcreatedtype
                        self.log.debug(("Injecting require for "
                                        "CDIST_ORDER_DEPENDENCY: %s for %s"),
                                       lastcreatedtype,
                                       self.cdist_object.name)
                except IndexError:
                    # if no second last line, we are on the first type,
                    # so do not set a requirement
                    pass

        reqs = set()
        if "require" in self.env:
            requirements = self.env['require']
            self.log.debug("reqs = " + requirements)
            for requirement in requirements.split(" "):
                # Ignore empty fields - probably the only field anyway
                if len(requirement) == 0:
                    continue
                object_name = self.record_requirement(requirement)
                reqs.add(object_name)
        if self._existing_reqs is not None:
            # If object exists then compare existing and new requirements.
            if self._existing_reqs != reqs:
                warnmsg = ("Object {} already exists with requirements:\n"
                           "{}: {}\n"
                           "{}: {}\n"
                           "Dependency resolver could not handle dependencies "
                           "as expected.".format(
                               self.cdist_object.name,
                               " ".join(self.cdist_object.source),
                               self._existing_reqs,
                               self.object_source,
                               reqs
                           ))
                self.log.warning(warnmsg)

    def record_auto_requirements(self):
        """An object shall automatically depend on all objects that it
           defined in it's type manifest.
        """
        # __object_name is the name of the object whose type manifest is
        # currently executed
        __object_name = self.env.get('__object_name', None)
        if __object_name:
            # The object whose type manifest is currently run
            parent = self.cdist_object.object_from_name(__object_name)
            # The object currently being defined
            current_object = self.cdist_object
            # As parent defined current_object it shall automatically
            # depend on it.
            # But only if the user hasn't said otherwise.
            # Must prevent circular dependencies.
            if parent.name not in current_object.requirements:
                self.log.debug("Recording autorequirement %s for %s",
                               current_object.name, parent.name)
                parent.autorequire.append(current_object.name)
