import os
import random
import subprocess
import tempfile
import time

from scripts.test import shared

from . import utils


# Runs the fuzzer many times and allows checking for specific variety in the
# output. Calls hooks:
#
#  self.found_variety()  - checks if we found what we are looking for
#  self.process_wat(wat) - receives the current fuzz wat
#
class FuzzerVarietyTester:
    # Run until we find what we want. Stop only if we reached a max number
    # of iterations and a timeout.
    max_time = 60
    min_iters = 200

    # The maximum size of the wasm-generating input
    max_size = 1024

    def __init__(self, initial):
        self.initial = initial

    def test(self):
        start_time = time.time()
        stop_time = start_time + self.max_time

        self.temp_dir = tempfile.TemporaryDirectory()

        i = 0
        while True:
            i += 1

            # Stop early if we found what we are looking for.
            if self.found_variety():
                print(f"{i} iterations {round(time.time() - start_time, 2)} seconds)")
                print(f'proper import_params : {self.import_params}')
                print(f'proper export_results: {self.export_results}')
                return

            if i > self.min_iters and time.time() > stop_time:
                raise Exception('looked too long and still failed')

            # Generate raw random data.
            size = random.randint(1, self.max_size)
            temp_dat = os.path.join(self.temp_dir.name, f'temp_{i}.dat')
            with open(temp_dat, 'wb') as f:
                f.write(bytes([random.randint(0, 255) for x in range(size)]))

            # Generate the fuzz testcase from the random data + the initial
            # contents.
            args = ['-ttf', temp_dat, '--initial-fuzz=' + self.initial, '-all']
            args += self.ttf_args
            args += ['--print']
            wat = shared.run_process(shared.WASM_OPT + args,
                                   stdout=subprocess.PIPE).stdout

            self.process_wat(wat)


class FuzzAgainstJSVarietyTester(FuzzerVarietyTester):
    # When --fuzz-against-js is used, the wasm is only going to be fuzzed
    # against JS, so the fuzzer mutates the boundary in valid ways, even if
    # --fuzz-preserve-imports-exports is set.
    #
    # Testing this deterministically is too hard (as the fuzzer evolves, it
    # will handle random data differently, and the test would constantly get
    # out of date). Instead, test randomly, but in a way that the chance of
    # a flake is unrealistic.
    ttf_args = ['--fuzz-preserve-imports-exports', '--fuzz-against-js']

    def __init__(self, initial):
        super().__init__(initial)

        # The set of all params we see, for the import that is refinable. Ditto
        # for export results.
        self.import_params = set()
        self.export_results = set()

    def found_variety(self):
        return self.found_expected(self.import_params) and self.found_expected(self.export_results)

    def process_wat(self, wat):
        # The things that begin reffed might end up not reffed, if mutation
        # removes the refs. Check for that.
        import_reffed_is_reffed = '(ref.func $import-reffed)' in wat
        export_reffed_is_reffed = '(ref.func $export-reffed)' in wat

        # Find the params/results that might be refined.
        for line in wat.splitlines():
            if line.startswith(' (import "module" "base" (func $import '):
                params, results = self.parse_params_results(line)
                self.import_params.add(params)
                assert results == '(result eqref)', 'cannot refine import result'
            elif line.startswith(' (import "module" "base" (func $import-reffed '):
                params, results = self.parse_params_results(line)
                if import_reffed_is_reffed:
                    assert params == '(param i32 anyref)', 'cannot refine reffed stuff'
                assert results == '(result eqref)', 'cannot refine import result'
            if line.startswith(' (func $export '):
                params, results = self.parse_params_results(line)
                assert params == '(param $0 i32) (param $1 anyref)', 'cannot refine export params'
                self.export_results.add(results)
            if line.startswith(' (func $export-reffed '):
                params, results = self.parse_params_results(line)
                assert params == '(param $0 i32) (param $1 anyref)', 'cannot refine export params'
                if export_reffed_is_reffed:
                    assert results == '(result eqref)', 'cannot refine reffed stuff'

    # Given the types we saw for params or results, look in detail for the
    # things we expect to see.
    def found_expected(self, data):
        # The many returns here seem to be the best way to write this code.
        # ruff: noqa: PLR0911

        # Look for significant variety.
        if len(data) < 5:
            return False

        string = str(data)

        # Each of the following has a 50% chance to get emitted each time, so
        # over many iterations, the chance of failing to find them goes
        # exponentially to nothing.

        # There must be nullable types.
        if '(ref null' not in string:
            return False

        # There must be non-nullable types.
        if '(ref (' not in string and '(ref $' not in string:
            return False

        string = string.replace('null ', '')

        # There must be defined types.
        if ' $' not in string:
            return False

        # There must be exact types.
        if '(exact ' not in string:
            return False

        # There must be inexact types.
        if '(ref $' not in string:
            return False

        return True

    # Given a line with wat params and results, parse and return them.
    def parse_params_results(self, line):
        # Find either params or results.
        def get(what, line):
            ret = ''
            pos = 0

            while True:
                # Find the thing we are looking for.
                start = line.find(what, pos)
                if start < 0:
                    break

                # Find the end paren.
                parens = 1
                end = start + 1
                while parens > 0:
                    if line[end] == '(':
                        parens += 1
                    elif line[end] == ')':
                        parens -= 1
                    end += 1

                # Add (separated by a space).
                if ret:
                    ret += ' '
                ret += line[start:end]

                # Keep looking.
                pos = end

            return ret

        return get('(param', line), get('(result', line)


class PreserveFuzzTest(utils.BinaryenTestCase):
    def test_against_js(self):
        FuzzAgainstJSVarietyTester(self.input_path('fuzz.wat')).test()
