# Current code status:
#
# This was originally written by Philip Taylor for use at
# http://philip.html5.org/tests/canvas/suite/tests/
#
# It has been adapted for use with the Web Platform Test Suite suite at
# https://github.com/web-platform-tests/wpt/
#
# The original version had a number of now-removed features (multiple versions
# of each test case of varying verbosity, Mozilla mochitests, semi-automated
# test harness). It also had a different directory structure.

# To update or add test cases:
#
# * Modify the tests*.yaml files.
#  - 'name' is an arbitrary hierarchical name to help categorise tests.
#  - 'desc' is a rough description of what behaviour the test aims to test.
#  - 'code' is JavaScript code to execute, with some special commands starting
#    with '@'
#  - 'expected' is what the final canvas output should be: a string 'green' or
#    'clear' (100x50 images in both cases), or a string 'size 100 50' (or any
#    other size) followed by Python code using Pycairo to generate the image.
#
# * Run "./build.sh".
# This requires a few Python modules which might not be ubiquitous.
# It will usually emit some warnings, which ideally should be fixed but can
# generally be safely ignored.
#
# * Test the tests, add new ones to Git, remove deleted ones from Git, etc.

from typing import List, Mapping

import re
import importlib
import os
import pathlib
import sys

try:
    import cairocffi as cairo  # type: ignore
except ImportError:
    import cairo

try:
    # Compatible and lots faster.
    import syck as yaml  # type: ignore
except ImportError:
    import yaml


class Error(Exception):
    """Base class for all exceptions raised by this module"""


class InvalidTestDefinitionError(Error):
    """Raised on invalid test definition."""


def _simpleEscapeJS(string: str) -> str:
    return string.replace('\\', '\\\\').replace('"', '\\"')


def _escapeJS(string: str) -> str:
    string = _simpleEscapeJS(string)
    # Kind of an ugly hack, for nicer failure-message output.
    string = re.sub(r'\[(\w+)\]', r'[\\""+(\1)+"\\"]', string)
    return string


def _expand_nonfinite(method: str, argstr: str, tail: str) -> str:
    """
    >>> print _expand_nonfinite('f', '<0 a>, <0 b>', ';')
    f(a, 0);
    f(0, b);
    f(a, b);
    >>> print _expand_nonfinite('f', '<0 a>, <0 b c>, <0 d>', ';')
    f(a, 0, 0);
    f(0, b, 0);
    f(0, c, 0);
    f(0, 0, d);
    f(a, b, 0);
    f(a, b, d);
    f(a, 0, d);
    f(0, b, d);
    """
    # argstr is "<valid-1 invalid1-1 invalid2-1 ...>, ..." (where usually
    # 'invalid' is Infinity/-Infinity/NaN).
    args = []
    for arg in argstr.split(', '):
        match = re.match('<(.*)>', arg)
        if match is None:
            raise InvalidTestDefinitionError(
                f"Expected arg to match format '<(.*)>', but was: {arg}")
        a = match.group(1)
        args.append(a.split(' '))
    calls = []
    # Start with the valid argument list.
    call = [args[j][0] for j in range(len(args))]
    # For each argument alone, try setting it to all its invalid values:
    for i in range(len(args)):
        for a in args[i][1:]:
            c2 = call[:]
            c2[i] = a
            calls.append(c2)
    # For all combinations of >= 2 arguments, try setting them to their
    # first invalid values. (Don't do all invalid values, because the
    # number of combinations explodes.)
    def f(c: List[str], start: int, depth: int) -> None:
        for i in range(start, len(args)):
            if len(args[i]) > 1:
                a = args[i][1]
                c2 = c[:]
                c2[i] = a
                if depth > 0:
                    calls.append(c2)
                f(c2, i + 1, depth + 1)

    f(call, 0, 0)

    return '\n'.join('%s(%s)%s' % (method, ', '.join(c), tail) for c in calls)


def _get_test_sub_dir(name: str, name_to_sub_dir: Mapping[str, str]) -> str:
    for prefix in sorted(name_to_sub_dir.keys(), key=len, reverse=True):
        if name.startswith(prefix):
            return name_to_sub_dir[prefix]
    raise InvalidTestDefinitionError(
        'Test "%s" has no defined target directory mapping' % name)


def _expand_test_code(code: str) -> str:
    code = re.sub(r'@nonfinite ([^(]+)\(([^)]+)\)(.*)', lambda m:
                  _expand_nonfinite(m.group(1), m.group(2), m.group(3)),
                  code)  # Must come before '@assert throws'.

    code = re.sub(r'@assert pixel (\d+,\d+) == (\d+,\d+,\d+,\d+);',
                  r'_assertPixel(canvas, \1, \2);', code)

    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+);',
                  r'_assertPixelApprox(canvas, \1, \2, 2);', code)

    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+) \+/- (\d+);',
                  r'_assertPixelApprox(canvas, \1, \2, \3);', code)

    code = re.sub(r'@assert throws (\S+_ERR) (.*);',
                  r'assert_throws_dom("\1", function() { \2; });', code)

    code = re.sub(r'@assert throws (\S+Error) (.*);',
                  r'assert_throws_js(\1, function() { \2; });', code)

    code = re.sub(
        r'@assert (.*) === (.*);', lambda m: '_assertSame(%s, %s, "%s", "%s");'
        % (m.group(1), m.group(2), _escapeJS(m.group(1)), _escapeJS(m.group(2))
           ), code)

    code = re.sub(
        r'@assert (.*) !== (.*);', lambda m:
        '_assertDifferent(%s, %s, "%s", "%s");' % (m.group(1), m.group(
            2), _escapeJS(m.group(1)), _escapeJS(m.group(2))), code)

    code = re.sub(
        r'@assert (.*) =~ (.*);', lambda m: 'assert_regexp_match(%s, %s);' % (
            m.group(1), m.group(2)), code)

    code = re.sub(
        r'@assert (.*);', lambda m: '_assert(%s, "%s");' % (m.group(
            1), _escapeJS(m.group(1))), code)

    code = re.sub(r' @moz-todo', '', code)

    code = re.sub(r'@moz-UniversalBrowserRead;', '', code)

    assert ('@' not in code)

    return code


_CANVAS_SIZE_REGEX = re.compile(r'(?P<width>.*), (?P<height>.*)',
                                re.MULTILINE | re.DOTALL)


def _get_canvas_size(test: Mapping[str, str]):
    size = test.get('size', '100, 50')
    match = _CANVAS_SIZE_REGEX.match(size)
    if not match:
        raise InvalidTestDefinitionError(
            'Invalid canvas size "%s" in test %s. Expected a string matching '
            'this pattern: "%%s, %%s" %% (width, height)' %
            (size, test['name']))
    return match.group('width'), match.group('height')


def _generate_test(test: Mapping[str, str], templates: Mapping[str, str],
                   sub_dir: str, test_output_dir: str, image_output_dir: str,
                   is_offscreen_canvas: bool):
    name = test['name']

    if test.get('expected', '') == 'green' and re.search(
            r'@assert pixel .* 0,0,0,0;', test['code']):
        print('Probable incorrect pixel test in %s' % name)

    code = _expand_test_code(test['code'])

    expectation_html = ''
    if 'expected' in test and test['expected'] is not None:
        expected = test['expected']
        expected_img = None
        if expected == 'green':
            expected_img = '/images/green-100x50.png'
        elif expected == 'clear':
            expected_img = '/images/clear-100x50.png'
        else:
            if ';' in expected:
                print('Found semicolon in %s' % name)
            expected = re.sub(
                r'^size (\d+) (\d+)',
                r'surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, \1, \2)'
                r'\ncr = cairo.Context(surface)', expected)

            expected += ("\nsurface.write_to_png('%s.png')\n" %
                         os.path.join(image_output_dir, sub_dir, name))
            eval(compile(expected, '<test %s>' % name, 'exec'), {},
                 {'cairo': cairo})
            expected_img = '%s.png' % name

        if expected_img:
            expectation_html = (
                '<p class="output expectedtext">Expected output:<p>'
                '<img src="%s" class="output expected" id="expected" '
                'alt="">' % expected_img)

    canvas = ' ' + test['canvas'] if 'canvas' in test else ''
    width, height = _get_canvas_size(test)

    notes = '<p class="notes">%s' % test['notes'] if 'notes' in test else ''

    timeout = ('\n<meta name="timeout" content="%s">' %
               test['timeout'] if 'timeout' in test else '')
    timeout_js = ('// META: timeout=%s\n' % test['timeout']
                  if 'timeout' in test else '')

    images = ''
    for src in test.get('images', []):
        img_id = src.split('/')[-1]
        if '/' not in src:
            src = '../images/%s' % src
        images += '<img src="%s" id="%s" class="resource">\n' % (src, img_id)
    for src in test.get('svgimages', []):
        img_id = src.split('/')[-1]
        if '/' not in src:
            src = '../images/%s' % src
        images += ('<svg><image xlink:href="%s" id="%s" class="resource">'
                   '</svg>\n' % (src, img_id))
    images = images.replace('../images/', '/images/')

    fonts = ''
    fonthack = ''
    for font in test.get('fonts', []):
        fonts += ('@font-face {\n  font-family: %s;\n'
                  '  src: url("/fonts/%s.ttf");\n}\n' % (font, font))
        # Browsers require the font to actually be used in the page.
        if test.get('fonthack', 1):
            fonthack += ('<span style="font-family: %s; position: '
                         'absolute; visibility: hidden">A</span>\n' % font)
    if fonts:
        fonts = '<style>\n%s</style>\n' % fonts

    fallback = test.get('fallback',
                        '<p class="fallback">FAIL (fallback content)</p>')

    desc = test.get('desc', '')
    escaped_desc = _simpleEscapeJS(desc)

    attributes = test.get('attributes', '')
    if attributes:
        context_args = "'2d', %s" % attributes.strip()
        attributes = ', ' + attributes.strip()
    else:
        context_args = "'2d'"

    template_params = {
        'name': name,
        'desc': desc,
        'escaped_desc': escaped_desc,
        'notes': notes,
        'images': images,
        'fonts': fonts,
        'fonthack': fonthack,
        'timeout': timeout,
        'timeout_js': timeout_js,
        'canvas': canvas,
        'width': width,
        'height': height,
        'expected': expectation_html,
        'code': code,
        'fallback': fallback,
        'attributes': attributes,
        'context_args': context_args
    }

    test_path = os.path.join(test_output_dir, sub_dir, name)
    if 'manual' in test:
        test_path += '-manual'

    if is_offscreen_canvas:
        pathlib.Path(f'{test_path}.html').write_text(
            templates['offscreen'] % template_params, 'utf-8')
        pathlib.Path(f'{test_path}.worker.js').write_text(
            templates['worker'] % template_params, 'utf-8')
    else:
        pathlib.Path(f'{test_path}.html').write_text(
            templates['element'] % template_params, 'utf-8')


def genTestUtils(TESTOUTPUTDIR: str, IMAGEOUTPUTDIR: str, TEMPLATEFILE: str,
                 NAME2DIRFILE: str, ISOFFSCREENCANVAS: bool) -> None:
    # Run with --test argument to run unit tests
    if len(sys.argv) > 1 and sys.argv[1] == '--test':
        doctest = importlib.import_module('doctest')
        doctest.testmod()
        sys.exit()

    templates = yaml.safe_load(pathlib.Path(TEMPLATEFILE).read_text())
    name_to_sub_dir = yaml.safe_load(pathlib.Path(NAME2DIRFILE).read_text())

    tests = []
    test_yaml_directory = 'yaml/element'
    if ISOFFSCREENCANVAS:
        test_yaml_directory = 'yaml/offscreen'
    TESTSFILES = [
        os.path.join(test_yaml_directory, f)
        for f in os.listdir(test_yaml_directory) if f.endswith('.yaml')
    ]
    for t in sum(
        [yaml.safe_load(pathlib.Path(f).read_text()) for f in TESTSFILES], []):
        if 'DISABLED' in t:
            continue
        if 'meta' in t:
            eval(compile(t['meta'], '<meta test>', 'exec'), {},
                 {'tests': tests})
        else:
            tests.append(t)

    # Ensure the test output directories exist.
    testdirs = [TESTOUTPUTDIR, IMAGEOUTPUTDIR]
    for sub_dir in set(name_to_sub_dir.values()):
        testdirs.append('%s/%s' % (TESTOUTPUTDIR, sub_dir))
    for d in testdirs:
        try:
            os.mkdir(d)
        except FileExistsError:
            pass  # Ignore if it already exists.

    used_tests = {}
    for test in tests:
        name = test['name']
        print('\r(%s)' % name, ' ' * 32, '\t')

        if name in used_tests:
            print('Test %s is defined twice' % name)
        used_tests[name] = 1

        sub_dir = _get_test_sub_dir(name, name_to_sub_dir)
        _generate_test(test, templates, sub_dir, TESTOUTPUTDIR, IMAGEOUTPUTDIR,
                       ISOFFSCREENCANVAS)

    print()
