"""
Describes the genetic algorithm from Cairns et al. 2017.
This algorithm uses a crossover method which is based on the binary representation of the parameters. It does not work (is not defined for) negative parameters and struggles to identify parameters under 1e-6.
"""
import numpy as np
import ionbench
import ionbench.utils.population_optimisers as pop_opt
import copy


# noinspection PyShadowingNames
def run(bm, x0=None, nGens=10, popSize=2500, tournamentSize=5, debug=False):
    """
    Runs the genetic algorithm from Cairns et al. 2017.

    Parameters
    ----------
    bm : Benchmarker
        A benchmarker to evaluate the performance of the optimisation algorithm.
    x0 : list, optional
        Initial parameter guess. Population is generated by randomly perturbing this initial guess +-50%, clamped to bounds if necessary. If x0=None (the default), then the population will be sampled using bm.sample().
    nGens : int, optional
        The number of generations to run the optimisation algorithm for. The default is 10.
    popSize : int, optional
        The size of the population in each generation. The default is 2500.
    tournamentSize : int, optional
        The size of the tournament to run during tournament selection. The default is 5.
    debug : bool, optional
        If True, debug information will be printed, reporting that status of the optimisation each generation. The default is False.

    Returns
    -------
    xbest : list
        The best parameters identified.

    """
    cost_func = ionbench.utils.cache.get_cached_cost(bm)

    if not bm.parametersBounded:
        raise RuntimeError('Cairns et al 2017 GA optimiser requires bounds.')

    boundsRange = bm.input_parameter_space(bm.ub) - bm.input_parameter_space(bm.lb)

    pop = pop_opt.get_pop(bm, x0, popSize, cost_func)

    gen = None
    for gen in range(nGens):
        elites = pop_opt.get_elites(pop, 1)
        if debug:
            print("------------")
            print(f'Gen {gen}, Best cost: {elites[0].cost}')

        # Tournament selection
        parents = []
        # Generate 2*popSize number of parents
        for j in range(popSize * 2):
            # Tournament indices
            tour = np.random.choice(np.arange(popSize), tournamentSize, replace=False)
            # Find the best individual in the tournament
            bestInd = pop[tour[0]]
            for i in tour[1:]:
                if pop[i].cost < bestInd.cost:
                    bestInd = pop[i]
            # Save best individual as parent
            parents.append(copy.deepcopy(bestInd))

        # Crossover
        newPop = []
        for j in range(popSize):
            # Get parents 1 and 2
            par1 = parents[2 * j]
            par2 = parents[2 * j + 1]
            # Construct child
            child = pop_opt.Individual(bm, bm.sample(), cost_func)
            for i in range(bm.n_parameters()):
                # Get parameter i from parents 1 and 2
                num1 = par1.x[i]
                num2 = par2.x[i]
                # Want to convert to integer and maintain 6 decimal places of accuracy
                num1 = int(np.round(num1 * 1e6))
                num2 = int(np.round(num2 * 1e6))
                # Then map to binary string
                # Find length of binary strings
                if num1 == num2:
                    # Don't do crossover as not different, also avoids avoids issue with log2(0)
                    numNew = num1/1e6
                else:
                    binStrLen = int(np.log2(max(num1, num2))) + 1
                    bin1 = format(num1, '0' + str(binStrLen) + 'b')
                    bin2 = format(num2, '0' + str(binStrLen) + 'b')
                    # Random position between 0 and binStrLen (inclusive)
                    randPos = np.random.randint(0, binStrLen + 1)
                    # Crossover in binary
                    binNew = bin1[:randPos] + bin2[randPos:]
                    # Convert back to decimal float
                    numNew = int(binNew, 2) / 1e6
                child.x[i] = numNew
            newPop.append(child)

        # Mutation
        for j in range(popSize):
            for i in range(bm.n_parameters()):
                # Change of mutation is 0.1%
                if np.random.rand() < 0.001:
                    perturb = np.random.normal(0.05 * boundsRange[i], 0.05 * boundsRange[i] / 3)
                    if np.random.rand() < 0.5:
                        newPop[j].x[i] += perturb
                    else:
                        newPop[j].x[i] -= perturb
        pop = newPop

        if debug:
            print(f'Finishing gen {gen}')

        # Find costs
        pop = pop_opt.find_pop_costs(pop)

        # Elitism
        pop = pop_opt.set_elites(pop, elites)

        if bm.is_converged():
            break

    if gen >= nGens - 1:
        bm.set_max_iter_flag()

    elites = pop_opt.get_elites(pop, 1)
    bm.evaluate()
    return elites[0].x


# noinspection PyUnusedLocal,PyShadowingNames
def get_modification(modNum=1):
    """
    modNum = 1 -> Cairns2017

    Returns
    -------
    mod : modification
        Modification corresponding to inputted modNum. Default is modNum = 1, so Cairns2017.

    """
    mod = ionbench.modification.Cairns2017()
    return mod
