import json
import logging
import os
import subprocess
import sys
import tempfile
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union
from packaging import version

from dda_python_terraform.tfstate import Tfstate

logger = logging.getLogger(__name__)

COMMAND_WITH_SUBCOMMANDS = {"workspace"}


class TerraformFlag:
    pass


class IsFlagged(TerraformFlag):
    pass


class IsNotFlagged(TerraformFlag):
    pass


CommandOutput = Tuple[Optional[int], Optional[str], Optional[str]]


class TerraformCommandError(subprocess.CalledProcessError):
    def __init__(self, ret_code: int, cmd: str, out: Optional[str], err: Optional[str]):
        super(TerraformCommandError, self).__init__(ret_code, cmd)
        self.out = out
        self.err = err
        logger.error("Error with command %s. Reason: %s", self.cmd, self.err)


class Terraform:
    """Wrapper of terraform command line tool.

    https://www.terraform.io/
    """

    def __init__(
        self,
        working_dir: Optional[str] = None,
        targets: Optional[Sequence[str]] = None,
        state: Optional[str] = None,
        variables: Optional[Dict[str, str]] = None,
        parallelism: Optional[str] = None,
        var_file: Optional[str] = None,
        terraform_bin_path: Optional[str] = None,
        is_env_vars_included: bool = True,
        terraform_semantic_version: Optional[str] = "0.13.0"
    ):
        """
        :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
        :param terrform_semantic_version encodes major.minor.patch version of terraform. Defaults to 0.13.0
        """
        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.terraform_semantic_version = terraform_semantic_version
        self.var_file = var_file
        self.temp_var_files = VariableFiles()

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

        self.latest_cmd = ''

    def apply(
        self,
        dir_or_plan: Optional[str] = None,
        input: bool = False,
        skip_plan: bool = True,
        no_color: Type[TerraformFlag] = IsFlagged,
        **kwargs,
    ) -> CommandOutput:
        """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
        """
        if not skip_plan:
            return self.plan(dir_or_plan=dir_or_plan, **kwargs)
        global_opts = self._generate_default_general_options(dir_or_plan)
        default = kwargs.copy()
        default["input"] = input
        default["no_color"] = no_color
        default["auto-approve"] = True  # a False value will require an input
        option_dict = self._generate_default_options(default)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd(global_opts, "apply", *args, **option_dict)

    def refresh(
        self,
        dir_or_plan: Optional[str] = None,
        input: bool = False,
        no_color: Type[TerraformFlag] = IsFlagged,
        **kwargs,
    ) -> CommandOutput:
        """Refer to https://terraform.io/docs/commands/refresh.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 kwargs: same as kwags in method 'cmd'
        :returns return_code, stdout, stderr
        """
        global_opts = self._generate_default_general_options(dir_or_plan)
        default = kwargs.copy()
        default["input"] = input
        default["no_color"] = no_color
        option_dict = self._generate_default_options(default)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd(global_opts, "refresh", *args, **option_dict)

    def destroy(
        self,
        dir_or_plan: Optional[str] = None,
        force: Type[TerraformFlag] = IsFlagged,
        **kwargs,
    ) -> CommandOutput:
        """Refer to https://www.terraform.io/docs/commands/destroy.html

        force/no-color option is flagged by default
        :return: ret_code, stdout, stderr
        """
        global_opts = self._generate_default_general_options(dir_or_plan)
        default = kwargs.copy()
        # force is no longer a flag in version >= 1.0
        if version.parse(self.terraform_semantic_version) < version.parse("1.0.0"):
            default["force"] = force
        default["auto-approve"] = True
        options = self._generate_default_options(default)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd(global_opts, "destroy", *args, **options)

    def plan(
        self,
        dir_or_plan: Optional[str] = None,
        detailed_exitcode: Type[TerraformFlag] = IsFlagged,
        **kwargs,
    ) -> CommandOutput:
        """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
        """
        global_opts = self._generate_default_general_options(dir_or_plan)
        options = kwargs.copy()
        options["detailed_exitcode"] = detailed_exitcode
        options = self._generate_default_options(options)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd(global_opts, "plan", *args, **options)

    def init(
        self,
        dir_or_plan: Optional[str] = None,
        backend_config: Optional[Dict[str, str]] = None,
        reconfigure: Type[TerraformFlag] = IsFlagged,
        backend: bool = True,
        **kwargs,
    ) -> CommandOutput:
        """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.copy()
        options.update(
            {
                "backend_config": backend_config,
                "reconfigure": reconfigure,
                "backend": backend,
            }
        )
        options = self._generate_default_options(options)
        global_opts = self._generate_default_general_options(dir_or_plan)
        args = self._generate_default_args(dir_or_plan)
        return self.cmd(global_opts, "init", *args, **options)

    def generate_cmd_string(self, global_options: Dict[str, Any], cmd: str, *args, **kwargs) -> List[str]:
        """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 = [self.terraform_bin_path]
        cmds += self._generate_cmd_options(**global_options)
        cmds += cmd.split()
        if cmd in COMMAND_WITH_SUBCOMMANDS:
            args = list(args)
            subcommand = args.pop(0)
            cmds.append(subcommand)

        cmds += self._generate_cmd_options(**kwargs)

        cmds += args
        self.latest_cmd = ' '.join(cmds)
        return cmds

    def cmd(
        self,
        global_opts: Dict[str, Any],
        cmd: str,
        *args,
        capture_output: Union[bool, str] = True,
        raise_on_error: bool = True,
        synchronous: bool = True,
        **kwargs,
    ) -> CommandOutput:
        """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
        """
        if capture_output is True:
            stderr = subprocess.PIPE
            stdout = subprocess.PIPE
        elif capture_output == "framework":
            stderr = None
            stdout = None
        else:
            stderr = sys.stderr
            stdout = sys.stdout

        cmds = self.generate_cmd_string(global_opts, cmd, *args, **kwargs)
        logger.info("Command: %s", " ".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
        )

        if not synchronous:
            return None, None, None

        out, err = p.communicate()
        ret_code = p.returncode
        logger.info("output: %s", out)

        if ret_code == 0:
            self.read_state_file()
        else:
            logger.warning("error: %s", err)

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

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

        return ret_code, out, err

    def output(
        self, dir_or_plan: Optional[str] = None, *args, capture_output: bool = True, **kwargs
    ) -> Union[None, str, Dict[str, str], Dict[str, Dict[str, str]]]:
        """Refer 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
        """
        kwargs["json"] = IsFlagged
        if capture_output is False:
            raise ValueError("capture_output is required for this method")

        global_opts = self._generate_default_general_options(dir_or_plan)
        ret, out, _ = self.cmd(global_opts, "output", *args, **kwargs)

        # ret, out, _ = self.output_cmd(global_opts, *args, **kwargs)

        if ret:
            return None

        return json.loads(out.lstrip())

    def read_state_file(self, file_path=None) -> 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, *args, **kwargs) -> CommandOutput:
        """Set workspace

        :param workspace: the desired workspace.
        :return: status
        """
        global_opts = self._generate_default_general_options(False)
        return self.cmd(global_opts, "workspace", "select", workspace, *args, **kwargs)

    def create_workspace(self, workspace, *args, **kwargs) -> CommandOutput:
        """Create workspace

        :param workspace: the desired workspace.
        :return: status
        """
        global_opts = self._generate_default_general_options(False)
        return self.cmd(global_opts, "workspace", "new", workspace, *args, **kwargs)

    def delete_workspace(self, workspace, *args, **kwargs) -> CommandOutput:
        """Delete workspace

        :param workspace: the desired workspace.
        :return: status
        """
        global_opts = self._generate_default_general_options(False)
        return self.cmd(global_opts, "workspace", "delete", workspace, *args, **kwargs)

    def show_workspace(self, **kwargs) -> CommandOutput:
        """Show workspace, this command does not need the [DIR] part

        :return: workspace
        """
        global_opts = self._generate_default_general_options(False)
        return self.cmd(global_opts, "workspace", "show", **kwargs)

    def list_workspace(self) -> List[str]:
        """List of workspaces

        :return: workspaces
        :example:
            >>> tf = Terraform()
            >>> tf.list_workspace()
            ['default', 'test']
        """
        global_opts = self._generate_default_general_options(False)
        return list(
            filter(
                lambda workspace: len(workspace) > 0,
                map(
                    lambda workspace: workspace.strip('*').strip(),                    
                    (self.cmd(global_opts, "workspace", "list")[1] or '').split()
                )
            )
        )

    def _generate_default_args(self, dir_or_plan: Optional[str]) -> Sequence[str]:
        if (version.parse(self.terraform_semantic_version) < version.parse("1.0.0") and dir_or_plan):
            return [dir_or_plan]
        else:
            return []

    def _generate_default_general_options(self, dir_or_plan: Optional[str]) -> Dict[str, Any]:
        if (version.parse(self.terraform_semantic_version) >= version.parse("1.0.0") and dir_or_plan):
            return {"chdir": dir_or_plan}
        else:
            return {}

    def _generate_default_options(
        self, input_options: Dict[str, Any]
    ) -> Dict[str, Any]:
        return {
            "state": self.state,
            "target": self.targets,
            "var": self.variables,
            "var_file": self.var_file,
            "parallelism": self.parallelism,
            "no_color": IsFlagged,
            "input": False,
            **input_options,
        }

    def _generate_cmd_options(self, **kwargs) -> List[str]:
        """
        """
        result = []

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

            if isinstance(value, list):
                for sub_v in value:
                    result += [f"-{option}={sub_v}"]
                continue

            if isinstance(value, dict):
                if "backend-config" in option:
                    for bk, bv in value.items():
                        result += [f"-backend-config={bk}={bv}"]
                    continue

                # since map type sent in string won't work, create temp var file for
                # variables, and clean it up later
                elif option == "var":
                    # We do not create empty var-files if there is no var passed.
                    # An empty var-file would result in an error: An argument or block definition is required here
                    if value:
                        filename = self.temp_var_files.create(value)
                        result += [f"-var-file={filename}"]

                    continue

            # simple flag,
            if value is IsFlagged:
                result += [f"-{option}"]
                continue

            if value is None or value is IsNotFlagged:
                continue

            if isinstance(value, bool):
                value = "true" if value else "false"

            result += [f"-{option}={value}"]
        return result

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

    def __getattr__(self, item: str) -> Callable:
        def wrapper(*args, **kwargs):
            cmd_name = str(item)
            if cmd_name.endswith("_cmd"):
                cmd_name = cmd_name[:-4]
            logger.debug("called with %r and %r", args, kwargs)
            global_opts = self._generate_default_general_options(False)
            return self.cmd(global_opts, cmd_name, *args, **kwargs)

        return wrapper



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

    def create(self, variables: Dict[str, str]) -> str:
        with tempfile.NamedTemporaryFile(
            "w+t", suffix=".tfvars.json", delete=False
        ) as temp:
            logger.debug("%s is created", temp.name)
            self.files.append(temp)
            logger.debug("variables wrote to tempfile: %s", 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 = []
