# License: Apache 2.0. See LICENSE file in root directory.
# Copyright(c) 2021 Intel Corporation. All Rights Reserved.

import re, os, subprocess, time, sys, platform
from abc import ABC, abstractmethod

from rspy import log, file

# this script is in unit-test/py/rspy
unit_tests_dir = os.path.dirname( os.path.dirname( os.path.dirname( os.path.abspath( __file__ ) ) ) )
# the full path to the directory that should hold the unit-tests logs. It is updated in run-unit-tests when we know
# the target directory. If None we assume the output should go to stdout
logdir = None

# LibCI needs a directory in which to look for a configuration and/or collaterals: we call that our "home"
# This is always a directory called "LibCI", but may be in different locations, in this order of priority:
#     1. C:\LibCI  (on the LibCI machine)
#     2. ~/LibCI   (in Windows, ~ is likely C:\Users\<username>)
if platform.system() == 'Linux':
    home = '/usr/local/lib/ci'
else:
    home = 'C:\\LibCI'
if not os.path.isdir( home ):
    home = os.path.normpath( os.path.expanduser( '~/LibCI' ))
#
# Configuration (git config format) is kept in this file:
configfile = home + os.sep + 'configfile'
#
# And exceptions for configuration specs are stored here:
exceptionsfile = home + os.sep + 'exceptions.specs'


def run( cmd, stdout = None, timeout = 200, append = False ):
    """
    Wrapper function for subprocess.run.
    If the child process times out or ends with a non-zero exit status an exception is raised!

    :param cmd: the command and argument for the child process, as a list
    :param stdout: path of file to direct the output of the process to (None to disable)
    :param timeout: number of seconds to give the process before forcefully ending it (None to disable)
    :param append: if True and stdout is not None, the log of the test will be appended to the file instead of
                   overwriting it
    :return: the output written by the child, if stdout is None -- otherwise N/A
    """
    log.d( 'running:', cmd )
    handle = None
    start_time = time.time()
    try:
        log.debug_indent()
        if stdout is None:
            sys.stdout.flush()
        elif stdout and stdout != subprocess.PIPE:
            if append:
                handle = open( stdout, "a" )
                handle.write(
                    "\n----------TEST-SEPARATOR----------\n\n" )
                handle.flush()
            else:
                handle = open( stdout, "w" )
            stdout = handle
        rv = subprocess.run( cmd,
                             stdout=stdout,
                             stderr=subprocess.STDOUT,
                             universal_newlines=True,
                             timeout=timeout,
                             check=True )
        result = rv.stdout
        if not result:
            result = []
        else:
            result = result.split( '\n' )
        return result
    finally:
        if handle:
            handle.close()
        log.debug_unindent()
        run_time = time.time() - start_time
        log.d( "test took", run_time, "seconds" )


class TestConfig( ABC ):  # Abstract Base Class
    """
    Configuration for a test, encompassing any metadata needed to control its run, like retries etc.
    """

    def __init__( self, context ):
        self._configurations = list()
        self._priority = 1000
        self._tags = set()
        self._flags = set()
        self._timeout = 200
        self._context = context
        self._donotrun = False

    def debug_dump( self ):
        if self._donotrun:
            log.d( 'THIS TEST WILL BE SKIPPED (donotrun specified)' )
        if self._priority != 1000:
            log.d( 'priority:', self._priority )
        if self._timeout != 200:
            log.d( 'timeout:', self._timeout )
        if len( self._tags ) > 1:
            log.d( 'tags:', { tag for tag in self._tags if tag != "exe" and tag != "py" } )
        if self._flags:
            log.d( 'flags:', self._flags )
        if len( self._configurations ) > 1:
            log.d( len( self._configurations ), 'configurations' )
            # don't show them... they are output separately

    @property
    def configurations( self ):
        return self._configurations

    @property
    def priority( self ):
        return self._priority

    @property
    def timeout( self ):
        return self._timeout

    @property
    def tags( self ):
        return self._tags

    @property
    def flags( self ):
        return self._flags

    @property
    def context( self ):
        return self._context

    @property
    def donotrun( self ):
        return  self._donotrun

class TestConfigFromText( TestConfig ):
    """
    Configuration for a test -- from any text-based syntax with a given prefix, e.g. for python:
        #test:usb2
        #test:device L500* D400*
        #test:retries 3
        #test:priority 0
    And, for C++ the prefix could be:
        //#test:...
    """

    def __init__( self, source, line_prefix, context ):
        """
        :param source: The absolute path to the text file
        :param line_prefix: A regex to denote a directive (must be first thing in a line), which will
            be immediately followed by the directive itself and optional arguments
        :param context: context in which to configure the test
        """
        TestConfig.__init__( self, context )

        self.derive_config_from_text( source, line_prefix )
        self.derive_tags_from_path( source )

    def derive_config_from_text( self, source, line_prefix ):
        # Configuration is made up of directives:
        #     #test:<directive>[:[!]<context>] <param>*
        # If a context is not specified, the directive always applies. Any directive with a context
        # will only get applied if we're running under the context it specifies (! means not, so
        # !nightly means when not under nightly).
        regex  = r'^' + line_prefix
        regex += r'([^\s:]+)'          # 1: directive
        regex += r'(?::(\S+))?'        # 2: optional context
        regex += r'((?:\s+\S+)*?)'     # 3: params
        regex += r'\s*(?:#\s*(.*))?$'  # 4: optional comment
        for line in file.grep( regex, source ):
            match = line['match']
            directive = match.group( 1 )
            directive_context = match.group( 2 )
            text_params = match.group( 3 ).strip()
            params = [s for s in text_params.split()]
            comment = match.group( 4 )
            if directive_context:
                not_context = directive_context.startswith('!')
                if not_context:
                    directive_context = directive_context[1:]
                # not_context | directive_ctx in context | RESULT
                # ----------- | ------------------------ | ------
                #      0      |            0             | IGNORE
                #      0      |            1             | USE
                #      1      |            0             | USE
                #      1      |            1             | IGNORE
                if not_context == (directive_context in self._context):
                    # log.d( "directive", line['line'], "ignored because of context mismatch with running context",
                    #       self._context)
                    continue
            if directive == 'device':
                # log.d( '    configuration:', params )
                params_lower_list = text_params.lower().split()
                if not params:
                    log.e( source + '+' + str( line['index'] ) + ': device directive with no devices listed' )
                elif sum(s.startswith('each(') for s in params_lower_list) > 1:
                    log.e( source + '+' + str(
                            line['index'] ) + ': each() cannot be used multiple times in same line', params )
                elif params_lower_list[0].startswith('each('):
                    if not re.fullmatch( r'each\(.+\)', params_lower_list[0], re.IGNORECASE ):
                        log.e( source + '+' + str( line['index'] ) + ': invalid \'each\' syntax:', params )
                    else:
                        for param in params_lower_list[1:]:
                            if not param.startswith("!"):
                                log.e(source + '+' + str(line['index']) + ': invalid syntax:', params,
                                      '. All device names after \'' + params[0] +
                                      '\' must start with \'!\' in order to skip them')
                                break
                        else:
                            self._configurations.append( params )
                else:
                    self._configurations.append( params )
            elif directive == 'priority':
                if len( params ) == 1 and params[0].isdigit():
                    self._priority = int( params[0] )
                else:
                    log.e( source + '+' + str( line['index'] ) + ': priority directive with invalid parameters:',
                           params )
            elif directive == 'timeout':
                if len( params ) == 1 and params[0].isdigit():
                    self._timeout = int( params[0] )
                else:
                    log.e( source + '+' + str( line['index'] ) + ': timeout directive with invalid parameters:',
                           params )
            elif directive == 'tag':
                self._tags.update( map( str.lower, params ))  # tags are case-insensitive
            elif directive == 'flag':
                self._flags.update( params )
            elif directive == 'donotrun':
                if params:
                    log.e( source + '+' + str( line['index'] ) + ': donotrun directive should not have parameters:',
                           params )
                self._donotrun = True
            else:
                log.e( source + '+' + str( line['index'] ) + ': invalid directive "' + directive + '"; ignoring' )

    def derive_tags_from_path( self, source ):
        # we need the relative path starting at the unit-tests directory
        relative_path = re.split( r"[/\\]unit-tests[/\\]", source )[-1]
        sub_dirs = re.split( r"[/\\]", relative_path )[:-1] # last element will be the name of the test
        self._tags.update( sub_dirs )


class TestConfigFromCpp( TestConfigFromText ):
    def __init__( self, source, context ):
        TestConfigFromText.__init__( self, source, r'//#\s*test:', context )
        self._tags.add( 'exe' )


class TestConfigFromPy( TestConfigFromText ):
    def __init__( self, source, context ):
        TestConfigFromText.__init__( self, source, r'#\s*test:', context )
        self._tags.add( 'py' )


class Test( ABC ):  # Abstract Base Class
    """
    Abstract class for a test. Holds the name of the test
    """

    def __init__( self, testname ):
        # log.d( 'found', testname )
        self._name = testname
        self._config = None
        self._ran = False

    @abstractmethod
    def run_test( self, configuration = None, log_path = None, opts = set() ):
        pass

    def debug_dump( self ):
        if self._config:
            self._config.debug_dump()

    @property
    def config( self ):
        return self._config

    @property
    def name( self ):
        return self._name

    @property
    def ran( self ):
        return self._ran

    def get_log( self ):
        global logdir
        if not logdir:
            path = None
        else:
            path = logdir + os.sep + self.name + ".log"
        return path

    def is_live( self ):
        """
        Returns True if the test configurations specify devices (test has a 'device' directive)
        """
        return self._config and len( self._config.configurations ) > 0

    def find_source_path( self ):
        """
        :return: The relative path from unit-tests directory to the test's source file (cpp or py). If the source
                 file is not found None will be returned
        """
        # TODO: this is limited to a structure in which .cpp files and directories do not share names
        # For example:
        #     unit-tests/
        #         func/
        #             ...
        #         test-func.cpp
        # test-func.cpp will not be found!

        global unit_tests_dir
        split_testname = self.name.split( '-' )
        path = unit_tests_dir
        relative_path = ""
        found_test_dir = False

        while not found_test_dir:
            # index 0 should be 'test' as tests always start with it
            found_test_dir = True
            for i in range( 2,
                            len( split_testname ) ):  # Checking if the next part of the test name is a sub-directory
                possible_sub_dir = '-'.join( split_testname[1:i] )  # The next sub-directory could have several words
                sub_dir_path = path + os.sep + possible_sub_dir
                if os.path.isdir( sub_dir_path ):
                    path = sub_dir_path
                    relative_path += possible_sub_dir + os.sep
                    del split_testname[1:i]
                    found_test_dir = False
                    break

        path += os.sep + '-'.join( split_testname )
        relative_path += '-'.join( split_testname )
        if os.path.isfile( path + ".cpp" ):
            relative_path += ".cpp"
        elif os.path.isfile( path + ".py" ):
            relative_path += ".py"
        else:
            log.w( log.red + self.name + log.reset + ':',
                   'No matching .cpp or .py file was found; no configuration will be used!' )
            return None

        return relative_path


class PyTest( Test ):
    """
    Class for python tests. Hold the path to the script of the test
    """

    def __init__( self, testname, path_to_test, context = None ):
        """
        :param testname: name of the test
        :param path_to_test: the relative path from the current directory to the path
        :param context: context in which the test will run
        """
        global unit_tests_dir
        Test.__init__( self, testname )
        self.path_to_script = unit_tests_dir + os.sep + path_to_test
        self._config = TestConfigFromPy( self.path_to_script, context )

    def debug_dump( self ):
        log.d( 'script:', self.path_to_script )
        Test.debug_dump( self )

    @property
    def command( self ):
        cmd = [sys.executable]
        #
        # PYTHON FLAGS
        #
        #     -u     : force the stdout and stderr streams to be unbuffered; same as PYTHONUNBUFFERED=1
        # With buffering we may end up losing output in case of crashes! (in Python 3.7 the text layer of the
        # streams is unbuffered, but we assume 3.6)
        cmd += ['-u']
        #
        if sys.flags.verbose:
            cmd += ["-v"]
        #
        cmd += [self.path_to_script]
        #
        # SCRIPT FLAGS
        #
        # If the script has a custom-arguments flag, then we don't pass any of the standard options
        if 'custom-args' not in self.config.flags:
            #
            if log.is_debug_on():
                cmd += ['--debug']
            #
            if log.is_color_on():
                cmd += ['--color']
            elif log.is_color_disabled():
                cmd += ['--no-color']
            #
            if self.config.context:
                cmd += ['--context', ' '.join(self.config.context)]
        return cmd

    def run_test( self, configuration = None, log_path = None, opts = set() ):
        try:
            cmd = self.command
            if opts:
                cmd += [opt for opt in opts]
            run( cmd, stdout=log_path, append=self.ran, timeout=self.config.timeout )
        finally:
            self._ran = True


class ExeTest( Test ):
    """
    Class for c/cpp tests. Hold the path to the executable for the test
    """

    def __init__( self, testname, exe = None, context = None ):
        """
        :param testname: name of the test
        :param exe: full path to executable
        :param context: context in which the test will run
        """
        global unit_tests_dir
        if exe and not os.path.isfile( exe ):
            log.d( "Tried to create exe test with invalid exe file: " + exe )
        Test.__init__( self, testname )
        self.exe = exe

        relative_test_path = self.find_source_path()
        if relative_test_path:
            self._config = TestConfigFromCpp( unit_tests_dir + os.sep + relative_test_path, context )
        else:
            self._config = TestConfig(context)

    @property
    def command( self ):
        cmd = [self.exe]
        if 'custom-args' not in self.config.flags:
            # Assume we're a Catch2 exe, so:
            # if sys.flags.verbose:
            #    cmd +=
            if log.is_debug_on():
                cmd += ['-d', 'yes']  # show durations for each test-case
                # cmd += ['--success']  # show successful assertions in output
                cmd += ['--debug']
            # if log.is_color_on():
            #    cmd += ['--use-colour', 'yes']
            if self.config.context:
                cmd += ['--context', ' '.join(self.config.context)]
        return cmd

    def run_test( self, configuration = None, log_path = None, opts = set() ):
        if not self.exe:
            raise RuntimeError("Tried to run test " + self.name + " with no exe file provided")
        try:
            cmd = self.command
            if opts:
                cmd += [opt for opt in opts]
            run( cmd, stdout=log_path, append=self.ran, timeout=self.config.timeout )
        finally:
            self._ran = True
