#!/usr/bin/env python3
#
# Copyright (c) 2015-2018, Fabian Greif
# Copyright (c) 2018, Niklas Hauser
# All Rights Reserved.
#
# The file is part of the lbuild project and is released under the
# 2-clause BSD license. See the file `LICENSE.txt` for the full license
# governing this code.

import os
import time
import shutil
import fnmatch
import jinja2
import logging

import lbuild.filter
import lbuild.utils

from .facade import EnvironmentValidateFacade, EnvironmentBuildFacade, EnvironmentPostBuildFacade
from .exception import LbuildException, LbuildTemplateException, LbuildForwardException

simulate = False


def _copyfile(sourcepath, destpath):
    """
    Copy a file if the source file time stamp is newer than the destination
    timestamp.
    """
    if not os.path.exists(destpath):
        if not simulate:
            shutil.copy2(sourcepath, destpath)
    else:
        time_diff = os.stat(sourcepath).st_mtime - os.stat(destpath).st_mtime
        if time_diff > 1:
            print(destpath, "override")
            if not simulate:
                shutil.copy2(sourcepath, destpath)


def _copytree(logger, src, dst, ignore=None):
    """
    Implementation of shutil.copytree that overwrites files instead
    of aborting.
    """
    files = os.listdir(src)
    if ignore is not None:
        ignored = ignore(src, files)
    else:
        ignored = set()

    for filename in files:
        if filename not in ignored:
            sourcepath = os.path.join(src, filename)
            destpath = os.path.join(dst, filename)
            if os.path.isdir(sourcepath):
                _copytree(logger, sourcepath, destpath, ignore)
            else:
                starttime = time.time()
                if not os.path.exists(dst):
                    os.makedirs(dst)
                _copyfile(sourcepath, destpath)
                endtime = time.time()
                total = endtime - starttime
                logger(sourcepath, destpath, total)


class Environment:

    def __init__(self, module, buildlog):
        self.options = module.option_value_resolver
        self.modules = module.module_resolver
        self.__module = module
        self.__modulepath = module._filepath
        self.__repopath = module.repository._filepath
        self.__outpath = buildlog.outpath

        self.__buildlog = buildlog
        self.__template_environment = None
        self.__template_environment_filters = None
        self.__template_global_substitutions = {
            'time': time.strftime("%d %b %Y, %H:%M:%S", time.localtime()),
            'options': self.options,
        }

        self.log = logging.getLogger("user." + module.fullname.replace(":", "."))
        self.outbasepath = None
        self.substitutions = {}

    @property
    def facade_validate(self):
        return EnvironmentValidateFacade(self)

    @property
    def facade_build(self):
        return EnvironmentBuildFacade(self)

    @property
    def facade_post_build(self):
        return EnvironmentPostBuildFacade(self)

    def copy(self, src, dest=None, ignore=None):
        """
        Copy file or directory from the modulepath to the buildpath.

        If src or dest is a relative path it will be relocated to the
        module/buildpath. Absolute paths are not changed.

        If dest is empty the same name as src is used (relocated to
        the output path).
        """
        def wrap_ignore(path, files):
            ignored = lbuild.utils.ignore_patterns(*self.__module._ignore_patterns)(path, files)
            ignored.add(self.__module._filename)
            if ignore:
                ignored |= set(ignore(path, files))
            return ignored

        if dest is None:
            dest = src

        starttime = time.time()

        srcpath = os.path.normpath(src if os.path.isabs(src) else self.modulepath(src))
        destpath = os.path.normpath(dest if os.path.isabs(dest) else self.outpath(dest))

        srcrelpath = os.path.relpath(srcpath, self.__repopath)
        if srcrelpath.startswith(".."):
            raise LbuildException("Cannot access files outside of the repository!\n"
                                "'{}'".format(srcrelpath))

        if os.path.isdir(srcpath):
            _copytree(lambda src, dest, time: self.__buildlog.log(self.__module, src, dest, time),
                      srcpath,
                      destpath,
                      wrap_ignore)
        else:
            if not os.path.exists(os.path.dirname(destpath)):
                os.makedirs(os.path.dirname(destpath))
            _copyfile(srcpath, destpath)

            endtime = time.time()
            total = endtime - starttime
            self.__buildlog.log(self.__module, srcpath, destpath, total)

    def __reload_template_environment(self, filters):
        if self.__template_environment_filters == filters:
            return

        self.__template_environment_filters = filters

        # Overwrite jinja2 Environment in order to enable relative paths
        # since this runs locally that should not be a security concern
        # Code from:
        # http://stackoverflow.com/questions/8512677/how-to-include-a-template-with-relative-path-in-jinja2
        class RelEnvironment(jinja2.Environment):
            """
            Override join_path() to enable relative template paths.

            Take care of paths. Jinja seems to use '/' as path separator in
            templates.
            """

            def join_path(self, template, parent):
                path = os.path.join(os.path.dirname(parent), template)
                return os.path.normpath(path).replace('\\','/')

        environment = RelEnvironment(loader=jinja2.FileSystemLoader(self.__repopath),
                                     extensions=['jinja2.ext.do'],
                                     undefined=jinja2.StrictUndefined)

        environment.filters.update(self.__module._filters)
        environment.filters.update(filters)

        # Jinja2 Line Statements
        environment.line_statement_prefix = '%%'
        environment.line_comment_prefix = '%#'

        self.__template_environment = environment

    @property
    def template_environment(self):
        if self.__template_environment is None:
            self.__reload_template_environment({})
        return self.__template_environment

    def template(self, src, dest=None, substitutions=None, filters=None):
        """
        Uses the Jinja2 template engine to generate files.

        If dest is empty the same name as src is used (relocated to
        the output path).
        """
        starttime = time.time()

        if dest is None:
            # If src ends in ".in" remove that and use the remaing part
            # as new destination.
            parts = src.split(".")
            if parts[-1] == "in":
                dest = ".".join(parts[:-1])
            else:
                dest = src

        src = self.repopath(src)
        if src.startswith(".."):
            raise LbuildException("Cannot access template outside of repository!\n"
                                "'{}'".format(src))

        if substitutions is None:
            substitutions = {}

        substitutions.update(self.substitutions)
        try:
            if filters is not None:
                # Reload environment if it uses different filters than
                # the previous environment
                self.__reload_template_environment(filters)

            template = self.template_environment.get_template(src.replace('\\','/'), globals=self.__template_global_substitutions)
            output = template.render(substitutions)
        except jinja2.TemplateNotFound as error:
            raise LbuildException('Failed to retrieve Template: %s' % error)
        except (jinja2.exceptions.TemplateAssertionError,
                jinja2.exceptions.TemplateSyntaxError) as error:
            raise LbuildException("Error in template '{}:{}':\n"
                                " {}: {}".format(error.filename,
                                                 error.lineno,
                                                 error.__class__.__name__,
                                                 error))
        except jinja2.exceptions.UndefinedError as error:
            raise LbuildTemplateException("Error in template '{}':\n"
                                        " {}: {}".format(self.modulepath(src),
                                                         error.__class__.__name__,
                                                         error))
        except LbuildException as error:
            raise LbuildException("Error in template '{}': \n"
                                "{}".format(self.modulepath(src), error))
        except Exception as error:
            raise LbuildForwardException("Error in template '{}': \n"
                                       "{}".format(self.modulepath(src), error),
                                       error)

        outfile_name = self.outpath(dest)

        # Create folder structure if it doesn't exists
        if not simulate:
            if not os.path.exists(os.path.dirname(outfile_name)):
                os.makedirs(os.path.dirname(outfile_name))

            with open(outfile_name, 'w') as outfile:
                outfile.write(output)

        endtime = time.time()
        total = endtime - starttime
        self.__buildlog.log(self.__module, self.modulepath(src), outfile_name, total)

    def modulepath(self, *path):
        """Relocate given path to the path of the module file."""
        return os.path.join(self.__modulepath, *path)

    def repopath(self, *path):
        """Relocate given path to the path of the repo file."""
        return os.path.relpath(self.modulepath(*path), self.__repopath)

    def outpath(self, *path, basepath=None):
        """Relocate given path to the output path."""
        if basepath is None:
            basepath = self.outbasepath
        if basepath is None:
            return os.path.join(self.__outpath, *path)
        else:
            return os.path.join(self.__outpath, basepath, *path)

    def generated_local_files(self, filterfunc=None):
        """
        Get all files which have been generated by this module and its
        submodules so far.

        Keyword arguments:
        filterfunc -- Function which is called to determine whether a path
            should be added to the output. If the function returns `True`
            the path is added.
        """
        filenames = []
        operations = self.__buildlog.operations_per_module(self.__module.fullname)
        for operation in operations:
            filename = os.path.normpath(os.path.join(os.path.relpath(os.path.dirname(operation.filename_out),
                                                                     self.outpath('.')),
                                                     os.path.basename(operation.filename_out)))
            if filterfunc is None or filterfunc(filename):
                filenames.append(filename)
        return filenames

    def append_metadata(self, key, value):
        """
        Append additional information to the build log which can be used in the
        post-build step to generate additional files/data.
        """
        self.__buildlog.metadata[key].append(value)

    def append_metadata_unique(self, key, value):
        """
        Append additional information build log if it is not already present.

        See also `append_metadata`.
        """
        if value not in self.__buildlog.metadata[key]:
            self.__buildlog.metadata[key].append(value)

    def has_option(self, key):
        """Query whether an option exists."""
        try:
            _ = self.options[key]
            return True
        except LbuildException:
            return False

    def has_module(self, key):
        """Query whether a module exists."""
        try:
            _ = self.modules[key]
            return True
        except LbuildException:
            return False

    def get_option(self, key, default=None):
        """
        Get an option value.

        Returns a user configurable default value if the option is not found.
        """
        try:
            return self.options[key]
        except LbuildException:
            return default

    def __getitem__(self, key):
        """
        Get an option value.

        Raises an exception if the option is not found.
        """
        return self.options[key]

    def __repr__(self):
        return repr(self.options)

    def __len__(self):
        return len(self.options)
