import copy
import random
import string
import time
from typing import Any, Dict, List, Set, Tuple

from multipledispatch import dispatch

from evalplus.gen.mut_gen import MutateGen
from evalplus.gen.util import trusted_check_exec

MAX_MULTI_STEP_SIZE = 5
MUTATE_BOUND_SIZE = 8

NoneType = type(None)


# decorator to use ingredients
class use_ingredient:
    def __init__(self, prob: float):
        assert 0 <= prob <= 0.95
        self.prob = prob

    def __call__(obj, func):
        def wrapper(self, seed_input):
            if random.random() < obj.prob and self.ingredients[type(seed_input)]:
                return random.choice(list(self.ingredients[type(seed_input)]))
            else:
                return func(self, seed_input)

        return wrapper


class TypedMutGen(MutateGen):
    def __init__(self, inputs: List, signature: str, contract_code: str):
        super().__init__(inputs, signature, contract_code)
        self.timeout = 60 * 60  # 1 hour
        self.ingredients = {
            int: set(),
            float: set(),
            str: set(),
        }
        for x in inputs:
            self.fetch_ingredient(x)

    def seed_selection(self):
        # random for now.
        return random.choice(self.seed_pool)

    def mutate(self, seed_input: Any) -> List:
        new_input = copy.deepcopy(seed_input)

        patience = MUTATE_BOUND_SIZE
        while new_input == seed_input or patience == 0:
            new_input = self.typed_mutate(new_input)
            patience -= 1

        return new_input

    #########################
    # Type-aware generation #
    #########################
    @dispatch(NoneType)
    def typed_gen(self, _):
        return None

    @dispatch(int)
    def typed_gen(self, _):
        @use_ingredient(0.5)
        def _impl(*_):
            return random.randint(-100, 100)

        return _impl(self, _)

    @dispatch(float)
    def typed_gen(self, _):
        @use_ingredient(0.5)
        def _impl(*_):
            return random.uniform(-100, 100)

        return _impl(self, _)

    @dispatch(bool)
    def typed_gen(self, _):
        return random.choice([True, False])

    @dispatch(str)
    def typed_gen(self, _):
        @use_ingredient(0.5)
        def _impl(*_):
            return "".join(
                random.choice(string.ascii_letters)
                for _ in range(random.randint(0, 10))
            )

        return _impl(self, _)

    def any_gen(self):
        # weighted choose
        choice = random.choices(
            [
                True,
                1,
                1.1,
                "str",
                [],  # list
                tuple(),  # tuple
                dict(),  # dict
                None,  # None
            ],
            [0.2, 0.2, 0.2, 0.2, 0.05, 0.05, 0.05, 0.05],
        )[0]
        return self.typed_gen(choice)

    @dispatch(list)
    def typed_gen(self, _):
        ret = []
        size = random.randint(0, 10)
        if random.randint(0, 4) == 0:  # heterogeneous
            for _ in range(size):
                ret.append(self.any_gen())
        else:  # homogeneous
            t = random.choice([bool(), int(), float(), str()])
            for _ in range(size):
                ret.append(self.typed_gen(t))
        return ret

    @dispatch(tuple)
    def typed_gen(self, _):
        return tuple(self.typed_gen([]))

    @dispatch(dict)
    def typed_gen(self, _):
        ret = dict()
        values = self.typed_gen([])
        # NOTE: Assumption: nobody uses dict with heterogeneous keys
        # NOTE: Assumption: nobody uses dict with boolean keys
        key_type = random.choice([int(), float(), str()])
        for v in values:
            ret[self.typed_gen(key_type)] = self.typed_gen(v)
        return ret

    ########################
    # Type-aware mutation  #
    ########################
    # Simple primitives
    @dispatch(int)
    def typed_mutate(self, seed_input: int):
        @use_ingredient(0.5)
        def _impl(_, seed_input: int):
            return seed_input + random.randint(-1, 1)

        return _impl(self, seed_input)

    @dispatch(float)
    def typed_mutate(self, seed_input: float):
        @use_ingredient(0.5)
        def _impl(_, seed_input: float):
            if random.randint(0, 1):
                return seed_input + random.uniform(-1, 1)
            return seed_input * (1 + random.uniform(-0.5, 0.5))

        return _impl(self, seed_input)

    @dispatch(bool)
    def typed_mutate(self, seed_input: bool):
        return random.choice([True, False])

    @dispatch(NoneType)
    def typed_mutate(self, seed_input: NoneType):
        return None

    # List-like
    @dispatch(list)
    def typed_mutate(self, seed_input: List):
        if len(seed_input) == 0:
            return self.typed_gen([])

        choice = random.randint(0, 3)
        idx = random.randint(0, len(seed_input) - 1)
        if choice == 0:  # remove one element
            seed_input.pop(random.randint(0, len(seed_input) - 1))
        elif choice == 1 and len(seed_input) > 0:  # add one mutated element
            seed_input.insert(
                random.randint(0, len(seed_input) - 1),
                self.typed_mutate(seed_input[idx]),
            )
        elif choice == 2 and len(seed_input) > 0:  # repeat one element
            seed_input.append(seed_input[idx])
        else:  # inplace element change
            seed_input[idx] = self.typed_mutate(seed_input[idx])
        return seed_input

    @dispatch(tuple)
    def typed_mutate(self, seed_input: Tuple):
        return tuple(self.typed_mutate(list(seed_input)))

    # String
    @dispatch(str)
    def typed_mutate(self, seed_input: str):
        @use_ingredient(0.4)
        def _impl(_, seed_input: str):
            choice = random.randint(0, 2) if seed_input else 0
            if choice == 0 and self.ingredients[str]:  # insert an ingredient
                idx = random.randint(0, len(seed_input))
                return (
                    seed_input[:idx]
                    + random.choice(list(self.ingredients[str]))
                    + seed_input[idx:]
                )
            # other choices assume len(seed_input) > 0
            elif choice == 1:  # replace a substring with empty or mutated string
                start = random.randint(0, len(seed_input) - 1)
                end = random.randint(start + 1, len(seed_input))
                mid = (
                    ""
                    if random.randint(0, 1)
                    else self.typed_mutate(seed_input[start:end])
                )
                return seed_input[:start] + mid + seed_input[end:]
            elif choice == 2:  # repeat one element
                idx = random.randint(0, len(seed_input) - 1)
                return (
                    seed_input[:idx]
                    + seed_input[random.randint(0, len(seed_input) - 1)]
                    + seed_input[idx:]
                )

            # random char
            return self.typed_gen(str())

        return _impl(self, seed_input)

    # Set
    @dispatch(set)
    def typed_mutate(self, seed_input: Set):
        return set(self.typed_mutate(list(seed_input)))

    # Dict
    @dispatch(dict)
    def typed_mutate(self, seed_input: Dict):
        if len(seed_input) == 0:
            return self.typed_gen(dict())

        choice = random.randint(0, 2)
        if choice == 0:  # remove a kv
            del seed_input[random.choice(list(seed_input.keys()))]
        elif choice == 1:  # add a kv
            k = self.typed_mutate(random.choice(list(seed_input.keys())))
            v = self.typed_mutate(random.choice(list(seed_input.values())))
            seed_input[k] = v
        elif choice == 2:  # inplace value change
            k0, v0 = random.choice(list(seed_input.items()))
            seed_input[k0] = self.typed_mutate(v0)
        return seed_input

    ############################################
    # Fetching ingredients to self.ingredients #
    ############################################
    def fetch_ingredient(self, seed_input):
        self.typed_fetch(seed_input)

    @dispatch(int)
    def typed_fetch(self, seed_input: int):
        self.ingredients[int].add(seed_input)

    @dispatch(float)
    def typed_fetch(self, seed_input: float):
        self.ingredients[float].add(seed_input)

    @dispatch(str)
    def typed_fetch(self, seed_input: str):
        self.ingredients[str].add(seed_input)
        for token in seed_input.strip().split():
            self.ingredients[str].add(token)

    # List-like
    def _fetch_list_like(self, seed_input):
        for x in seed_input:
            if self.typed_fetch.dispatch(type(x)):
                self.fetch_ingredient(x)

    @dispatch(list)
    def typed_fetch(self, seed_input: List):
        self._fetch_list_like(seed_input)

    @dispatch(tuple)
    def typed_fetch(self, seed_input: Tuple):
        self._fetch_list_like(seed_input)

    # Dict
    @dispatch(dict)
    def typed_fetch(self, seed_input: Dict):
        self._fetch_list_like(seed_input.keys())
        self._fetch_list_like(seed_input.values())

    def generate(self, num: int):
        start = time.time()
        num_generated = 1
        while len(self.new_inputs) < num and time.time() - start < self.timeout:
            if num_generated % 1000 == 0:
                print(
                    f"generated {num_generated} already with {len(self.new_inputs)} new inputs ... "
                )
            new_input = self.seed_selection()
            # Multi-step instead of single-step
            for _ in range(random.randint(1, MAX_MULTI_STEP_SIZE)):
                new_input = self.mutate(new_input)
            num_generated += 1
            if hash(str(new_input)) not in self.seed_hash:
                if trusted_check_exec(self.contract, [new_input], self.entry_point):
                    self.typed_fetch(new_input)
                    self.seed_pool.append(new_input)
                    self.new_inputs.append(new_input)
                self.seed_hash.add(hash(str(new_input)))
        return self.new_inputs[:num]
