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

# Copyright (C) 1994-2021 Altair Engineering, Inc.
# For more information, contact Altair at www.altair.com.
#
# This file is part of both the OpenPBS software ("OpenPBS")
# and the PBS Professional ("PBS Pro") software.
#
# Open Source License Information:
#
# OpenPBS is free software. You can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# OpenPBS is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public
# License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# Commercial License Information:
#
# PBS Pro is commercially licensed software that shares a common core with
# the OpenPBS software.  For a copy of the commercial license terms and
# conditions, go to: (http://www.pbspro.com/agreement.html) or contact the
# Altair Legal Department.
#
# Altair's dual-license business model allows companies, individuals, and
# organizations to create proprietary derivative works of OpenPBS and
# distribute them - whether embedded or bundled with other software -
# under a commercial license agreement.
#
# Use of Altair's trademarks, including but not limited to "PBS™",
# "OpenPBS®", "PBS Professional®", and "PBS Pro™" and Altair's logos is
# subject to Altair's trademark licensing policies.


import sys
import os
import getopt
import logging
import logging.config
import platform
import errno
import signal
import importlib
import ptl
import nose
from nose.plugins.base import Plugin
from nose.plugins.manager import PluginManager
from ptl.lib.pbs_testlib import PtlConfig
from distutils.version import LooseVersion
from ptl.utils.pbs_cliutils import CliUtils
from ptl.utils.pbs_dshutils import TimeOut
from ptl.utils.plugins.ptl_test_loader import PTLTestLoader
from ptl.utils.plugins.ptl_test_runner import PTLTestRunner
from ptl.utils.plugins.ptl_test_db import PTLTestDb
from ptl.utils.plugins.ptl_test_info import PTLTestInfo
from ptl.utils.plugins.ptl_test_tags import PTLTestTags
from ptl.utils.plugins.ptl_test_data import PTLTestData


class PTLNoseConfig(nose.config.Config):
    def __init__(self, log_format=None, outfile_handler=None, **kw):
        super().__init__(**kw)
        self.outfile_handler = outfile_handler
        self.log_format = log_format

    def configureLogging(self):
        super().configureLogging()
        nose_logger = logging.getLogger('nose')
        if self.log_format:
            formatter = logging.Formatter(self.log_format)
            for handler in nose_logger.handlers:
                handler.setFormatter(formatter)
        if self.outfile_handler:
            nose_logger.addHandler(self.outfile_handler)


# trap SIGINT and SIGPIPE
def trap_exceptions(etype, value, tb):
    sys.excepthook = sys.__excepthook__
    if issubclass(etype, IOError) and value.errno == errno.EPIPE:
        pass
    else:
        sys.__excepthook__(etype, value, tb)


sys.excepthook = trap_exceptions


def sighandler(signum, frames):
    signal.alarm(0)
    raise KeyboardInterrupt('Signal %d received' % (signum))


def timeout_handler(signum, frames):
    raise TimeOut('pbs_benchpress timed out by signal %d' % (signum))


# join process group of caller makes it possible to programmatically interrupt
# when run in a subshell
if os.getpgrp() != os.getpid():
    os.setpgrp()
signal.signal(signal.SIGINT, sighandler)
signal.signal(signal.SIGTERM, sighandler)


def usage():
    msg = []
    msg += ['Usage: ' + os.path.basename(sys.argv[0]) + ' [OPTION]\n\n']
    msg += ['  Test harness used to run or list test suites and test ' +
            'cases\n\n']
    msg += ['-f <file names>: comma-separated list of file names to run\n']
    msg += ['-F: set logging format to include timestamp and level\n']
    msg += ['-g <testgroup file>: path to file containing comma-separated']
    msg += [' list of testsuites\n']
    msg += ['-h: display usage information\n']
    msg += ['-i: show test info\n']
    msg += ['-l <level>: log level\n']
    msg += ['-L: display list of tests\n']
    msg += ['-o <logfile>: log file name\n']
    msg += ['-p <param>: test parameter. Comma-separated list of key=val']
    msg += [' pairs. Note that the comma can not be used in val\n']
    msg += ['-t <test suites>: comma-separated list of test suites to run\n']
    msg += ['--exclude=<names>: comma-separated string of tests to exclude\n']
    msg += ['--user-plugins=<names>: comma-separated list of key=val of']
    msg += [' user plugins to load, where key is module and val is']
    msg += [' classname of plugin which is subclass of nose.plugins.base']
    msg += ['.Plugin\n']
    msg += ['--db-type=<type>: Type of database to use.']
    msg += [' can be one of "html", "file", "sqlite", "pgsql", "json".']
    msg += [' Default to "json"\n']
    msg += ['--db-name=<name>: database name. Default to']
    msg += [' ptl_test_results.json\n']
    msg += ['--db-access=<path>: Path to a file that defines db options '
            '(PostreSQL only)\n']
    msg += ['--lcov-bin=<bin>: path to lcov binary. Defaults to lcov\n']
    msg += ['--genhtml-bin=<bin>: path to genhtml binary. '
            'Defaults to genhtml\n']
    msg += ['--lcov-data=<dir>: path to directory containig .gcno files\n']
    msg += ['--lcov-out=<dir>: path to output directory\n']
    msg += ['--lcov-baseurl=<url>: use <url> as baseurl in html report\n']
    msg += ['--lcov-nosrc: don\'t include PBS source in coverage analysis.']
    msg += [' Default PBS source will be included in coverage analysis\n']
    msg += ['--log-conf=<file>: logging config file\n']
    msg += ['--min-pyver=<version>: minimum Python version\n']
    msg += ['--max-pyver=<version>: maximum Python version\n']
    msg += ['--param-file=<file>: get params from file. Overrides -p\n']
    msg += ['--post-analysis-data=<dir>: path to post analysis data' +
            ' directory\n']
    msg += ['--max-postdata-threshold=<count>: max post analysis data' +
            ' threshold per testsuite. Defaults to 10. <count>=0 will' +
            ' disable this threshold\n']
    msg += ['--tc-failure-threshold=<count>: test case failure threshold' +
            ' per testsuite. Defaults to 10. <count>=0 will disable this' +
            ' threshold\n']
    msg += ['--cumulative-tc-failure-threshold=<count>: cumulative test' +
            ' case failure threshold. Defaults to 100. <count>=0 will' +
            ' disable this threshold.\n                              ' +
            '             Must be greater or equal to' +
            ' \'tc-failure-threshold\'\n']
    msg += ['--stop-on-failure: if set, stop when one of multiple tests ' +
            'fails\n']
    msg += ['--timeout=<seconds>: duration after which no test suites are '
            'run\n']
    msg += ['--repeat-count=<count>: repeat given all tests <count> times\n']
    msg += ['--repeat-delay=<seconds>: delay between two repetition\n']
    msg += ['--follow-child: if set, walk the test hierarchy and run ' +
            'each test\n']
    msg += ['--tags=<tag>: Select only tests that have <tag> tag.']
    msg += [' can be applied multiple times\n']
    msg += ['               Format: [!]tag[,tag]\n']
    msg += ['               Example:\n']
    msg += ['                 smoke - This will select all tests which has']
    msg += [' "smoke" tag\n']
    msg += ['                 !smoke - This will select all tests which']
    msg += [' doesn\'t have "smoke" tag\n']
    msg += ['                 smoke,regression - This will select all tests']
    msg += [' which has both "smoke" and "regression" tag\n']
    msg += ['--eval-tags=\'<Python expression>\': Select only tests for whose']
    msg += [' tags evaluates <Python expression> to True.']
    msg += [' can be applied multiple times\n']
    msg += ['            Example:\n']
    msg += ['               \'smoke and (not regression)\' - This will select']
    msg += [' all tests which has "smoke" tag and does\'t have "regression"']
    msg += [' tag\n']
    msg += ['               \'priority>4\' - This will select all tests which']
    msg += [' has "priority" tag and its value is >4\n']
    msg += ['--tags-info: List all selected test suite (or test cases if ']
    msg += ['--verbose applied)\n']
    msg += [
        '            used with --tags or --eval-tags (also -t or --exclude']
    msg += [' can be applied to limit selection)\n']
    msg += ['--list-tags: List all currenlty used tags\n']
    msg += [
        '--verbose: show verbose output (used with -i, -L or --tag-info)\n']
    msg += ['--use-current-setup: Runs test on current PBS setup\n']
    msg += ['--version: show version number and exit\n']

    print(''.join(msg))


if __name__ == '__main__':

    if len(sys.argv) < 2:
        usage()
        sys.exit(1)

    level = 'INFOCLI2'
    fmt = '%(asctime)-15s %(levelname)-8s %(message)s'
    outfile = None
    dbtype = None
    dbname = None
    use_cur_setup = False
    dbaccess = None
    testfiles = None
    testsuites = None
    testparam = None
    testgroup = None
    list_test = False
    showinfo = False
    follow = False
    excludes = None
    stoponfail = False
    paramfile = None
    logconf = None
    minpyver = None
    maxpyver = None
    verbose = False
    lcov_data = None
    lcov_bin = None
    lcov_out = None
    lcov_nosrc = False
    lcov_baseurl = None
    genhtml_bin = None
    timeout = None
    repeat_count = 1
    repeat_delay = 0
    nosedebug = False
    only_info = False
    tags = []
    eval_tags = []
    tags_info = False
    list_tags = False
    post_data_dir = None
    gen_ts_tree = False
    tc_failure_threshold = 10
    cumulative_tc_failure_threshold = 100
    max_postdata_threshold = 10
    user_plugins = None
    PtlConfig()

    largs = ['exclude=', 'log-conf=', 'timeout=', 'repeat-count=']
    largs += ['param-file=', 'min-pyver=', 'max-pyver=']
    largs += ['db-name=', 'db-access=', 'db-type=', 'genhtml-bin=']
    largs += ['lcov-bin=', 'lcov-data=', 'lcov-out=', 'lcov-nosrc']
    largs += ['lcov-baseurl=', 'tags=', 'eval-tags=', 'tags-info', 'list-tags']
    largs += ['version', 'verbose', 'follow-child']
    largs += ['stop-on-failure', 'enable-nose-debug']
    largs += ['post-analysis-data=', 'gen-ts-tree', 'repeat-delay=']
    largs += ['tc-failure-threshold=', 'cumulative-tc-failure-threshold=']
    largs += ['max-postdata-threshold=', 'user-plugins=', 'use-current-setup']

    try:
        opts, args = getopt.getopt(
            sys.argv[1:], 'f:il:t:o:p:g:hLF', largs)
    except Exception:
        sys.stderr.write('Unrecognized option. Exiting\n')
        usage()
        sys.exit(1)

    if args:
        sys.stderr.write('Invalid usage. Exiting\n')
        usage()
        sys.exit(1)

    for o, val in opts:
        if o == '-i':
            showinfo = True
            list_test = False
            gen_ts_tree = False
            only_info = True
        elif o == '-L':
            showinfo = False
            list_test = True
            gen_ts_tree = False
            only_info = True
        elif o == '--gen-ts-tree':
            showinfo = False
            list_test = False
            gen_ts_tree = True
            only_info = True
        elif o == '-l':
            level = val
        elif o == '-o':
            outfile = CliUtils.expand_abs_path(val)
        elif o == '-f':
            testfiles = CliUtils.expand_abs_path(val)
        elif o == '-t':
            testsuites = val
        elif o == '--user-plugins':
            user_plugins = val
        elif o == '--tags':
            tags.append(val.strip())
        elif o == '--eval-tags':
            eval_tags.append(val.strip())
        elif o == '--tags-info':
            tags_info = True
        elif o == '--list-tags':
            list_tags = True
        elif o == '--exclude':
            excludes = val
        elif o == '-F':
            fmt = '%(asctime)-15s %(levelname)-8s %(name)s %(message)s'
        elif o == '-p':
            testparam = val
        elif o == '-g':
            testgroup = val
        elif o == '--timeout':
            timeout = int(val)
        elif o == '--repeat-count':
            repeat_count = int(val)
        elif o == '--repeat-delay':
            repeat_delay = int(val)
        elif o == '--db-type':
            dbtype = val
        elif o == '--db-name':
            dbname = CliUtils.expand_abs_path(val)
        elif o == '--db-access':
            dbaccess = CliUtils.expand_abs_path(val)
        elif o == '--genhtml-bin':
            genhtml_bin = CliUtils.expand_abs_path(val)
        elif o == '--lcov-bin':
            lcov_bin = CliUtils.expand_abs_path(val)
        elif o == '--lcov-data':
            lcov_data = CliUtils.expand_abs_path(val)
        elif o == '--lcov-out':
            lcov_out = CliUtils.expand_abs_path(val)
        elif o == '--lcov-nosrc':
            lcov_nosrc = True
        elif o == '--lcov-baseurl':
            lcov_baseurl = val
        elif o == '--param-file':
            paramfile = CliUtils.expand_abs_path(val)
        elif o == '--stop-on-failure':
            stoponfail = True
        elif o == '--follow-child':
            follow = True
        elif o == '--log-conf':
            logconf = val
        elif o == '--min-pyver':
            minpyver = val
        elif o == '--max-pyver':
            maxpyver = val
        elif o == '--enable-nose-debug':
            nosedebug = True
        elif o == '--verbose':
            verbose = True
        elif o == '--post-analysis-data':
            post_data_dir = CliUtils.expand_abs_path(val)
        elif o == '--tc-failure-threshold':
            tc_failure_threshold = val
        elif o == '--cumulative-tc-failure-threshold':
            cumulative_tc_failure_threshold = val
        elif o == '--max-postdata-threshold':
            max_postdata_threshold = val
        elif o == '-h':
            usage()
            sys.exit(0)
        elif o == '--use-current-setup':
            use_cur_setup = True
        elif o == '--version':
            print(ptl.__version__)
            sys.exit(0)
        else:
            sys.stderr.write('Unreocgnized option %s\n' % o)
            usage()
            sys.exit(1)

    if nosedebug:
        level = 'DEBUG'

    log_lvl = CliUtils.get_logging_level(level)
    if logconf:
        logging.config.fileConfig(logconf)
    else:
        logging.basicConfig(level=log_lvl, format=fmt)

    if not log_lvl and level:
        logging.error('Invalid log level:%s', level)
        sys.exit(1)
    if outfile:
        outfile_hdlr = logging.FileHandler(outfile)
        outfile_hdlr.setLevel(log_lvl)
        outfile_hdlr.setFormatter(logging.Formatter(fmt))
        ptl_logger = logging.getLogger('ptl')
        ptl_logger.addHandler(outfile_hdlr)
        ptl_logger.setLevel(log_lvl)
    else:
        outfile_hdlr = None

    pyver = platform.python_version()
    if minpyver is not None and LooseVersion(pyver) < LooseVersion(minpyver):
        logging.error('Python version ' + str(pyver) + ' does not meet ' +
                      'required minimum version of ' + minpyver)
        sys.exit(1)
    if maxpyver is not None and LooseVersion(pyver) > LooseVersion(maxpyver):
        logging.error('Python version ' + str(pyver) + ' does not meet ' +
                      'required max version of ' + maxpyver)
        sys.exit(1)

    if showinfo and testsuites is None:
        logging.error(
            'Testsuites names require (see -t) along with -i option!')
        sys.exit(1)

    try:
        tc_failure_threshold = int(tc_failure_threshold)
        if tc_failure_threshold < 0:
            raise ValueError
    except ValueError:
        _msg = 'Invalid value provided for testcase failure threshold, '
        _msg += 'please provide integer'
        logging.error(_msg)
        sys.exit(1)

    try:
        cumulative_tc_failure_threshold = int(cumulative_tc_failure_threshold)
        if cumulative_tc_failure_threshold < 0:
            raise ValueError
    except ValueError:
        _msg = 'Invalid value provided for cumulative-tc-failure-threshold, '
        _msg += 'please provide integer'
        logging.error(_msg)
        sys.exit(1)

    if cumulative_tc_failure_threshold < tc_failure_threshold:
        _msg = 'Value for cumulative-tc-failure-threshould should'
        _msg += ' be greater or equal to \'tc-failure-threshold\''
        logging.error(_msg)
        sys.exit(1)

    try:
        max_postdata_threshold = int(max_postdata_threshold)
        if max_postdata_threshold < 0:
            raise ValueError
    except ValueError:
        _msg = 'Invalid value provided for max-postdata-threshold, '
        _msg += 'please provide integer'
        logging.error(_msg)
        sys.exit(1)

    if outfile is not None and not os.path.isdir(os.path.dirname(outfile)):
        os.mkdir(os.path.dirname(outfile))

    if timeout is not None:
        PTLTestRunner.timeout = timeout
        signal.signal(signal.SIGALRM, timeout_handler)
        signal.alarm(timeout)

    if list_test:
        excludes = None
        testgroup = None
        follow = True
    if testfiles is not None:
        tests = testfiles.split(',')
    else:
        tests = os.getcwd()

    if testsuites is None:
        testsuites = 'PBSTestSuite'
        follow = True

    loader = PTLTestLoader()

    if only_info:
        testinfo = PTLTestInfo()
        loader.set_data(testgroup, testsuites, excludes, True,
                        testfiles)
        testinfo.set_data(testsuites, list_test,
                          showinfo, verbose, gen_ts_tree)
        plugins = (loader, testinfo)
    elif (tags_info or list_tags):
        testtags = PTLTestTags()
        loader.set_data(testgroup, testsuites, excludes, True)
        testtags.set_data(tags, eval_tags, tags_info, list_tags, verbose)
        plugins = (loader, testtags)
    else:
        testtags = PTLTestTags()
        runner = PTLTestRunner()
        db = PTLTestDb()
        data = PTLTestData()
        loader.set_data(testgroup, testsuites, excludes, follow, testfiles)
        testtags.set_data(tags, eval_tags)
        runner.set_data(paramfile, testparam, repeat_count,
                        repeat_delay, lcov_bin, lcov_data,
                        lcov_out, genhtml_bin, lcov_nosrc,
                        lcov_baseurl, tc_failure_threshold,
                        cumulative_tc_failure_threshold, use_cur_setup)
        db.set_data(dbtype, dbname, dbaccess)
        data.set_data(post_data_dir, max_postdata_threshold)
        plugins = (loader, testtags, runner, db, data)
    if user_plugins:
        for plugin in user_plugins.split(','):
            if '=' not in plugin:
                _msg = 'Invalid value (%s)' % (plugin)
                _msg += ' provided in user-plugins, it should be key value'
                _msg += ' pair where key is module name and value is class'
                _msg += ' name of plugin'
                logging.error(_msg)
                sys.exit(1)
            mod, clsname = plugin.split('=', 1)
            try:
                loaded_mod = importlib.import_module(mod)
            except ImportError:
                _msg = 'Failed to load module (%s)' % mod
                _msg += ' for plugin (%s)' % plugin
                logging.error(_msg)
                sys.exit(1)
            _plugin = getattr(loaded_mod, clsname, None)
            if not _plugin:
                _msg = 'Could not find class named "%s"' % clsname
                _msg += ' in module (%s)' % mod
                logging.error(_msg)
                sys.exit(1)
            if not issubclass(_plugin, Plugin):
                _msg = 'Plugin class (%s) should be subclass of ' % (clsname)
                _msg += 'nose.plugins.base.Plugin'
                logging.error(_msg)
                sys.exit(1)
            plugins += (_plugin(),)
    test_regex = r'(^(?:[\w]+|^)Test|pbs_|^test_[\(]*)'
    os.environ['NOSE_TESTMATCH'] = test_regex
    nose_config = PTLNoseConfig(
        env=os.environ,
        log_format=fmt,
        outfile_handler=outfile_hdlr,
        plugins=PluginManager(plugins=plugins),
        verbosity=7 if nosedebug else 2,
        stopOnError=stoponfail)
    nose.main(defaultTest=tests, argv=[sys.argv[0]], config=nose_config)
    if outfile_hdlr:
        outfile_hdlr.close()
