# -*- coding: utf-8 -*-
# above is for compatibility of python2.7.11

import subprocess
import os
import sys
import json
import logging
import tempfile
from typing import Optional

from lemniscat.plugin.terraform.tfstate import Tfstate

from lemniscat.core.util.helpers import LogUtil
from lemniscat.core.model.models import VariableValue


try:  # Python 2.7+
    from logging import NullHandler
except ImportError:
    class NullHandler(logging.Handler):
        def emit(self, record):
            pass

logging.setLoggerClass(LogUtil)
log = logging.getLogger(__name__.replace('lemniscat.', ''))

class IsFlagged:
    pass


class IsNotFlagged:
    pass


class TerraformCommandError(subprocess.CalledProcessError):
  def __init__(self, ret_code, cmd, out, err):
      super(TerraformCommandError, self).__init__(ret_code, cmd)
      self.out = out
      self.err = err

class Terraform(object):
    """
    Wrapper of terraform command line tool
    https://www.terraform.io/
    """

    def __init__(self, working_dir=None,
                 targets=None,
                 state=None,
                 variables=None,
                 parallelism=None,
                 var_file=None,
                 terraform_bin_path=None,
                 is_env_vars_included=True, 
                 ):
        """
        :param working_dir: the folder of the working folder, if not given,
                            will be current working folder
        :param targets: list of target
                        as default value of apply/destroy/plan command
        :param state: path of state file relative to working folder,
                    as a default value of apply/destroy/plan command
        :param variables: default variables for apply/destroy/plan command,
                        will be override by variable passing by apply/destroy/plan method
        :param parallelism: default parallelism value for apply/destroy command
        :param var_file: passed as value of -var-file option,
                could be string or list, list stands for multiple -var-file option
        :param terraform_bin_path: binary path of terraform
        :type is_env_vars_included: bool
        :param is_env_vars_included: included env variables when calling terraform cmd
        """
        self.is_env_vars_included = is_env_vars_included
        self.working_dir = working_dir
        self.state = state
        self.targets = [] if targets is None else targets
        self.variables = dict() if variables is None else variables
        self.parallelism = parallelism
        self.terraform_bin_path = terraform_bin_path \
            if terraform_bin_path else 'terraform'
        self.var_file = var_file
        self.temp_var_files = VariableFiles()

        # store the tfstate data
        self.tfstate = None
        self.read_state_file(self.state)

    def __getattr__(self, item):
        def wrapper(*args, **kwargs):
            cmd_name = str(item)
            if cmd_name.endswith('_cmd'):
                cmd_name = cmd_name[:-4]
            logging.debug('called with %r and %r' % (args, kwargs))
            return self.cmd(cmd_name, *args, **kwargs)

        return wrapper
        

    def apply(self, dir_or_plan=None, input=False, skip_plan=False, no_color=IsNotFlagged,
              **kwargs):
        """
        refer to https://terraform.io/docs/commands/apply.html
        no-color is flagged by default
        :param no_color: disable color of stdout
        :param input: disable prompt for a missing variable
        :param dir_or_plan: folder relative to working folder
        :param skip_plan: force apply without plan (default: false)
        :param kwargs: same as kwags in method 'cmd'
        :returns return_code, stdout, stderr
        """
        default = kwargs
        default['input'] = input
        default['no_color'] = no_color
        default['auto-approve'] = (skip_plan == True)
        option_dict = self._generate_default_options(default)
        if(skip_plan == False):
            if(option_dict.keys().__contains__('var')):
                option_dict.pop('var')
            if(option_dict.keys().__contains__('var_file')):
                option_dict.pop('var_file')
                     
        args = self._generate_default_args(dir_or_plan)
        return self.cmd('apply', *args, **option_dict)

    def _generate_default_args(self, dir_or_plan):
        return [dir_or_plan] if dir_or_plan else []

    def _generate_default_options(self, input_options):
        option_dict = dict()
        option_dict['state'] = self.state
        option_dict['target'] = self.targets
        option_dict['var'] = self.variables
        option_dict['var_file'] = self.var_file
        option_dict['parallelism'] = self.parallelism
        option_dict['no_color'] = IsNotFlagged
        option_dict['input'] = False
        option_dict.update(input_options)
        return option_dict

    def destroy(self, dir_or_plan=None, force=IsFlagged, **kwargs):
        """
        refer to https://www.terraform.io/docs/commands/destroy.html
        force/no-color option is flagged by default
        :return: ret_code, stdout, stderr
        """
        default = kwargs
        default['auto-approve'] = force
        options = self._generate_default_options(default)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd('destroy', *args, **options)

    def plan(self, dir_or_plan=None, detailed_exitcode=IsFlagged, **kwargs):
        """
        refer to https://www.terraform.io/docs/commands/plan.html
        :param detailed_exitcode: Return a detailed exit code when the command exits.
        :param dir_or_plan: relative path to plan/folder
        :param kwargs: options
        :return: ret_code, stdout, stderr
        """
        options = kwargs
        options['detailed_exitcode'] = detailed_exitcode
        options = self._generate_default_options(options)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd('plan', *args, **options)

    def init(self, dir_or_plan=None, backend_config=None,
             reconfigure=IsFlagged, backend=True, **kwargs):
        """
        refer to https://www.terraform.io/docs/commands/init.html

        By default, this assumes you want to use backend config, and tries to
        init fresh. The flags -reconfigure and -backend=true are default.

        :param dir_or_plan: relative path to the folder want to init
        :param backend_config: a dictionary of backend config options. eg.
                t = Terraform()
                t.init(backend_config={'access_key': 'myaccesskey', 
                'secret_key': 'mysecretkey', 'bucket': 'mybucketname'})
        :param reconfigure: whether or not to force reconfiguration of backend
        :param backend: whether or not to use backend settings for init
        :param kwargs: options
        :return: ret_code, stdout, stderr
        """
        options = kwargs
        options['backend_config'] = backend_config
        options['reconfigure'] = reconfigure
        options['backend'] = backend
        options = self._generate_default_options(options)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd('init', *args, **options)

    def generate_cmd_string(self, cmd, *args, **kwargs):
        """
        for any generate_cmd_string doesn't written as public method of terraform

        examples:
        1. call import command,
        ref to https://www.terraform.io/docs/commands/import.html
        --> generate_cmd_string call:
                terraform import -input=true aws_instance.foo i-abcd1234
        --> python call:
                tf.generate_cmd_string('import', 'aws_instance.foo', 'i-abcd1234', input=True)

        2. call apply command,
        --> generate_cmd_string call:
                terraform apply -var='a=b' -var='c=d' -no-color the_folder
        --> python call:
                tf.generate_cmd_string('apply', the_folder, no_color=IsFlagged, var={'a':'b', 'c':'d'})

        :param cmd: command and sub-command of terraform, seperated with space
                    refer to https://www.terraform.io/docs/commands/index.html
        :param args: arguments of a command
        :param kwargs: same as kwags in method 'cmd'
        :return: string of valid terraform command
        """
        cmds = cmd.split()
        cmds = [self.terraform_bin_path] + cmds

        for option, value in kwargs.items():
            if '_' in option:
                option = option.replace('_', '-')

            if type(value) is list:
                for sub_v in value:
                    cmds += ['-{k}={v}'.format(k=option, v=sub_v)]
                continue

            if type(value) is dict:
                if 'backend-config' in option:
                    for bk, bv in value.items():
                        cmds += ['-backend-config={k}={v}'.format(k=bk, v=bv)]
                    continue

                # since map type sent in string won't work, create temp var file for
                # variables, and clean it up later
                else:
                    filename = self.temp_var_files.create(value)
                    cmds += ['-var-file={0}'.format(filename)]
                    continue

            # simple flag,
            if value is IsFlagged:
                cmds += ['-{k}'.format(k=option)]
                continue

            if value is None or value is IsNotFlagged:
                continue

            if type(value) is bool:
                value = 'true' if value else 'false'

            cmds += ['-{k}={v}'.format(k=option, v=value)]

        cmds += args
        return cmds

    def cmd(self, cmd, *args, **kwargs):
        """
        run a terraform command, if success, will try to read state file
        :param cmd: command and sub-command of terraform, seperated with space
                    refer to https://www.terraform.io/docs/commands/index.html
        :param args: arguments of a command
        :param kwargs:  any option flag with key value without prefixed dash character
                if there's a dash in the option name, use under line instead of dash,
                    ex. -no-color --> no_color
                if it's a simple flag with no value, value should be IsFlagged
                    ex. cmd('taint', allow＿missing=IsFlagged)
                if it's a boolean value flag, assign True or false
                if it's a flag could be used multiple times, assign list to it's value
                if it's a "var" variable flag, assign dictionary to it
                if a value is None, will skip this option
                if the option 'capture_output' is passed (with any value other than
                    True), terraform output will be printed to stdout/stderr and
                    "None" will be returned as out and err.
                if the option 'raise_on_error' is passed (with any value that evaluates to True),
                    and the terraform command returns a nonzerop return code, then
                    a TerraformCommandError exception will be raised. The exception object will
                    have the following properties:
                      returncode: The command's return code
                      out: The captured stdout, or None if not captured
                      err: The captured stderr, or None if not captured
        :return: ret_code, out, err
        """
        capture_output = kwargs.pop('capture_output', True)
        raise_on_error = kwargs.pop('raise_on_error', False)
        disable_logs = kwargs.pop('disable_logs', False)
        if capture_output is True:
            stderr = subprocess.PIPE
            stdout = subprocess.PIPE
        else:
            stderr = sys.stderr
            stdout = sys.stdout

        cmds = self.generate_cmd_string(cmd, *args, **kwargs)
        log.info('command: {c}'.format(c=' '.join(cmds)))

        working_folder = self.working_dir if self.working_dir else None

        environ_vars = {}
        if self.is_env_vars_included:
            environ_vars = os.environ.copy()

        p = subprocess.Popen(cmds, stdout=stdout, stderr=stderr,
                             cwd=working_folder, env=environ_vars)

        synchronous = kwargs.pop('synchronous', True)
        if not synchronous:
            return p, None, None

        if(disable_logs is False):
            while p.poll() is None:
                line = p.stdout.readline()
                error = p.stderr.readline()
                if(line != b''):
                    ltrace = line.decode('utf-8').replace('\n', '')
                    # hide the outputs of terraform apply
                    if(ltrace == 'Outputs:'):
                        disable_logs = True
                    if(disable_logs is False):
                        log.info(f'  {ltrace}')
                if(error != b''):
                    ltrace = error.decode("utf-8").rstrip("\r\n")
                    if(ltrace.startswith("ERROR:")):
                        log.error(f'  {ltrace}')
                    else:
                        log.warning(f'  {ltrace}')

        out, err = p.communicate()
        ret_code = p.returncode

        if ret_code == 0 or ret_code == 2:
            self.read_state_file()
        else:
            subProcessErrors = list(filter(lambda x: x != "",err.decode('utf-8').splitlines()))
            log.warn('❌ Terraform error:')
            for errorLine in subProcessErrors:
                log.warn(f'  {errorLine}')

        self.temp_var_files.clean_up()
        if capture_output is True:
            out = out.decode('utf-8')
            err = err.decode('utf-8')
        else:
            out = None
            err = None

        if ret_code != 0 and raise_on_error:
            raise TerraformCommandError(
                ret_code, ' '.join(cmds), out=out, err=err)

        return ret_code, out, err

    def output(self, prefix: str=None, *args, **kwargs) -> Optional[dict]:
        """
        https://www.terraform.io/docs/commands/output.html

        Note that this method does not conform to the (ret_code, out, err) return convention. To use
        the "output" command with the standard convention, call "output_cmd" instead of
        "output".

        :param args:   Positional arguments. There is one optional positional
                       argument NAME; if supplied, the returned output text
                       will be the json for a single named output value.
        :param kwargs: Named options, passed to the command. In addition, 
                          'full_value': If True, and NAME is provided, then
                                        the return value will be a dict with
                                        "value', 'type', and 'sensitive'
                                        properties.
        :return: None, if an error occured
                 Output value as a string, if NAME is provided and full_value
                    is False or not provided
                 Output value as a dict with 'value', 'sensitive', and 'type' if
                    NAME is provided and full_value is True.
                 dict of named dicts each with 'value', 'sensitive', and 'type',
                    if NAME is not provided
        """
        full_value = kwargs.pop('full_value', False)
        name_provided = (len(args) > 0)
        kwargs['json'] = IsFlagged
        kwargs['disable_logs'] = IsFlagged
        if not kwargs.get('capture_output', True) is True:
          raise ValueError('capture_output is required for this method')

        ret, out, err = self.cmd('output', *args, **kwargs)

        if ret != 0:
            return None

        out = out.lstrip()

        values = json.loads(out)
        
        if name_provided and not full_value:
            values = values['value']

        # append prefix to the key if prefix is provided
        outputs = {}
        if(prefix is not None):
            for key, value in values.items():
                outputs[f'{prefix}.{key}'] = VariableValue(value['value'], value['sensitive'])
        else:
            for key, value in values.items():
                outputs[f'{key}'] = VariableValue(value['value'], value['sensitive'])

        return outputs

    def read_state_file(self, file_path=None):
        """
        read .tfstate file
        :param file_path: relative path to working dir
        :return: states file in dict type
        """

        working_dir = self.working_dir or ''

        file_path = file_path or self.state or ''

        if not file_path:
            backend_path = os.path.join(file_path, '.terraform',
                                        'terraform.tfstate')

            if os.path.exists(os.path.join(working_dir, backend_path)):
                file_path = backend_path
            else:
                file_path = os.path.join(file_path, 'terraform.tfstate')

        file_path = os.path.join(working_dir, file_path)

        self.tfstate = Tfstate.load_file(file_path)

    def set_workspace(self, workspace):
        """
        set workspace
        :param workspace: the desired workspace.
        :return: status
        """

        return self.cmd('workspace' ,'select', workspace)  

    def create_workspace(self, workspace):
        """
        create workspace
        :param workspace: the desired workspace.
        :return: status
        """

        return self.cmd('workspace', 'new', workspace)     

    def delete_workspace(self, workspace):
        """
        delete workspace
        :param workspace: the desired workspace.
        :return: status
        """

        return self.cmd('workspace', 'delete', workspace)    

    def show_workspace(self):
        """
        show workspace
        :return: workspace
        """

        return self.cmd('workspace', 'show')  

    def __exit__(self, exc_type, exc_value, traceback):
        self.temp_var_files.clean_up()


class VariableFiles(object):
    def __init__(self):
        self.files = []

    def create(self, variables):
        with tempfile.NamedTemporaryFile('w+t', suffix='.tfvars.json', delete=False) as temp:
            log.info('{0} is created'.format(temp.name))
            self.files.append(temp)
            log.info(
                'variables wrote to tempfile: {0}'.format(str(variables)))
            temp.write(json.dumps(variables))
            file_name = temp.name

        return file_name

    def clean_up(self):
        for f in self.files:
            os.unlink(f.name)

        self.files = []