#!/usr/bin/env python
# coding: utf-8
# TST test
# (c) 2012-2016 Dalton Serey, UFCG

from __future__ import print_function
from __future__ import unicode_literals
from collections import Counter
from builtins import str
import os
import re
import sys
import json
import glob
import shlex
import signal
import string
import codecs
import unicodedata
import argparse
import difflib
import logging

from tst.jsonfile import JsonFile
import tst
from tst.utils import _assert, data2json
from tst.utils import cprint, to_unicode
from tst.colors import *

from subprocess import Popen, PIPE, check_output, CalledProcessError

TIMEOUT_DEFAULT = 2
PYTHON = 'python2.7'
if sys.version_info < (2,7):
    sys.stderr.write('tst.py: requires python 2.7 or later\n')
    sys.exit(1)

REDIRECTED = os.fstat(0) != os.fstat(1)

STATUS_CODE = {
    # TST test codes
    'Error': 'e',
    'Timeout': 't',
    'Success': '.',
    'MissingTokens': '%',
    'Fail': 'F',
    'ScriptTestError': '!',
    'NoInterpreterError': 'X',

    # Python ERROR codes
    'AttributeError': 'a',
    'SyntaxError': 's',
    'EOFError': 'o',
    'ZeroDivisionError': 'z',
    'IndentationError': 'i',
    'IndexError': 'x',
    'ValueError': 'v',
    'TypeError': 'y',
    'NameError': 'n',
}

TSTJSONFAILMSG = LRED + "Spec file is not a valid config object" + RESET

def alarm_handler(signum, frame):
    raise CutTimeOut

signal.signal(signal.SIGALRM, alarm_handler)


def unpack_results(run_method):

    def wrapper(self, *args, **kwargs):
        run_method(self, *args, **kwargs)

        if self.testcase.type == 'io':
            self.result['summary'] = STATUS_CODE[self.result['status']]

        return self.result

    return wrapper


def parse_test_report(data):
    if data and data[0] == '{':
        try:
            # assume it is json
            report = json.loads(data)
            assert 'summary' in report, f'invalid json report: {repr(report)}'
            return report['summary'], report.get('feedback')
        except json.decoder.JSONDecodeError:
            # it is not json
            pass

    parts = data.split('\n', 1)
    summary = parts[0]
    feedback = parts[1] if len(parts) > 1 else None
    return summary, feedback


class TestRun:

    def __init__(self, subject, testcase, index=None):
        self.subject = subject
        self.testcase = testcase
        self.result = {"index": index}
        self.result['type'] = self.testcase.type

    def run(self, timeout=TIMEOUT_DEFAULT):
        if self.testcase.type == 'io':
            return self.run_iotest()

        elif self.testcase.type == 'script':
            return self.run_script()

        else:
            _assert(False, 'unknown test type')

    @unpack_results
    def run_script(self, timeout=TIMEOUT_DEFAULT):
        if "{}" in self.testcase.script:
            cmd_str = self.testcase.script.format(self.subject.filename)
        else:
            cmd_str = self.testcase.script
        cmd_str = re.sub(r'\bpython\b', 'python3', cmd_str)
        command = shlex.split(cmd_str)

        self.result['command'] = cmd_str
        stdout, stderr = None, None
        try:
            process = Popen(command, stdout=PIPE, stderr=PIPE)
            signal.alarm(timeout)
            stdout, stderr = map(to_unicode, process.communicate())
            signal.alarm(0) # reset the alarm
            process.wait()
            assert process.returncode == 0, f"script test error: exit code = {process.returncode}"

            # collect test data
            self.result['exit_status'] = process.returncode
            self.result['stderr'] = stderr # comment out to remove from report
            self.result['stdout'] = stdout # comment out to remove from report

            # collect report from either stderr or stdout
            summary, feedback = parse_test_report(stderr)
            if not summary:
                summary, feedback = parse_test_report(stdout)
            assert not ' ' in summary and summary == summary.strip(), f'invalid summary: {repr(summary)}'
            assert summary, f"script test error: empty summary"
            self.result['summary'] = summary
            self.result['feedback'] = feedback

        except (FileNotFoundError, PermissionError, AssertionError) as e:
            # the test script command itself failed (or it was not found)
            self.result['status'] = 'ScriptTestError'
            self.result['summary'] = '!'
            self.result['error'] = f'{e}'
            self.result['stderr'] = stderr if stderr else None
            self.result['stdout'] = stdout if stdout else None
            log.removeHandler(handler_console)
            log.warning(f'test script error: CMD=`{cmd_str}` ERROR={e.__class__.__name__} MSG=`{e}`')
            log.addHandler(handler_console)
            return self.result

        except CutTimeOut:
            # test script running too long: possibly a loop in the subject
            process.terminate()
            self.result['status'] = 'Timeout'
            self.result['summary'] = 't'
            return self.result

        self.result['summary'] = summary
        if summary == len(summary) * '.':
            self.result['status'] = 'Success'
        else:
            self.result['status'] = 'Fail'

        return self.result

    @unpack_results
    def run_iotest(self, timeout=TIMEOUT_DEFAULT):

        # define command
        config = tst.get_config()
        if config.get('run'):
            # use run option
            run = config['run']
            extensions = run.keys()
            ext = self.subject.filename.split('.')[-1]
            if ext not in extensions:
                self.result['status'] = 'NoInterpreterError'
                return self.result
            _assert(ext in extensions, "\nfatal: missing command for extension %s" % ext)
            command = config['run'][ext]
            cmd_str = "%s %s" % (command, self.subject.filename)
        else:
            # default is running through python
            cmd_str = '%s %s' % (PYTHON, self.subject.filename)

        command = shlex.split(cmd_str)

        # encode test input data
        if self.testcase.input:
            input_data = self.testcase.input.encode('utf-8')
        else:
            input_data = ''
        self.result['input'] = self.testcase.input
        self.result['output'] = self.testcase.output
        self.result['match'] = self.testcase.match

        # run the test (loop until succeeding)
        while True:
            process = Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE)
            signal.alarm(timeout)
            try:

                # run subject as external process
                process_data = process.communicate(input=input_data)
                stdout, stderr = map(to_unicode, process_data)

                # collect output data
                self.result['stdout'] = stdout # comment out to remove from report
                self.result['stderr'] = stderr # comment out to remove from report

                # reset alarm for future use
                signal.alarm(0)
                process.wait()

                # leave loop
                break

            except CutTimeOut:
                # timeout... give up
                process.terminate()
                self.result['status'] = 'Timeout'
                return self.result

            except OSError:
                # external error... try running again!
                process.terminate()

        # check for ERROR during execution
        if process.returncode != 0:

            # set generic error status
            self.result['status'] = 'Error'

            # try being more specific for known python errors
            for error_code in STATUS_CODE:
                if error_code in stderr:
                    self.result['status'] = error_code
                    break

            return self.result

        # check for a perfecs match
        preprocessed_stdout = preprocess(stdout, self.testcase.ignore)
        if self.testcase.preprocessed_output == preprocessed_stdout:
            self.result['status'] = 'Success'
            return self.result

        # check for partial (regex based) match
        if self.testcase.match:
            stdout_regex = stdout
            if self.testcase.re.match(stdout_regex):
                self.result['status'] = 'Success'
                return self.result

        # or fail...
        self.result['status'] = 'Fail'
        return self.result


class TestSubject:

    def __init__(self, filename):
        self.filename = filename
        self._io_results = ''
        self.analyzer_results = ''
        self.testruns = []
        self._results = None
        self._summaries = None

    def results(self):
        if not self._results:
            self._results = []
            for tr in self.testruns:
                self._results.append(tr.result)

        return self._results

    def add_testrun(self, testrun):
        self.testruns.append(testrun)
        self._summaries = None

    def feedbacks(self):
        return [tr['feedback'] for tr in self.results() if tr.get('feedback')]

    def summaries(self, join_io=False):
        if not self._summaries:
            iosummaries = []
            self._summaries = []
            for tr in self.results():
                if join_io and tr['type'] == 'io':
                    iosummaries.append(tr['summary'])
                else:
                    self._summaries.append(tr['summary'])

            if iosummaries:
                self._summaries.insert(0, ''.join(iosummaries))

        return self._summaries

    def verdict(self):
        return 'success' if all(c == '.' for c in ''.join(self.summaries())) else 'fail'

    def summary(self):
        status_codes = [tr['summary'] for tr in self.results()]
        return ''.join(status_codes)


class TestCase():

    def __init__(self, test):

        # get data from tst.json
        self.input = test.get('input', '')
        self.output = test.get('output')
        self.match = test.get('match')
        self.tokens = test.get('tokens')
        self.ignore = test.get('ignore', [])
        self.script = test.get('script')
        self.type = test.get('type') or ('script' if self.script else 'io')
        if self.type == 'script':
            _assert(self.script, "script tests must have a script")
            _assert(not self.input and not self.output, "script tests cannot have input/output")
            _assert(not self.tokens, "script tests cannot have tokens")

        # check
        if self.output:
            _assert(not self.match and not self.tokens, "cannot be used together: output, match, tokens")
        if self.tokens:
            _assert(not self.match and not self.output, "cannot be used together: output, match, tokens")
        if self.match:
            _assert(not self.output and not self.tokens, "cannot be used together: output, match, tokens")
        _assert(not self.match or isinstance(self.match, str), "match must be a string")
        _assert(self.tokens is None, "tokens based io tests is disabled: requires revision")


        # convert ignore to a list of strings, if necessary
        if isinstance(self.ignore, str):
            self.ignore = self.ignore.split()

        # compile the re object
        if self.match and 'case' in self.ignore:
            self.re = re.compile(self.match, re.IGNORECASE | re.MULTILINE | re.DOTALL)
        elif self.match and 'case' not in self.ignore:
            self.re = re.compile(self.match, re.MULTILINE | re.DOTALL)
        else:
            self.re = None

        # convert tokens to a list of strings, if necessary
        if isinstance(self.tokens, str):
            self.tokens = self.tokens.split()

        # identify tokens within the expected output
        if not self.tokens and self.output and '{{' in self.output:
            p = r'{{(.*?)}}' # r'{{.*?}}|\[\[.*?\]\]'
            self.tokens = re.findall(p, self.output)
            # remove tokens' markup from expected output
            self.output = re.sub(p, lambda m: m.group(0)[2:-2], self.output)

        # preprocess individual tokens
        if self.tokens:
            for i in range(len(self.tokens)):
                self.tokens[i] = preprocess(self.tokens[i], self.ignore)

        # set up preprocessed output
        self.preprocessed_output = preprocess(self.output, self.ignore)

        # set up normalized output
        self.normalized_output = preprocess(self.output, DEFAULT_OPS)


def preprocess(text, operator_names):
    if text is None: return None

    # add whites if punctuation is used
    if 'punctuation' in operator_names and 'whites' not in operator_names:
        operator_names = operator_names + ['whites']

    # expand if all is requested
    if operator_names == ['all']:
        operator_names = OPERATOR.keys()

    _assert(all(name in OPERATOR for name in operator_names), "unknown operator in ignore")

    # sort to assure 'whites' is last
    operators = [OPERATOR[name] for name in sorted(operator_names)]

    # apply operators to text
    for op in operators:
        text = op(text)

    return text


def squeeze_whites(text):
    data = [lin.strip().split() for lin in text.splitlines()]
    data = [' '.join(line) for line in data]
    return '\n'.join(data)


def remove_linebreaks(text):
    # TODO: use carefully! it substitutes linebreaks for ' '
    return ' '.join(text.splitlines())


def drop_whites(text):
    # TODO: Use carefully! deletes all whites
    #       string.whitespace = '\t\n\x0b\x0c\r '
    table = dict((ord(char), None) for char in string.whitespace)
    return text.translate(table)


def punctuation_to_white(text):
    # WARNING: preprocess() silently adds 'whites' if punctuation is used
    # TODO: Use carefully! it substitutes punctuation for ' '
    # TODO: The specification is wrong!
    #       Punctuation should be changed to spaces? This will
    #       duplicate some whites... Should punctuation be deleted?
    #       This would merge tokens into a single one... Should we
    #       have a mixed behavior? All punctuation surrounded by white
    #       would be deleted and punctuation not surrounded
    # For now, it should be used with whites to work properly.
    table = dict((ord(char), u' ') for char in string.punctuation)
    return text.translate(table)


def strip_accents(text):
    # text must be a unicode object, not str
    try:
        nkfd_form = unicodedata.normalize('NFKD', text.decode('utf-8'))
        only_ascii = nkfd_form.encode('ASCII', 'ignore')
    except:
        nkfd_form = unicodedata.normalize('NFKD', text)
        only_ascii = nkfd_form.encode('ASCII', 'ignore')

    return only_ascii.decode('utf-8')


OPERATOR = {
    # 2to3: 'case': string.lower,
    'case': lambda c: c.lower(),
    'accents': strip_accents,
    'extra_whites': squeeze_whites,
    'linebreaks': remove_linebreaks, # not default
    'punctuation': punctuation_to_white, # not default
    'whites': drop_whites, # not default
}

DEFAULT_OPS = ['case', 'accents', 'extra_whites']

class CutTimeOut(Exception): pass

class StatusLine:

    def __init__(self):
        if not sys.stdout.isatty():
            self.terminal = False
            return

        self.terminal = True
        self.lastline = ''
        self.stty_size = os.popen('stty size', 'r').read()
        self.columns = int(self.stty_size.split()[1]) - 10

    def set(self, line):
        if not self.terminal:
            return
        GREEN = '\033[92m'
        RESET = '\033[0m'
        line = GREEN + line[:self.columns]+ RESET
        sys.stderr.write('\r%s\r' % ((1+len(self.lastline)) * ' '))
        sys.stderr.write(line)
        sys.stderr.flush()
        self.lastline = line

    def clear(self):
        if not self.terminal:
            return
        sys.stderr.write('\r%s\r' % ((1+len(self.lastline)) * ' '))
        sys.stderr.write('')
        sys.stderr.flush()
        self.lastline = ''


def indent(text):
    lines = text.splitlines()
    text = "\n".join(["    %s" % l for l in lines])
    return text


def color(color, text):
    reset = RESET
    if REDIRECTED:
        color = ""
        reset = ""
    return color + text + reset


class Reporter(object):
    VALID_REPORT_STYLES = ['summary', 'debug', 'failed', 'passed', 'worker']

    @staticmethod
    def get(style, num_tests=0, options=None):
        """
        Factory method. The style argument specifies the type
        that will be intantiated. If style is None, the base
        class Reporter will be used. If can also be debug,
        failed, passed or worker to create either DebugReporter,
        FilterReporter (case of failed or passed) and
        WorkerReporter.
        """
        options = options or {}
        _assert(style is None or style in Reporter.VALID_REPORT_STYLES, 'invalid report style: {}'.format(style))
        if style is None or style == 'summary':
            return Reporter(options)

        elif style == 'debug':
            return DebugReporter(max_fails=1, options=options)

        elif style == 'failed':
            fail_filter = lambda s: s.count('.') != len(s)
            return FilterReporter(filtro=fail_filter, options=options)

        elif style == 'passed':
            passed_filter = lambda s: s.count('.') == len(s)
            return FilterReporter(filtro=passed_filter, options=options)

        elif style == 'worker':
            return WorkerReporter(options=options)

    def __init__(self, options=None):
        self.options = options or {}
        self.num_tests = 0
        self.tests_performed = 0
        self.current_subject = None
        self.testresults = None
        self.testcases = None
        self.subjects = []
        self.status_line = StatusLine()

    def update(self, subject, testcase, testresult):
        if self.current_subject is None:
            hasattr(self, 'before_all') and self.before_all()

        if self.current_subject and subject != self.current_subject:
            # end current_subject report
            self.status_line and self.status_line.clear()
            self.print_report()
            hasattr(self, 'after_file') and self.after_file()
            hasattr(self, 'between_files') and self.between_files()

        if subject != self.current_subject:
            # change to new subject
            hasattr(self, 'before_file') and self.before_file()
            self.current_subject = subject
            self.testresults = []
            self.testcases = []
            self.subjects.append(subject)

        if subject:
            # update data
            self.testresults.append(testresult)
            self.testcases.append(testcase)
            if testresult['type']:
                self.tests_performed += 1
                self._status(self.tests_performed)

    def close(self):
        self.status_line and self.status_line.clear()
        self.print_report()
        hasattr(self, 'after_file') and self.after_file()
        hasattr(self, 'after_all') and self.after_all()

    def _status(self, tests_performed=None):
        if tests_performed is None:
            self.status_line.clear()
            return

        if not self.status_line: return
        self.status_line.set('test %d out of %d' % (tests_performed, self.num_tests))

    def _summaries(self):
        _summaries = []
        for tr in self.testresults:
            _summaries.append(tr['summary'])

        return ''.join(_summaries)

    def print_report(self):
        summary = self._summaries()
        subject_format = self.options.get('format')
        if subject_format and self.options['format'][0] == ':':
            fnformat = self.options['format'][1:]
            line = f'{self.current_subject.filename:{fnformat}} {summary}'
        else:
            line = f'{self.current_subject.filename} {summary}'
        print(line, file=sys.stderr)


class FilterReporter(Reporter):
    def __init__(self, filtro=lambda s: True, options=None):
        options = options or {}
        super(FilterReporter, self).__init__(options)
        self.filtro = filtro

    def print_report(self):
        summary = self._summaries()
        if not self.filtro(re.sub("[ |]", "", summary)): return
        self._filtered = True
        line = '%s%s' % (summary, self.current_subject.filename)
        print(line, file=sys.stderr)


class WorkerReporter(Reporter):
    def __init__(self, options=None):
        options = options or {}
        super(WorkerReporter, self).__init__(options)
        self._report = {}

    def print_report(self):
        self._report[self.current_subject.filename] = {
            "summary": "".join(self._summaries())
        }

    def after_all(self):
        print(data2json(self._report))


class DebugReporter(Reporter):
    def __init__(self, max_fails=1, options=None):
        options = options or {}
        super(DebugReporter, self).__init__(options)
        self.max_fails = max_fails

    def print_report(self):
        summary = self._summaries()
        subject_format = self.options.get('format')
        if subject_format and self.options['format'][0] == ':':
            fnformat = self.options['format'][1:]
            line = f'{self.current_subject.filename:{fnformat}} {summary}'
        else:
            line = f'{self.current_subject.filename} {summary}'
        print(line, file=sys.stderr)

        if summary.count('.') == len(summary): return

        num_reported = 0
        for i in range(len(self.testcases)):
            if self.testresults[i]['summary'] == len(self.testresults[i]['summary']) * '.': continue
            testresult = self.testresults[i]
            if testresult['type'] is None: continue

            print(LCYAN + f"\ntest {testresult['index']}: {self.testcases[i].type}" + RESET)
            if testresult['type'] == 'io':
                print(LGREEN + "/tmp/input: " + RESET + repr(testresult['input']))
                testresult['output'] and print(LGREEN + "/tmp/output: " + RESET + repr(testresult['output']))
                print(LGREEN + "/tmp/stdout: " + RESET + repr(testresult['stdout']))
                testresult['match'] and print(LGREEN + "match: " + RESET + testresult['match'])
                status_map = {v:k for k, v in STATUS_CODE.items()}
                status = status_map.get(testresult["summary"])
                print(LGREEN + f"summary:{RESET} {testresult['summary']} ({status})")

                if self.options.get('diff'):
                    print(DebugReporter.external_diff(testresult))

                if self.options.get('compare'):
                    print(LGREEN + "compare:" + RESET)
                    print(indent(DebugReporter.internal_diff(testresult)))


            elif testresult['type'] == 'script':
                print(f"{LGREEN}command:{RESET} {testresult['command']}")
                print(LGREEN + f"summary:{RESET} {testresult['summary']}")

                if testresult.get('error'):
                    print(f'{YELLOW}script error:{RESET} {testresult["error"]}')
                if testresult.get('feedback'):
                    cprint(LGREEN, 'script test feedback/report:')
                    print(indent(testresult['feedback']))

            elif testresult['type'] is None:
                pass

            else:
                log.warning('unrecognized test type')

            num_reported += 1
            #if num_reported == self.max_fails: break

    @staticmethod
    def external_diff(result):
        open('/tmp/input', encoding='utf-8', mode="w").write(result['input'] or '')
        open('/tmp/output', encoding='utf-8', mode="w").write(result['output'])
        open('/tmp/stdout', encoding='utf-8', mode="w").write(result['stdout'])

        try:
            command = tst.get_config().get('diff', 'diff').split() + ["/tmp/stdout", "/tmp/output"]
            process = Popen(command, stdout=PIPE, stderr=PIPE)
            stdout, stderr = map(to_unicode, process.communicate())
            process.wait()
            return stdout.strip()
        except (CalledProcessError, OSError):
            log.error("diff: cannot run external diff command")

    @staticmethod
    def internal_diff(result):
        differ = difflib.Differ()
        diff = differ.compare(result['stdout'].splitlines(), result['output'].splitlines())
        result = ''
        for e in diff:
            if e[0] == '+':
                line = color(LGREEN, "+ ") + color('\033[1;32;100m', e[2:])
            elif e[0] == '-':
                line = color(LRED, "- ") + color('\033[1;31;100m', e[2:])
            else:
                line = color('\033[2m', e)
            result += line + '\n'
        return result


def parse_cli():
    parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument('-a', '--activity', type=str, default=None, help='read activity specification from file ACTIVITY\n(default: tst.yaml and tst.json)')
    parser.add_argument('-t', '--test-files', type=str, default='*.yaml,*.json', help='read tests from TEST_FILES')
    parser.add_argument('-T', '--timeout', type=int, default=5, help='stop execution at TIMEOUT seconds')

    parser.add_argument('-n', '--ordinal-number', type=str, default='', help='ordinal of tests to run')
    parser.add_argument('-m', '--messages', action="store_true", default=False, help='print running messages')
    parser.add_argument('-e', '--script-errors', action="store_true", default=False, help='print script errors')
    parser.add_argument('-d', '--diff', action="store_true", default=False, help='output failed testcases and expected output diff')
    parser.add_argument('-c', '--compare', action="store_true", default=False, help='output failed testcases and expected output color comparison')

    parser.add_argument('-f', '--subject-format', type=str, help='set subject filename format')
    parser.add_argument('-s', '--report-style', type=str, choices=['debug', 'passed', 'failed', 'worker'], help='choose report style')
    parser.add_argument('filename', nargs='*', default=[''])
    args = parser.parse_args()

    # identify answer files (files to be tested)
    if len(args.filename) == 1 and os.path.exists(args.filename[0]):
        files2test = [args.filename[0]]
    elif len(args.filename) == 1:
        fn_pattern = '*%s*' % args.filename[0]
        files2test = glob.glob(fn_pattern)
    else:
        files2test = args.filename

    _assert(not args.subject_format or args.subject_format[0] == ':', f"invalid format: {repr(args.subject_format)}")
    _assert(not (args.diff or args.compare) or args.report_style in [None, 'debug'], 'diff and compare implies debug format')

    # identify test files (files containing tests)
    patterns2scan = args.test_files.split(",") if args.test_files else []
    test_files = []
    for pattern in patterns2scan:
        for filename in glob.glob(pattern):
            if filename not in test_files:
                test_files.append(filename)

    options = {
        "timeout": args.timeout,
        "messages": args.messages,
        "errors": args.script_errors,
        "format": args.subject_format,
        "report-style": args.report_style,
        "diff": args.diff,
        "compare": args.compare,
        "number": [v for v in args.ordinal_number.split(",") if v.isdigit()]
    }
    return args.activity, files2test, test_files, options


def print_script_errors(script_errors):
    for testresult in script_errors:
        cprint(YELLOW, f"\ncommand:{RESET} {repr(testresult['command'])}")
        cprint(YELLOW, f"script error:{RESET} {repr(testresult['error'])}")
        stderr = indent((testresult.get('stderr') or '').strip())
        stdout = indent((testresult.get('stdout') or '').strip())
        if stderr: cprint(YELLOW, f'stderr:')
        if stderr: cprint(LRED, f'{stderr}')
        if stdout: cprint(YELLOW, f'stdout:')
        if stdout: cprint(LRED, f'{stdout}')

def main():
    # parse command line
    activity, files2test, test_files, options = parse_cli()

    # read specification file
    tstjson = tst.read_specification(activity, verbose=True)

    # check for files required by specification
    for pattern in tstjson.get('require', []):
        _assert(glob.glob(pattern), "Missing required files for this test: %s" % pattern)

    # read tests
    hints = []
    tests = []
    test_suites = []
    for filename in test_files:
        options['messages'] and cprint(LCYAN, "Reading %s:" % filename, end='')
        try:
            testsfile = JsonFile(filename, array2map="tests")
            options['messages'] and cprint(LGREEN, " %s tests" % len(testsfile.get("tests", [])))
            tests.extend(testsfile["tests"])
            test_suites.append((filename, testsfile["tests"], testsfile.get('level', 0)))
        except KeyError as e:
            #_msg = " %s✗%s (no tests found)" % (LRED, YELLOW)
            #cprint(YELLOW, _msg)
            pass
        except tst.jsonfile.CorruptedJsonFile as e:
            _msg = f"{LRED}invalid tst file: {filename}{RESET}"
            cprint(YELLOW, _msg)

    # make sure there are tests
    _assert(tests, '0 tests found')
    _assert(any(tests for _, tests, _ in test_suites), '0 tests found')
    test_suites.sort(key=lambda ts: ts[2])

    # filter files2test based on extensions and ignore_files
    config = tst.get_config()
    extensions = tstjson.get('extensions') or config['run'].keys() if 'run' in config else ['py']
    ignore_files = tstjson.get('ignore', []) + config.get('ignore', [])
    files2test = [f for f in files2test if any(f.endswith(e) for e in extensions) and f not in ignore_files]
    files2test.sort()
    _assert(files2test, 'No files to test')

    # read subjects and testcases
    subjects = [TestSubject(fn) for fn in files2test]
    testcases = [TestCase(t) for t in tests]

    # identify style
    style = options['report-style']
    style = style or tst.get_config().get('report-style') or ('debug' if len(subjects) == 1 else None)

    reporter = Reporter.get(style=style, options=options)
    reporter.num_tests = len(subjects) * len(testcases)
    count, script_errors = 0, []
    for subject in subjects:
        for suite, tests, level in test_suites:
            if not tests: continue
            testcases = [TestCase(t) for t in tests]
            for testcase in testcases:
                count += 1
                if options['number'] and str(count) not in options['number']: continue
                testrun = TestRun(subject, testcase, index=count)
                testresult = testrun.run(timeout=options['timeout'])
                ('error' in testresult) and script_errors.append(testresult)
                subject.add_testrun(testrun)
                reporter.update(subject, testcase, testresult)
            log.removeHandler(handler_console)
            log.info(f"{subject.filename}: {reporter._summaries()}")
            log.addHandler(handler_console)
            reporter.update(subject, testcase, {"type": None, "summary": " "})

    reporter.close()

    if script_errors and not options['errors'] and style != 'debug':
        cprint(YELLOW, f"{len(script_errors)} test scripts failed")
        hints.append('(use "tst --script-errors" or "tst -e" to print fails)')

    if options.get('errors'):
        print_script_errors(script_errors)

    if len(subjects) == 1:
        hints.append('(use "tst -n <n1>,<n2>..." to run only tests number <n1>, <n2> ...)')
    else:
        hints.append('(use "tst <string>" to test only files matching string)')

    if hints:
        print()
        cprint(LCYAN, indent("\n".join(hints)) + "\n")

class ColorizingStreamHandler(logging.StreamHandler):
    colors_map = {
        logging.DEBUG: LBLUE,
        logging.INFO: LCYAN,
        logging.WARNING: YELLOW,
        logging.ERROR: LRED,
        logging.CRITICAL: CRITICAL
    }

    def emit(self, record):
        try:
            message = self.format(record)
            self.stream.write(message + '\n')
            self.flush()
        except (KeyboardInterrupt, SystemExit):
            raise
        except:
            self.handleError(record)

    def format(self, record):
        message = logging.StreamHandler.format(self, record)
        if getattr(self.stream, 'isatty', lambda: None)():
            parts = message.split('\n', 1)
            color = self.colors_map.get(record.levelno, '')
            parts[0] = color + parts[0] + RESET
            message = '\n'.join(parts)
        return message


if __name__ == '__main__':
    config = tst.get_config()

    log = logging.getLogger('tst-test')
    log.setLevel(logging.DEBUG)

    handler_file = logging.FileHandler(os.path.expanduser('~/.tst/logs'))
    handler_file.setFormatter(logging.Formatter('%(asctime)s|%(name)s|%(levelname)s|%(message)s'))
    log.addHandler(handler_file)

    log.info(f"CWD={os.getcwd()}")

    handler_console = ColorizingStreamHandler()
    log.addHandler(handler_console)

    if len(sys.argv) > 1 and sys.argv[1] == '--one-line-help':
        print('run tests specified in tst.json')
        sys.exit(0)

    try:
        main()
    except KeyboardInterrupt:
        log.info('tst-test: user interrupted')
    except Exception:
        log.critical('sorry... critical error')
        log.removeHandler(handler_console)
        log.exception('oops... an error occurred')
        sys.exit(1)
