#!/usr/bin/env python
"""Terraform Wrapper

Usage:
    tf [--no-resolve-envvars] (<tf_path> <tf_command>) [<additional_arguments>...]
    tf -h,--help

Options:
    tf_path                 A path to a directory containing Terraform files.
    tf_command              The terraform command to run in that directory. Ex: init, plan, apply, etc.
    additional_arguments    Space separated arguments to pass to the wrapped terraform command. Ex: -lock=True
    -h,--help               Display this help message and quit.
    --no-resolve-envvars    Disable automatic resolution of envvars from .tf_wrapper files.

Examples:
    tf some/path/to/tf_files plan
    tf some/path/to/tf_files apply -var foo=bar -lock=True
"""
from __future__ import print_function

import json
import os
import re
import shutil
import sys
from datetime import datetime, timedelta
from dateutil.parser import parse
from pytz import timezone
from typing import List, Dict, Tuple

import boto3

from terrawrap.utils.cli import execute_command
from terrawrap.utils.config import (
    find_variable_files,
    parse_wrapper_configs,
    calc_backend_config,
    parse_variable_files,
    find_wrapper_config_files,
    resolve_envvars,
    parse_backend_config_for_dir,
)
from terrawrap.utils.dynamodb import DynamoDB
from terrawrap.utils.plugin_download import PluginDownload
from terrawrap.utils.path import get_absolute_path, calc_repo_path
from terrawrap.utils.version import version_check
from terrawrap.version import __version__

LOCK_TIMEOUT = timedelta(minutes=60)
MAX_COUNT = 5


def does_command_get_variables(command: str, path: str, arguments: List[str]) -> bool:
    commands_with_variables = [
        "init",
        "plan",
        "import",
        "refresh",
        "console",
        "destroy",
        "push",
        "validate",
    ]

    if command == "apply":
        if arguments:
            last_argument = arguments[-1]
            return not os.path.isfile(os.path.join(path, last_argument))
        else:
            return True

    return command in commands_with_variables


def convert_variables_to_envvars(variables: Dict[str, str]) -> Dict[str, str]:
    envvars = {}

    for key, value in variables.items():
        new_key = "TF_VAR_%s" % key
        if not isinstance(value, str):
            envvars[new_key] = json.dumps(value)
        else:
            envvars[new_key] = value

    return envvars


def exec_tf_command(
    command: str,
    path: str,
    variables: Dict[str, str],
    arguments: List[str],
    additional_envvars: Dict[str, str],
    audit_api_url: str,
):
    variable_envvars = convert_variables_to_envvars(variables=variables)

    command_env = os.environ.copy()
    command_env.update(variable_envvars)
    command_env.update(additional_envvars)

    if command == "init":
        shutil.rmtree(os.path.join(path, ".terraform"), ignore_errors=True)
        # Respect whatever is set, but default to true if it isn't set
        command_env.setdefault("TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE", "true")

    if command == "import":
        response = input(
            "WARNING: Running 'import' inside of an existing Terraform directory/state file can result in "
            "resources being deleted when apply is run.\nPlease make sure to merge your changes or disable "
            "automated apply jobs first.\nAre you sure you want to run 'import' now? (y/N): "
        )
        if response.lower().strip() != "y":
            print(
                "Please do better by taking one of the appropriate precautions suggested above."
            )
            sys.exit(1)

    try_count = 0
    while True:
        exit_code, stdout = execute_command(
            ["terraform", command] + arguments,
            cwd=path,
            capture_stderr=True,
            print_command=True,
            retry=True,
            env=command_env,
            audit_api_url=audit_api_url,
        )

        try_count += 1

        if exit_code != 0 and try_count <= MAX_COUNT:
            error = "".join([line for line in stdout])
            if "Error acquiring the state lock" in error:
                if tf_unlock(error, path, LOCK_TIMEOUT):
                    continue
            elif "state data in S3 does not have the expected content" in error:
                if update_digest(error, path, variables):
                    continue
            elif "Failed to persist state to backend" in error:
                if repush_state(path):
                    continue

        sys.exit(exit_code)


def tf_unlock(error: str, path: str, lock_timeout: timedelta) -> bool:
    """
    Function to unlock terraform
    :param error: Error log text from stdout
    :param path: Locked terraform path
    :param lock_timeout: Time to wait before running unlock command (timedelta object)
    :return: True if unlock command runs successfully, otherwise False
    """
    match = re.search(r"[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}", error)
    if not match:
        return False

    lock_id = match.group()
    # Creation time example: '2018-10-10 15:23:09.715308766 +0000 UTC'
    created_time = error.split("Created:")[1].split("\n")[0]
    delta = datetime.now(timezone("UTC")) - parse(created_time, fuzzy=True)
    if delta > lock_timeout:
        exit_code, output = execute_command(
            ["terraform", "force-unlock", "-force", lock_id],
            print_command=True,
            cwd=path,
            retry=True,
        )
        if exit_code == 0:
            return True

    return False


def update_digest(error: str, path: str, variables: Dict[str, str]) -> bool:
    """
    Update DynamoDB table item with the Terraform suggested digest value
    :param error: Error log text from stdout
    :param path: Terraform path with wrong digest
    :param variables: Terraform command variables (dictionary)
    :return: True if digest is updated, otherwise False
    """
    dynamodb = DynamoDB(region=variables["region"])
    try:
        digest = re.search(r"[\da-f]{32}", error).group()  # type: ignore
    except AttributeError:
        digest = ""

    default_terraform_bucket = (
        "{region}--mclass--terraform--{account_short_name}".format(
            region=variables.get("region"),
            account_short_name=variables.get("account_short_name"),
        )
    )

    terraform_bucket = variables.get("terraform_state_bucket", default_terraform_bucket)

    lock_id = "{bucket_name}/{path}.tfstate-md5".format(
        bucket_name=terraform_bucket,
        path=calc_repo_path(path=path),
    )
    if digest:
        response = dynamodb.upsert_item(
            table_name="terraform-locking",
            primary_key_name="LockID",
            primary_key_value=lock_id,
            attribute_name="Digest",
            attribute_value=digest,
        )
    else:
        response = dynamodb.delete_item(
            table_name="terraform-locking",
            primary_key_name="LockID",
            primary_key_value=lock_id,
        )
    if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
        return True

    return False


def repush_state(path: str) -> bool:
    """
    Repush terraform state if there was an error pushing it originally
    :param path: Terraform path with errored state
    :return: True if state is pushed successfully, false otherwise
    """
    exit_code, output = execute_command(
        ["terraform", "state", "push", "errored.tfstate"],
        print_command=True,
        cwd=path,
        retry=True,
    )
    if exit_code == 0:
        return True

    return False


def process_arguments(args: List[str]) -> Tuple[str, str, List[str], bool]:
    try:
        resolve_envvars = True

        if args[1] in ["-h", "--help"]:
            print(__doc__)
            sys.exit(0)

        if args[1] in ["--version"]:
            print("Terrawrap %s" % __version__)
            sys.exit(0)

        if args[1] in ["--no-resolve-envvars"]:
            resolve_envvars = False
            args.pop(1)

        path = args[1]
        command = args[2]
        additional_arguments = args[3:]

        return path, command, additional_arguments, resolve_envvars
    except IndexError:
        print(__doc__)
        sys.exit(0)


def handler():
    version_check(current_version=__version__)
    path, command, additional_arguments, should_resolve_envvars = process_arguments(
        sys.argv
    )

    tf_config_path = get_absolute_path(path=path)

    if not os.path.isdir(tf_config_path):
        print(__doc__)
        print(
            "Error: Path '%s' evaluated as '%s' and is not a directory."
            % (path, tf_config_path),
            file=sys.stderr,
        )
        sys.exit(1)

    wrapper_config_files = find_wrapper_config_files(path=tf_config_path)
    wrapper_config = parse_wrapper_configs(wrapper_config_files=wrapper_config_files)
    additional_envvars = (
        resolve_envvars(wrapper_config.envvars) if should_resolve_envvars else {}
    )

    add_variables = does_command_get_variables(
        command=command, path=tf_config_path, arguments=additional_arguments
    )

    if add_variables:
        variable_files = find_variable_files(path=tf_config_path)
    else:
        variable_files = []

    variables = parse_variable_files(variable_files=variable_files)

    if command == "init" and wrapper_config.configure_backend:
        # insert extra backend specific arguments to the command if a backend has been defined in the config
        existing_backend_config = parse_backend_config_for_dir(tf_config_path)
        if existing_backend_config:
            backend_config = calc_backend_config(
                path=tf_config_path,
                variables=variables,
                wrapper_config=wrapper_config,
                existing_backend_config=existing_backend_config,
            )
            additional_arguments = backend_config + additional_arguments

    if command == "init":
        plugin_download = PluginDownload(boto3.client("s3"))
        plugin_download.download_plugins(wrapper_config.plugins)

    exec_tf_command(
        command=command,
        path=tf_config_path,
        variables=variables,
        arguments=additional_arguments,
        additional_envvars=additional_envvars,
        audit_api_url=wrapper_config.audit_api_url,
    )


if __name__ == "__main__":
    handler()
