import random
from math import ceil

def function_info(decorator):
    """Recovers the name and doc-string for decorators throughout EasyGA for documentation purposes.""" 

    def new_decorator(method):

        # Apply old decorator
        new_method = decorator(method)

        # Recover name and doc-string
        new_method.__name__ = method.__name__
        new_method.__doc__  = method.__doc__

        # Return new method with proper name and doc-string
        return new_method

    return new_decorator


#=======================#
# Crossover decorators: #
#=======================#

@function_info
def _check_weight(individual_method):
    """Checks if the weight is between 0 and 1 before running.
    Exception may occur when using ga.adapt, which will catch
    the error and try again with valid weight.
    """

    def new_method(ga, parent_1, parent_2, *, weight = individual_method.__kwdefaults__.get('weight', None)):

        if weight is None:
            individual_method(ga, parent_1, parent_2)
        elif 0 < weight < 1:
            individual_method(ga, parent_1, parent_2, weight = weight)
        else:
            raise ValueError(f"Weight must be between 0 and 1 when using {individual_method.__name__}.")

    return new_method

@function_info
def _gene_by_gene(individual_method):
    """Perform crossover by making a single new chromosome by combining each gene by gene."""

    def new_method(ga, parent_1, parent_2, *, weight = individual_method.__kwdefaults__.get('weight', 'None')):

        ga.population.add_child(
            individual_method(ga, value_1, value_2)
            if weight == 'None' else
            individual_method(ga, value_1, value_2, weight = weight)
            for value_1, value_2
            in zip(parent_1.gene_value_iter, parent_2.gene_value_iter)
        )

    return new_method


#====================#
# Parent decorators: #
#====================#

@function_info
def _check_selection_probability(selection_method):
    """Raises a ValueError if the selection_probability is not between 0 and 1 inclusively.
    Otherwise runs the selection method."""

    def new_method(ga):
        if 0 <= ga.selection_probability <= 1:
            selection_method(ga)
        else:
            raise ValueError("Selection probability must be between 0 and 1 to select parents.")

    return new_method


@function_info
def _check_positive_fitness(selection_method):
    """Raises a ValueError if the population contains a chromosome with negative fitness.
    Otherwise runs the selection method."""

    def new_method(ga):
        if ga.get_chromosome_fitness(0) > 0 and ga.get_chromosome_fitness(-1) >= 0:
            selection_method(ga)
        else:
            raise ValueError("Converted fitness values can't have negative values or be all 0."
                             + " Consider using rank selection or stochastic selection instead.")

    return new_method

@function_info
def _ensure_sorted(selection_method):
    """Sorts the population by fitness and then runs the selection method."""

    def new_method(ga):
        ga.sort_by_best_fitness()
        selection_method(ga)

    return new_method

@function_info
def _compute_parent_amount(selection_method):
    """Computes the amount of parents needed to be selected,
    and passes it as another argument for the method."""

    def new_method(ga):
        parent_amount = max(2, round(len(ga.population)*ga.parent_ratio))
        selection_method(ga, parent_amount)

    return new_method


#======================#
# Mutation decorators: #
#======================#


@function_info
def _check_chromosome_mutation_rate(population_method):
    """Checks if the chromosome mutation rate is a float between 0 and 1 before running."""

    def new_method(ga):

        if not isinstance(ga.chromosome_mutation_rate, float):
            raise TypeError("Chromosome mutation rate must be a float.")

        elif 0 < ga.chromosome_mutation_rate < 1:
            population_method(ga)

        else:
            raise ValueError("Chromosome mutation rate must be between 0 and 1.")

    return new_method


@function_info
def _check_gene_mutation_rate(individual_method):
    """Checks if the gene mutation rate is a float between 0 and 1 before running."""

    def new_method(ga, index):

        if not isinstance(ga.gene_mutation_rate, float):
            raise TypeError("Gene mutation rate must be a float.")

        elif 0 < ga.gene_mutation_rate <= 1:
            individual_method(ga, index)

        else:
            raise ValueError("Gene mutation rate must be between 0 and 1.")

    return new_method


@function_info
def _reset_fitness(individual_method):
    """Resets the fitness value of the chromosome."""

    def new_method(ga, chromosome):
        chromosome.fitness = None
        individual_method(ga, chromosome)

    return new_method


@function_info
def _loop_random_mutations(individual_method):
    """Runs the individual method until enough
    genes are mutated on the indexed chromosome."""

    # Change input to include the gene index being mutated.
    def new_method(ga, chromosome):

        sample_space = range(len(chromosome))
        sample_size  = ceil(len(chromosome)*ga.gene_mutation_rate)

        # Loop the individual method until enough genes are mutated.
        for index in random.sample(sample_space, sample_size):
            individual_method(ga, chromosome, index)

    return new_method


#======================#
# Survivor decorators: #
#======================#


#=========================#
# Termination decorators: #
#=========================#

@function_info
def _add_by_fitness_goal(termination_impl):
    """Adds termination by fitness goal to the method."""

    def new_method(ga):

        # Try to check the fitness goal
        try:

            # If minimum fitness goal reached, stop ga.
            if ga.target_fitness_type == 'min' and ga.population[0].fitness <= ga.fitness_goal:
                return False

            # If maximum fitness goal reached, stop ga.
            elif ga.target_fitness_type == 'max' and ga.population[0].fitness >= ga.fitness_goal:
                return False

        # Fitness or fitness goals are None, or Population not initialized
        except (TypeError, AttributeError):
            pass

        # Check other termination methods
        return termination_impl(ga)

    return new_method


@function_info
def _add_by_generation_goal(termination_impl):
    """Adds termination by generation goal to the method."""

    def new_method(ga):

        # If generation goal is set, check it.
        if ga.generation_goal is not None and ga.current_generation >= ga.generation_goal:
            return False

        # Check other termination methods
        return termination_impl(ga)

    return new_method


@function_info
def _add_by_tolerance_goal(termination_impl):
    """Adds termination by tolerance goal to the method."""

    def new_method(ga):

        # If tolerance is set, check it, if possible.
        try:
            best_fitness = ga.population[0].fitness
            threshhold_fitness = ga.population[round(ga.percent_converged*len(ga.population))].fitness
            tol = ga.tolerance_goal * (1 + abs(best_fitness))

            # Terminate if the specified amount of the population has converged to the specified tolerance
            if abs(best_fitness - threshhold_fitness) < tol:
                return False

        # Fitness or tolerance goals are None, or population is not initialized
        except (TypeError, AttributeError):
            pass

        # Check other termination methods
        return termination_impl(ga)

    return new_method

