import os
import jinja2
import yaml
from collections import MutableMapping, defaultdict, Iterable, OrderedDict
from glob import glob
import yaml.constructor


class OrderedDictYAMLLoader(yaml.Loader):
    """
    A YAML loader that loads mappings into ordered dictionaries.
    """

    def __init__(self, *args, **kwargs):
        yaml.Loader.__init__(self, *args, **kwargs)

        self.add_constructor(u'tag:yaml.org,2002:map', type(self).construct_yaml_map)
        self.add_constructor(u'tag:yaml.org,2002:omap', type(self).construct_yaml_map)

    def construct_yaml_map(self, node):
        data = OrderedDict()
        yield data
        value = self.construct_mapping(node)
        data.update(value)

    def construct_mapping(self, node, deep=False):
        if isinstance(node, yaml.MappingNode):
            self.flatten_mapping(node)
        else:
            raise yaml.constructor.ConstructorError(None, None,
                                                    'expected a mapping node, but found %s' % node.id, node.start_mark)

        mapping = OrderedDict()
        for key_node, value_node in node.value:
            key = self.construct_object(key_node, deep=deep)
            try:
                hash(key)
            except TypeError, exc:
                raise yaml.constructor.ConstructorError('while constructing a mapping',
                                                        node.start_mark,
                                                        'found unacceptable key (%s)' % exc, key_node.start_mark)
            value = self.construct_object(value_node, deep=deep)
            mapping[key] = value
        return mapping


class MetaYamlException(Exception):
    pass


class FileNotFound(MetaYamlException):
    pass


def _to_str(value):
    if isinstance(value, unicode):
        return value
    return str(value)


class MetaYaml(object):
    eager_brackets = "${", "}"
    lazy_brackets = "$(", ")"

    DEL_MARKER = "${__del__}"
    DEL_ALL_MARKER = "${__del_all__}"
    EXTEND_MARKER = "${__extend__}"

    def __init__(self, yaml_file, defaults=None, extend_key_word="extend",
                 ignore_errors=False, ignore_not_existed_files=False):
        """
          Reads and process yaml config files

          :param yaml_file
          :type  yaml_file      str or list[str]
          :param defaults       Dictionary with default values which can be use during parsing yaml files
          :param extend_key_word  The name of section with list of included files
          :param ignore_errors  Do not rise exception when value can't be rendered
          :param ignore_not_existed_files Do not rise exception if the file not found
        """

        self._extend_key_word = extend_key_word
        self.data = OrderedDict(defaults) if defaults else {}
        self.cache_template = defaultdict(lambda: {})
        self.ignore_errors = ignore_errors
        self.ignore_not_existed_files = ignore_not_existed_files
        self.processed_files = set()

        if isinstance(yaml_file, basestring):
            yaml_file = [yaml_file]
        else:
            assert isinstance(yaml_file, Iterable), "yaml_file should be string or list of strings"

        files = self.extend_filename(yaml_file)
        for filename in files:
            self.load(filename, self.data)

        self.substitute(self.data, self.data, [os.path.basename(files[0])], False)
        self.data.pop(self._extend_key_word, None)

    def extend_filename(self, file_list, path=None):
        files = []
        for filename in file_list:
            if not os.path.isabs(filename):
                if not path:
                    path = os.getcwdu() if isinstance(filename, unicode) else os.getcwd()

                filename = os.path.join(path, filename)

            found_files = glob(filename)
            if not self.ignore_not_existed_files and not found_files:
                raise FileNotFound("File %s not found" % filename)
            found_files.sort()
            files.extend(found_files)

        return files

    def load(self, path, data):
        if path in self.processed_files:
            return data  # file was already processed

        self.processed_files.add(path)
        basename = [os.path.basename(path)]
        file_dir = os.path.dirname(path)

        with open(path, "rb") as f:
            file_data = yaml.load(f, OrderedDictYAMLLoader) or {}

        data[self._extend_key_word] = file_data.get(self._extend_key_word, [])
        self.substitute(data[self._extend_key_word], data, basename, eager=True)

        extends = data[self._extend_key_word]

        if extends:
            self.substitute(data, data, basename, eager=True)
            if isinstance(extends, basestring):
                extends = [extends]

            if not isinstance(extends, Iterable):
                raise MetaYamlException("The value of %s should be list of string or string" %
                                        self._path_to_str([basename, self._extend_key_word]))

            for file_name in extends:
                if not isinstance(file_name, basestring):
                    raise MetaYamlException("The value of %s should be list of string or string" %
                                            self._path_to_str([basename, self._extend_key_word]))

            for file_name in self.extend_filename(extends, file_dir):
                try:
                    self.load(file_name, data)
                except IOError as e:
                    raise FileNotFound("Open file %s error from %s: %s" % (file_name, path, e))

        self._merge(data, file_data, data)
        self.substitute(data, data, basename, eager=True)

        return data

    def substitute(self, value, data, path, eager):
        path = path or []
        if isinstance(value, MutableMapping):
            to_remove = []
            for key, val in value.iteritems():
                new_path = path + [_to_str(key)]
                new_key = self.eval_value(key, new_path, data, eager)
                if new_key != key:
                    to_remove.append(key)
                value[new_key] = self.substitute(val, data, new_path, eager)

            for k in to_remove:
                del value[k]
        elif isinstance(value, list):
            for key in xrange(len(value)):
                value[key] = self.substitute(value[key], data, path + [_to_str(key)], eager)
        elif isinstance(value, basestring):
            value = self.eval_value(value, path, data, eager)

        return value

    @staticmethod
    def _path_to_str(path):
        def to_str(p):
            if isinstance(p, basestring):
                # noinspection PyTypeChecker
                return "." + p
            else:
                return "[%s]" % (p,)

        l = (to_str(p) for p in path)
        s = "".join(l)
        if s.startswith("."):
            s = s[1:]
        return s

    # noinspection PyUnresolvedReferences
    def eval_value(self, val, path, data, eager):
        if not isinstance(val, basestring):
            return val

        brackets = self.eager_brackets if eager else self.lazy_brackets
        if brackets[0] not in val:
            return val

        # disable force string conversion
        original_to_string = jinja2.runtime.to_string
        jinja2.runtime.to_string = lambda x: x

        cache = self.cache_template[eager]
        t = cache.get(val)
        if t is None:
            try:
                t = jinja2.Template(val, variable_start_string=brackets[0], variable_end_string=brackets[1])
            except Exception, e:
                if not self.ignore_errors:
                    raise MetaYamlException("Template compiling error of key: %s, value: %s, error: %s" % (
                        self._path_to_str(path), val, e))
            cache[val] = t
        try:
            r = list(t.root_render_func(t.new_context(data)))
            if len(r) == 1:
                result = r[0]
            else:
                r = [unicode(rr) for rr in r]
                result = u"".join(r)
        except Exception as e:
            result = val
            if not self.ignore_errors:
                raise MetaYamlException("Render template error of %s, %s: %s" % (self._path_to_str(path), val, e))
        finally:
            jinja2.runtime.to_string = original_to_string

        if isinstance(result, basestring):
            for t in [int, float]:
                try:
                    result = t(result)
                    break
                except (ValueError, TypeError):
                    pass

        return result

    def _merge(self, a, b, data, path=None):
        if path is None:
            path = []
        for key, b_value in b.iteritems():
            if key == self.DEL_MARKER:
                new_value = self.substitute(b_value, data, path + [str(key)], True)
                if isinstance(new_value, Iterable) and not isinstance(new_value, basestring):
                    for v in new_value:
                        a.pop(v, None)
                else:
                    a.pop(new_value, None)
            elif key == self.DEL_ALL_MARKER:
                a.clear()

        for key, b_value in b.iteritems():
            if key in (self.DEL_MARKER, self.DEL_ALL_MARKER):
                continue
            a_value = a.get(key)
            if isinstance(a_value, MutableMapping) and isinstance(b_value, MutableMapping):
                self._merge(a[key], b_value, path + [str(key)])
            elif (isinstance(a_value, list) and isinstance(b_value, MutableMapping) and
                  len(b_value) == 1 and self.EXTEND_MARKER in b_value):
                extend = b_value[self.EXTEND_MARKER]
                if not isinstance(extend, list):
                    raise MetaYamlException("The value of %s.%s must be list" %
                                            (self._path_to_str(path), self.EXTEND_MARKER))

                a[key].extend(extend)
            else:
                a[key] = b_value
        return a


def read(yaml_file, defaults=None, extend_key_word="extend", ignore_errors=False, ignore_not_existed_files=False):
    """
      Reads and process yaml config files

      :param yaml_file
      :type  yaml_file      str or list[str]
      :param defaults       Dictionary with default values which can be use during parsing yaml files
      :param extend_key_word  The name of section with list of included files
      :param ignore_errors  Do not rise exception when value can't be rendered
      :param ignore_not_existed_files Do not rise exception if the file not found
    """
    m = MetaYaml(yaml_file, defaults, extend_key_word, ignore_errors, ignore_not_existed_files)
    return m.data
