#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import json
import os
from pipes import quote
import sys
import zipfile
import copy
import subprocess
import tempfile
import glob
import shutil
import re
import importlib.util
from enum import Enum


INSTALLER_DIR_PREFIX = 'keytalkclient-'


class DeploymentKind(Enum):
    apache = 1
    tomcat = 2
    remove = 3


def load_module_from_file(module_name, module_path):
    spec = importlib.util.spec_from_file_location(module_name, module_path)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def run_cmd(cmd, suppress_sderr=False):
    stdout = ''
    stderr = ''
    try:
        result = subprocess.run(cmd,
                                shell=True,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE)
        stdout = result.stdout.decode("utf-8").strip()
        if not suppress_sderr:
            stderr = result.stderr.decode("utf-8").strip()
    except Exception as e:
        raise Exception("Failed to execute {0}. {1}".format(cmd, e))

    if result.returncode == 0:
        return stdout
    else:
        raise CmdFailedException(cmd, result.returncode, stdout, stderr)


class CmdFailedException(Exception):

    def __init__(self, cmd, retval, stdout, stderr):
        super(
            CmdFailedException,
            self).__init__(
            "{0} finished with code {1}. Stdout: {2}. Stderr: {3}".format(
                cmd,
                retval,
                stdout,
                stderr))
        self.cmd = cmd
        self.retval = retval
        self.stdout = stdout
        self.stderr = stderr

    def format_indented_message(self, message):
        lines = []
        for line in message.splitlines():
            lines += [line]
        if self.stderr:
            lines += ['    Stderr:']
            for line in self.stderr.splitlines():
                lines += ['        {0}'.format(line)]
        if self.stdout:
            lines += ['    Stdout:']
            for line in self.stdout.splitlines():
                lines += ['        {0}'.format(line)]
        return '\n'.join(lines)


def _run_remote_cmd(host, command, connect_timeout=5, only_stdout=False):
    return run_cmd(
        'ssh -o ConnectTimeout={0} {1} {2}{3}'.format(
            int(connect_timeout),
            quote(host),
            quote(command),
            ' 2>&1' if only_stdout else ''),
        suppress_sderr=only_stdout)


def _try_run_remote_cmd(host, command, connect_timeout=5, only_stdout=False):
    try:
        _run_remote_cmd(host, command, connect_timeout, only_stdout)
        return True
    except CmdFailedException:
        return False


def print_usage():
    print('Usage:')
    print((
        '    {0} install-for-apache|install-for-tomcat /path/to/deployment.ini /path/to/KeyTalkClient-X.Y.Z-ubuntu-version.tgz /path/to/rccd-file.rccd'.format(
            sys.argv[0])))
    print(
        ('    {0} remove <username@remotehost> [<username@remotehost2>, ...]'.format(sys.argv[0])))
    print(('    {0} remove <vhosts_config.ini>'.format(sys.argv[0])))


def parse_install_args(deployment_kind):
    if len(sys.argv) != 5:
        print_usage()
        sys.exit(1)

    deployment_conf_path = sys.argv[2]
    installer_path = sys.argv[3]
    rccd_path = sys.argv[4]

    if not os.path.exists(deployment_conf_path):
        raise Exception(
            'The specified deployment config file "{0}" does not exist.'.format(deployment_conf_path))

    if not os.path.exists(installer_path) or INSTALLER_DIR_PREFIX not in run_cmd(
            'tar tfv {0}'.format(quote(installer_path))):
        raise Exception(
            'The specified installer "{0}" does not exist or is not a KeyTalk agent Linux installer package.'.format(installer_path))

    if not os.path.exists(rccd_path):
        raise Exception('The specified RCCD path "{0}" does not exist.'.format(rccd_path))

    try:
        with zipfile.ZipFile(rccd_path, 'r') as z:
            if 'content/user.ini' not in z.namelist():
                raise Exception('The specified file is not a valid RCCD file')
    except Exception:
        raise Exception('The specified file is not a valid RCCD file')

    return {'deployment_kind': deployment_kind,
            'installer_path': installer_path,
            'config_path': deployment_conf_path,
            'rccd_path': rccd_path}


def parse_remove_args():
    if len(sys.argv) < 3:
        print_usage()
        sys.exit(1)

    if len(sys.argv) == 3 and os.path.isfile(sys.argv[2]):
        return {'deployment_kind': DeploymentKind.remove,
                'config_path': sys.argv[2]}
    else:
        return {'deployment_kind': DeploymentKind.remove,
                'ssh_hosts': sys.argv[2:]}


def parse_args():
    if len(sys.argv) < 2:
        print_usage()
        sys.exit(1)

    command = sys.argv[1]
    if command == 'install-for-apache':
        return parse_install_args(DeploymentKind.apache)
    elif command == 'install-for-tomcat':
        return parse_install_args(DeploymentKind.tomcat)
    elif command == 'remove':
        return parse_remove_args()
    else:
        print_usage()
        sys.exit(1)


def validate_sites(sites, util, tomcat_util, config_path):
    """:returns: A list of error messages found during validation."""
    error_messages = []
    if os.path.basename(config_path) == "tomcat.ini":
        known_settings = copy.deepcopy(tomcat_util.TOMCAT_RENEWAL_SETTINGS)
        known_settings['RemoteHost'] = {'required': True,
                                        'dependencies': []}
        for site_index, site in enumerate(sites):
            _, errors = util.parse_settings(site, known_settings)
            vhost = site.get('Host', 'Host number {0}'.format(site_index + 1))
            server_name = site.get('ServerName', '')
            if errors:
                error_messages.append('Errors in Host {0} {1}:'.format(vhost, server_name))
                for error in errors:
                    error_messages.append('    ' + error)
    elif os.path.basename(config_path) == "apache.ini":
        known_settings = copy.deepcopy(util.APACHE_RENEWAL_SETTINGS)
        known_settings['RemoteHost'] = {'required': True,
                                        'dependencies': []}
        for site_index, site in enumerate(sites):
            _, errors = util.parse_settings(site, known_settings)
            vhost = site.get('VHost', 'VHost number {0}'.format(site_index + 1))
            server_name = site.get('ServerName', '')
            if errors:
                error_messages.append('Errors in Host {0} {1}:'.format(vhost, server_name))
                for error in errors:
                    error_messages.append('    ' + error)

    return error_messages


def deploy_site_config(deployment_kind, ssh_host, site_config_path, installer_path, rccd_path):
    """:returns: {"error": error, "warning":warning } """
    try:
        remote_temp_dir = quote(_run_remote_cmd(ssh_host, 'mktemp -d'))
        installer_filename = quote(os.path.basename(installer_path))
        rccd_filename = quote(os.path.basename(rccd_path))
        config_filename = quote(os.path.basename(site_config_path))

        copy_files_cmd = "scp {installer} {rccd} {config} {ssh_host}:{temp_dir}".format(
            installer=quote(installer_path),
            rccd=quote(rccd_path),
            config=quote(site_config_path),
            ssh_host=quote(ssh_host),
            temp_dir=remote_temp_dir)
        run_cmd(copy_files_cmd)

        if deployment_kind == DeploymentKind.apache:
            if not _try_run_remote_cmd(ssh_host, "type apache2", only_stdout=True):
                return {
                    "error": 'Could not deploy to {0} because Apache is not installed on the remote host'.format(ssh_host)}

            # Make sure Apache is configured with SSL, adding default site if necessary
            cmd = """
            if ! sudo a2query -m  ssl; then \
                 sudo a2enmod ssl; \
                 sudo a2ensite default-ssl; \
                 sudo service apache2 restart; \
            fi"""
            if not _try_run_remote_cmd(ssh_host, cmd, only_stdout=True):
                return {
                    "error": 'Could not deploy to {0} because SSL could not be enabled on Apache remotely'.format(ssh_host)}

            cmd = """set -e;
                         set -x;
                         (
                             cd {temp_dir} &&
                             tar xfz {installer_filename} &&
                             (
                               cd keytalkclient-* &&
                                sudo ./install.sh
                             ) &&
                             sudo /usr/local/bin/keytalk/ktconfig --rccd-path {rccd_filename} &&
                             sudo cp {config_filename} /etc/keytalk/apache.ini
                         ) &&
                         rm -rf {temp_dir}""".format(temp_dir=remote_temp_dir,
                                                     installer_filename=installer_filename,
                                                     rccd_filename=rccd_filename,
                                                     config_filename=config_filename)
            _run_remote_cmd(ssh_host, cmd, only_stdout=True)
            if not _try_run_remote_cmd(
                ssh_host,
                "sudo /usr/local/bin/keytalk/renew_apache_ssl_cert --force",
                    only_stdout=True):
                return {
                    "warning": 'Deployment to {0} was successful, however Apache certificate failed to  be enrolled. Please check logs on the remote host.'.format(ssh_host)}

        elif deployment_kind == DeploymentKind.tomcat:
            cmds = []
            for probe in ["tomcat9" "tomcat8" "tomcat"]:
                cmds.append(
                    "test -f /usr/lib/systemd/system/${0}.service || test -f /etc/systemd/system/${0}.service || test -f /etc/init.d/${0}".format(probe))
            if not _try_run_remote_cmd(ssh_host, " || ".join(cmds), only_stdout=True):
                return {
                    "error": 'Could not deploy to {0} because Tomcat is not installed on the remote host'.format(ssh_host)}
            cmd = """set -e;
                         set -x;
                         (
                             cd {temp_dir} &&
                             tar xfz {installer_filename} &&
                             (
                               cd keytalkclient-* &&
                                sudo ./install.sh
                             ) &&
                             sudo/usr/local/bin/keytalk/ktconfig --rccd-path {rccd_filename} &&
                             sudo cp {config_filename} /etc/keytalk/tomcat.ini
                         ) &&
                         rm -rf {temp_dir}""".format(temp_dir=remote_temp_dir, installer_filename=installer_filename, rccd_filename=rccd_filename, config_filename=config_filename)
            _run_remote_cmd(ssh_host, cmd, only_stdout=True)
            if not _try_run_remote_cmd(
                ssh_host,
                "sudo /usr/local/bin/keytalk/renew_tomcat_ssl_cert --force",
                    only_stdout=True):
                return {
                    "warning": 'Deployment to {0} was successful, however Tomcat certificate failed to  be enrolled. Please check logs on the remote host under /root/tmp/keytalk.'.format(ssh_host)}

        else:
            raise Exception("Unsupported deployment {0}".format(deployment_kind.name))

    except CmdFailedException as ex:
        return {"error": ex.format_indented_message('Could not deploy to {0}:'.format(ssh_host))}

    return {}


def _remote_uninstall(ssh_host, verbose=True):
    """:returns: An error message upon failure or None on success."""
    if _try_run_remote_cmd(ssh_host, "test -f /usr/local/bin/keytalk/uninstall_keytalk"):
        print('Uninstalling KeyTalk agent on {0}'.format(ssh_host))
        sys.stdout.flush()
        try:
            _run_remote_cmd(
                ssh_host,
                'sudo /usr/local/bin/keytalk/uninstall_keytalk',
                only_stdout=True)
            print('OK')
        except CmdFailedException as ex:
            print('ERROR')
            return ex.format_indented_message('Could not uninstall on {0}:'.format(ssh_host))
    else:
        if verbose:
            print('No KeyTalk installation found on {0}, skip uninstalling'.format(ssh_host))
    return None


def _create_temp_config_file(sites):
    """:returns: Path to a created (temporary) vhost configuration file."""
    my_sites = copy.deepcopy(sites)

    for my_site in my_sites:
        del my_site['RemoteHost']

    file_name = None
    with tempfile.NamedTemporaryFile(delete=False) as f:
        file_content = json.dumps(my_sites, sort_keys=True, indent=4).encode('utf-8')
        f.write(file_content)
        file_name = f.name
    return file_name


def _print_config_file_errors(config_path, errors):
    if errors:
        print('The configuration {0} file contains the following errors:'.format(config_path))
        for message in errors:
            print(('    {0}'.format(message)))


def strip_json_comments(s):
    """Remove one-line comments starting with # or // from JSON-like content and return valid JSON."""
    # we intentionally substitute comment with empty line i.o. removing them
    # in order to preserve line numbers when reporting errors by JSON parser further on
    return re.sub(r"(?m)^\s*(#|//).*$", "", s)


def parse_sites_per_remote_host(config_path, util, tomcat_util):
    """:returns: Dict containing remote hosts and a list of VHosts to be deployed to this remote host."""
    # Parse and validate sites
    with open(config_path) as f:
        config = strip_json_comments(f.read())
        try:
            sites = json.loads(config)
        except Exception as ex:
            raise Exception(
                'Could not parse configuration template "{0}": {1}'.format(
                    config_path, ex))

    errors = validate_sites(sites, util, tomcat_util, config_path)
    _print_config_file_errors(config_path, errors)
    if errors:
        sys.exit(1)

    # Collect sites per remote host
    remote_host_sites = {}
    for site in sites:
        remote_host = site['RemoteHost']
        if remote_host not in remote_host_sites:
            remote_host_sites[remote_host] = []
        remote_host_sites[remote_host].append(site)

    return remote_host_sites


def remote_deploy(deployment_kind, config_path, installer_path, rccd_path):
    errors = []
    temp_dir = tempfile.mkdtemp()
    try:
        # load helper modules from the installation package
        run_cmd('tar xfv {0} -C {1}'.format(quote(installer_path), quote(temp_dir)))
        installer_dir = glob.glob('{0}/{1}*'.format(temp_dir, INSTALLER_DIR_PREFIX))[0]
        util = load_module_from_file('ktinstaller_util', installer_dir + '/util.py')
        tomcat_util = load_module_from_file('ktinstaller_util', installer_dir + '/tomcat_util.py')
    finally:
        shutil.rmtree(temp_dir)

    remote_host_sites = parse_sites_per_remote_host(config_path, util, tomcat_util)

    # deploy sites per remote host
    for remote_host, host_sites in remote_host_sites.items():
        print('Deploying {0} sites for {1}'.format(deployment_kind.name, remote_host))
        sys.stdout.flush()
        # Generate based on the "raw" (but validated) site instead of a parsed/populated one
        # Reason: prevent introduction of null values in the JSON file
        site_temp_config = _create_temp_config_file(host_sites)
        result = deploy_site_config(
            deployment_kind,
            remote_host,
            site_temp_config,
            installer_path,
            rccd_path)
        if "error" in result:
            print('ERROR')
            errors.append(result["error"])
            uninstall_error = _remote_uninstall(remote_host, verbose=False)
            if uninstall_error:
                errors.append(uninstall_error)
        else:
            print('OK')
        os.remove(site_temp_config)

    if errors:
        print('Errors during remote deployment:')
        indented_messages = []
        for message in errors:
            indented_message = '    ' + '\n    '.join(message.splitlines())
            indented_messages.append(indented_message)
        print(('\n\n\n\n'.join(indented_messages)))
        sys.exit(1)
    if "warning" in result:
        print('Warnings during remote deployment:')
        print('    ' + result["warning"] + '\n\n\n\n\n')


def remote_uninstall_hosts(ssh_hosts):
    print("Trying to uninstall KeyTalk from the following hosts: {0}".format(ssh_hosts))
    errors = []
    for host in ssh_hosts:
        error = _remote_uninstall(host)
        if error:
            errors.append(error)

    if errors:
        print('Errors during remote uninstall:')
        indented_messages = []
        for message in errors:
            indented_message = '    ' + '\n    '.join(message.splitlines())
            indented_messages.append(indented_message)

        print(('\n\n\n\n'.join(indented_messages)))
        sys.exit(1)


def remote_uninstall_from_config(config_path):
    print("Trying to uninstall KeyTalk from deployment configuration: {0}".format(config_path))
    with open(config_path) as f:
        config = strip_json_comments(f.read())
        try:
            sites = json.loads(config)
        except Exception as ex:
            raise Exception(
                'Could not parse vhosts configuration file "{0}": {1}'.format(
                    config_path, ex))

    remote_hosts = []
    for site in sites:
        if 'RemoteHost' in site:
            remote_hosts.append(site['RemoteHost'])

    remote_uninstall_hosts(remote_hosts)


def main():
    args = parse_args()

    deployment_kind = args['deployment_kind']
    if deployment_kind in (DeploymentKind.apache, DeploymentKind.tomcat):
        remote_deploy(
            deployment_kind,
            args['config_path'],
            args['installer_path'],
            args['rccd_path'])
    elif deployment_kind == DeploymentKind.remove and 'ssh_hosts' in args:
        remote_uninstall_hosts(args['ssh_hosts'])
    elif deployment_kind == DeploymentKind.remove and 'config_path' in args:
        remote_uninstall_from_config(args['config_path'])
    else:
        print("Failed to parse arguments for {0} deployment type".format(deployment_kind.name))
        exit(1)


#
# Entry point
#
if __name__ == "__main__":
    main()
