#!/usr/bin/python3 -u
# "-u": Force stdin, stdout and stderr to be  totally  unbuffered.
#       To get more accurate log files
#
# see also
# http://www.faqts.com/knowledge_base/entry/versions/index.phtml?aid=4419

from __future__ import print_function

import datetime
import glob
import logging
import multiprocessing
import os
import shutil
import sys
import time
import subprocess
import codecs

try:
    from configparser import SafeConfigParser
except ImportError:
    from ConfigParser import SafeConfigParser

# check if we run from a bzr checkout
if os.path.exists("AutoUpgradeTester"):
    sys.path.insert(0, ".")

from optparse import OptionParser
from AutoUpgradeTester.UpgradeTestBackend import UpgradeTestBackend


# bigger exitcode means more problematic error,
# exitcode 101 means generic error
class FailedToBootstrapError(Exception):
    """ Failed to initial bootstrap the test system """
    exitcode = 99


class FailedToUpgradeError(Exception):
    """ Failed to upgrade the test system """
    exitcode = 98


class FailedPostUpgradeTestError(Exception):
    """ Some post upgrade tests failed """
    exitcode = 97


class OutputThread(multiprocessing.Process):

    def __init__(self, filename):
        multiprocessing.Process.__init__(self)
        self.file = codecs.open(filename, 'r', 'utf-8')
        if sys.version_info < (3, 0):
            self.output = sys.stdout.write
        else:
            self.output = sys.stdout.buffer.write

    def run(self):
        while True:
            # the read() seems to not block for some reason
            # but return "" ?!?
            s = self.file.read(1024)
            if s:
                self.output(s.encode('utf-8'))
            else:
                time.sleep(0.1)


# FIXME: move this into the generic backend code
def login(backend):
    """ login into a backend """
    backend.bootstrap()
    backend.login()


def createBackend(backend_name, profile):
    """ create a backend of the given name for the given profile """
    try:
        backend_full_name = "AutoUpgradeTester.%s" % backend_name
        backend_modul = __import__(backend_full_name, globals(), fromlist="AutoUpgradeTester")
        backend_class = getattr(backend_modul, backend_name)
    except (ImportError, AttributeError, TypeError) as e:
        logging.exception("can not import '%s'" % backend_name)
        return None
    return backend_class(profile)


class HtmlOutputStream:
    HTML_HEADER=r"""
<?xml version="1.0" encoding="ascii"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
          "DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <title>Auto upgrade tester</title>
<style type="text/css">
.error { background-color:#FF6600; }
.warning { background-color:#FFA000; }
.aright { text-align:right; }
table { width:90%%; }
</style>
</head>
<body>
<h1>Automatic upgrade tester test results</h1>

<p>Upgrade test started %(date)s</p>

<table border="1">
<tr><th>Profile</th><th>Result</th><th>Bugs</th><th>Date Finished</th><th>Runtime</th><th>Full Logs</th></tr>
"""

    HTML_FOOTER="""
</table>
<p>Upgrade test finished %s</p>
</body>
"""

    def __init__(self, outputdir=None):
        self.html_output = None
        if outputdir:
            self.outputdir = outputdir
            if not os.path.exists(outputdir):
                os.makedirs(outputdir)
            self.html_output = open(os.path.join(outputdir, "index.html"), "w")
    def write(self, s):
        if self.html_output:
            self.html_output.write(s)
            self.html_output.flush()
    def copy_results_and_add_table_link(self, profile_name, resultdir):
        if not self.html_output:
            return
        # copy logs & sanitize permissions
        targetdir = os.path.join(self.outputdir, profile_name)
        html_output.copytree(resultdir, targetdir)
        for f in glob.glob(targetdir+"/*"):
            os.chmod(f, 0o644)
        # write html line that links to output dir
        s = "<td><a href=\"./%(logdir)s\">Logs for %(profile)s test</a></td>" %  {
            'logdir' : profile_name,
            'profile': profile_name, }
        html_output.write(s)
    def copytree(self, src, dst):
        if self.html_output:
            shutil.copytree(src, dst)
    def write_html_header(self, time_started):
        self.write(self.HTML_HEADER % { 'date' : time_started })
    def write_html_footer(self):
        self.write(self.HTML_FOOTER % datetime.datetime.now())

    
def testUpgrade(backend_name, profile, add_pkgs, quiet=False,
                html_output=HtmlOutputStream()):
    """ test upgrade for the given backend/profile """
    backend = createBackend(backend_name, profile)
    assert(backend != None)
    if not os.path.exists(profile):
        print("ERROR: Can't find '%s' " % profile)
        html_output.write("<tr><td>Can't find profile '%s'</td>" % profile)
        html_output.write(4*"<td></td>")
        return 2
    print("Storing the result in '%s'" % backend.resultdir)
    profile_name = os.path.split(os.path.normpath(profile))[1]
    # setup output
    outfile = os.path.join(backend.resultdir, "bootstrap.log")
    fd = os.open(outfile, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o644)
    out = OutputThread(outfile)
    out.daemon = True
    if not quiet:
        out.start()
    old_stdout = os.dup(1)
    old_stderr = os.dup(2)
    os.dup2(fd, 1)
    os.dup2(fd, 2)
    time_started = datetime.datetime.now()
    print("%s log started" % time_started)
    result = 0
    try:
        # write what we working on
        html_output.write("<td>%s</td>" % profile_name)
        # init 
        if not backend.bootstrap():
            print("FAILED: bootstrap for '%s'" % profile)
            html_output.write("<td class=\"error\">Failed to bootstrap</td>")
            raise FailedToBootstrapError("Failed to bootstrap '%s'" % profile)
        if add_pkgs:
            if not backend.installPackages(add_pkgs):
                print("FAILED: installPacakges '%s'" % add_pkgs)
                html_output.write("<td class=\"error\">Failed to add pkgs '%s'</td>" % ",".join(add_pkgs))
                raise Exception("Failed to install packages '%s'" % add_pkgs)
        if not backend.upgrade():
            print("FAILED: upgrade for '%s'" % profile)
            html_output.write("<td class=\"error\">Failed to upgrade</td>")
            raise FailedToUpgradeError("Failed to upgrade %s" % profile)
        if not backend.test():
            print("FAILED: test for '%s'" % profile)
            html_output.write("<td class=\"warning\">Upgraded, but post upgrade test failed</td>")
            raise FailedPostUpgradeTestError("Failed in post upgrade test %s" % profile)
        print("profile: %s worked" % profile)
        html_output.write("<td>ok</td>")
    except (FailedToBootstrapError, FailedToUpgradeError, FailedPostUpgradeTestError) as e:
        print(e)
        result = e.exitcode
    except Exception as e:
        import traceback
        traceback.print_exc()
        print("Caught exception in testUpgrade for '%s' (%s)" % (profile, e))
        html_output.write("<td class=\"error\">Unknown failure (should not happen)</td>")
        result = 2
    finally:
        # print out result details
        print("Logs can be found here:")
        for n in ["bootstrap.log", "main.log", "apt.log"]:
            print(" %s/%s" % (backend.resultdir, n))

        time_ended = datetime.datetime.now()
        print("%s log ended." % time_ended)
        print("Duration: %s" % (time_ended - time_started))

        # give the output time to settle and kill the daemon
        time.sleep(2)
        if out.is_alive():
            out.terminate()
            out.join()
        # write result line
        s="<td></td><td>%(date)s</td><td class=\"aright\">%(runtime)s</td>" % {
            'date'    : datetime.datetime.now(),
            'runtime' : datetime.datetime.now() - time_started}
        html_output.write(s)
        html_output.copy_results_and_add_table_link(profile_name, backend.resultdir)
        html_output.write("</tr>")
    # close and restore file descriptors
    os.close(fd)
    os.dup2(old_stdout, 1)
    os.dup2(old_stderr, 2)
    return result


def profileFromAptClone(aptclonefile):
    """ Create a profile from an apt-clone archive

    :param aptclonefile: Path to an apt-clone archive
    :return: path to profile or None if it fails to recognize the clone file
    """
    profile = None
    try:
        cmd = ('apt-clone', 'info', aptclonefile)
        output = subprocess.check_output(cmd, universal_newlines=True)
        clone_properties = {}
        for k, v in [ line.split(': ') for line in output.split('\n') if ': ' in line]:
            clone_properties[k] = v
        try:
            clone_date = datetime.datetime.strptime(clone_properties['Date'], '%c').strftime('_%Y%m%d-%H%M%S')
        except:
            clone_date = ''
        profilename = "%s_%s_%s%s" % (
            clone_properties['Distro'],
            clone_properties['Arch'],
            clone_properties['Hostname'],
            clone_date)

        # Generate configuration file for this profile
        # FIXME:
        # update packaging to grant write access on base for upgrade-tester user
        ppath = 'profile' if os.path.exists("./UpgradeTestBackend.py") else base
        profile = os.path.join(ppath, profilename)
        profilecfg = SafeConfigParser()
        if not os.path.exists(profile):
            os.makedirs(profile)

        profilecfg.set('DEFAULT', 'SourceRelease', clone_properties['Distro'])
        profilecfg.add_section('NonInteractive')
        profilecfg.set('NonInteractive', 'ProfileName', profilename)
        profilecfg.set('NonInteractive', 'AptcloneFile', os.path.abspath(aptclonefile))
        profilecfg.add_section('KVM')
        profilecfg.set('KVM', 'Arch', clone_properties['Arch'])
        with open(os.path.join(profile, 'DistUpgrade.cfg'), 'wb') as profilefile:
            profilecfg.write(profilefile)

    except subprocess.CalledProcessError:
        return None

    return profile


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)

    parser = OptionParser()
    parser.add_option("--additinoal", "--additional-pkgs", dest="add_pkgs", 
                      default="",
                      help="add additional pkgs before running the upgrade")
    parser.add_option("-a", "--auto", dest="auto", default=False,
                      action="store_true",
                      help="run all tests in profile/ dir")
    parser.add_option("--bootstrap-only", "--bootstrap-only",dest="bootstrap_only", 
                      default=False,
                      action="store_true",
                      help="only bootstrap the image")
    parser.add_option("--tests-only", "", default=False,
                      action="store_true",
                      help="only run post_upgrade_tests in the image")
    parser.add_option("-l", "--login", dest="login", default=False,
                      action="store_true",
                      help="log into the a profile")
    parser.add_option("-b", "--backend", dest="backend",
                      default="UpgradeTestBackendQemu",
                      help="UpgradeTestBackend to use: UpgradeTestBackendChroot, UpgradeTestBackendQemu")
    parser.add_option("-d", "--diff", dest="gen_diff",
                      default=False, action="store_true",
                      help="generate a diff of the upgraded image versus a fresh installed image")
    parser.add_option("--quiet", "--quiet", default=False, action="store_true",
                      help="quiet operation")
    parser.add_option("", "--html-output-dir", default=None,
                      help="html output directory")
    parser.add_option("", "--data-dir", 
                      default="/usr/share/auto-upgrade-tester/",
                      help="directory with the profiles")

    (options, args) = parser.parse_args()

    # save for later
    fd1 = os.dup(1)
    fd2 = os.dup(2)

    # set basedir from datadir
    base = os.path.join(options.data_dir, "profiles")

    # check if we have something to do
    profiles = args
    if not profiles and not options.auto:
        print(sys.argv[0])
        print("No profile specified, available default profiles:")
        print("\n".join(os.listdir(base)))
        print()
        print("Or use '-a' for 'auto' mode")
        sys.exit(1)

    # generic
    res = 0
    add_pkgs = []
    if  options.add_pkgs:
        add_pkgs = options.add_pkgs.split(",")
    # auto mode
    if options.auto:
        print("running in auto-mode")
        for d in os.listdir(base):
            os.dup2(fd1, 1)
            os.dup2(fd2, 2)
            print("Testing '%s'" % d)
            res = max(res, testUpgrade(options.backend, base+d, add_pkgs))
        sys.exit(res)
    # profile mode, test the given profiles
    time_started = datetime.datetime.now()

    # clean output dir
    html_output = HtmlOutputStream(options.html_output_dir)
    html_output.write_html_header(time_started)
    for profile in profiles:

        # case of an apt-clone archive
        if os.path.isfile(profile):
            profilename = profileFromAptClone(profile)
            if not profilename:
                print("File is not a valid apt-clone image: %s" % profilename)
                continue
            profile = profilename
            print("Using generated profile: %s" % profile)

        if not "/" in profile:
            profile = base + profile
        try:
            if options.login:
                backend = createBackend(options.backend, profile)
                login(backend)
            elif options.bootstrap_only:
                backend = createBackend(options.backend, profile)
                backend.bootstrap(force=True)
            elif options.tests_only:
                backend = createBackend(options.backend, profile)
                backend.test()
            elif options.gen_diff:
                backend = createBackend(options.backend, profile)
                backend.genDiff()
            else:
                print("Testing '%s'" % profile)
                now = datetime.datetime.now()
                current_res = testUpgrade(options.backend, profile, 
                                          add_pkgs, options.quiet,
                                          html_output)
                if current_res == 0: 
                    print("Profile '%s' worked" % profile)
                else:
                    print("Profile '%s' FAILED" % profile)
                res = max(res, current_res)
        except Exception as e:
            import traceback
            print("ERROR: exception caught '%s' " % e)
            html_output.write('<pre class="error">unhandled ERROR %s:\n%s</pre>' % (e, traceback.format_exc()))
            traceback.print_exc()
            if hasattr(e, "exitcode"):
                res = max(res, e.exitcode)
            else:
                res = max(res, 101)
    html_output.write_html_footer()

    # return exit code
    sys.exit(res)
