# -*- coding: utf-8 -*-
"""Shell command mocker for unit testing purposes
A module that creates fake binaries from valid configuration
( a yaml file or a dict ) and launch them in the context of
your Python script, without efforts.

Built for testing Python scripts that rely on other binaries
output.

TODO:
    [x] Create some Documentation
    [x] Install tox and ensure it works on previous Python
        releases (2.7 - 3.7)
        - [x] 2.7
        - [x] 3.5
        - [x] 3.6
        - [x] 3.7
"""

import os
import logging
from shutil import rmtree, copyfile
from subprocess import check_output

import yaml
from voluptuous import (
    Schema,
    Required,
    Optional,
    Exclusive,
    Match,
    Any,
    ALLOW_EXTRA
)
from fakear import templates

logging.basicConfig(level=logging.DEBUG)


class FakearMultipleSourceException(Exception):
    """
    Multiple Sources are provided to a Fakear instance, which can
    generate conflict between sources
    """
    pass

class FakearFileNotFound(Exception):
    """
    One file from a configuration is missing.
    """
    pass

class Fakear:
    """
    The main class, the one that creates fake programs from yaml configuration
    file or a dict.

    Fakear instances can be initialized by 2 ways:
     - Fakear(cfg="cfg_path/cfg_file.yml")    # from a YAML File
     - Fakear(raw=fakedata)                   # from a dict

    Configuration should look like this:
        ---
        __command1_name__:
            # Default fake program that output "I am a fake binary !"
        __command2_name__ :
            # Sub command with args
            - args:
                - first_arg
                - sec_arg
              return_code: 0
              output: This is an example of fake command
            # Sub command with no args
            - return_code: -1
              output: This is a fake program, please give the correct arguments

    You can declare multiple commands with multiple behaviour in each of them
    at once but you need to match the correct signature
    """
    __validate_file = Schema({
        Match(r'^[A-Za-z0-9]+$'): Any(list, None)
    }, extra=ALLOW_EXTRA)

    __validate_args = Schema([{
        Optional('args'): list,
        Required('return_code'): int,
        Exclusive('output', 'output'): str,
        Exclusive('output_file', 'output'): str
    }])

    def __init__(self, cfg=None, rawdata=None, path="/tmp/fakear/bin"):
        self.__log = logging.getLogger('fakear')
        self.__fakedcmds = {}
        self.__enabled = False
        self.__faked_path = path
        self.__cfg_paths = []
        self.__search_for_interpreter()

        if all([not cfg, not rawdata]):
            return
        if all([cfg, rawdata]):
            raise FakearMultipleSourceException()
        if cfg:
            rawdata = self.__add_configuration(cfg)

        data = self.__validate_file(yaml.load(rawdata))
        self.__load_fake_cmds(data)

    # Properties

    @property
    def commands(self):
        """
        Returns a dict with all faked programs embedded in this Fakear instance
        Can be set at instantiation
        """
        return self.__fakedcmds

    @property
    def faked_path(self):
        """
        Returns the path used to store fake programs generated by Fakear
        Default is: /tmp/fakear/bin

        Use self.set_path() when instance is disabled to modify
        """
        return self.__faked_path

    @property
    def shell(self):
        """
        Returns the shell path Fakear will use for making fake programs
        """
        return self.__shell

    # Private method

    def __search_for_interpreter(self):
        process = check_output(["which", "bash"])
        self.__shell = process.decode().replace("\n", "")
        return self.__shell

    def __load_fake_cmds(self, data):
        for cmd, args in data.items():
            if args:
                self.__fakedcmds[cmd] = self.__validate_args(args)
            else:
                self.__fakedcmds[cmd] = []

    def __add_configuration(self, filepath):
        if "/" in filepath:
            path = "/".join(filepath.split("/")[:-1])
            self.__cfg_paths.append(path)
        with open(filepath) as cfg:
            rawdata = cfg.read()
            return rawdata

    def __search_for_file(self, filepath):
        self.__log.debug("checking availability of file {}".format(filepath))
        for path in self.__cfg_paths:
            tmp_path = os.path.join(path, filepath)
            self.__log.debug("  checking {}".format(tmp_path))
            if os.path.exists(tmp_path):
                self.__log.debug("  {} found !".format(tmp_path))
                return tmp_path
        raise FakearFileNotFound()

    def __write_binaries(self):
        for command, subcmds in self.__fakedcmds.items():
            subs = sorted(subcmds,
                          key=lambda subcmd: len(subcmd.get('args', [])),
                          reverse=True)
            prg = []

            for sub in subs:
                sub_extract = sub.get('args', [])
                zipped_subs = list(
                    zip(range(1, len(sub_extract) + 1), sub_extract)
                )

                sub_args = {
                    'length': len(zipped_subs),
                    'arg_line': " && ".join(
                        ['"${arg}" = "{value}"'.format(arg=arg[0],
                                                       value=arg[1])
                         for arg in zipped_subs]
                    )
                }

                if sub_args['arg_line']:
                    if not prg:
                        prg.append(templates.SH_IF.format(**sub_args))
                    else:
                        prg.append(templates.SH_ELIF.format(**sub_args))
                else:
                    if prg:
                        prg.append(templates.SH_ELSE)

                if "output_file" in sub.keys():
                    output_path = os.path.join(self.__faked_path,
                                               "{}_files".format(command))
                    if not os.path.exists(output_path):
                        os.makedirs(output_path)
                    out_file = sub.get('output_file', None)
                    if out_file:
                        src_filepath = self.__search_for_file(out_file)
                        src_filename = src_filepath.split("/")[-1]
                        sub['output_file'] = os.path.join(output_path,
                                                          src_filename)
                        copyfile(src_filepath, sub['output_file'])
                        prg.append(templates.SH_OUTPUT_FILE.format(**sub))
                else:
                    prg.append(templates.SH_OUTPUT.format(**sub))

            if len(prg) > 1:
                prg.append(templates.SH_FI)
            if not prg:
                prg.append(templates.SH_DEFAULT)

            self.__write_file(command, prg)

    def __write_file(self, command, prg):

        filepath = os.path.join(self.faked_path, command)

        with open(filepath, 'w+') as prgfile:
            header = templates.SH_HEADER.format(shell_path=self.__shell)
            prgfile.writelines(header)
            prgfile.writelines(prg)

        os.chmod(filepath, 0o777)

    def __enable_path(self):
        if self.__faked_path not in os.environ["PATH"]:
            os.environ["PATH"] = '{}:{}'.format(self.__faked_path,
                                                os.environ["PATH"])

    def __disable_path(self):
        if self.__faked_path in os.environ["PATH"]:
            path = ":".join([
                p for p in os.environ["PATH"].split(":")
                if self.__faked_path not in p
            ])
            os.environ['PATH'] = path

    # Context Manager

    def __enter__(self):
        self.enable()
        return self

    def __exit__(self, exception_type, exception_val, trace):
        self.disable()

    # API

    def set_path(self, path):
        """
        Set a new path where fake programs would be generated and
        invoked
        Path is not modifiable when this instance is enabled or
        used inside a context
        """
        if not self.__enabled:
            self.__faked_path = path

    def enable(self):
        """
        Enable this Fakear instance:
         - Create the path for fake programs
         - Write programs according to the configuration data you
           provide
         - Adds fakear path to env PATH variable

        When an instance is enabled, you can't modify data inside
        """
        if not os.path.exists(self.__faked_path):
            os.makedirs(self.__faked_path)
        self.__write_binaries()
        self.__enable_path()
        self.__enabled = True

    def disable(self):
        """
        Disable this Fakear instance and clean everything
        """
        if os.path.exists(self.__faked_path):
            rmtree(self.__faked_path)
        self.__disable_path()
        self.__enabled = False
