#! /usr/bin/env python -tt
# vim: set fileencoding=utf-8
from __future__ import unicode_literals
"""
Hierarchical Yaml Python Config
===============================

A simple python lib allowing hierarchical config files in YAML syntax.

License
-------

(c) 2014 - 2020 Klaus Zerwes zero-sys.net
This package is free software.
This software is licensed under the terms of the
GNU GENERAL PUBLIC LICENSE version 3 or later,
as published by the Free Software Foundation.
See https://www.gnu.org/licenses/gpl.html
"""


import sys
import os
import yaml
from yaml import parser
import logging
from distutils.util import strtobool
import re
import io
from jinja2 import Environment, Undefined, DebugUndefined, StrictUndefined, TemplateError

from . import odyldo

__all__ = [
    'load',
    'dump',
    'HiYaPyCo',
    'HiYaPyCoInvocationException',
    'HiYaPyCoImplementationException',
    ]

from . import version
__version__ = version.VERSION

logger = logging.getLogger(__name__)

_usedefaultyamlloader = False

class HiYaPyCoInvocationException(Exception):
    """dummy Exception raised on wrong invocation"""
    pass

class HiYaPyCoImplementationException(Exception):
    """dummy Exception raised if we are unable to merge some YAML stuff"""
    pass

try:
    primitiveTypes = (int, str, bool, float, unicode)
    strTypes = (str, unicode)
except NameError:
    primitiveTypes = (int, str, bool, float)
    strTypes = (str)
listTypes = (list, tuple)

# you may set this to something suitable for you
jinja2env = Environment(undefined=Undefined)

METHODS = { 'METHOD_SIMPLE':0x0001, 'METHOD_MERGE':0x0002, 'METHOD_SUBSTITUTE':0x0003, }
METHOD_SIMPLE = METHODS['METHOD_SIMPLE']
METHOD_MERGE = METHODS['METHOD_MERGE']
METHOD_SUBSTITUTE = METHODS['METHOD_SUBSTITUTE']


class HiYaPyCo():
    """Main class"""
    def __init__(self, *args, **kwargs):
        """
        args: YAMLfile(s)
        kwargs:
          * method: one of hiyapyco.METHOD_SIMPLE | hiyapyco.METHOD_MERGE | hiyapyco.METHOD_SUBSTITUTE
          * mergelists: boolean (default: True) try to merge lists (only makes sense if hiyapyco.METHOD_MERGE or hiyapyco.METHOD_SUBSTITUTE)
          * interpolate: boolean (default: False)
          * castinterpolated: boolean (default: False) try to cast values after interpolating
          * usedefaultyamlloader: boolean (default: False)
          * encoding: (default: 'utf-8') encoding used to read yaml files
          * loglevel: one of  the valid levels from the logging module
          * failonmissingfiles: boolean (default: True)
          * loglevelmissingfiles

        Returns a representation of the merged and (if requested) interpolated config.
        Will mostly be a OrderedDict (dict if usedefaultyamlloader), but can be of any other type, depending on the yaml files.
        """
        self._data = None
        self._files = []

        self.method = None
        if 'method' in kwargs:
            logger.debug('parse kwarg method: %s ...' % kwargs['method'])
            if kwargs['method'] not in METHODS.values():
                raise HiYaPyCoInvocationException(
                        'undefined method used, must be one of: %s' %
                        ' '.join(METHODS.keys())
                    )
            self.method = kwargs['method']
            del kwargs['method']
        if self.method == None:
            self.method = METHOD_SIMPLE

        self.mergelists = True
        if 'mergelists' in kwargs:
            if not isinstance(kwargs['mergelists'], bool):
                raise HiYaPyCoInvocationException(
                        'value of "mergelists" must be boolean (got: "%s" as %s)' %
                        (kwargs['mergelists'], type(kwargs['mergelists']),)
                        )
            self.mergelists = kwargs['mergelists']
            del kwargs['mergelists']

        self.interpolate = False
        self.castinterpolated = False
        if 'interpolate' in kwargs:
            if not isinstance(kwargs['interpolate'], bool):
                raise HiYaPyCoInvocationException(
                        'value of "interpolate" must be boolean (got: "%s" as %s)' %
                        (kwargs['interpolate'], type(kwargs['interpolate']),)
                        )
            self.interpolate = kwargs['interpolate']
            del kwargs['interpolate']
            if 'castinterpolated' in kwargs:
                if not isinstance(kwargs['castinterpolated'], bool):
                    raise HiYaPyCoInvocationException(
                            'value of "castinterpolated" must be boolean (got: "%s" as %s)' %
                            (kwargs['castinterpolated'], type(kwargs['castinterpolated']),)
                        )
                self.castinterpolated = kwargs['castinterpolated']
                del kwargs['castinterpolated']

        if 'usedefaultyamlloader' in kwargs:
            if not isinstance(kwargs['usedefaultyamlloader'], bool):
                raise HiYaPyCoInvocationException(
                        'value of "usedefaultyamlloader" must be boolean (got: "%s" as %s)' %
                        (kwargs['usedefaultyamlloader'], type(kwargs['usedefaultyamlloader']),)
                        )
            global _usedefaultyamlloader
            _usedefaultyamlloader = kwargs['usedefaultyamlloader']
            del kwargs['usedefaultyamlloader']

        self.failonmissingfiles = True
        if 'failonmissingfiles' in kwargs:
            if not isinstance(kwargs['failonmissingfiles'], bool):
                raise HiYaPyCoInvocationException(
                        'value of "failonmissingfiles" must be boolean (got: "%s" as %s)' %
                        (kwargs['failonmissingfiles'], type(kwargs['failonmissingfiles']),)
                        )
            self.failonmissingfiles = bool(kwargs['failonmissingfiles'])
            del kwargs['failonmissingfiles']

        if 'loglevelmissingfiles' in kwargs:
            logging.getLogger('testlevellogger').setLevel(kwargs['loglevelmissingfiles'])
            self.loglevelonmissingfiles = logging.getLogger('testlevellogger').getEffectiveLevel()
            del kwargs['loglevelmissingfiles']
        else:
            self.loglevelonmissingfiles = logging.ERROR
            if not self.failonmissingfiles:
                self.loglevelonmissingfiles = logging.WARN

        if 'loglevel' in kwargs:
            logger.setLevel(kwargs['loglevel'])
            del kwargs['loglevel']

        self.encoding = 'utf-8'
        if 'encoding' in kwargs:
            self.encoding = kwargs['encoding']
            del kwargs['encoding']

        if kwargs:
            raise HiYaPyCoInvocationException('undefined keywords: %s' % ' '.join(kwargs.keys()))

        if not args:
            raise HiYaPyCoInvocationException('no yaml files defined')

        for arg in args:
            self._updatefiles(arg)

        for yamlfile in self._files[:]:
            logger.debug('yamlfile: %s ...' % yamlfile)
            try:
                if '\n' in yamlfile:
                    logger.debug('loading yaml doc from str ...')
                    f = yamlfile
                    self._load_data(_usedefaultyamlloader, yamlfile)
                else:
                    fn = yamlfile
                    if not os.path.isabs(yamlfile):
                        fn = os.path.join(os.getcwd(), yamlfile)
                        logger.debug('path extended for yamlfile: %s' % fn)
                    try:
                        with io.open(fn, 'r', encoding=self.encoding) as f:
                            logger.debug('open4reading: file %s' % f)
                            self._load_data(_usedefaultyamlloader, f)
                    except IOError as e:
                        logger.log(self.loglevelonmissingfiles, e)
                        if not fn == yamlfile:
                            logger.log(self.loglevelonmissingfiles,
                                    'file not found: %s (%s)' % (yamlfile, fn,))
                        else:
                            logger.log(self.loglevelonmissingfiles,
                                    'file not found: %s' % yamlfile)
                        if self.failonmissingfiles:
                            raise HiYaPyCoInvocationException(
                                    'yaml file not found: \'%s\'' % yamlfile
                                )
                        self._files.remove(yamlfile)
                        continue
            except yaml.parser.ParserError as e:
                logger.log(self.loglevelonmissingfiles, e)
                logger.log(self.loglevelonmissingfiles,
                        'error while parsing yaml %s' % f)
                if self.failonmissingfiles:
                    raise HiYaPyCoInvocationException(
                            'error while parsing file: \'%s\'' % f
                        )
                self._files.remove(yamlfile)
                continue


        if self.interpolate:
            self._data = self._interpolate(self._data)

    def _load_data(self, _usedefaultyamlloader, f):
        if _usedefaultyamlloader:
            ydata_generator = yaml.safe_load_all(f)
        else:
            ydata_generator = odyldo.safe_load_all(f)
        for ydata in ydata_generator:
            if logger.isEnabledFor(logging.DEBUG):
                logger.debug('yaml data: %s' % ydata)
            if self._data is None:
                self._data = ydata
            else:
                if self.method == METHOD_SIMPLE:
                    self._data = self._simplemerge(self._data, ydata)
                elif self.method == METHOD_MERGE:
                    self._data = self._deepmerge(self._data, ydata)
                elif self.method == METHOD_SUBSTITUTE:
                    self._data = self._substmerge(self._data, ydata)
                else:
                    raise HiYaPyCoInvocationException('unknown merge method \'%s\'' % self.method)
                logger.debug('merged data: %s' % self._data)

    def _updatefiles(self, arg):
        if isinstance(arg, strTypes):
            if arg in self._files:
                logger.warn('ignoring duplicated file %s' % arg)
                return
            self._files.append(arg)
        elif isinstance(arg, listTypes):
            for larg in arg:
                self._updatefiles(larg)
        else:
            raise HiYaPyCoInvocationException('unable to handle arg %s of type %s' % (arg, type(arg),))

    def _interpolate(self, d):
        logger.debug('interpolate "%s" of type %s ...' % (d, type(d),))
        if d is None:
            return None
        if isinstance(d, strTypes):
            return self._interpolatestr(d)
        if isinstance(d, primitiveTypes):
            return d
        if isinstance(d, listTypes):
            for k, v in enumerate(d):
                d[k] = self._interpolate(v)
            return d
        if isinstance(d, dict):
            for k in d.keys():
                d[k] = self._interpolate(d[k])
            return d
        raise HiYaPyCoImplementationException('can not interpolate "%s" of type %s' % (d, type(d),))

    def _interpolatestr(self, s):
        try:
            si = jinja2env.from_string(s).render(self._data)
        except TemplateError as e:
            # FIXME: this seems to be broken for unicode str?
            raise HiYaPyCoImplementationException('error interpolating string "%s" : %s' % (s, e,))
        if not s == si:
            if self.castinterpolated:
                if not re.match( r'^\d+\.*\d*$', si):
                    try:
                        si = bool(strtobool(si))
                    except ValueError:
                        pass
                else:
                    try:
                        if '.' in si:
                            si = float(si)
                        else:
                            si = int(si)
                    except ValueError:
                        pass
            logger.debug('interpolated "%s" to "%s" (type: %s)' % (s, si, type(si),))
        return si

    def _simplemerge(self, a, b):
        logger.debug('simplemerge %s (%s) and %s (%s)' % (a, type(a), b, type(b),))
        # FIXME: make None usage configurable
        if b is None:
            logger.debug('pass as b is None')
            pass
        elif isinstance(b, primitiveTypes):
            logger.debug('simplemerge: primitiveTypes replace a "%s"  w/ b "%s"' % (a, b,))
            a = b
        elif isinstance(b, listTypes):
            logger.debug('simplemerge: listTypes a "%s"  w/ b "%s"' % (a, b,))
            if isinstance(a, listTypes):
                for k, v in enumerate(b):
                    try:
                        a[k] = self._simplemerge(a[k], b[k])
                    except IndexError:
                        a[k] = b[k]
            else:
                logger.debug('simplemerge: replace %s w/ list %s' % (a, b,))
                a = b
        elif isinstance(b, dict):
            if isinstance(a, dict):
                logger.debug('simplemerge: update %s:"%s" by %s:"%s"' % (type(a), a, type(b), b,))
                a.update(b)
            else:
                logger.debug('simplemerge: replace %s w/ dict %s' % (a, b,))
                a = b
        else:
            raise HiYaPyCoImplementationException(
                    'can not (simple)merge %s to %s (@ "%s" try to merge "%s")' %
                    (type(b), type(a), a, b,)
                    )
        return a

    def _substmerge(self, a, b):
        logger.debug('>' * 30)
        logger.debug('deepmerge %s and %s' % (a, b,))
        # FIXME: make None usage configurable
        if b is None:
            logger.debug('pass as b is None')
            pass

        # treat listTypes as primitiveTypes in merge
        # subsititues list, don't merge them

        if a is None or isinstance(b, primitiveTypes) or isinstance(b, listTypes):
            logger.debug('deepmerge: replace a "%s"  w/ b "%s"' % (a, b,))
            a = b

        elif isinstance(a, dict):
            if isinstance(b, dict):
                logger.debug('deepmerge: dict ... "%s" and "%s"' % (a, b,))
                for k in b:
                    if k in a:
                        logger.debug('deepmerge dict: loop for key "%s": "%s" and "%s"' % (k, a[k], b[k],))
                        a[k] = self._deepmerge(a[k], b[k])
                    else:
                        logger.debug('deepmerge dict: set key %s' % k)
                        a[k] = b[k]
            elif isinstance(b, listTypes):
                logger.debug('deepmerge: dict <- list ... "%s" <- "%s"' % (a, b,))
                for bd in b:
                    if isinstance(bd, dict):
                        a = self._deepmerge(a, bd)
                    else:
                        raise HiYaPyCoImplementationException(
                            'can not merge element from list of type %s to dict (@ "%s" try to merge "%s")' %
                            (type(b), a, b,)
                        )
            else:
                raise HiYaPyCoImplementationException(
                    'can not merge %s to %s (@ "%s" try to merge "%s")' %
                    (type(b), type(a), a, b,)
                )
        logger.debug('end deepmerge part: return: "%s"' % a)
        logger.debug('<' * 30)
        return a

    def _deepmerge(self, a, b):
        logger.debug('>'*30)
        logger.debug('deepmerge %s and %s' % (a, b,))
        # FIXME: make None usage configurable
        if b is None:
            logger.debug('pass as b is None')
            pass
        if a is None or isinstance(b, primitiveTypes):
            logger.debug('deepmerge: replace a "%s"  w/ b "%s"' % (a, b,))
            a = b
        elif isinstance(a, listTypes):
            if isinstance(b, listTypes):
                logger.debug('deepmerge: lists extend %s:"%s" by %s:"%s"' % (type(a), a, type(b), b,))
                a.extend(be for be in b if be not in a and
                            (isinstance(be, primitiveTypes) or isinstance(be, listTypes))
                        )
                srcdicts = {}
                for k, bd in enumerate(b):
                    if isinstance(bd, dict):
                        srcdicts.update({k:bd})
                logger.debug('srcdicts: %s' % srcdicts)
                for k, ad in enumerate(a):
                    logger.debug('deepmerge ad "%s" w/ k "%s" of type %s' % (ad, k, type(ad)))
                    if isinstance(ad, dict):
                        if k in srcdicts.keys():
                            # we merge only if at least one key in dict is matching
                            merge = False
                            if self.mergelists:
                                for ak in ad.keys():
                                    if ak in srcdicts[k].keys():
                                        merge = True
                                        break
                            if merge:
                                logger.debug(
                                        'deepmerge ad: deep merge list dict elem w/ key:%s: "%s" and "%s"'
                                        % (ak, ad, srcdicts[k],)
                                    )
                                a[k] = self._deepmerge(ad, srcdicts[k])
                                del srcdicts[k]
                logger.debug('deepmerge list: remaining srcdicts elems: %s' % srcdicts)
                for k in srcdicts.keys():
                    logger.debug('deepmerge list: new dict append %s:%s' % (k, srcdicts[k]))
                    a.append(srcdicts[k])
            else:
                raise HiYaPyCoImplementationException(
                        'can not merge %s to %s (@ "%s"  try to merge "%s")' %
                        (type(b), type(a), a, b,)
                        )
        elif isinstance(a, dict):
            if isinstance(b, dict):
                logger.debug('deepmerge: dict ... "%s" and "%s"' % (a, b,))
                for k in b:
                    if k in a:
                        logger.debug('deepmerge dict: loop for key "%s": "%s" and "%s"' % (k, a[k], b[k],))
                        a[k] = self._deepmerge(a[k], b[k])
                    else:
                        logger.debug('deepmerge dict: set key %s' % k)
                        a[k] = b[k]
            elif isinstance(b, listTypes):
                logger.debug('deepmerge: dict <- list ... "%s" <- "%s"' % (a, b,))
                for bd in b:
                    if isinstance(bd, dict):
                        a = self._deepmerge(a, bd)
                    else:
                        raise HiYaPyCoImplementationException(
                                'can not merge element from list of type %s to dict (@ "%s" try to merge "%s")' %
                                (type(b), a, b,)
                                )
            else:
                raise HiYaPyCoImplementationException(
                        'can not merge %s to %s (@ "%s" try to merge "%s")' %
                        (type(b), type(a), a, b,)
                        )
        logger.debug('end deepmerge part: return: "%s"' % a)
        logger.debug('<'*30)
        return a

    def yamlfiles(self):
        return self._files

    def __str__(self):
        """String representation of the class"""
        return '%s [%s]' % (__name__, os.pathsep.join(self._files))

    def data(self):
        """return the data, merged and interpolated if required"""
        return self._data

    def dump(self, **kwds):
        """dump the data as YAML"""
        return dump(self._data, **kwds)

def dump(data, **kwds):
    """dump the data as YAML"""
    if _usedefaultyamlloader:
        return yaml.safe_dump(data, **kwds)
    else:
        return odyldo.safe_dump(data, **kwds)

def load(*args, **kwargs):
    """
    Load a Hierarchical Yaml Python Config
    --------------------------------------

    args: YAMLfile(s)
    kwargs:
      * method: one of hiyapyco.METHOD_SIMPLE | hiyapyco.METHOD_MERGE | hiyapyco.METHOD_SUBSTITUTE
      * mergelists: boolean (default: True) try to merge lists (only makes sense if hiyapyco.METHOD_MERGE or hiyapyco.METHOD_SUBSTITUTE)
      * interpolate: boolean (default: False)
      * castinterpolated: boolean (default: False) try to cast values after interpolating
      * usedefaultyamlloader: boolean (default: False)
      * encoding: (default: 'utf-8') encoding used to read yaml files
      * loglevel: one of  the valid levels from the logging module
      * failonmissingfiles: boolean (default: True)
      * loglevelmissingfiles

    Returns a representation of the merged and (if requested) interpolated config.
    Will mostly be a OrderedDict (dict if usedefaultyamlloader), but can be of any other type, depending on the yaml files.
    """
    hiyapyco = HiYaPyCo(*args, **kwargs)
    return hiyapyco.data()

# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 smartindent nu