#! /usr/bin/env python3
"""Brute-force test script: test libpqxx against many compilers etc.

This script makes no changes in the source tree; all builds happen in
temporary directories.

To make this possible, you may need to run "make distclean" in the
source tree.  The configure script will refuse to configure otherwise.
"""

# Without this, pocketlint does not yet understand the print function.
from __future__ import print_function

from abc import (
    ABCMeta,
    abstractmethod,
    )
from argparse import ArgumentParser
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from functools import partial
import json
from multiprocessing import (
    JoinableQueue,
    Process,
    Queue,
    )
from multiprocessing.pool import (
    Pool,
    )
from os import (
    cpu_count,
    getcwd,
    )
import os.path
from queue import Empty
from shutil import rmtree
from subprocess import (
    CalledProcessError,
    check_call,
    check_output,
    DEVNULL,
    )
import sys
from tempfile import mkdtemp
from textwrap import dedent


CPUS = cpu_count()

GCC_VERSIONS = list(range(8, 14))
GCC = [f'g++-{ver}' for ver in GCC_VERSIONS]
CLANG_VERSIONS = list(range(7, 15))
CLANG = [f'clang++-{ver}' for ver in CLANG_VERSIONS]
CXX = GCC + CLANG

DIALECTS = ['17', '20', '2b']

STDLIB = (
    '',
    '-stdlib=libc++',
    )

OPT = ('-O0', '-O3')

LINK = {
    'static': ['--enable-static', '--disable-shared'],
    'dynamic': ['--disable-static', '--enable-shared'],
}

DEBUG = {
    'plain': [],
    'audit': ['--enable-audit'],
    'maintainer': ['--enable-maintainer-mode'],
    'full': ['--enable-audit', '--enable-maintainer-mode'],
}


# CMake "generators."  Maps a value for cmake's -G option to a command line to
# run.
#
# I prefer Ninja if available, because it's fast.  But hey, the default will
# work.
#
# Maps the name of the generator (as used with cmake's -G option) to the
# actual command line needed to do the build.
CMAKE_GENERATORS = {
    'Ninja': ['ninja'],
    'Unix Makefiles': ['make', f'-j{CPUS}'],
}


class Fail(Exception):
    """A known, well-handled exception.  Doesn't need a traceback."""


class Skip(Exception):
    """"We're not doing this build.  It's not an error though."""


def run(cmd, output, cwd=None):
    """Run a command, write output to file-like object."""
    command_line = ' '.join(cmd)
    output.write(f"{command_line}\n\n")
    check_call(cmd, stdout=output, stderr=output, cwd=cwd)


def report(output, message):
    """Report a message to output, and standard output."""
    print(message, flush=True)
    output.write('\n\n')
    output.write(message)
    output.write('\n')


def file_contains(path, text):
    """Does the file at path contain text?"""
    with open(path) as stream:
        for line in stream:
            if text in line:
                return True
    return False


@contextmanager
def tmp_dir():
    """Create a temporary directory, and clean it up again."""
    tmp = mkdtemp()
    try:
        yield tmp
    finally:
        rmtree(tmp)


def write_check_code(work_dir):
    """Write a simple C++ program so we can tesst whether we can compile it.

    Returns the file's full path.
    """
    path = os.path.join(work_dir, "check.cxx")
    with open(path, 'w') as source:
        source.write(dedent("""\
            #include <iostream>
            int main()
            {
                std::cout << "Hello world." << std::endl;
            }
            """))

    return path


def check_compiler(work_dir, cxx, stdlib, check, verbose=False):
    """Is the given compiler combo available?"""
    err_file = os.path.join(work_dir, 'stderr.log')
    if verbose:
        err_output = open(err_file, 'w')
    else:
        err_output = DEVNULL
    try:
        command = [cxx, check]
        if stdlib != '':
            command.append(stdlib)
        check_call(command, cwd=work_dir, stderr=err_output)
    except (OSError, CalledProcessError):
        if verbose:
            with open(err_file) as errors:
                sys.stdout.write(errors.read())
            print(f"Can't build with '{cxx} {stdlib}'.  Skipping.")
        return False
    else:
        return True


# TODO: Use Pool.
def check_compilers(compilers, stdlibs, verbose=False):
    """Check which compiler configurations are viable."""
    with tmp_dir() as work_dir:
        check = write_check_code(work_dir)
        return [
            (cxx, stdlib)
            for stdlib in stdlibs
            for cxx in compilers
            if check_compiler(
                work_dir, cxx, stdlib, check=check, verbose=verbose)
        ]


def find_cmake_command():
    """Figure out a CMake generator we can use, or None."""
    try:
        caps = check_output(['cmake', '-E', 'capabilities'])
    except FileNotFoundError:
        return None

    names = {generator['name'] for generator in json.loads(caps)['generators']}
    for gen in CMAKE_GENERATORS:
        if gen in names:
            return gen
    return None


class Config(metaclass=ABCMeta):
    """Configuration for a build.

    These classes must be suitable for pickling, so we can send its objects to
    worker processes.
    """
    @abstractmethod
    def name(self):
        """Return an identifier for this build configuration."""

    def make_log_name(self):
        """Compose log file name for this build."""
        return f"build-{self.name()}.out"


class Build:
    """A pending or ondoing build, in its own directory.

    Each step returns True for Success, or False for failure.

    These classes must be suitable for pickling, so we can send its objects to
    worker processes.
    """
    def __init__(self, logs_dir, config=None):
        self.config = config
        self.log = os.path.join(logs_dir, config.make_log_name())
        # Start a fresh log file.
        with open(self.log, 'w') as log:
            log.write(f"Starting {datetime.utcnow()}.\n")
        self.work_dir = mkdtemp()

    def clean_up(self):
        """Delete the build tree."""
        rmtree(self.work_dir)

    @abstractmethod
    def configure(self, log):
        """Prepare for a build."""

    @abstractmethod
    def build(self, log):
        """Build the code, including the tests.  Don't run tests though."""

    def test(self, log):
        """Run tests."""
        run(
            [os.path.join(os.path.curdir, 'test', 'runner')], log,
            cwd=self.work_dir)

    def logging(self, function):
        """Call function, pass open write handle for `self.log`."""
# TODO: Should probably be a decorator.
        with open(self.log, 'a') as log:
            try:
                function(log)
            except Exception as error:
                log.write(f"{error}\n")
                raise

    def do_configure(self):
        """Call `configure`, writing output to `self.log`."""
        self.logging(self.configure)

    def do_build(self):
        """Call `build`, writing output to `self.log`."""
        self.logging(self.build)

    def do_test(self):
        """Call `test`, writing output to `self.log`."""
        self.logging(self.test)


@dataclass
class AutotoolsConfig(Config):
    """A combination of build options for the "configure" script."""
    cxx: str
    opt: str
    stdlib: str
    link: str
    link_opts: str
    debug: str
    debug_opts: str
    dialect: str

    def name(self):
        return '_'.join([
            self.cxx,
            self.dialect,
            self.opt,
            self.stdlib,
            self.link,
            self.debug,
        ])


class AutotoolsBuild(Build):
    """Build using the "configure" script."""
    def configure(self, log):
        configure = [
            os.path.join(getcwd(), "configure"),
            f"CXX={self.config.cxx}",
            ]

        dialect_option = f'-std=c++{self.config.dialect}'

        cxx_flags = ' '.join(filter(
            None, [dialect_option, self.config.opt, self.config.stdlib]))

        configure += [f"CXXFLAGS={cxx_flags}"]
        if self.config.stdlib != "":
            configure += [f"LDFLAGS={self.config.stdlib}"]

        configure += [
            "--disable-documentation",
            ] + self.config.link_opts + self.config.debug_opts

        run(configure, log, cwd=self.work_dir)

    def build(self, log):
        run(['make', f'-j{CPUS}'], log, cwd=self.work_dir)
        # Passing "TESTS=" like this will suppress the actual running of
        # the tests.  We run them in the "test" stage.
        run(['make', f'-j{CPUS}', 'check', 'TESTS='], log, cwd=self.work_dir)


class CMakeConfig(Config):
    """Configuration for a CMake build."""
    def __init__(self, generator, dialect):
        self.generator = generator
        self.builder = CMAKE_GENERATORS[generator]
        self.dialect = dialect

    def name(self):
        return "cmake"


class CMakeBuild(Build, metaclass=ABCMeta):
    """Build using CMake.

    Ignores the config for now.
    """
    def configure(self, log):
        source_dir = getcwd()
        generator = self.config.generator
        dialect_flag = f'-DCMAKE_CXX_VERSION={self.config.dialect}'
        run(
            ['cmake', '-G', generator, source_dir, dialect_flag], output=log,
            cwd=self.work_dir)

    def build(self, log):
        run(self.config.builder, log, cwd=self.work_dir)


def parse_args():
    """Parse command-line arguments."""
    parser = ArgumentParser(description=__doc__)
    parser.add_argument('--verbose', '-v', action='store_true')
    parser.add_argument(
        '--compilers', '-c', default=','.join(CXX),
        help="Compilers, separated by commas.  Default is %(default)s.")
    parser.add_argument(
        '--dialects', '-d', default=','.join(DIALECTS),
        help=(
            "C++ dialects, separated by commas.  "
            f"Default is {','.join(DIALECTS)}."))
    parser.add_argument(
        '--optimize', '-O', default=','.join(OPT),
        help=(
            "Alternative optimisation options, separated by commas.  "
            "Default is %(default)s."))
    parser.add_argument(
        '--stdlibs', '-L', default=','.join(STDLIB),
        help=(
            "Comma-separated options for choosing standard library.  "
            "Defaults to %(default)s."))
    parser.add_argument(
        '--logs', '-l', default='.', metavar='DIRECTORY',
        help="Write build logs to DIRECTORY.")
    parser.add_argument(
        '--jobs', '-j', default=CPUS, metavar='CPUS',
        help=(
            "When running 'make', run up to CPUS concurrent processes.  "
            "Defaults to %(default)s."))
    parser.add_argument(
        '--minimal', '-m', action='store_true',
        help="Make it as short a run as possible.  For testing this script.")
    return parser.parse_args()


def soft_get(queue, block=True):
    """Get an item off `queue`, or `None` if the queue is empty."""
    try:
        return queue.get(block)
    except Empty:
        return None


def read_queue(queue, block=True):
    """Read entries off `queue`, terminating when it gets a `None`.

    Also terminates when the queue is empty.
    """
    entry = soft_get(queue, block)
    while entry is not None:
        yield entry
        entry = soft_get(queue, block)


def service_builds(in_queue, fail_queue, out_queue):
    """Worker process for "build" stage: process one job at a time.

    Sends successful builds to `out_queue`, and failed builds to `fail_queue`.

    Terminates when it receives a `None`, at which point it will send a `None`
    into `out_queue` in turn.
    """
    for build in read_queue(in_queue):
        try:
            build.do_build()
        # (Ignore warning about "too broad" an except.)
        except Exception as error:  # pylint: disable=W0703
            fail_queue.put((build, f"{error}"))
        else:
            out_queue.put(build)
        in_queue.task_done()

    # Mark the end of the queue.
    out_queue.put(None)


def service_tests(in_queue, fail_queue, out_queue):
    """Worker process for "test" stage: test one build at a time.

    Sends successful builds to `out_queue`, and failed builds to `fail_queue`.

    Terminates when it receives a final `None`.  Does not send out a final
    `None` of its own.
    """
    for build in read_queue(in_queue):
        try:
            build.do_test()
        # (Ignore warning about "too broad" an except.)
        except Exception as error:  # pylint: disable=W0703
            fail_queue.put((build, f"{error}"))
        else:
            out_queue.put(build)
        in_queue.task_done()


def report_failures(queue, message):
    """Report failures from a failure queue.  Return total number."""
    failures = 0
    for build, error in read_queue(queue, block=False):
        print(f"{message}: {build.config.name()} - {error}")
        failures += 1
    return failures


def count_entries(queue):
    """Get and discard all entries from `queue`, return the total count."""
    total = 0
    for _ in read_queue(queue, block=False):
        total += 1
    return total


def gather_builds(args):
    """Produce the list of builds we want to perform."""
    if args.verbose:
        print("\nChecking available compilers.")

    compiler_candidates = args.compilers.split(',')
    compilers = check_compilers(
        compiler_candidates, args.stdlibs.split(','),
        verbose=args.verbose)
    if list(compilers) == []:
        raise Fail(
            "Did not find any viable compilers.  "
            f"Tried: {', '.join(compiler_candidates)}.")

    dialects = args.dialects.split(',')
    opt_levels = args.optimize.split(',')
    link_types = LINK.items()
    debug_mixes = DEBUG.items()

    if args.minimal:
        compilers = compilers[:1]
        opt_levels = opt_levels[:1]
        link_types = list(link_types)[:1]
        debug_mixes = list(debug_mixes)[:1]

    builds = [
        AutotoolsBuild(
            args.logs,
            AutotoolsConfig(
                opt=opt, link=link, link_opts=link_opts, debug=debug,
                debug_opts=debug_opts, cxx=cxx, stdlib=stdlib,
                dialect=dialect))
        for opt in sorted(opt_levels)
        for link, link_opts in sorted(link_types)
        for debug, debug_opts in sorted(debug_mixes)
        for cxx, stdlib in compilers
        for dialect in dialects
    ]

    cmake = find_cmake_command()
    if cmake is not None:
        builds.append(CMakeBuild(
            args.logs, CMakeConfig(cmake, dialect=dialects[0])))
    return builds


# (Ignore warning about unused parameter.)
def enqueue(queue, build, *args):  # pylint: disable=W0613
    """Put `build` on `queue`.

    Ignores additional arguments, so that it can be used as a callback for
    `Pool`.

    We do this instead of a lambda in order to get the closure right.  We want
    the build for the current iteration, not the last one that was executed
    before the lambda runs.
    """
    queue.put(build)


def enqueue_error(queue, build, error):
    """Put the pair of `build` and `error` on `queue`."""
    queue.put((build, error))


# Disable pylint warning about too many local variables.  I see no way to
# simplify this that doesn't make it less clear.
def main(args):  # pylint: disable=R0914
    """Do it all."""
    if not os.path.isdir(args.logs):
        raise Fail(f"Logs location '{args.logs}' is not a directory.")

    builds = gather_builds(args)
    if args.verbose:
        print(f"Lined up {len(builds)} builds.")

    # The "configure" step is single-threaded.  We can run many at the same
    # time, even when we're also running a "build" step at the same time.
    # This means we may run a lot more processes than we have CPUs, but there's
    # no law against that.  There's also I/O time to be covered.
    configure_pool = Pool()

    # Builds which have failed the "configure" stage, with their errors.  This
    # queue must never stall, so that we can let results pile up here while the
    # work continues.
    configure_fails = Queue(len(builds))

    # Waiting list for the "build" stage.  It contains Build objects,
    # terminated by a final None to signify that there are no more builds to be
    # done.
    build_queue = JoinableQueue(10)

    # Builds that have failed the "build" stage.
    build_fails = Queue(len(builds))

    # Waiting list for the "test" stage.  It contains Build objects, terminated
    # by a final None.
    test_queue = JoinableQueue(10)

    # The "build" step tries to utilise all CPUs, and it may use a fair bit of
    # memory.  Run only one of these at a time, in a single worker process.
    build_worker = Process(
        target=service_builds, args=(build_queue, build_fails, test_queue))
    build_worker.start()

    # Builds that have failed the "test" stage.
    test_fails = Queue(len(builds))

    # Completed builds.  This must never stall.
    done_queue = JoinableQueue(len(builds))

    # The "test" step can not run concurrently (yet).  So, run tests serially
    # in a single worker process.  It takes its jobs directly from the "build"
    # worker.
    test_worker = Process(
        target=service_tests, args=(test_queue, test_fails, done_queue))
    test_worker.start()

    # Feed all builds into the "configure" pool.  Each build which passes this
    # stage goes into the "build" queue.
    for build in builds:
        configure_pool.apply_async(
            build.do_configure, callback=partial(enqueue, build_queue, build),
            error_callback=partial(enqueue_error, configure_fails, build))
    if args.verbose:
        print("All jobs are underway.")
    configure_pool.close()
    configure_pool.join()

# TODO: Async reporting for faster feedback.
    configure_fail_count = report_failures(configure_fails, "CONFIGURE FAIL")
    if args.verbose:
        print("Configure stage done.")

    # Mark the end of the build queue for the build worker.
    build_queue.put(None)

    build_worker.join()
# TODO: Async reporting for faster feedback.
    build_fail_count = report_failures(build_fails, "BUILD FAIL")
    if args.verbose:
        print("Build step done.")

    # Mark the end of the test queue for the test worker.
    test_queue.put(None)

    test_worker.join()
# TODO: Async reporting for faster feedback.
# TODO: Collate failures into meaningful output, e.g. "shared library fails."
    test_fail_count = report_failures(test_fails, "TEST FAIL")
    if args.verbose:
        print("Test step done.")

    # All done.  Clean up.
    for build in builds:
        build.clean_up()

    ok_count = count_entries(done_queue)
    if ok_count == len(builds):
        print("All tests OK.")
    else:
        print(
            f"Failures during configure: {configure_fail_count} - "
            f"build: {build_fail_count} - "
            f"test: {test_fail_count}.  "
            f"OK: {ok_count}."
        )


if __name__ == '__main__':
    try:
        main(parse_args())
    except Fail as failure:
        sys.stderr.write(f"{failure}\n")
        sys.exit(2)
