#!/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 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)
        lock_file_path = os.path.join(path, ".terraform.lock.hcl")
        if os.path.exists(lock_file_path):
            os.remove(lock_file_path)

    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

        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'
    # strptime can handle microseconds, not nanoseconds
    # utcnow object does not have offset and timezone name
    # Strip last 13 characters from lock creation time
    created_time = re.search(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d+', error).group()
    delta = datetime.utcnow() - datetime.strptime(created_time, '%Y-%m-%d %H:%M:%S.%f')
    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()
    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 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()
