# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
from __future__ import annotations

"""
testrunner
==========

Provide an interface to the pyside tests.
-----------------------------------------

This program can only be run if PySide was built with tests enabled.
All tests are run in a single pass, and if not blacklisted, an error
is raised at the end of the run.

Recommended build process:
There is no need to install the project.
Building the project with something like

    python setup.py build --build-tests --qmake=<qmakepath> --ignore-git --debug

is sufficient. The tests are run by changing into the latest build dir and there
into pyside6, then 'make test'.


New testing policy:
-------------------

The tests are now run 5 times, and errors are reported
when they appear at least 3 times. With the variable COIN_RERUN_FAILED_ONLY it is
possible to configure if all tests should be rerun or the failed ones, only.

The full mode can be tested locally by setting

    export COIN_RERUN_FAILED_ONLY=0
"""

import argparse
import os
import sys
from collections import OrderedDict
from pathlib import Path
from textwrap import dedent
from timeit import default_timer as timer

from .blacklist import BlackList
from .buildlog import builds
from .helper import decorate, script_dir
from .parser import TestParser
from .runner import TestRunner

# Should we repeat only failed tests?
COIN_RERUN_FAILED_ONLY = True
COIN_THRESHOLD = 3  # report error if >=
COIN_TESTING = 5  # number of runs
TIMEOUT = 20 * 60

if os.environ.get("COIN_RERUN_FAILED_ONLY", "1").lower() in "0 f false n no".split():
    COIN_RERUN_FAILED_ONLY = False


def test_project(project, args, blacklist, runs):
    ret = []

    if "pypy" in builds.classifiers:
        # As long as PyPy has so many bugs, we use 1 test only...
        global COIN_TESTING
        COIN_TESTING = runs = 1
        # ...and extend the timeout.
        global TIMEOUT
        TIMEOUT = 100 * 60

    # remove files from a former run
    for idx in range(runs):
        index = idx + 1
        runner = TestRunner(builds.selected, project, index)
        if os.path.exists(runner.logfile) and not args.skip:
            os.unlink(runner.logfile)
    # now start the real run
    rerun_list = None
    for idx in range(runs):
        index = idx + 1
        runner = TestRunner(builds.selected, project, index)
        # For the full Python version we need to ask the TestRunner.
        builds.set_python_version(runner.get_python_version())
        print()
        print(f"********* Start testing of {project} *********")
        print("Config: Using", " ".join(builds.classifiers))
        print()
        if os.path.exists(runner.logfile) and args.skip:
            print("Parsing existing log file:", runner.logfile)
        else:
            if index > 1 and COIN_RERUN_FAILED_ONLY:
                rerun = rerun_list
                if not rerun:
                    print(f"--- no re-runs found, stopping before test {index} ---")
                    break
            else:
                rerun = None
            runner.run(f"RUN {idx + 1}:", rerun, TIMEOUT)
        results = TestParser(runner.logfile)
        r = 5 * [0]
        rerun_list = []
        print()
        fatal = False
        for item in results.iter_blacklist(blacklist):
            res = item.rich_result
            sharp = f"#{item.sharp}"
            mod_name = decorate(item.mod_name)
            print(f"RES {index}: Test {sharp:>4}: {res:<6} {mod_name}()")
            r[0] += 1 if res == "PASS" else 0
            r[1] += 1 if res == "FAIL!" else 0
            r[2] += 1 if res == "SKIPPED" else 0  # not yet supported
            r[3] += 1 if res == "BFAIL" else 0
            r[4] += 1 if res == "BPASS" else 0
            if res not in ("PASS", "BPASS"):
                rerun_list.append(item.mod_name)
            # PYSIDE-1229: When a fatal error happens, bail out immediately!
            if item.fatal:
                fatal = item

        print("\n  #### Top 20 slow tests:")
        for item in results.get_slowest_tests(20):
            print(f"    {item.mod_name:<50} {item.time:6}s")

        print(
            f"\nTotals: {sum(r)} tests. "
            f"{r[0]} passed, {r[1]} failed, {r[2]} skipped, {r[3]} blacklisted, {r[4]} bpassed."
        )
        print()
        print(f"********* Finished testing of {project} *********")
        print()
        ret.append(r)
        if fatal:
            print("FATAL ERROR:", fatal)
            print("Repetitions cancelled!")
            break
    return ret, fatal, runs


def main():
    global COIN_THRESHOLD
    # create the top-level command parser
    start_time = timer()
    all_projects = "shiboken6 pyside6".split()
    tested_projects = "shiboken6 pyside6".split()
    tested_projects_quoted = " ".join("'i'" for i in tested_projects)
    runs = COIN_TESTING
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description=dedent(
            f"""\
        Run the tests for some projects, default = {tested_projects_quoted}.

        Testing is now repeated up to {COIN_TESTING} times, and errors are
        only reported if they occur {COIN_THRESHOLD} or more times.
        The environment variable COIN_RERUN_FAILED_ONLY controls if errors
        are only repeated if there are errors. The default is "1".
        """
        ),
    )
    subparsers = parser.add_subparsers(dest="subparser_name")

    # create the parser for the "test" command
    parser_test = subparsers.add_parser("test")
    group = parser_test.add_mutually_exclusive_group(required=False)
    blacklist_default = os.path.join(script_dir, "build_history", "blacklist.txt")
    group.add_argument(
        "--blacklist",
        "-b",
        type=str,
        default=blacklist_default,
        help=f"a Qt blacklist file (default: {blacklist_default})",
    )
    parser_test.add_argument(
        "--skip", action="store_true", help="skip the tests if they were run before"
    )
    parser_test.add_argument(
        "--environ", nargs="+", help="use name=value ... to set environment variables"
    )
    parser_test.add_argument(
        "--buildno",
        default=-1,
        type=int,
        help="use build number n (0-based), latest = -1 (default)",
    )
    parser_test.add_argument("--reruns", "-r", default=COIN_TESTING, type=int,
                             help=f"Number of re-runs (defaults to {COIN_TESTING})")
    parser_test.add_argument(
        "--projects",
        nargs="+",
        type=str,
        default=tested_projects,
        choices=all_projects,
        help=f"use {tested_projects_quoted} (default) or other projects",
    )
    parser_getcwd = subparsers.add_parser("getcwd")
    parser_getcwd.add_argument(
        "filename", type=str, help="write the build dir name into a file"
    )
    parser_getcwd.add_argument(
        "--buildno",
        default=-1,
        type=int,
        help="use build number n (0-based), latest = -1 (default)",
    )
    parser_list = subparsers.add_parser("list")
    args = parser.parse_args()

    if hasattr(args, "buildno"):
        try:
            builds.set_buildno(args.buildno)
        except IndexError:
            print(f"history out of range. Try '{__file__} list'")
            sys.exit(1)

    if args.subparser_name == "getcwd":
        Path(args.filename).write_text(builds.selected.build_dir + '\n')
        print(builds.selected.build_dir, "written to file", args.filename)
        sys.exit(0)
    elif args.subparser_name == "test":
        runs = args.reruns
        if runs < COIN_TESTING:
            COIN_THRESHOLD = 1
    elif args.subparser_name == "list":
        rp = os.path.relpath
        print()
        print("History")
        print("-------")
        for idx, build in enumerate(builds.history):
            print(idx, rp(build.log_dir), rp(build.build_dir))
        print()
        print("Note: only the last history entry of a folder is valid!")
        sys.exit(0)
    else:
        parser.print_help()
        sys.exit(1)

    if args.blacklist:
        bl = BlackList(args.blacklist)
    else:
        bl = BlackList(None)
    if args.environ:
        for line in args.environ:
            things = line.split("=")
            if len(things) != 2:
                raise ValueError("you need to pass one or more name=value pairs.")
            key, value = things
            os.environ[key] = value

    version = sys.version.replace("\n", " ")
    print(
        dedent(
            f"""\
        System:
          Platform={sys.platform}
          Executable={sys.executable}
          Version={version}
          API version={sys.api_version}

        Environment:"""
        )
    )
    for key, value in sorted(os.environ.items()):
        print(f"  {key}={value}")
    print()

    q = 5 * [0]

    fail_crit = COIN_THRESHOLD
    # now loop over the projects and accumulate
    fatal = False
    for project in args.projects:
        res, fatal, runs = test_project(project, args, bl, runs)
        if fatal:
            runs = 1
        for idx, r in enumerate(res):
            q = list(map(lambda x, y: x + y, r, q))

    if len(args.projects) > 1:
        print(
            f"All above projects: {sum(q)} tests. "
            f"{q[0]} passed, {q[1]} failed, {q[2]} skipped, {q[3]} blacklisted, {q[4]} bpassed."
        )
        print()

    tot_res = OrderedDict()
    for project in args.projects:
        for idx in range(runs):
            index = idx + 1
            runner = TestRunner(builds.selected, project, index)
            results = TestParser(runner.logfile)
            for item in results.iter_blacklist(bl):
                key = f"{project}:{item.mod_name}"
                tot_res.setdefault(key, [])
                tot_res[key].append(item.rich_result)
    tot_flaky = 0
    print("*" * 79)
    print("**")
    print("*   Summary Of All Tests")
    print("*")
    empty = True
    for test, res in tot_res.items():
        pass__c = res.count("PASS")
        bpass_c = res.count("BPASS")
        fail__c = res.count("FAIL!")
        bfail_c = res.count("BFAIL")
        fail2_c = fail__c + bfail_c
        fatal_c = res.count("FATAL")
        if pass__c == len(res):
            continue
        elif bpass_c >= runs and runs > 1:
            msg = "Remove blacklisting; test passes"
        elif fail__c >= runs:
            msg = "Newly detected Real test failure!"
        elif bfail_c >= runs:
            msg = "Keep blacklisting ;-("
        elif fail2_c > 0 and fail2_c < len(res):
            msg = "Flaky test"
            tot_flaky += 1
        elif fatal_c:
            msg = "FATAL format error, repetitions aborted!"
        else:
            continue
        empty = False
        padding = 6 * runs
        txt = " ".join((f"{piece:<5}" for piece in res))
        txt = (f"{txt}{padding * ' '}")[:padding]
        testpad = 36
        if len(test) < testpad:
            test += (testpad - len(test)) * " "
        print(txt, decorate(test), msg)
    if empty:
        print("*   (empty)")
    print("*")
    print("**")
    print("*" * 79)
    print()
    if runs > 1:
        print(f"Total flaky tests: errors but not always = {tot_flaky}")
        print()
    else:
        print("For info about flaky tests, we need to perform more than one run.")
        print("Please activate the COIN mode:    'export QTEST_ENVIRONMENT=ci'")
        print()
    # nag us about unsupported projects
    ap, tp = set(all_projects), set(tested_projects)
    if ap != tp:
        print("+++++ Note: please support", " ".join(ap - tp), "+++++")
        print()

    stop_time = timer()
    used_time = stop_time - start_time
    # Now create an error if the criterion is met:
    try:
        if fatal:
            raise ValueError(f"FATAL format error: {fatal}")
        err_crit = f"'FAIL! >= {fail_crit}'"
        fail_count = 0
        for res in tot_res.values():
            if res.count("FAIL!") >= fail_crit:
                fail_count += 1
        if fail_count == 1:
            raise ValueError(f"A test was not blacklisted and met the criterion {err_crit}")
        elif fail_count > 1:
            raise ValueError(
                f"{fail_count} failures were not blacklisted " f"and met the criterion {err_crit}"
            )
        print(f"No test met the error criterion {err_crit}")
    finally:
        print()
        print(f"Total time of whole Python script = {used_time:0.2f} sec")
        print()


# eof
