# Copyright © 2015-2018 STRG.AT GmbH, Vienna, Austria
# Copyright © 2020 Necdet Can Ateşman, Vienna, Austria
#
# This file is part of the The SCORE Framework.
#
# The SCORE Framework and all its parts are free software: you can redistribute
# them and/or modify them under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation which is in
# the file named COPYING.LESSER.txt.
#
# The SCORE Framework and all its parts are distributed without any WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE. For more details see the GNU Lesser General Public
# License.
#
# If you have not received a copy of the GNU Lesser General Public License see
# http://www.gnu.org/licenses/.
#
# The License-Agreement realised between you as Licensee and STRG.AT GmbH as
# Licenser including the issue of its valid conclusion and its pre- and
# post-contractual effects is governed by the laws of Austria. Any disputes
# concerning this License-Agreement including the issue of its valid conclusion
# and its pre- and post-contractual effects are exclusively decided by the
# competent court, in whose district STRG.AT GmbH has its registered seat, at
# the discretion of STRG.AT GmbH also the competent court, in whose district
# the Licensee has his registered seat, an establishment or assets.

import os
from ._exc import TemplateNotFound
from .loader import FileSystemLoader
from collections import namedtuple, defaultdict, OrderedDict
from score.init import (
    parse_list, extract_conf, ConfiguredModule, ConfigurationError)
import re


defaults = {
    'rootdirs': [],
}


def init(confdict):
    """
    Initializes this module acoording to :ref:`our module initialization
    guidelines <module_initialization>` with the following configuration keys:

    :confkey:`rootdirs` :confdefault:`None`
        Denotes the root folder containing all templates.
    """
    conf = dict(defaults.items())
    conf['rootdirs'] = []
    conf.update(confdict)
    if 'rootdir' in conf and conf['rootdirs']:
        import score.tpl
        raise ConfigurationError(
            score.tpl, "Both 'rootdir' and 'rootdirs' given")
    if 'rootdir' in conf:
        conf['rootdirs'] = conf['rootdir']
    rootdirs = parse_list(conf['rootdirs'])
    for rootdir in rootdirs:
        if not os.path.isdir(rootdir):
            import score.tpl
            raise ConfigurationError(
                score.tpl, 'Given rootdir is not a folder: %s' % (rootdir,))
    tpl = ConfiguredTplModule(rootdirs)
    extensions = set()
    for key in extract_conf(conf, 'filetype.'):
        extensions.add(key.rsplit('.', 1)[0])
    for ext in extensions:
        mimetype = conf.get('filetype.%s.mimetype' % ext)
        if mimetype is None:
            import score.tpl
            raise ConfigurationError(
                score.tpl, 'No mimetype configured for extension %s' % (ext,))
        tpl.filetypes[mimetype].extensions.append(ext)
    return tpl


class ConfiguredTplModule(ConfiguredModule):
    """
    This module's :class:`configuration class
    <score.init.ConfiguredModule>`.
    """

    def __init__(self, rootdirs):
        super().__init__(__package__)
        self.rootdirs = rootdirs
        self.filetypes = FileTypes(self)
        self.loaders = Loaders(self)
        self.engines = Engines(self)
        self._renderers = defaultdict(dict)

    def define_global(self, mimetype, name, value, escape=True):
        self.filetypes[mimetype].add_global(name, value, escape=escape)

    def iter_paths(self, mimetype=None):
        """
        Provides a generator iterating over all known template paths. If the
        optional parameter *mimetype* is present, only templates of that mime
        type will pe provided instead.
        """
        def all_paths():
            for mimetype in self.filetypes:
                for extension in self.filetypes[mimetype].extensions:
                    for loader in self.loaders[extension]:
                        yield from loader.iter_paths()

        def valid_paths():
            for path in all_paths():
                try:
                    self._find_filetype(path)
                    yield path
                except TemplateNotFound:
                    pass

        if mimetype:
            extensions = self.filetypes[mimetype].extensions
            regex = re.compile(r'(^|/)[^/.]+\.(%s)($|\.)' %
                               '|'.join(map(re.escape, extensions)))
            for path in valid_paths():
                if regex.search(path):
                    yield path
        else:
            yield from valid_paths()

    def load(self, path):
        """
        Loads given template *path*.

        See :meth:`Loader.load`.
        """
        return self._find_loader(path).load(path)

    def render(self, path, variables=None, *, apply_postprocessors=True):
        """
        Renders give template *path* with the optional `dict` of *variables*.
        It is possible to prevent running the file type's postprocessors by
        passing a falsy value for the *apply_postprocessors* parameter.
        """
        filetype = self._find_filetype(path)
        is_file, result = self.load(path)
        if variables is None:
            variables = {}
        for renderer in self._find_renderers(path, filetype=filetype):
            if is_file:
                result = renderer.render_file(result, variables, path=path)
                is_file = False
            else:
                result = renderer.render_string(result, variables, path=path)
        if is_file:
            result = open(result).read()
        if apply_postprocessors:
            for postprocessor in filetype.postprocessors:
                result = postprocessor(result)
        return result

    def mimetype(self, path):
        """
        Provides to mime type associated with given *path*.

        See :ref:`tpl_file_types`.
        """
        return self._find_filetype(path).mimetype

    def hash(self, path):
        """
        Provides a hash for given template *path*.

        See :meth:`Loader.hash`.
        """
        return self._find_loader(path).hash(path)

    def _finalize(self):
        # make sure that every file extension is associated with
        # at most one filetype
        all_extensions = set()
        for filetype in self.filetypes.values():
            duplicates = all_extensions.intersection(filetype.extensions)
            if not duplicates:
                all_extensions |= set(filetype.extensions)
                continue
            extension = duplicates.pop()
            filetypes = [f for f in self.filetypes
                         if extension in f.extensions.extensions]
            import score.tpl
            raise ConfigurationError(
                score.tpl,
                'Extension %s registered with multiple mimetypes: %s' % (
                    extension, filetypes))
        for extension in self.loaders:
            if extension not in all_extensions:
                import score.tpl
                raise ConfigurationError(
                    score.tpl,
                    'Loader Extension "%s" has no filetype' % (extension,))
        for extension in self.engines:
            if extension not in all_extensions:
                import score.tpl
                raise ConfigurationError(
                    score.tpl,
                    'Engine Extension "%s" has no filetype' % (extension,))
        # sort loaders and engines by length of extension string
        # this is important, as we want to test 'tar.gz' before 'gz'
        self.loaders = OrderedDict(
            (ext, self.loaders[ext])
            for ext in sorted(all_extensions, key=len, reverse=True))
        self.engines = OrderedDict(
            (ext, self.engines[ext])
            for ext in sorted(self.engines, key=len, reverse=True))

    def _find_loader(self, path):
        parts = os.path.basename(path).split('.', maxsplit=1)
        if len(parts) == 1:
            # TODO: other exception?
            raise TemplateNotFound(path)
        if parts[1] not in self.loaders:
            while '.' in parts[1]:
                more_parts = parts[1].split('.', maxsplit=1)
                parts[0] += '.' + more_parts[0]
                parts[1] = more_parts[1]
                if parts[1] in self.loaders:
                    break
            else:
                raise TemplateNotFound(path)
        for loader in self.loaders[parts[1]]:
            if loader.is_valid(path):
                return loader
        raise TemplateNotFound(path)

    def _find_renderers(self, path, *, filetype=None):
        renderers = []
        filename = os.path.basename(path)
        if filetype is None:
            filetype = self._find_filetype(path)
        while True:
            candidates = []
            for extension, engine in self.engines.items():
                idx = filename.rfind('.%s' % extension)
                if idx < 0:
                    continue
                candidates.append((extension, idx, engine))
            if not candidates:
                break
            extension, idx, engine = sorted(
                candidates, key=lambda x: (x[1] + len(x[0]), len(x[0])),
                reverse=True)[0]
            filename = filename[:len(extension) + 1]
            if filetype not in self._renderers[engine]:
                self._renderers[engine][filetype] = engine(self, filetype)
            renderers.append(self._renderers[engine][filetype])
        return renderers

    def _find_filetype(self, path):
        filename = os.path.basename(path)
        extensions = filename.split('.')[1:]
        for i in range(len(extensions), 0, -1):
            extension = '.'.join(extensions[:i])
            try:
                return next(filetype
                            for filetype in self.filetypes.values()
                            if extension in filetype.extensions)
            except StopIteration:
                pass
        raise TemplateNotFound(path)


class Loaders(defaultdict):

    def __init__(self, conf):
        self.conf = conf
        super().__init__()

    def __missing__(self, key):
        if '/' in key:
            raise ValueError('Invalid File extension "%s"' % (key,))
        loaders = list()
        if self.conf.rootdirs:
            loaders.append(FileSystemLoader(self.conf.rootdirs, key))
        self[key] = loaders
        return loaders


class Engines(dict):

    def __init__(self, conf):
        self.conf = conf
        super().__init__()

    def __setitem__(self, key, value):
        if key in self:
            raise ValueError(
                'Renderer for extension `%s` already defined as %s' %
                (key, repr(self[key])))
        if '/' in key:
            raise ValueError('Invalid File extension "%s"' % (key,))
        return super().__setitem__(key, value)


class FileTypes(defaultdict):

    def __init__(self, conf):
        self.conf = conf
        super().__init__()

    def __missing__(self, key):
        filetype = FileType(self.conf, key)
        self[key] = filetype
        return filetype


class FileType:
    """
    Represents a known :term:`file type`. Attributes may only be modified until
    :ref:`finalization <finalization>` of the module.
    """

    def __init__(self, conf, mimetype):
        self.__conf = conf
        self.__mimetype = mimetype
        self.__extensions = []
        self.__postprocessors = []
        self.__globals = []
        self.__finalized = False
        self.__escape = None

    def _finalize(self):
        # TODO: check for duplicates in extensions
        self.__extensions = tuple(self.__extensions)
        self.__postprocessors = tuple(self.__postprocessors)
        self.__globals = tuple(self.__globals)
        self.__finalized = True

    @property
    def mimetype(self):
        return self.__mimetype

    @property
    def extensions(self):
        return self.__extensions

    @extensions.setter
    def extensions(self, value):
        assert not self.__finalized
        self.__extensions = value

    @property
    def postprocessors(self):
        return self.__postprocessors

    @postprocessors.setter
    def postprocessors(self, value):
        assert not self.__finalized
        self.__postprocessors = value

    @property
    def escape(self):
        return self.__escape

    @escape.setter
    def escape(self, callback):
        assert not self.__finalized
        self.__escape = callback

    @property
    def globals(self):
        return self.__globals

    def add_global(self, name, value, *, escape=True):
        """
        Defines a new global variable with given *name* and *value* for this
        file type. The optional parameter *escape* determines, whether the
        value must be escaped in the final output.
        """
        assert not self.__finalized
        assert not any(x for x in self.__globals if x.name == name)
        self.__globals.append(VariableDefinition(name, value, escape))


VariableDefinition = namedtuple('VariableDefinition',
                                ('name', 'value', 'escape'))
