#!/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 requests
from filelock import FileLock

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.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]
):
    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)

    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
        )

        try_count += 1

        if exit_code != 0 and try_count <= MAX_COUNT:
            error = ''.join([line for line in stdout])
            if 'Error locking state' 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{6}', 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 download_plugins(plugin_paths: Dict[str, str]):
    """
    Download a set of Terraform plugins to the user's home directory
    :param plugin_paths: A dictionary of plugin names and URLs where to download them
    """
    for name, path in plugin_paths.items():
        home = os.path.expanduser("~")
        file_path = os.path.join(home, '.terraform.d/plugins', name)
        etag_path = '%s.%s' % (file_path, 'etag')
        lock_path = '%s.%s' % (file_path, 'lock')

        lock = FileLock(lock_path, timeout=600)

        # get the etag if it exists
        headers = {}
        if os.path.isfile(etag_path) and os.path.isfile(file_path):
            with(open(etag_path, 'r')) as etag_file:
                headers['If-None-Match'] = etag_file.read()

        # get the plugin. send etag header to avoid downloading file if we already have the same version
        # use a lock to prevent conflicts writing the file if running this command in parallel
        with lock:
            print('Downloading plugin %s' % path)
            response = requests.get(path, headers=headers)
            response.raise_for_status()

            # save the file if etag matches
            if response.status_code == 200:
                with(open(file_path, 'wb')) as plugin_file:
                    plugin_file.write(response.content)

                # write the etag to a file so we can use it in future requests
                etag = response.headers.get('etag')
                if etag:
                    # AWS returns the etag surrounded by quotes
                    # remove them if that happens
                    if etag[0] == '"':
                        etag = etag[1:-1]
                    with(open(etag_path, 'w')) as etag_file:
                        etag_file.write(etag)


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":
        download_plugins(wrapper_config.plugins)

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


if __name__ == "__main__":
    handler()
