"""The :mod:`variation` module defines classes for variation operators.

Variation operators (aka genetic operators) are used in evolutionary/genetic
algorithms to create "child" genomes from "parent" genomes.

"""
from abc import ABC, abstractmethod
from typing import Sequence, Union
import math

from numpy.random import random, choice

from pyshgp.push.types import PushType
from pyshgp.push.atoms import Literal
from pyshgp.gp.genome import Genome, GeneSpawner
from pyshgp.tap import tap
from pyshgp.utils import DiscreteProbDistrib, instantiate_using


class VariationOperator(ABC):
    """Base class of all VariationOperators.

    Parameters
    ----------
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    Attributes
    ----------
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self, num_parents: int):
        self.num_parents = num_parents

    def checknum_parents(self, parents: Sequence[Genome]):
        """Raise error if given too few parents.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.

        """
        if not len(parents) >= self.num_parents:
            raise ValueError("Variation operator given {a} parents. Expected {e}.".format(
                a=len(parents),
                e=self.num_parents)
            )

    @tap
    @abstractmethod
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        pass


class VariationStrategy(DiscreteProbDistrib):
    """A collection of VariationOperator and how frequently to use them."""

    def add(self, op: VariationOperator, p: float):
        """Add an element with a relative probability.

        Parameters
        ----------
        op : VariationOperator
            The VariationOperator to add to the variation strategy.
        p : float
            The probability of using the given operator relative to the other
            operators that have been added to the VariationStrategy.

        """
        super().add(op, p)


class VariationPipeline(VariationOperator):
    """Variation operator that sequentially applies multiple others variation operators.

    Parameters
    ----------
    operators : Sequence[VariationOperators]
        A list of operators to apply in order to produce the child Genome.

    Attributes
    ----------
    operators : Sequence[VariationOperators]
        A list of operators to apply in order to produce the child Genome.
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self, operators: Sequence[VariationOperator]):
        num_parents_needed = max([op.num_parents for op in operators])
        super().__init__(num_parents_needed)
        self.operators = operators

    @tap
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        super().produce(parents, spawner)
        self.checknum_parents(parents)
        child = parents[0]
        for op in self.operators:
            child = op.produce([child] + parents[1:], spawner)
        return child


# Utilities

def _gaussian_noise_factor():
    """Return Gaussian noise of mean 0, std dev 1.

    Returns
    --------
    Float samples from Gaussian distribution.

    Examples
    --------
    >>> gaussian_noise_factor()
    1.43412557975
    >>> gaussian_noise_factor()
    -0.0410900866765

    """
    return math.sqrt(-2.0 * math.log(random())) * math.cos(2.0 * math.pi * random())


# Mutations

# @TODO: Implement all the common literal mutations.
class LiteralMutation(VariationOperator, ABC):
    """Base class for mutations of literal Atoms.

    Parameters
    ----------
    push_type : pyshgp.push.types.PushType
        The PushType which the operator can mutate.
    rate : float
        The probability of applying the mutation to a given Literal.

    Attributes
    ----------
    push_type : pyshgp.push.types.PushType
        The PushType which the operator can mutate.
    rate : float
        The probability of applying the mutation to a given Literal.
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self, push_type: PushType, rate: float = 0.01):
        super().__init__(1)
        self.rate = rate
        self.push_type = push_type

    @abstractmethod
    def _mutate_literal(self, literal: Literal) -> Literal:
        ...

    @tap
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner = None) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        super().produce(parents, spawner)
        self.checknum_parents(parents)
        new_genome = Genome()
        for atom in parents[0]:
            if isinstance(atom, Literal) and self.push_type == atom.push_type and random() < self.rate:
                new_atom = self._mutate_literal(atom)
            else:
                new_atom = atom
            new_genome = new_genome.append(new_atom)
        return new_genome


class DeletionMutation(VariationOperator):
    """Uniformly randomly removes some Atoms from parent.

    Parameters
    ----------
    rate : float
        The probability of removing any given Atom in the parent Genome.
        Default is 0.01.

    Attributes
    ----------
    rate : float
        The probability of removing any given Atom in the parent Genome.
        Default is 0.01.
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self, deletion_rate: float = 0.01):
        super().__init__(1)
        self.rate = deletion_rate

    @tap
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        super().produce(parents, spawner)
        self.checknum_parents(parents)
        new_genome = Genome()
        for gene in parents[0]:
            if random() < self.rate:
                continue
            new_genome = new_genome.append(gene)
        return new_genome


class AdditionMutation(VariationOperator):
    """Uniformly randomly adds some Atoms to parent.

    Parameters
    ----------
    rate : float
        The probability of adding a new Atom at any given point in the parent
        Genome. Default is 0.01.

    Attributes
    ----------
    rate : float
        The probability of adding a new Atom at any given point in the parent
        Genome. Default is 0.01.
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self, addition_rate: float = 0.01):
        super().__init__(1)
        self.rate = addition_rate

    @tap
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        super().produce(parents, spawner)
        self.checknum_parents(parents)
        new_genome = Genome()
        for gene in parents[0]:
            if random() < self.rate:
                new_genome = new_genome.append(spawner.random_gene())
            new_genome = new_genome.append(gene)
        return new_genome


# Recombinations

class Alternation(VariationOperator):
    """Uniformly alternates between the two parent genomes.

    Parameters
    ----------
    rate : float, optional (default=0.01)
        The probability of switching which parent program elements are being
        copied from. Must be 0 <= rate <= 1. Defaults to 0.1.
    alignment_deviation : int, optional (default=10)
        The standard deviation of how far alternation may jump between indices
        when switching between parents.

    Attributes
    ----------
    rate : float, optional (default=0.01)
        The probability of switching which parent program elements are being
        copied from. Must be 0 <= rate <= 1. Defaults to 0.1.
    alignment_deviation : int, optional (default=10)
        The standard deviation of how far alternation may jump between indices
        when switching between parents.
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self, alternation_rate=0.01, alignment_deviation=10):
        super().__init__(2)
        self.rate = alternation_rate
        self.alignment_deviation = alignment_deviation

    @tap
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner = None) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        super().produce(parents, spawner)
        self.checknum_parents(parents)
        gn1 = parents[0]
        gn2 = parents[1]
        new_genome = Genome()
        # Random pick which parent to start from
        use_parent_1 = choice([True, False])
        loop_times = len(gn1)
        if not use_parent_1:
            loop_times = len(gn2)
        i = 0
        while (i < loop_times):
            if random() < self.rate:
                # Switch which parent we are pulling genes from
                i += round(self.alignment_deviation * _gaussian_noise_factor())
                i = int(max(0, i))
                use_parent_1 = not use_parent_1
            else:
                # Pull gene from parent
                if use_parent_1:
                    new_genome = new_genome.append(gn1[i])
                else:
                    new_genome = new_genome.append(gn2[i])
                i = int(i + 1)
            # Change loop stop condition
            loop_times = len(gn1)
            if not use_parent_1:
                loop_times = len(gn2)
        return new_genome


# Other

class Genesis(VariationOperator):
    """Creates an entirely new (and random) genome.

    Parameters
    ----------
    size
        The child genome will contain this many Atoms if size is an integer.
        If size is a pair of integers, the genome will be of a random
        size in the range of the two integers.

    Attributes
    ----------
    size
        The child genome will contain this many Atoms if size is an integer.
        If size is a pair of integers, the genome will be of a random
        size in the range of the two integers.
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self, *, size: Union[int, Sequence[int]]):
        super().__init__(0)
        self.size = size

    @tap
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        super().produce(parents, spawner)
        return spawner.spawn_genome(self.size)


class Cloning(VariationOperator):
    """Clones the parent genome.

    Attributes
    ----------
    num_parents : int
        Number of parent Genomes the operator needs to produce a child
        Individual.

    """

    def __init__(self):
        super().__init__(1)

    @tap
    def produce(self, parents: Sequence[Genome], spawner: GeneSpawner = None) -> Genome:
        """Produce a child Genome from parent Genomes and optional GenomeSpawner.

        Parameters
        ----------
        parents
            A list of parent Genomes given to the operator.
        spawner
            A GeneSpawner that can be used to produce new genes (aka Atoms).

        """
        super().produce(parents, spawner)
        return parents[0]


def get_variation_operator(name: str, **kwargs) -> VariationOperator:
    """Get the variaton operator class with the given name."""
    name_to_cls = {
        "deletion": DeletionMutation,
        "addition": AdditionMutation,
        "alternation": Alternation,
        "genesis": Genesis,
        "cloning": Cloning,
        # UMAD citation: https://dl.acm.org/citation.cfm?id=3205455.3205603
        "umad": VariationPipeline([AdditionMutation(0.09), DeletionMutation(0.0826)]),
        "umad-shrink": VariationPipeline([AdditionMutation(0.09), DeletionMutation(0.1)]),
        "umad-grow": VariationPipeline([AdditionMutation(0.09), DeletionMutation(0.0652)])
    }
    op = name_to_cls.get(name, None)
    if op is None:
        raise ValueError("No varition operator '{nm}'. Supported names: {lst}.".format(
            nm=name,
            lst=list(name_to_cls.keys())
        ))
    if isinstance(op, type):
        op = instantiate_using(op, kwargs)
    return op