#!/usr/bin/env python
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
# Copyright 2012 Canonical
# Author: Thomi Richards
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
# by the Free Software Foundation.

from __future__ import absolute_import

from codecs import open
from datetime import datetime
import logging
import os
import os.path
from os import putenv
from os.path import isabs, exists
from platform import node
import sys
import subprocess
from testtools import iterate_tests
from testtools import TextTestResult
from autopilot.testresult import AutopilotVerboseResult
from autopilot.utilities import LogFormatter
from textwrap import dedent
from argparse import ArgumentParser
from unittest.loader import TestLoader
from unittest import TestSuite
from autopilot.introspection.gtk import GtkIntrospectionTestMixin
from autopilot.introspection.qt import QtIntrospectionTestMixin

# list autopilot depends here, with the form:
# ('python module name', 'ubuntu package name'),
DEPENDS = [
    ('compizconfig', 'python-compizconfig'),
    ('dbus', 'python-dbus'),
    ('gi.repository.GConf', 'gir1.2-gconf-2.0'),
    ('gi.repository.IBus', 'gir1.2-ibus-1.0'),
    ('junitxml', 'python-junitxml'),
    ('testscenarios', 'python-testscenarios'),
    ('testtools', 'python-testtools'),
    ('xdg', 'python-xdg'),
    ('Xlib', 'python-xlib'),
]

_output_stream=None

def check_depends():
    """Check for required dependancies, and print a helpful message if any are
    missing.

    If all required modules are present, return True, False otherwise.
    """
    missing = []
    for module_name, package_name in DEPENDS:
        try:
            __import__(module_name)
        except ImportError:
            missing.append(package_name)
    if missing:
        print dedent("""\
            You are missing one or more packages required to run autopilot.
            They are:

            %s

            Please install these packages and re-run this script.
            """ % (' '.join(missing))
            )
        return False
    return True


def parse_arguments():
    """Parse command-line arguments, and return an argparse arguments
    object.
    """
    parser = ArgumentParser(description=dedent("""\
        Autopilot test tool.
        """))
    subparsers = parser.add_subparsers(help='Run modes', dest="mode")

    parser_run = subparsers.add_parser('run', help="Run autopilot tests")
    parser_run.add_argument('-o', "--output", required=False,
                            help='Write test result report to file.\
                            Defaults to stdout.\
                            If given a directory instead of a file will \
                            write to a file in that directory named: \
                            <hostname>_<dd.mm.yyy_HHMMSS>.log')
    parser_run.add_argument('-f', "--format", choices=['text', 'xml'],
                            default='text',
                            required=False,
                            help='Specify desired output format. \
                            Default is "text".')
    parser_run.add_argument('-r', '--record', action='store_true',
                            default=False, required=False,
                            help="Record failing tests. Required \
                            'recordmydesktop' app to be installed.\
                            Videos are stored in /tmp/autopilot.")
    parser_run.add_argument("-rd", "--record-directory", required=False,
                            default="/tmp/autopilot", type=str,
                            help="Directory to put recorded tests \
                            (only if -r) specified.")
    parser_run.add_argument('-v', '--verbose', default=False, required=False,
                            action='count',
                            help="If set, autopilot will output test log data \
                            to stderr during a test run. Set twice to also log \
                            data useful for debugging autopilot itself.")
    parser_run.add_argument("suite", nargs="+",
                            help="Specify test suite(s) to run.")

    parser_list = subparsers.add_parser('list', help="List autopilot tests")
    parser_list.add_argument("-ro", "--run-order", required=False, default=False,
                            action="store_true",
                            help="List tests in run order, rather than alphabetical \
                            order (the default).")
    parser_list.add_argument("suite", nargs="+",
                             help="Specify test suite(s) to run.")

    parser_vis = subparsers.add_parser('vis',
                                      help="Open the Autopilot visualiser tool")
    parser_vis.add_argument('-v', '--verbose', required=False, default=False,
                            action='count', help="Show autopilot log messages. \
                            Set twice to also log data useful for debugging \
                            autopilot itself.")

    parser_launch = subparsers.add_parser('launch',
                            help="Launch an application with introspection enabled")
    parser_launch.add_argument('-i', '--interface',
                            choices=('Gtk', 'Qt', 'Auto'), default='Auto',
                            help="Specify which introspection interface to load. \
                            The default ('Auto') uses ldd to try and detect which \
                            interface to load.")
    parser_launch.add_argument('-v', '--verbose', required=False, default=False,
                            action='count', help="Show autopilot log messages. \
                            Set twice to also log data useful for debugging \
                            autopilot itself.")
    parser_launch.add_argument('application', nargs=1, type=str,
                            help="The application to launch. Can be a full path, \
                            or just an application name (in which case Autopilot \
                                will search for it in $PATH).")
    args = parser.parse_args()

    return args


def list_tests(args):
    """Print a list of tests we find inside autopilot.tests."""
    num_tests = 0
    test_suite = load_test_suite_from_name(args.suite)

    if args.run_order:
        test_list_fn = lambda: iterate_tests(test_suite)
    else:
        test_list_fn = lambda: sorted(iterate_tests(test_suite),
            lambda a, b: cmp(a.id(), b.id()))

    for test in test_list_fn():
        has_scenarios = hasattr(test, "scenarios") and type(test.scenarios) is list
        if has_scenarios:
            num_tests += len(test.scenarios)
            print " *%d %s" % (len(test.scenarios), test.id())
        else:
            num_tests += 1
            print "   ", test.id()
    print "\n\n %d total tests." % (num_tests)


def run_tests(args):
    """Run tests, using input from `args`."""
    test_suite = load_test_suite_from_name(args.suite)

    import autopilot.globals

    if args.record:
        if subprocess.call(['which', 'recordmydesktop'], stdout=subprocess.PIPE) != 0:
            print "ERROR: The application 'recordmydesktop' needs to be installed to record failing jobs."
            exit(1)
        autopilot.globals.configure_video_recording(True, args.record_directory)

    if args.verbose:
        autopilot.globals.set_log_verbose(True)

    setup_logging(args.verbose)
    runner = construct_test_runner(args)
    success = runner.run(test_suite).wasSuccessful()
    if not success:
        exit(1)


def setup_logging(verbose):
    """Configure the root logger and verbose logging to stderr"""
    root_logger = logging.getLogger()
    root_logger.setLevel(logging.DEBUG)
    if verbose == 0:
        root_logger.addHandler(logging.NullHandler())
    if verbose >= 1:
        formatter = LogFormatter()
        stderr_handler = logging.StreamHandler(stream=sys.stderr)
        stderr_handler.setFormatter(formatter)
        root_logger.addHandler(stderr_handler)
    if verbose >= 2:
        from autopilot.utilities import DebugLogFilter
        DebugLogFilter.debug_log_enabled = True


def construct_test_runner(args):
    kwargs = dict(stdout=get_output_stream(args),
        output_format=get_output_format(args.format),
        )

    return ConfigurableTestRunner(**kwargs)


def get_output_format(format):
    """Return a Result object for each format we support."""

    if format == "text":
        return type('VerboseTextTestResult', (TextTestResult,),
                    dict(AutopilotVerboseResult.__dict__))

    elif format == "xml":
        from junitxml import JUnitXmlResult
        return type('VerboseXmlResult', (JUnitXmlResult,),
                    dict(AutopilotVerboseResult.__dict__))

    raise KeyError("Unknown format name '%s'" % format)


def get_output_stream(args):
    global _output_stream

    if _output_stream is None:
        if args.output:
            path = os.path.dirname(args.output)
            if path != '' and not os.path.exists(path):
                os.makedirs(path)
            log_file = args.output
            if os.path.isdir(log_file):
                default_log = "%s_%s.log" % (node(),
                                             datetime.now().strftime("%d.%m.%y-%H%M%S"))
                log_file = os.path.join(log_file, default_log)
                print "Using default log filename: %s " % default_log
            if args.format == 'xml':
                _output_stream = open(log_file, 'w')
            else:
                _output_stream = open(log_file, 'w', encoding='utf-8')
        else:
            _output_stream = sys.stdout
    return _output_stream


class ConfigurableTestRunner(object):
    """A configurable test runner class.

    This class alows us to configure the output format and whether of not we
    collect coverage information for the test run.

    """

    def __init__(self, stdout, output_format):
        self.stdout = stdout
        self.result_class = output_format

    def run(self, test):
        "Run the given test case or test suite."
        result = self.result_class(self.stdout)
        result.startTestRun()
        try:
            return test.run(result)
        finally:
            result.stopTestRun()


def load_test_suite_from_name(test_names):
    """Returns a test suite object given a dotted test names."""
    patch_python_path()
    loader = TestLoader()
    if isinstance(test_names, basestring):
        test_names = list(test_names)
    elif not isinstance(test_names, list):
        raise TypeError("test_names must be either a string or list, not %r"
                        % (type(test_names)))

    tests = []
    test_package_locations = []
    for test_name in test_names:
        top_level_pkg = test_name.split('.')[0]
        package = __import__(top_level_pkg)
        package_parent_path = os.path.abspath(
            os.path.join(
                os.path.dirname(package.__file__),
                '..'
                )
            )
        if package_parent_path not in test_package_locations:
            test_package_locations.append(package_parent_path)
        tests.append(loader.discover(top_level_pkg, top_level_dir=package_parent_path))
    all_tests = TestSuite(tests)

    test_dirs = ", ".join(sorted(test_package_locations))
    print "Loading tests from: %s\n" % test_dirs
    sys.stdout.flush()

    requested_tests = {}
    for test in iterate_tests(all_tests):
        # The test loader returns tests that start with 'unittest.loader' if for
        # whatever reason the test failed to load. We run the tests without the
        # built-in exception catching turned on, so we can get at the raised
        # exception, which we print, so the user knows that something in their
        # tests is broken.
        if test.id().startswith('unittest.loader'):
            test_id = test._testMethodName
            try:
                test.debug()
            except Exception as e:
                print e
        else:
            test_id = test.id()
        if any([test_id.startswith(name) for name in test_names]):
            requested_tests[test_id] = test

    return TestSuite(requested_tests.values())


def patch_python_path():
    """Prepend the current directory to sys.path to ensure that we can load & run
    autopilot tests if the caller is in the parent directory.

    """
    if os.getcwd() not in sys.path:
        sys.path.insert(0, os.getcwd())


def launch_app(args):
    """Launch an application, with introspection support."""
    setup_logging(args.verbose)
    app_name = args.application[0]
    if not isabs(app_name) or not exists(app_name):
        try:
            app_name = subprocess.check_output(["which", app_name]).strip()
        except subprocess.CalledProcessError:
            print "Error: cannot find application '%s'" % (app_name)
            exit(1)

    # We now have a full path to the application.
    IntrospectionBase = None
    if args.interface == 'Auto':
        IntrospectionBase = get_application_introspection_base(app_name)
    elif args.interface == 'Gtk':
        IntrospectionBase = GtkIntrospectionTestMixin
    elif args.interface == 'Qt':
        IntrospectionBase = QtIntrospectionTestMixin
    if IntrospectionBase is None:
        print "Error: Could not determine introspection type to use for application '%s'." % app_name
        exit(1)

    # IntrospectionBase is supposed to be a mixin class with a TestCase, and makes
    # use of addCleanup to shut down the app after the test has completed. We
    # patch that function here so we don't error...
    def fake_cleanup(self, *args, **kwargs):
        pass
    IntrospectionBase.addCleanup = fake_cleanup

    b = IntrospectionBase()
    try:
        b.launch_test_application(app_name, capture_output=False)
    except RuntimeError as e:
        print "Error: " + e.message
        exit(1)


def get_application_introspection_base(app_path):
    """Return a base class that knows how to enable introspection for this app,
    or None.

    """
    # TODO: this is a teeny bit hacky - we call ldd to check whether this application
    # links to certain library. We're assuming that linking to libQt* or libGtk*
    # means the application is introspectable. This excludes any non-dynamically
    # linked executables, which we may need to fix further down the line.
    try:
        ldd_output = subprocess.check_output(["ldd", app_path]).strip().lower()
    except subprocess.CalledProcessError:
        print "Error: Cannot auto-detect introspection plugin to load."
        print "Use the '-i' argument to specify an interface."
        exit(1)
    if 'libqtcore' in ldd_output:
        return QtIntrospectionTestMixin
    elif 'libgtk' in ldd_output:
        return GtkIntrospectionTestMixin
    return None


def run_vis(args):
    setup_logging(args.verbose)
    # importing this requires that DISPLAY is set. Since we don't always want
    # that requirement, do the import here:
    from autopilot.vis import vis_main

    # XXX - in quantal, overlay scrollbars make this process consume 100% of
    # the CPU. It's a known bug:
    #
    # https://bugs.launchpad.net/ubuntu/quantal/+source/qt4-x11/+bug/1005677
    #
    # Once that's been fixed we can remove the following line:
    #
    putenv('LIBOVERLAY_SCROLLBAR', '0')
    vis_main()


def main():
    args = parse_arguments()
    if args.mode == 'list':
        list_tests(args)
    elif args.mode == 'run':
        run_tests(args)
    elif args.mode == 'vis':
        run_vis(args)
    elif args.mode == 'launch':
        launch_app(args)


if __name__ == "__main__":
    if not check_depends():
        exit(1)
    main()
