# Copyright 2016 WebAssembly Community Group participants
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import filecmp
import os
import re
import shutil
import subprocess
import sys
import tempfile


def _open_archive(tarfile, tmp_dir):
    with tempfile.TemporaryFile(mode='w+') as f:
        try:
            subprocess.check_call(['tar', '-xvf', tarfile], cwd=tmp_dir, stdout=f)
        except Exception:
            f.seek(0)
            sys.stderr.write(f.read())
            raise
    return os.listdir(tmp_dir)


def _files_same(dir1, dir2, basenames):
    diff = filecmp.cmpfiles(dir1, dir2, basenames)
    return 0 == len(diff[1] + diff[2])


def _dirs_same(dir1, dir2, basenames):
    for d in basenames:
        left = os.path.join(dir1, d)
        right = os.path.join(dir2, d)
        if not (os.path.isdir(left) and os.path.isdir(right)):
            return False
        diff = filecmp.dircmp(right, right)
        if 0 != len(diff.left_only + diff.right_only + diff.diff_files +
                    diff.common_funny + diff.funny_files):
            return False
    return True


def _move_files(dirfrom, dirto, basenames):
    for f in basenames:
        from_file = os.path.join(dirfrom, f)
        to_file = os.path.join(dirto, f)
        if os.path.isfile(to_file):
            os.path.remove(to_file)
        shutil.move(from_file, to_file)


def _move_dirs(dirfrom, dirto, basenames):
    for d in basenames:
        from_dir = os.path.join(dirfrom, d)
        to_dir = os.path.join(dirto, d)
        if os.path.isdir(to_dir):
            shutil.rmtree(to_dir)
        shutil.move(from_dir, to_dir)


def untar(tarfile, outdir):
    """Returns True if untar content differs from pre-existing outdir content."""
    tmpdir = tempfile.mkdtemp()
    try:
        untared = _open_archive(tarfile, tmpdir)
        files = [f for f in untared if os.path.isfile(os.path.join(tmpdir, f))]
        dirs = [d for d in untared if os.path.isdir(os.path.join(tmpdir, d))]
        assert len(files) + len(dirs) == len(untared), 'Only files and directories'
        if _files_same(tmpdir, outdir, files) and _dirs_same(tmpdir, outdir, dirs):
            # Nothing new or different in the tarfile.
            return False
        # Some or all of the files / directories are new.
        _move_files(tmpdir, outdir, files)
        _move_dirs(tmpdir, outdir, dirs)
        return True
    finally:
        if os.path.isdir(tmpdir):
            shutil.rmtree(tmpdir)


QUOTED = re.compile(r'\(module\s*(\$\S*)?\s+(quote|binary)')


def split_wast(wastFile):
    # if it's a binary, leave it as is, we can't split it
    wast = None
    if not wastFile.endswith('.wasm'):
        try:
            wast = open(wastFile, 'r').read()
        except Exception:
            pass

    if not wast:
        return ((open(wastFile, 'rb').read(), []),)

    # .wast files can contain multiple modules, and assertions for each one.
    # this splits out a wast into [(module, assertions), ..]
    # we ignore module invalidity tests here.
    ret = []

    def to_end(j):
        depth = 1
        while depth > 0 and j < len(wast):
            if wast[j] == '"':
                while 1:
                    j = wast.find('"', j + 1)
                    if wast[j - 1] == '\\':
                        continue
                    break
                assert j > 0
            elif wast[j] == '(':
                depth += 1
            elif wast[j] == ')':
                depth -= 1
            elif wast[j] == ';' and wast[j + 1] == ';':
                j = wast.find('\n', j)
            j += 1
        return j

    i = 0
    ignoring_quoted = False
    while i >= 0:
        start = wast.find('(', i)
        if start >= 0 and wast[start + 1] == ';':
            # block comment
            i = wast.find(';)', start + 2)
            assert i > 0, wast[start:]
            i += 2
            continue
        skip = wast.find(';', i)
        if skip >= 0 and skip < start and skip + 1 < len(wast):
            if wast[skip + 1] == ';':
                i = wast.find('\n', i) + 1
                continue
        if start < 0:
            break
        i = to_end(start + 1)
        chunk = wast[start:i]
        if QUOTED.match(chunk):
            # There may be assertions after this quoted module, but we aren't
            # returning the module, so we need to skip the assertions as well.
            ignoring_quoted = True
            continue
        if chunk.startswith('(module'):
            ignoring_quoted = False
            ret += [(chunk, [])]
        elif chunk.startswith('(assert_invalid'):
            continue
        elif chunk.startswith(('(assert', '(invoke', '(register')) and not ignoring_quoted:
            # ret may be empty if there are some asserts before the first
            # module. in that case these are asserts *without* a module, which
            # are valid (they may check something that doesn't refer to a module
            # in any way).
            if not ret:
                ret += [(None, [])]
            ret[-1][1].append(chunk)
    return ret


# write a split wast from split_wast. the wast may be binary if the original
# file was binary
def write_wast(filename, wast, asserts=[]):
    if type(wast) == bytes:
        assert not asserts
        with open(filename, 'wb') as o:
            o.write(wast)
    else:
        with open(filename, 'w') as o:
            o.write(wast + '\n'.join(asserts))


def run_command(cmd, expected_status=0, stderr=None,
                expected_err=None, err_contains=False, err_ignore=None):
    if expected_err is not None:
        assert stderr == subprocess.PIPE or stderr is None,\
            "Can't redirect stderr if using expected_err"
        stderr = subprocess.PIPE
    print('executing: ', ' '.join(cmd))
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=stderr, universal_newlines=True, encoding='UTF-8')
    out, err = proc.communicate()
    code = proc.returncode
    if expected_status is not None and code != expected_status:
        raise Exception(('run_command failed (%s)' % code, out + str(err or '')))
    if expected_err is not None:
        if err_ignore is not None:
            err = "\n".join([line for line in err.split('\n') if err_ignore not in line])
        err_correct = expected_err in err if err_contains else expected_err == err
        if not err_correct:
            raise Exception(('run_command unexpected stderr',
                             "expected '%s', actual '%s'" % (expected_err, err)))
    return out


def node_has_webassembly(cmd):
    cmd = [cmd, '-e', 'process.stdout.write(typeof WebAssembly)']
    return run_command(cmd) == 'object'


def js_test_wrap():
    # common wrapper code for JS tests, waiting for binaryen.js to become ready
    # and providing common utility used by all tests:
    return '''
        (async function __in_test_code__() {
            var binaryen = await Binaryen()
            function assert(x) { if (!x) throw Error('Test assertion failed'); }
            %TEST%
        })();
    '''
