# -*- coding: utf-8 -*-

import atexit
import base64
import contextlib
from io import StringIO
import importlib
import os
import py_compile
import re
import shutil
import sre_constants
import subprocess
import sys
import tempfile
import traceback
import itertools
import collections
from unittest import TestCase

# get path to `tests` directory
TESTS_DIR = os.path.dirname(__file__)

# A state variable to determine if the test environment has been configured.
_suite_configured = False

# Temporary directory containing all files generated by this test process
# Set during setUpSuite()
_output_dir = ''


def setUpSuite():
    """Configure the entire test suite.

    This only needs to be run once, prior to the first test.
    """
    global _output_dir
    global _suite_configured
    if _suite_configured:
        return

    def remove_output_dir():
        global _output_dir
        if _output_dir != '':
            try:
                shutil.rmtree(_output_dir)
            except FileNotFoundError:
                pass

    atexit.register(remove_output_dir)
    _output_dir = tempfile.mkdtemp(dir=TESTS_DIR)

    if os.environ.get('PRECOMPILE', 'true').lower() == 'true':
        print("building 'batavia.js'")
        proc = subprocess.Popen(
            [os.path.join(os.path.dirname(TESTS_DIR), "node_modules", ".bin", "webpack"), "--bail"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            shell=True,
        )

        try:
            out, err = proc.communicate(timeout=60)
        except subprocess.TimeoutExpired:
            proc.kill()
            out, err = proc.communicate()
            raise

        if proc.returncode != 0:
            raise Exception("Error compiling batavia sources: " + out.decode('ascii'))
    else:
        print("Not precompiling 'batavia.js' as part of test run")

    _suite_configured = True


@contextlib.contextmanager
def capture_output(redirect_stderr=True):
    oldout, olderr = sys.stdout, sys.stderr
    try:
        out = StringIO()
        sys.stdout = out
        if redirect_stderr:
            sys.stderr = out
        else:
            sys.stderr = StringIO()
        yield out
    except:
        if redirect_stderr:
            traceback.print_exc()
        else:
            raise
    finally:
        sys.stdout, sys.stderr = oldout, olderr


def adjust(text, run_in_function=False):
    """Adjust a code sample to remove leading whitespace."""
    lines = text.split('\n')
    if len(lines) == 1:
        return text

    if lines[0].strip() == '':
        lines = lines[1:]
    first_line = lines[0].lstrip()
    n_spaces = len(lines[0]) - len(first_line)

    final_lines = [('    ' if run_in_function else '') + line[n_spaces:] for line in lines]

    if run_in_function:
        final_lines = [
            "def test_function():",
        ] + final_lines + [
            "test_function()",
        ]

    return '\n'.join(final_lines)


def runAsPython(test_dir, main_code, extra_code=None, run_in_function=False, args=None):
    """Run a block of Python code with the Python interpreter."""
    # Output source code into test directory
    with open(os.path.join(test_dir, 'test.py'), 'w', encoding='utf-8') as py_source:
        py_source.write(adjust(main_code, run_in_function=run_in_function))

    if extra_code:
        for name, code in extra_code.items():
            path = name.split('.')
            path[-1] = path[-1] + '.py'
            if len(path) != 1:
                try:
                    os.makedirs(os.path.join(test_dir, *path[:-1]))
                except FileExistsError:
                    pass
            with open(os.path.join(test_dir, *path), 'w', encoding="utf-8") as py_source:
                py_source.write(adjust(code))

    if args is None:
        args = []

    env_copy = os.environ.copy()
    env_copy['PYTHONIOENCODING'] = 'UTF-8'
    proc = subprocess.Popen(
        [sys.executable, "test.py"] + args,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        cwd=test_dir,
        env=env_copy,
    )
    out = proc.communicate()

    return out[0].decode('utf8')


JS_EXCEPTION = re.compile('Traceback \(most recent call last\):\r?\n(  File "(?P<file>.*)", line (?P<line>\d+), in .*\r?\n)+(?P<exception>.*?): (?P<message>.*\r?\n)')  # NOQA
JS_STACK = re.compile('  File "(?P<file>.*)", line (?P<line>\d+), in .*\r?\n')
JS_BOOL_TRUE = re.compile('true')
JS_BOOL_FALSE = re.compile('false')
JS_FLOAT_DECIMAL = re.compile(r'''(?<!['\"])(\d+\.\d+)''')
JS_FLOAT_EXP = re.compile('(\d+)e(-)?0?(\d+)')
JS_LARGE_COMPLEX = re.compile('\((\d{15}\d+)[-+]')

PYTHON_EXCEPTION = re.compile('Traceback \(most recent call last\):\r?\n(  File "(?P<file>.*)", line (?P<line>\d+), in .*\r?\n    .*\r?\n)+(?P<exception>.*?): (?P<message>.*\r?\n)')  # NOQA
PYTHON_STACK = re.compile('  File "(?P<file>.*)", line (?P<line>\d+), in .*\r?\n    .*\r?\n')
PYTHON_FLOAT_EXP = re.compile('(\d+)e(-)?0?(\d+)')
PYTHON_NEGATIVE_ZERO_J = re.compile('-0j\)')

# Prevent floating point discrepancies in very low significant digits from being an issue
FLOAT_PRECISION = re.compile('(\\.\d{5})\d+')
MEMORY_REFERENCE = re.compile('0x[\dABCDEFabcdef]{4,16}')

def transforms(**transform_args):
    """
    injects a JSCleaner and PYCleaner object into the function
    use this as a decarator to configure which transformations should be performed
    """
    def _dec(function):

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

            # js_cleaner
            js_excludes = ['py_test_script', 'py_str_excep']
            js_params = {
                jsk : jsv
                for jsk, jsv
                in transform_args.items()
                if jsk not in js_excludes
            }
            js_cleaner = JSCleaner(**js_params)

            # py_cleaner
            py_excludes = ['js_bool', 'decimal', 'float_exp']
            py_params = {
                pyk : pyv
                for pyk, pyv
                in transform_args.items()
                if pyk not in py_excludes
            }
            py_cleaner = PYCleaner(**py_params)

            res = function(self, js_cleaner, py_cleaner, *args, **kwargs)
            return res
        wrapper.__name__ = function.__name__
        wrapper.__doc__ = function.__doc__
        return wrapper

    return _dec


class JSCleaner:
    def __init__(self, err_msg = True, memory_ref = True, js_bool = False, decimal = True, float_exp = True, complex_num = True,
        high_precision_float = True, test_ref = True, custom = True):

        self.transforms = {
            'err_msg': err_msg,
            'memory_ref': memory_ref,
            'js_bool': js_bool,
            'decimal': decimal,
            'float_exp': float_exp,
            'complex_num': complex_num,
            'high_precision_float': high_precision_float,
            'test_ref': test_ref,
            'custom': custom
        }

    def cleanse(self, js_input, substitutions):
        """
        cleanse output from javascript
        """
        # Test the specific message
        out = js_input
        if self.transforms['err_msg']:
            out = JS_EXCEPTION.sub('### EXCEPTION ###{linesep}\\g<exception>: \\g<message>'.format(linesep=os.linesep), js_input)

            stack = JS_STACK.findall(js_input)

            stacklines = []
            test_dir = os.path.join(os.getcwd(), 'tests', 'temp')
            for filename, line in stack:
                if filename.startswith(test_dir):
                    filename = filename[len(test_dir) + 1:]
                stacklines.append(
                    "    %s:%s" % (
                        filename, line
                    )
                )

            out = '%s%s%s' % (
                out,
                os.linesep.join(stacklines),
                os.linesep if stack else ''
            )

        # Normalize memory references from output
        if self.transforms['memory_ref']:
            out = MEMORY_REFERENCE.sub("0xXXXXXXXX", out)

        if self.transforms['js_bool']:
            # Normalize true and false to True and False
            out = JS_BOOL_TRUE.sub("True", out)
            out = JS_BOOL_FALSE.sub("False", out)

        if self.transforms['decimal']:
            # Replace floating point numbers in decimal form with
            # the form used by python
            for match in JS_FLOAT_DECIMAL.findall(out):
                out = out.replace(match, str(float(match)))

        if self.transforms['float_exp']:
            # Format floating point numbers using a lower case e
            try:
                out = JS_FLOAT_EXP.sub('\\1e\\2\\3', out)
            except:
                pass

        if self.transforms['high_precision_float']:
            # Replace high precision floats with abbreviated forms
            out = FLOAT_PRECISION.sub('\\1...', out)

        if self.transforms['test_ref']:
            # Replace references to the test script with something generic
            out = out.replace("'test.py'", '***EXECUTABLE***')

        if self.transforms['custom']:
            # Replace all the explicit data substitutions
            if substitutions:
                for to_value, from_values in substitutions.items():
                    for from_value in from_values:
                        # check for regex
                        if hasattr(from_value, 'pattern'):
                            out = re.sub(from_value.pattern, re.escape(to_value), out, 0, re.MULTILINE)
                        else:
                            out = out.replace(from_value, to_value)


            out = out.replace('\r\n', '\n')
            # trim trailing whitespace on non-blank lines
            out = '\n'.join(o.rstrip() for o in out.split('\n'))

        return out


class PYCleaner:
    def __init__(self, err_msg = True, memory_ref = True, float_exp = True, complex_num = True,
        high_precision_float = True, test_ref = True, custom = True):

        self.transforms = {
            'err_msg': err_msg,
            'memory_ref': memory_ref,
            'float_exp': float_exp,
            'complex_num': complex_num,
            'high_precision_float': high_precision_float,
            'test_ref': test_ref,
            'custom': custom
        }

    def cleanse(self, py_input, substitutions):
        """
        cleanse output from python
        """
        out = py_input
        if self.transforms['err_msg']:
            # Test the specific message
            out = PYTHON_EXCEPTION.sub(
                '### EXCEPTION ###{linesep}\\g<exception>: \\g<message>'.format(linesep=os.linesep),
                py_input
            )

            stack = PYTHON_STACK.findall(py_input)
            out = '%s%s%s' % (
                out,
                os.linesep.join(
                    [
                        "    %s:%s" % (s[0], s[1])
                        for s in stack
                    ]
                ),
                os.linesep if stack else ''
            )

        if self.transforms['memory_ref']:
            # Normalize memory references from output
            out = MEMORY_REFERENCE.sub("0xXXXXXXXX", out)

        if self.transforms['float_exp']:
            # Format floating point numbers using a lower case e

            try:
                out = PYTHON_FLOAT_EXP.sub('\\1e\\2\\3', out)
            except sre_constants.error:
                pass

        if self.transforms['high_precision_float']:
            # Replace high precision floats with abbreviated forms
            out = FLOAT_PRECISION.sub('\\1...', out)

        if self.transforms['test_ref']:
            # Replace references to the test script with something generic
            out = out.replace("'test.py'", '***EXECUTABLE***')

        # Python 3.4.4 changed the message describing strings in exceptions
        out = out.replace(
            'argument must be a string or',
            'argument must be a string, a bytes-like object or'
        )

        if self.transforms['custom']:
            if substitutions:
                for to_value, from_values in substitutions.items():
                    for from_value in from_values:
                        # check for regex
                        if hasattr(from_value, 'pattern'):
                            out = re.sub(from_value.pattern, re.escape(to_value), out, 0, re.MULTILINE)
                        else:
                            out = out.replace(from_value, to_value)

            out = out.replace('\r\n', '\n')
            # trim trailing whitespace on non-blank lines
            out = '\n'.join(o.rstrip() for o in out.split('\n'))

        return out


def _normalize(value):
    """
    ||| -- lines starting with this pattern will be `eval`uated and compared as
        native python objects. Mostly used for output data from scripts.
    /// -- error messages might print out information from the object that
        generated it. This might lead to failure due to the ordering randomness
        of some object types. Lines starting with this pattern will be treated specially
        as to overcome this problem.

    Notice that a line might start with '||| ///'. This is helpful for string replacement cases.
    """
    native = value
    if value:
        if value.startswith('||| '):
            value = value[4:]
            try:
                native = eval(value)
            except:
                pass

        if value.startswith('/// '):
            value = value[4:]
            native = collections.Counter(value)

    return value, native


def _normalize_outputs(code1, code2, transform_output=None):
    """
    transform_output -- a function that receives one argument
        and returns a value to be used for comparing output values
    """
    if not transform_output:
        transform_output = lambda x: x

    processed_code1 = []
    processed_code2 = []

    lines1 = code1.split('\n')
    lines2 = code2.split('\n')

    for line1, line2 in itertools.zip_longest(lines1, lines2, fillvalue=None):

        line1, val1 = _normalize(line1)
        line2, val2 = _normalize(line2)
        if transform_output(val1) == transform_output(val2):
            line2 = line1

        if line1 is not None:
            processed_code1.append(line1)
        if line2 is not None:
            processed_code2.append(line2)

    return '\n'.join(processed_code1), '\n'.join(processed_code2)


class TranspileTestCase(TestCase):
    @classmethod
    def setUpClass(cls):
        global _output_dir
        setUpSuite()
        cls.temp_dir = _output_dir

    def assertCodeExecution(
            self, code,
            message=None,
            extra_code=None,
            run_in_global=True, run_in_function=True, transform_output=None,
            args=None, substitutions=None, js_cleaner=JSCleaner(), py_cleaner=PYCleaner()):
        "Run code as native python, and under JavaScript and check the output is identical"
        self.maxDiff = None
        # ==================================================
        # Pass 1 - run the code in the global context
        # ==================================================
        if run_in_global:
            try:
                self.makeTempDir()

                # Run the code as Python and as JavaScript.
                py_out = runAsPython(
                    self.temp_dir,
                    code,
                    extra_code=extra_code,
                    run_in_function=False,
                    args=args
                )
                js_out = self.runAsJavaScript(
                    code,
                    extra_code=extra_code,
                    run_in_function=False,
                    args=args,
                    python_exists=True
                )
            except Exception as e:
                self.fail(e)
            finally:
                # Clean up the test directory where the class file was written.
                shutil.rmtree(self.temp_dir)
            # Cleanse the Python and JavaScript output, producing a simple
            # normalized format for exceptions, floats etc.
            js_out, py_out = _normalize_outputs(js_out, py_out, transform_output=transform_output)
            js_out = js_cleaner.cleanse(js_out, substitutions)
            py_out = py_cleaner.cleanse(py_out, substitutions)

            # Confirm that the output of the JavaScript code is the same as the Python code.
            if message:
                context = 'Global context: %s' % message
            else:
                context = 'Global context'

            self.assertEqual(js_out, py_out, context)

        # ==================================================
        # Pass 2 - run the code in a function's context
        # ==================================================
        if run_in_function:
            try:
                self.makeTempDir()
                # Run the code as Python and as Java.
                py_out = runAsPython(
                    self.temp_dir,
                    code,
                    extra_code=extra_code,
                    run_in_function=True,
                    args=args
                )
                js_out = self.runAsJavaScript(
                    code,
                    extra_code=extra_code,
                    run_in_function=True,
                    args=args,
                    python_exists=True
                )
            except Exception as e:
                self.fail(e)
            finally:
                # Clean up the test directory where the class file was written.
                shutil.rmtree(self.temp_dir)

            # Cleanse the Python and JavaScript output, producing a simple
            # normalized format for exceptions, floats etc.
            js_out, py_out = _normalize_outputs(js_out, py_out, transform_output=transform_output)
            js_out = js_cleaner.cleanse(js_out, substitutions)
            py_out = py_cleaner.cleanse(py_out, substitutions)

            # Confirm that the output of the JavaScript code is the same as the Python code.
            if message:
                context = 'Function context: %s' % message
            else:
                context = 'Function context'

            self.assertEqual(js_out, py_out, context)

    def assertJavaScriptExecution(
            self, code, out,
            extra_code=None, js=None,
            run_in_global=True, run_in_function=True,
            args=None, substitutions=None, same=True, js_cleaner=JSCleaner()):
        "Run code under JavaScript and check the output is as expected"
        self.maxDiff = None
        # ==================================================
        # Prep - compile any required JavaScript sources
        # ==================================================
        # Cleanse the Python output, producing a simple
        # normalized format for exceptions, floats etc.
        py_out = adjust(out)

        # ==================================================
        # Pass 1 - run the code in the global context
        # ==================================================
        if run_in_global:
            try:
                self.makeTempDir()

                # Run the code as Javascript.
                js_out = self.runAsJavaScript(
                    code,
                    extra_code=extra_code,
                    js=js,
                    run_in_function=False,
                    args=args
                )
            except Exception as e:
                self.fail(e)
            finally:
                # Clean up the test directory where the class file was written.
                shutil.rmtree(self.temp_dir)

            # Cleanse the JavaScript output, producing a simple
            # normalized format for exceptions, floats etc.
            js_out = js_cleaner.cleanse(js_out, substitutions)

            # Compare the output of the JavaScript code with the Python code.
            if same:
                self.assertEqual(js_out, py_out, 'Global context')
            else:
                self.assertNotEqual(js_out, py_out, 'Global context')

        # ==================================================
        # Pass 2 - run the code in a function's context
        # ==================================================
        if run_in_function:
            try:
                self.makeTempDir()

                # Run the code as JavaScript.
                js_out = self.runAsJavaScript(
                    code,
                    extra_code=extra_code,
                    js=js,
                    run_in_function=True,
                    args=args
                )
            except Exception as e:
                self.fail(e)
            finally:
                # Clean up the test directory where the class file was written.
                shutil.rmtree(self.temp_dir)

            # Cleanse the JavaScript output, producing a simple
            # normalized format for exceptions, floats etc.
            js_out = js_cleaner.cleanse(js_out, substitutions)

            # Compare the output of the JavaScript code with the Python code.
            if same:
                self.assertEqual(js_out, py_out, 'Function context')
            else:
                self.assertNotEqual(js_out, py_out, 'Function context')

    def makeTempDir(self):
        """Create a "temp" subdirectory in the class's generated temporary directory if it doesn't currently exist."""
        try:
            os.mkdir(self.temp_dir)
        except FileExistsError:
            pass

    def runAsJavaScript(
                self, main_code, extra_code=None, js=None,
                run_in_function=False, args=None, python_exists=False
            ):
        # Output source code into test directory
        assert isinstance(main_code, (str, bytes)), (
            'I have no idea how to run tests for code of type {}'
            ''.format(type(main_code))
        )

        # print("MAIN CODE:")
        # print(main_code)

        if not python_exists:
            if isinstance(main_code, str):
                py_filename = os.path.join(self.temp_dir, 'test.py')
                with open(py_filename, 'w', encoding='utf-8') as py_source:
                    py_source.write(adjust(main_code, run_in_function=run_in_function))

        modules = []

        # Temporarily move into the test directory.
        cwd = os.getcwd()
        os.chdir(self.temp_dir)

        if isinstance(main_code, str):
            py_compile.compile('test.py')
            with open(importlib.util.cache_from_source('test.py'), 'rb') as compiled:
                modules.append(('test', base64.encodebytes(compiled.read()), 'test.py'))
        elif isinstance(main_code, bytes):
            modules.append(('test', main_code, 'test.py'))

        if extra_code:
            for name, code in extra_code.items():
                path = name.split('.')
                path[-1] = path[-1] + '.py'
                py_filename = os.path.join(*path)
                if not python_exists:
                    if len(path) != 1:
                        try:
                            os.makedirs(os.path.join(self.temp_dir, *path[:-1]))
                        except FileExistsError:
                            pass

                    with open(py_filename, 'w') as py_source:
                        py_source.write(adjust(code))

                py_compile.compile(py_filename)

                with open(importlib.util.cache_from_source(py_filename), 'rb') as compiled:
                    modules.append((name, base64.encodebytes(compiled.read()), py_filename))

        if args is None:
            args = []

        # Convert the dictionary of modules into a payload
        payload = []
        for name, code, filename in modules:
            lines = code.decode('utf-8').split('\n')
            output = '"%s"' % '" +\n            "'.join(line for line in lines if line)
            if name.endswith('.__init__'):
                name = name.rsplit('.', 1)[0]
            payload.append(
                '    "%s": {\n' % name +
                '        "__python__": true,\n' +
                '        "bytecode": %s,\n' % output +
                '        "filename": "%s"\n' % filename +
                '    }'
            )

        if js:
            for name, code in js.items():
                payload.append(
                    '    "%s": {\n' % name +
                    '        "javascript": %s\n' % name +
                    '    }'
                )

        with open(os.path.join(self.temp_dir, 'test.js'), 'w') as js_file:
            js_file.write(adjust("""
                var batavia = require('../../dist/batavia.js');
                %s
                var modules = {
                %s
                };


                var vm = new batavia.VirtualMachine({
                    loader: function(name) {
                        var payload = modules[name];
                        if (payload === undefined) {
                            return null;
                        }
                        return payload;
                    },
                    frame: null
                });
                vm.run('test', []);
                """) % (
                    '\n'.join(
                        adjust(code)
                        for name, code in sorted(js.items())
                    ) if js else '',
                    ',\n'.join(payload)
                )
            )

        # print("JS CODE:")
        # with open(os.path.join(self.temp_dir, 'test.js')) as js_file:
        #     print(js_file.read())

        proc = subprocess.Popen(
            ['node', 'test.js'] + args,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            # cwd=self.temp_dir,
        )
        out = proc.communicate()

        # Move back to the old current working directory.
        os.chdir(cwd)

        return out[0].decode('utf8')


class NotImplementedToExpectedFailure:

    def _is_flakey(self):
        return self._testMethodName in getattr(self, "is_flakey", [])

    def _is_not_implemented(self):
        '''
        A test is expected to fail if:
          (a) Its name can be found in the test case's 'not_implemented' list
          (b) Its name can be found in the test case's 'is_flakey' list
          (c) Its name can be found in the test case's
              'not_implemented_versions' dictionary _and_ the current
              python version is in the dict entry's list
        :return: True if test is expected to fail
        '''
        method_name = self._testMethodName
        if method_name in getattr(self, 'not_implemented', []):
            return True

        if self._is_flakey():
            # -- Flakey tests sometimes fail, sometimes pass
            return True

        not_implemented_versions = getattr(self, 'not_implemented_versions', {})
        if method_name in not_implemented_versions:
            py_version = "%s.%s" % (sys.version_info.major, sys.version_info.minor)
            if py_version in not_implemented_versions[method_name]:
                return True

        return False


    def run(self, result=None):
        # Override the run method to inject the "expectingFailure" marker
        # when the test case runs.
        if self._is_not_implemented():
            # Mark 'expecting failure' on class. It will only be applicable
            # for this specific run.
            method = getattr(self, self._testMethodName)

            def wrapper(*args, **kwargs):
                if self._is_flakey():
                    raise Exception("Flakey test that sometimes fails and sometimes passes")
                return method(*args, **kwargs)

            wrapper.__unittest_expecting_failure__ = True
            setattr(self, self._testMethodName, wrapper)
        return super().run(result=result)


SAMPLE_DATA = {
    'bool': [
            'True',
            'False',
        ],
    'bytearray': [
            # 'bytearray()',
            # 'bytearray(1)',
            # 'bytearray([1, 2, 3])',
            'bytearray(b"hello world")',
        ],
    'bytes': [
            'b""',
            'b"This is another string of bytes"',
            'b"\x01\x75"',  # mixing nonprintable and printable bytes
        ],
    'class': [
            'type(1)',
            'type("a")',
            # 'type(object())', # TODO: re-enable this when object() is implemented
            'type("MyClass", (object,), {})',
        ],
    'complex': [
            '1j',
            '3.14159265j',
            '1+2j',
            '3-4j',
            # '-5j',
        ],
    'dict': [
            '{}',
            '{"a": 1, "c": 2.3456, "d": "another"}',
            '{"a": {"a": 1}, "b": {"b": 2}}',
        ],
    'float': [
            '2.3456',
            '0.0',
            '-3.14159',
            '-4.81756',
            '5.5',
            '-3.5',
            '4.5',
            '-4.5',
        ],
    'frozenset': [
            'frozenset()',
            'frozenset([1])',
            # 'frozenset({"1"})', this reveals some bugs in our code
            'frozenset({1, 2.3456, "another"})',
        ],
    'int': [
            '3',
            '0',
            '-5',
            '-3',
            '5',
            '1',
            '-1',
            '9223372036854775807',
            '9223372036854775808',
            '-9223372036854775807',
            '-9223372036854775808',
            '18446744073709551615',
            '18446744073709551616',
            '18446744073709551617',
            '-18446744073709551615',
            '-18446744073709551616',
            '-18446744073709551617',
            '1361129467683753853853498429727072845824',
            '-1361129467683753853853498429727072845824',
            '179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216',  # NOQA
            '-179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216',  # NOQA
        ],
    'list': [
            '[]',
            '[1]',
            '[3, 4, 5]',
            '[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]',
            '["a","b","c"]',
            '[[1, 2], [3, 4]]',
        ],
    'range': [
            'range(0)',
            'range(5)',
            'range(2, 7)',
            'range(2, 7, 2)',
            'range(7, 2, -1)',
            'range(7, 2, -2)',
        ],
    'set': [
            'set()',
            '{1, 2.3456, "another"}',
        ],
    'slice': [
            'slice(0)',
            'slice(5)',
            'slice(2, 7)',
            'slice(2, 7, 2)',
            'slice(7, 2, -1)',
            'slice(7, 2, -2)',
        ],
    'str': [
            '""',
            '"3"',
            '"This is another string"',
            '"Mÿ hôvèrçràft îß fûłl öf éêlś"',
            '"/// One arg: %s"',
            '"Three args: %s | %s | %s"',
        ],
    'tuple': [
            '()',
            '(False,)',
            '(1,)',
            '(1, 2)',
            '(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)',
            '(3, 1.2, True, )',
            '(1, 2.3456, "another")',
            '((1, 2), (3, 4))',
            '((3, 4), (1, 2))'
        ],
    'None': [
            'None',
        ],
    'NotImplemented': [
            'NotImplemented',
        ],
}


SAMPLE_SUBSTITUTIONS = {

}


def _unary_test(test_name, operation):
    def func(self):
        self.assertUnaryOperation(
            x_values=SAMPLE_DATA[self.data_type],
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class UnaryOperationTestCase(NotImplementedToExpectedFailure):
    format = ''

    def assertUnaryOperation(self, x_values, operation, format, substitutions):
        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> x = %(x)s')
                        print('>>> %(format)s%(operation)sx')
                        x = %(x)s
                        print('|||', %(format)s%(operation)sx)
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                    """ % {
                        'x': x,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x in x_values
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    test_unary_positive = _unary_test('test_unary_positive', '+')
    test_unary_negative = _unary_test('test_unary_negative', '-')
    test_unary_not = _unary_test('test_unary_not', 'not ')
    test_unary_invert = _unary_test('test_unary_invert', '~')


def _binary_test(test_name, operation, examples, small_ints=False):
    def func(self):
        # CPython will attempt to malloc itself to death for some operations,
        # e.g., 1 << (2**32)
        # so we have this dirty hack
        actuals = examples
        if small_ints and test_name.endswith('_int'):
            actuals = [x for x in examples if abs(int(x)) < 8192]
        self.assertBinaryOperation(
            x_values=SAMPLE_DATA[self.data_type],
            y_values=actuals,
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class BinaryOperationTestCase(NotImplementedToExpectedFailure):
    format = ''
    y = 3

    def assertBinaryOperation(self, x_values, y_values, operation, format, substitutions):
        data = []
        for x in x_values:
            for y in y_values:
                data.append((x, y))

        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> x = %(x)s')
                        print('>>> y = %(y)s')
                        print('>>> %(format)s%(operation)s')
                        x = %(x)s
                        y = %(y)s
                        print('|||', %(format)s%(operation)s)
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                    """ % {
                        'x': x,
                        'y': y,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x, y in data
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    for datatype, examples in SAMPLE_DATA.items():
        vars()['test_add_%s' % datatype] = _binary_test(
            'test_add_%s' % datatype, 'x + y', examples
        )
        vars()['test_subtract_%s' % datatype] = _binary_test(
            'test_subtract_%s' % datatype, 'x - y', examples
        )
        vars()['test_multiply_%s' % datatype] = _binary_test(
            'test_multiply_%s' % datatype, 'x * y', examples, small_ints=True
        )
        vars()['test_floor_divide_%s' % datatype] = _binary_test(
            'test_floor_divide_%s' % datatype, 'x // y', examples
        )
        vars()['test_true_divide_%s' % datatype] = _binary_test(
            'test_true_divide_%s' % datatype, 'x / y', examples
        )
        vars()['test_modulo_%s' % datatype] = _binary_test(
            'test_modulo_%s' % datatype, 'x % y', examples
        )
        vars()['test_power_%s' % datatype] = _binary_test(
            'test_power_%s' % datatype, 'x ** y', examples, small_ints=True
        )
        vars()['test_subscr_%s' % datatype] = _binary_test(
            'test_subscr_%s' % datatype, 'x[y]', examples
        )
        vars()['test_lshift_%s' % datatype] = _binary_test(
            'test_lshift_%s' % datatype, 'x << y', examples, small_ints=True
        )
        vars()['test_rshift_%s' % datatype] = _binary_test(
            'test_rshift_%s' % datatype, 'x >> y', examples, small_ints=True
        )
        vars()['test_and_%s' % datatype] = _binary_test(
            'test_and_%s' % datatype, 'x & y', examples
        )
        vars()['test_xor_%s' % datatype] = _binary_test(
            'test_xor_%s' % datatype, 'x ^ y', examples
        )
        vars()['test_or_%s' % datatype] = _binary_test(
            'test_or_%s' % datatype, 'x | y', examples
        )

        vars()['test_lt_%s' % datatype] = _binary_test(
            'test_lt_%s' % datatype, 'x < y', examples
        )
        vars()['test_le_%s' % datatype] = _binary_test(
            'test_le_%s' % datatype, 'x <= y', examples
        )
        vars()['test_gt_%s' % datatype] = _binary_test(
            'test_gt_%s' % datatype, 'x > y', examples
        )
        vars()['test_ge_%s' % datatype] = _binary_test(
            'test_ge_%s' % datatype, 'x >= y', examples
        )
        vars()['test_eq_%s' % datatype] = _binary_test(
            'test_eq_%s' % datatype, 'x == y', examples
        )
        vars()['test_ne_%s' % datatype] = _binary_test(
            'test_ne_%s' % datatype, 'x != y', examples
        )


def _inplace_test(test_name, operation, examples, small_ints=False):
    def func(self):
        actuals = examples
        if small_ints and test_name.endswith('_int'):
            actuals = [x for x in examples if abs(int(x)) < 8192]
        self.assertInplaceOperation(
            x_values=SAMPLE_DATA[self.data_type],
            y_values=actuals,
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class InplaceOperationTestCase(NotImplementedToExpectedFailure):
    format = ''
    y = 3

    def assertInplaceOperation(self, x_values, y_values, operation, format, substitutions):
        data = []
        for x in x_values:
            for y in y_values:
                data.append((x, y))

        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> x = %(x)s')
                        print('>>> y = %(y)s')
                        print('>>> %(operation)s')
                        print('>>> %(format)sx')
                        x = %(x)s
                        y = %(y)s
                        %(operation)s
                        print('|||', %(format)sx)
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                    """ % {
                        'x': x,
                        'y': y,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x, y in data
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    for datatype, examples in SAMPLE_DATA.items():
        vars()['test_add_%s' % datatype] = _inplace_test(
            'test_add_%s' % datatype, 'x += y', examples
        )
        vars()['test_subtract_%s' % datatype] = _inplace_test(
            'test_subtract_%s' % datatype, 'x -= y', examples
        )
        vars()['test_multiply_%s' % datatype] = _inplace_test(
            'test_multiply_%s' % datatype, 'x *= y', examples, small_ints=True
        )
        vars()['test_floor_divide_%s' % datatype] = _inplace_test(
            'test_floor_divide_%s' % datatype, 'x //= y', examples
        )
        vars()['test_true_divide_%s' % datatype] = _inplace_test(
            'test_true_divide_%s' % datatype, 'x /= y', examples
        )
        vars()['test_modulo_%s' % datatype] = _inplace_test(
            'test_modulo_%s' % datatype, 'x %= y', examples
        )
        vars()['test_power_%s' % datatype] = _inplace_test(
            'test_power_%s' % datatype, 'x **= y', examples, small_ints=True
        )
        vars()['test_lshift_%s' % datatype] = _inplace_test(
            'test_lshift_%s' % datatype, 'x <<= y', examples, small_ints=True
        )
        vars()['test_rshift_%s' % datatype] = _inplace_test(
            'test_rshift_%s' % datatype, 'x >>= y', examples, small_ints=True
        )
        vars()['test_and_%s' % datatype] = _inplace_test(
            'test_and_%s' % datatype, 'x &= y', examples
        )
        vars()['test_xor_%s' % datatype] = _inplace_test(
            'test_xor_%s' % datatype, 'x ^= y', examples
        )
        vars()['test_or_%s' % datatype] = _inplace_test(
            'test_or_%s' % datatype, 'x |= y', examples
        )


IGNORE_ORDER_DICTIONARY = {
    'tuple': ['set', 'frozenset', 'dict'],
    'list': ['set', 'frozenset', 'dict'],
}


def _builtin_test(test_name, datatype, operation, small_ints=False):
    def func(self):
        # bytes() gives implementation-dependent errors for sizes > 2**64,
        # we'll skip testing with those values rather than cargo-culting
        # the exact same exceptions
        examples = SAMPLE_DATA.get(datatype, ['"_noargs (should not be use)"'])
        if self.small_ints and test_name.endswith('_int'):
            examples = [x for x in examples if abs(int(x)) < 8192]

        selected_operation = operation
        if self.operation:
            selected_operation = self.operation

        transform_output = None
        ignore_order_cases = IGNORE_ORDER_DICTIONARY.get(self.function, [])
        if datatype in ignore_order_cases:
            transform_output = lambda x: set(x)

        self.assertBuiltinFunction(
            self.function,
            x_values=examples,
            operation=selected_operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS),
            transform_output=transform_output,
        )
    return func


class BuiltinFunctionTestCase(NotImplementedToExpectedFailure):
    format = ''
    operation = None
    substitutions = SAMPLE_SUBSTITUTIONS
    small_ints = False

    def assertBuiltinFunction(self, function, x_values, operation, format, substitutions, transform_output):
        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> f = %(f)s')
                        print('>>> x = %(x)s')
                        print('>>> %(format)s%(operation)s')
                        f = %(f)s
                        x = %(x)s
                        print('|||', %(format)s%(operation)s)
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                    """ % {
                        'f': function,
                        'x': x,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x in x_values
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
            transform_output=transform_output,
        )

    for datatype in SAMPLE_DATA.keys():
        vars()['test_%s' % datatype] = _builtin_test('test_%s' % datatype, datatype, 'f(x)')
    vars()['test_noargs'] = _builtin_test('test_noargs', None, 'f()')


def _builtin_twoarg_test(test_name, operation, examples1, examples2):
    def func(self):
        self.assertBuiltinTwoargFunction(
            self.function,
            x_values=examples1,
            y_values=examples2,
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class BuiltinTwoargFunctionTestCase(NotImplementedToExpectedFailure):
    format = ''

    def assertBuiltinTwoargFunction(self, function, x_values, y_values, operation, format, substitutions):
        data = []
        for x in x_values:
            for y in y_values:
                data.append((x, y))

        # filter out very large integers for some operations so as not
        # to crash CPython
        data = [(x, y) for x, y in data if not
                (function == 'pow' and
                 x.lstrip('-').isdigit() and
                 y.lstrip('-').isdigit() and
                 (abs(int(x)) > 8192 or abs(int(y)) > 8192))]

        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> f = %(f)s')
                        print('>>> x = %(x)s')
                        print('>>> y = %(y)s')
                        print('>>> %(format)s%(operation)s')
                        f = %(f)s
                        x = %(x)s
                        y = %(y)s
                        print('|||', %(format)s%(operation)s)
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                    """ % {
                        'f': function,
                        'x': x,
                        'y': y,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x, y in data
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    for datatype1, examples1 in SAMPLE_DATA.items():
        for datatype2, examples2 in SAMPLE_DATA.items():
            vars()['test_%s_%s' % (datatype1, datatype2)] = _builtin_twoarg_test(
                'test_%s_%s' % (datatype1, datatype2),
                'f(x, y)', examples1, examples2
            )


def _module_one_arg_func_test(name, module, f, examples, small_ints=False):
    # Factorials can make us run out of memory and crash.
    # so we have this dirty hack
    actuals = examples
    if small_ints and name.endswith('_int'):
        actuals = [x for x in examples if abs(int(x)) < 8192]

    def func(self):
        self.assertOneArgModuleFunction(
            name=name,
            module=module,
            func=f,
            x_values=actuals,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


def _module_two_arg_func_test(name, module, f,  examples, examples2):
    def func(self):
        self.assertTwoArgModuleFunction(
            name=name,
            module=module,
            func=f,
            x_values=examples,
            y_values=examples2,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)

        )
    return func


numerics = {'bool', 'float', 'int'}


class ModuleFunctionTestCase(NotImplementedToExpectedFailure):
    numerics_only = False

    def assertOneArgModuleFunction(
        self, name, module, func, x_values, substitutions, **kwargs
    ):
        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> import %(m)s')
                        print('>>> f = %(m)s.%(f)s')
                        print('>>> x = %(x)s')
                        print('>>> f(x)')
                        import %(m)s
                        f = %(m)s.%(f)s
                        x = %(x)s
                        print(f(x))
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                    """ % {
                        'f': func,
                        'x': x,
                        'm': module,
                    }
                )
                for x in x_values
            ),
            "Error running %s module %s" % (module, name),
            substitutions=substitutions,
            run_in_function=False,
            **kwargs
        )

    def assertTwoArgModuleFunction(
        self, name, module, func, x_values, y_values, substitutions, **kwargs
    ):
        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> import %(m)s')
                        print('>>> f = %(m)s.%(f)s')
                        print('>>> x = %(x)s')
                        print('>>> y = %(y)s')
                        print('>>> f(x, y)')
                        import %(m)s
                        f = %(m)s.%(f)s
                        x = %(x)s
                        y = %(y)s
                        print(f(x, y))
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                    """ % {
                        'f': func,
                        'x': x,
                        'y': y,
                        'm': module,
                    }
                )
                for x in x_values for y in y_values
            ),
            "Error running %s module %s" % (module, name),
            substitutions=substitutions,
            run_in_function=False,
            **kwargs
        )

    @classmethod
    def add_one_arg_tests(klass, module, functions, numerics_only=False):
        for func in functions:
            for datatype, examples in SAMPLE_DATA.items():
                if numerics_only and datatype not in numerics:
                    continue
                name = 'test_%s_%s_%s' % (module, func, datatype)
                small_ints = module == 'math' and func == 'factorial'
                setattr(klass, name, _module_one_arg_func_test(name, module, func, examples, small_ints=small_ints))

    @classmethod
    def add_two_arg_tests(klass, module, functions, numerics_only=False):
        for func in functions:
            for datatype, examples in SAMPLE_DATA.items():
                if numerics_only and datatype not in numerics:
                    continue
                for datatype2, examples2 in SAMPLE_DATA.items():
                    if numerics_only and datatype2 not in numerics:
                        continue
                    name = 'test_%s_%s_%s_%s' % (module, func, datatype, datatype2)
                    setattr(klass, name, _module_two_arg_func_test(name, module, func, examples, examples2))


def _one_arg_method_test(name, module, cls_, f, examples):
    def func(self):
        self.assertOneArgMethod(
            name=name,
            module=module,
            cls_name=cls_,
            method_name=f,
            arg_values=examples,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )

    return func


class MethodTestCase(NotImplementedToExpectedFailure):
    def assertOneArgMethod(
        self,
        name,
        module,
        cls_name,
        method_name,
        arg_values,
        substitutions,
        **kwargs
    ):
        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> import {m}')
                        print('>>> obj = {m}.{c}()')
                        print('>>> f = obj.{f}')
                        print('>>> x = {a}')
                        print('>>> f(x)')
                        import {m}
                        obj = {m}.{c}()
                        f = obj.{f}
                        x = {a}
                        print(f(x))
                    except Exception as e:
                        print('///', type(e), ':', e)
                    print()
                """.format(m=module, c=cls_name, f=method_name, a=arg))
                for arg in arg_values
            ),
            'Error running {} module {}'.format(module, name),
            substitutions=substitutions,
            run_in_function=False,
            **kwargs
        )

    @classmethod
    def add_one_arg_method_tests(test_cls, module, cls_name, functions):
        for func in functions:
            for datatype, examples in SAMPLE_DATA.items():
                name = 'test_{}_{}_{}_{}'.format(
                    module, cls_name, func, datatype
                )
                setattr(
                    test_cls,
                    name,
                    _one_arg_method_test(name, module, cls_name, func, examples)
                )
