# -*- coding: utf-8 -*-
##
#
# Vaisala software source code file
#
# Copyright (c) Vaisala Oyj 2016-2019. All rights reserved.
#
##

from __future__ import print_function

import StringIO
import datetime
import httplib
import logging
import os
import subprocess
import sys
import threading
import time
import traceback

import pwd

LINE_LENGTH = 78
ALLOWED_CENTOS_VERSIONS = ["release 7.1.", "release 7.2.", "release 7.3.", "release 7.4.",
                           "release 7.5.", "release 7.6."]


class StepFailed(Exception):
    pass


class Step(object):
    def __init__(self, description, prerequisite=False, help_message=None):
        self.description = description
        self.prerequisite = prerequisite
        self.help_message = help_message
        self.buffer = []

    def __str__(self):
        return self.description


class ShellCommandStep(Step):
    def __init__(self, command, description, logger, shell=False, *args, **kwargs):
        super(ShellCommandStep, self).__init__(description, *args, **kwargs)
        self.command = command
        self.shell = shell
        self._logger = logger

    def printable_command(self):
        if isinstance(self.command, basestring):
            return self.command
        else:
            return " ".join(self.command)

    def __call__(self):
        self._logger.debug(u"Running command: '" + self.printable_command() + u"'")
        proc = subprocess.Popen(self.command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                shell=self.shell)

        def log_stream_lines(stream):
            for line in iter(stream.readline, b''):
                self._logger.debug(line.rstrip('\n'))
                self.buffer.append(line.rstrip('\n'))

        while proc.poll() is None:
            log_stream_lines(proc.stdout)
            log_stream_lines(proc.stderr)
            time.sleep(0.01)

        # Handle lines possibly left in the buffers
        log_stream_lines(proc.stdout)
        log_stream_lines(proc.stderr)

        status = proc.returncode
        if status != 0:
            raise StepFailed(
                u"Command `{0}` failed with status code {1}".format(
                    self.printable_command(), status))


class FunctionStep(Step):
    def __init__(self, func, description, *args, **kwargs):
        super(FunctionStep, self).__init__(description, *args, **kwargs)
        self.func = func

    def __call__(self):
        return self.func()


class PreFunctionStep(FunctionStep):
    def __init__(self, func, description, *args, **kwargs):
        super(PreFunctionStep, self).__init__(func, description, *args, **kwargs)
        self.prerequisite = True


class RepoState(object):
    def __init__(self, online_allowed):
        self.online = online_allowed
        self.offline = not online_allowed

        self.repo_ok = False
        self.disabled_repos = []


class Dotter(object):
    def __init__(self, msg):
        self.stop_running = threading.Event()
        self.thread = threading.Thread(target=self.init_dotter)
        self.msg = msg
        self.max_dots = LINE_LENGTH - len(msg) - 1

    def start(self):
        self.thread.start()

    def stop(self):
        self.stop_running.set()
        self.thread.join()

    def init_dotter(self):
        dots = 0
        while not self.stop_running.is_set():
            if dots > self.max_dots:
                sys.stderr.write(u"\r\033[K" + self.msg)
                dots = 0
            sys.stderr.write(u".")
            sys.stderr.flush()
            time.sleep(0.25)
            dots += 1


class StepExecutor(object):
    def __init__(self, steps, logger, dry_run):
        self._steps = steps
        self._logger = logger
        self._dry_run = dry_run

    def execute(self):
        for idx, step in enumerate(self._steps):
            if str(step) == "User Confirmation":
                step()
            console(u"# Step {}/{}\n".format(idx + 1, len(self._steps)))
            msg = u"## " + str(step)
            console(msg)
            msg_len = len(msg)
            dots = LINE_LENGTH - msg_len - 4
            self._logger.debug(msg)

            while True:
                try:
                    if self._dry_run:
                        console((u"\r" + msg + u"." * dots) + u"SKIP\n")
                    else:
                        dotter = Dotter(msg)
                        dotter.start()
                        if str(step) != "User Confirmation":
                            step()
                        dotter.stop()
                        console((u"\r" + msg + u"." * dots) + u"DONE\n")
                    break
                except SystemExit, se:
                    dotter.stop()
                    console((u"\r" + msg + u"." * dots) + u"FAIL")
                    sys.exit(*se.args)
                except StepFailed, f:
                    try:
                        if dotter:
                            dotter.stop()
                        console((u"\r" + msg + u"." * dots) + u"FAIL\n")
                        for line in step.buffer:
                            self._logger.info(line)
                        self._logger.info(u"Step failed: {0}".format(str(f)))
                        if step.help_message is not None:
                            self._logger.info(step.help_message)
                        action = self._skip_retry_fail()
                        if action == 'skip':
                            break
                        elif action == 'retry':
                            pass
                        elif action == 'fail':
                            raise f
                    except KeyboardInterrupt, kbi:
                        self._logger.error(traceback.format_exc())
                        raise kbi
                finally:
                    if not self._dry_run and not str(step) == "User Confirmation" and dotter:
                        dotter.stop()

    def _skip_retry_fail(self):
        allowed_inputs = ['skip', 'retry', 'fail']
        while True:
            self._logger.info('Skip, retry or fail step (skip/retry/fail)?')
            reply = raw_input().replace('\n', '').strip()
            if reply not in allowed_inputs:
                self._logger.info(u"Allowed inputs are {0}.".format(", ".join(allowed_inputs)))
            else:
                break
        return reply


class InstallerLogger(object):
    def __init__(self, name, log_file=None):
        self.log_file = log_file
        self._logger = logging.getLogger(name)

    def debug(self, *args, **kwargs):
        buf = StringIO.StringIO()
        self.log_file = buf
        self._logger.debug(buf.getvalue().rstrip('\n'), *args, **kwargs)
        buf.close()

    def info(self, *args, **kwargs):
        buf = StringIO.StringIO()
        self.log_file = buf
        self._logger.info(buf.getvalue().rstrip('\n'), *args, **kwargs)
        buf.close()

    def error(self, *args, **kwargs):
        buf = StringIO.StringIO()
        self.log_file = buf
        self._logger.error(buf.getvalue().rstrip('\n'), *args, **kwargs)
        buf.close()

    def add_file_handler(self, log_file):
        f_handler = logging.FileHandler(log_file)
        f_handler.setLevel(logging.DEBUG)
        self._logger.addHandler(f_handler)

    def add_stdout_handler(self, debug):
        stdout_handler = logging.StreamHandler(sys.stdout)
        if debug:
            stdout_handler.setLevel(logging.DEBUG)
        else:
            stdout_handler.setLevel(logging.INFO)
        self._logger.addHandler(stdout_handler)


def console(*args, **kwargs):
    """Output the arguments to stderr and flush the stream."""
    sys.stderr.write(*args, **kwargs)
    sys.stderr.flush()


def ensure_run_by_root():
    if pwd.getpwuid(os.getuid()).pw_name != 'root':
        raise StepFailed(u"This script must be run as root.")


def ensure_correct_centos_version(skip_os_version_check):
    version = get_centos_version()
    for allowed_version in ALLOWED_CENTOS_VERSIONS:
        if allowed_version in version or skip_os_version_check:
            return

    msg = u"""\nYour current operating system is: {}
This software should be installed on CentOS version 7.4.
Run the installer with the '--skip-os-version-check' flag to install anyway.""".format(version)
    raise StepFailed(msg)


def ensure_good_path_environment(logger):
    """Adds the directory this executable resides in into PATH so that we can
    call rsw-rpm-radarsw-repo-create and various PostgreSQL upgrade-related scripts
    in the same directory."""
    try:
        subprocess.check_output(["rsw-rpm-radarsw-repo-create", "--test"])
        logger.debug(u"No changes needed for PATH environment variable.")
    except:
        installer_dir = os.path.dirname(os.path.realpath(__file__))
        os.environ['PATH'] = "{}:{}".format(installer_dir, os.environ['PATH'])
        logger.debug(u"'{}' added to PATH environment variable.".format(installer_dir))


def resolve_webapp_version():
    args = "rpm -qa --qf '%{NAME} %{VERSION}\n' | grep 'vaisala-radarsw-webapp ' | awk -F ' ' '{print $2}'"
    output = subprocess.check_output(['bash', '-c', args])
    output = output.strip()
    return output


def log_system_information(logger):
    logger.debug(extract_system_information())
    logger.debug("Current directory: {}".format(os.getcwd()))
    logger.debug("Started with command line {}".format(" ".join(sys.argv)))


def get_centos_version():
    try:
        with open("/etc/centos-release") as f:
            return f.readline().strip()
    except IOError:
        return "Unknown OS"


def ensure_config_dir_set(config_dir):
    if "VAISALA_RADARSW_CONFIG_DIR" not in os.environ:
        os.environ["VAISALA_RADARSW_CONFIG_DIR"] = config_dir


def extract_system_information():
    sep = LINE_LENGTH * "#"

    # Header
    sysinfo = sep + "\n"
    sysinfo += "## System information\n"
    sysinfo += sep + "\n"

    sysinfo += _section(_no_check_output(['hostname']).replace('\n', ''), "hostname")
    sysinfo += _section(
        _no_check_output(['md5sum', sys.argv[0]]).replace('\n', '').strip().split(' ')[0],
        "rsw-installer.md5sum")
    sysinfo += _section(_no_check_output(['uptime']).replace('\n', '').strip(), "uptime")
    sysinfo += _section(_no_check_output(['df', '-h']), "df-h")
    sysinfo += _section(_no_check_output(['mount']), "mount")
    sysinfo += _section(_no_check_output(['free', '-ht']), "free-ht")
    sysinfo += _section(_no_check_output(['cat', '/proc/cpuinfo']), "cat-cpuinfo")

    # Footer
    sysinfo += sep + "\n"
    sysinfo += "## End of system information\n"
    sysinfo += sep + "\n"

    return sysinfo


def _section(message, title=None):
    sep = LINE_LENGTH * "-"
    return _title_section(title) + "\n" + message + "\n" + sep + "\n"


def _title_section(title):
    if title and len(title) < (LINE_LENGTH - 2):
        title_length = len(title)
        sep_length = LINE_LENGTH - (title_length + 2)
        sep_left = sep_length / 2
        sep_right = sep_length - sep_left
        return (sep_left * "-") + " " + title + " " + (sep_right * "-")
    elif title is None:
        return LINE_LENGTH * "-"
    else:
        return title


def _no_check_output(*args, **kwargs):
    try:
        return subprocess.check_output(*args, **kwargs)
    except Exception, e:
        return str(e)


def call_dropping_return_code(*args, **kwargs):
    """Returns the output of subprocess.Popen.communicate. A convenience function
    when all we want is the output and we don't care if the process failed or
    not.
    """
    kwargs["stdout"] = subprocess.PIPE
    kwargs["stderr"] = subprocess.PIPE
    p = subprocess.Popen(*args, **kwargs)
    return p.communicate()


def _resolve_repos():
    """Returns a list of repo ids."""
    result = []
    repo_line = False
    (repolist_out, repolist_err) = call_dropping_return_code(["yum", "repolist"])
    for line in (repolist_out + repolist_err).split("\n"):
        if line.startswith("repo id"):
            repo_line = True
            continue
        elif line.startswith("repolist:"):
            repo_line = False

        if repo_line is False:
            continue

        repo_id, rest = line.split(" ", 1)
        result.append(repo_id)

    return result


def base_repo_setup(repo_state, logger):
    assert repo_state is not None

    if repo_state.repo_ok:
        return True

    if repo_state.online:
        for repo in repo_state.initial_repos:
            if repo["is-base-repo"] and repo["is-current"]:
                logger.debug(u"Suitable repo found:")
                _log_repos([repo])
                repo_state.repo_ok = True
                return True
        raise StepFailed(u"Couldn't find base repo for online installation")
    elif repo_state.offline:
        if call_with_logging(logger, ["rsw-rpm-centosbase-repo-create", "-c"]) != 0:
            raise StepFailed(u"No offline repo found")

        def disable_by_id(repo_id):
            if '/' in repo_id:
                repo_id = repo_id.split('/', 1)[0]
            subprocess.check_output(["yum-config-manager", "--disable", repo_id])  # discard output
            repo_state.disabled_repos.append(repo_id)
            logger.debug(u"Disabled repo by id '{}'.".format(repo_id))

        for repo_id in _resolve_repos():
            try:
                disable_by_id(repo_id)
            except subprocess.CalledProcessError, cpe:
                raise StepFailed(str(cpe))

        try:
            check_call_with_logging(logger, ["rsw-rpm-centosbase-repo-create"])
            check_call_with_logging(logger, ["yum", "clean", "expire-cache"])
            check_call_with_logging(logger, ["yum", "makecache"])
            logger.debug(u"Added offline base repo.")
        except Exception, e:
            raise StepFailed(str(e))


def base_repo_cleanup(repo_state, logger):
    assert repo_state is not None

    # Create a copy of the original list so we can modify the original on the fly.
    for repo_id in list(repo_state.disabled_repos):
        try:
            if '/' in repo_id:
                repo_id = repo_id.split('/', 1)[0]
            subprocess.check_output(["yum-config-manager", "--enable", repo_id])  # discard output
            repo_state.disabled_repos.remove(repo_id)
            logger.debug(u"Enabled repo by id {}".format(repo_id))
        except subprocess.CalledProcessError, cpe:
            raise StepFailed(str(cpe))

    try:
        base_repo_file = "/etc/yum.repos.d/base-offline.repo"
        if os.path.exists(base_repo_file):
            os.unlink(base_repo_file)
            logger.debug("Removed automatically added base repo.")
        remove_radarsw_repo(logger)
    except Exception, e:
        raise StepFailed(str(e))


def remove_radarsw_repo(logger):
    radarsw_repo_file = u"/etc/yum.repos.d/radarsw.repo"
    if os.path.exists(radarsw_repo_file):
        os.unlink(radarsw_repo_file)
        logger.debug(
            u"Removed automatically added radarsw repository: {}".format(radarsw_repo_file))


def _log_repos(repos, logger):
    for repo in repos:
        logger.debug(u"Repository: {}".format(repo))


def check_call_with_logging(logger, *args, **kwargs):
    """Wraps subprocess.check_call so that the output gets logged to both log file
    and to console.
    """
    kwargs['stdout'] = subprocess.PIPE
    kwargs['stderr'] = subprocess.PIPE
    proc = subprocess.Popen(*args, **kwargs)

    def log_stream_lines(stream):
        for line in iter(stream.readline, b''):
            logger.debug(line.rstrip('\n'))

    while proc.poll() is None:
        log_stream_lines(proc.stdout)
        log_stream_lines(proc.stderr)
        time.sleep(0.01)

    # Handle lines possibly left in the buffers
    log_stream_lines(proc.stdout)
    log_stream_lines(proc.stderr)

    status = proc.returncode
    if status != 0:
        raise subprocess.CalledProcessError(proc.returncode, cmd=args)
    return proc


def call_with_logging(logger, *args, **kwargs):
    """Wraps subprocess.call so that the output gets logged to both log file and
    to console.
    """
    try:
        proc = check_call_with_logging(logger, *args, **kwargs)
        return proc.returncode
    except subprocess.CalledProcessError, cpe:
        return cpe.returncode


def base_repo_available(repo_state, logger):
    assert repo_state is not None

    if repo_state.online:
        try:
            check_call_with_logging(logger, ["yum", "clean", "expire-cache"])
            check_call_with_logging(logger, ["yum", "makecache"])
        except Exception, e:
            raise StepFailed(u"Could not refresh repository caches: {}".format(e))

        for repo_id in _resolve_repos():
            if "base" in repo_id:
                logger.debug(u"Suitable repo found: '{}'".format(repo_id))
                repo_state.repo_ok = True
                return True
        raise StepFailed("No online base repo found.")
    elif repo_state.offline:
        logger.debug("Trying to find DVD/USB base repo...")
        try:
            check_call_with_logging(logger, ["rsw-rpm-centosbase-repo-create", "-c"])
            logger.debug("Suitable offline DVD/USB base repo found.")
        except Exception, e:
            raise StepFailed(
                u"Could not find an available offline CentOS base package repository: {}".format(
                    str(e)))


def create_hostname_resolvable_step(logger):
    return ShellCommandStep(
        "getent hosts `hostname` > /dev/null",
        "Resolve hostname", shell=True, help_message=
        """If you cannot add your installation host to your network's DNS system, you
        can add the hostname to /etc/hosts file. See `man hosts` for more information.

        The installation will not be able to run database migration steps if system's
        hostname is not resolvable via either DNS or from /etc/hosts.

        Some useful commands:
         - getent hosts | grep `hostname`
         - host `hostname`
         - echo "127.0.0.1   `hostname`" >> /etc/hosts""", logger=logger, prerequisite=True)


def create_base_repo_available_step(repo_state, logger):
    return PreFunctionStep(
        lambda: base_repo_available(repo_state, logger), u"Ensure CentOS base repository available",
        help_message=
        u"""For offline installation: have you plugged in the USB stick or DVD? To
        mount it, you need to open it in the graphical user interface (or mount it by
        hand using the mount command).

        For online installation: Is this computer connected to the internet and has
        CentOS base Yum repository available and enabled?

        The installation installs a number of packages, which require some packages
        from CentOS base repository to be available. You may skip this step if you
        know the packages will be installable for Yum via some other repository you
        have set up.

        Some useful commands:
         - yum repolist -v
         - yum-config-manager --enable/--disable <repo-id>""")


def make_verify_executable_is_callable_step(executable, logger):
    return ShellCommandStep(
        [executable, "--test"],
        u'Test \'{}\''.format(executable),
        prerequisite=True,
        logger=logger,
        help_message=u"on PATH; try 'export PATH=\".:$PATH\"'")


def log_and_exit(msg, logger):
    logger.debug(msg)
    sys.exit(msg)


def create_remove_yum_cache_step(logger):
    return ShellCommandStep("rsw-yum-cache-delete", u"Delete yum cache", logger)


def dbg(logger, *args, **kwargs):
    return logger.debug(*args, **kwargs)


def wait_until_code(code, netloc, path, target, logger, timeout_secs=90, attempt_delay_secs=1):
    dbg(logger, u"Waiting for {0} to be available...".format(target))
    start = datetime.datetime.now()
    timeout_delta = datetime.timedelta(seconds=timeout_secs)
    while True:
        try:
            conn = httplib.HTTPConnection(netloc, timeout=1)
            conn.request("HEAD", path)
            res = conn.getresponse()

            if res.status == code:
                now = datetime.datetime.now()
                waited = (now - start).total_seconds()
                dbg(logger, u"\n{0} up in {1} seconds.".format(target, waited))
                break
            else:
                dbg(logger, res.status)
        except Exception, e:
            dbg(logger, e)

        now = datetime.datetime.now()
        if (start + timeout_delta) < now:
            waited = (now - start).total_seconds()
            sys.stderr.write('\n')
            sys.stderr.flush()
            raise StepFailed(
                u"Timed out waiting for {0} to start up, waited for {1} seconds.".format(target, waited))

        time.sleep(attempt_delay_secs)


def file_exists(file, logger):
    dbg(logger, u"Checking whether '{0}' exists.".format(file))
    if not os.path.exists(file):
        raise StepFailed(u"Path '{0}' doesn't exist!".format(file))
    dbg(logger, u"Path '{}' exists.".format(file))


def validate_terrain_dir(terrain_dir, logger):
    file_exists(terrain_dir, logger)
    shade_zip_file = os.path.join(terrain_dir, "pyramid_shade.zip")
    slope_zip_file = os.path.join(terrain_dir, "pyramid_slope.zip")
    if not os.path.exists(shade_zip_file) or not os.path.exists(slope_zip_file):
        raise StepFailed(
            u"Could not locate files pyramid_shade.zip and pyramid_slope.zip in '{0}'. \
            Check that you are pointing to a valid terrain directory.".format(terrain_dir))
    dbg(logger, u"Path '{0}' points to a valid terrain directory.".format(terrain_dir))


def validate_gis_dump(gis_dump_path, logger):
    file_exists(gis_dump_path, logger)
    if os.path.isdir(gis_dump_path):
        md5sums_file = os.path.join(gis_dump_path, "md5sums.txt")
        if not os.path.exists(md5sums_file):
            raise StepFailed(
                u"Could not locate file md5sums.txt in '{0}'. \
                Check that you are pointing to a valid GIS dump directory.".format(
                    gis_dump_path))
        dbg(logger, u"Path '{0}' points to a valid GIS dump directory.".format(gis_dump_path))
    else:
        dbg(logger, u"Path '{0}' points to a valid GIS dump file.".format(gis_dump_path))

