# -*- python -*-
#
# This file is part of the CNO package
#
#  Copyright (c) 2012-2014 - EMBL-EBI
#
#  File author(s): Thomas Cokelaer (cokelaer@ebi.ac.uk)
#
#  Distributed under the GLPv3 License.
#  See accompanying file LICENSE.txt or copy at
#      http://www.gnu.org/licenses/gpl-3.0.html
#
#  website: http://github.com/cellnopt/cellnopt
#
##############################################################################
"""This module contains a base class to manipulate reactions

.. testsetup:: reactions

    from cno import Reaction
    from cno.io.reactions import Reactions
    r = Reactions()

"""
from __future__ import print_function
import re

from cno.misc import CNOError

__all__ = ["Reaction", "Reactions"]


class ReactionBase(object):
    valid_symbols = ["+", "!", "&", "^"]
    and_symbol = "^"


class Reaction(str, ReactionBase):
    """Logical Reaction

    A Reaction can encode logical ANDs and ORs as well as NOT::

        >>> from cno import Reaction
        >>> r = Reaction("A+B=C") # a OR reaction
        >>> r = Reaction("A^B=C") # an AND reaction
        >>> r = Reaction("A&B=C") # an AND reaction
        >>> r = Reaction("C=D")   # an activation
        >>> r = Reaction("!D=E")  # a NOT reaction

    The syntax is as follows:

    #. The **!** sign indicates a logical NOT.
    #. The **+** sign indicates a logical OR.
    #. The **=** sign indicates a relation (edge).
    #. The **^** or **&** signs indicate a logical AND. Note that **&** signs
       will be replaced by **^**.


    Internally, reactions are checked for validity (e.g., !=C is invalid).

    You can reset the name::

        >>> r.name = "A+B+C=D"

    or create an instance from another instance::

        >>> newr = Reaction(r)

    Sorting can be done inplace (default) or not. ::

        >>> r = Reaction("F+D^!B+!A=Z")
        >>> r.sort(inplace=False)
        '!A+!B^D+F=Z'

    Simple operator (e.g., equality) are available. Note that equality will sort the species
    internally so A+B=C would be equal to B+A=C and there is no need to call :meth:`sort`::

        >>> r = Reaction("F+D^!B+!A=Z")
        >>> r == '!A+!B^D+F=Z'
        True

    If a reaction **A+A=B** is provided, it can be simplified by calling :meth:`simplify`.
    ANDs operator are not simplified. More sophisticated simplifications using Truth
    Table could be used but will not be implemented in this class for now.
    """
    # use __new__ to inherit from str class.
    def __new__(cls, reaction=None, strict_rules=True):
        """

        :param str reaction: a valid reaction (e.g., A=B, A+B=C, !B=D, C^D=F, ...),
            or an instance of :class:`Reaction`.
        :param bool strict_rules: if True, reactions cannot start with =, ^ or +
            signs (default to True).
        """
        # Since __init__ is called after the object is constructed, 
        # it is too late to modify the value for immutable types. 
        # Note that __new__ is a classmethod, 
        self = str.__new__(cls, reaction)
        self._strict_rules = strict_rules

        # since strings are immutable, we use this attribute to play around
        # with the name
        self._name = None
        if reaction is not None:
            # could be a Reaction instance
            if hasattr(reaction, "name"):
                self.name = reaction.name[:]
            # or a string
            # no unicode to be python3 compatible.
            elif isinstance(reaction, (str)):
                self.name = reaction[:]
            else:
                raise CNOError("neither a string nor a Reaction instance")
        return self

    def _set_name(self, reaction):
        if reaction is not None:
            reaction = self._valid_reaction(reaction)
        self._name = reaction[:]
    def _get_name(self):
        return self._name
    name = property(_get_name, _set_name, 
            doc="Getter/Setter for the reaction name")

    def _get_species(self, reac=None):
        """

        .. doctest:: reactions

            >>> r = Reaction("!a+c^d^e^f+!h=b")
            >>> r.species
            ['a', 'c', 'd', 'e', 'f', 'h', 'b']


        """
        if reac is None:
            reac = self.name[:]
        species = re.split("[+|=|^|!]", reac)
        species = [x for x in species if x]
        return species
    species = property(_get_species)

    def get_signed_lhs_species(self):
        lhs = self.lhs[:]
        species = re.split("[+|^]", lhs)
        pos = [x for x in species if x.startswith("!") is False]
        neg = [x[1:] for x in species if x.startswith("!") is True]
        return {'-': neg, '+': pos}

    def _get_lhs(self):
        return self.name.split("=")[0]

    lhs = property(_get_lhs,
                   doc="Getter for the left hand side of the = character")

    def _get_lhs_species(self):
        lhs = self.name.split("=")[0]
        species = self._get_species(reac=lhs)
        return species

    lhs_species = property(_get_lhs_species,
                           doc="Getter for the list of species on the left hand side of the = character")

    def _get_rhs(self):
        return self.name.split("=")[1]

    rhs = property(_get_rhs,
                   doc="Getter for the right hand side of the = character")

    # FIXME does not make sense if A^!B^C=D ?? 
    def _get_sign(self):
        # if we have an AND gate, for instance A^B=C
        if "!" in self.name and self.and_symbol not in self.name:
            return "-1"
        else:
            return "1"
    sign = property(_get_sign, doc="return sign of the reaction")

    def _valid_reaction(self, reaction):
        reaction = reaction.strip()
        reaction = reaction.replace("&", self.and_symbol)

        # = sign is compulsory
        N = reaction.count("=")
        if N != 1:
            raise CNOError("Invalid reaction name (only one = character expected. found {0})".format(N))
        #
        if self._strict_rules:
            if reaction[0] in ["=", "^", "+"]:
                raise CNOError("Reaction (%s) cannot start with %s" %
                               (reaction, "=, ^, +"))

        #
        lhs, rhs = reaction.split("=")
        for this in self.valid_symbols:
            if this in rhs:
                raise CNOError("Found an unexpected character (%s) in the LHS of reactions %s" %
                               (reaction, self.valid_symbols))

        if reaction.startswith("="):
            pass
        else:
            species = re.split("[+|^]", reaction.split("=")[0])
            if "" in species:
                raise CNOError("Reaction (%s) has two many + or ^ signs" % reaction)
            # Finally, a ! must be preceded by either nothing or a special sign but not another species
            # e.g. A!B does not make sense.
            for i, x in enumerate(species):
                if x == "!" and i!=0:
                    if species[i-1] not in self.valid_symbols:
                        raise CNOError("Reaction (%s) may be ill-formed with incorrect ! not preceded by another reaction ")

        return reaction

    def ands2ors(self, reaction):
        reaction = Reaction(reaction)
        lhs = reaction.get_signed_lhs_species()
        rhs = reaction.rhs
        reactions = [x + "=" + rhs for x in lhs['+']]
        reactions += ["!" + x + "=" + rhs for x in lhs['-']]
        return reactions

    def sort(self, inplace=True):
        """Rearrange species in alphabetical order

        :param bool inplace: defaults to True

        ::

            >>> r = Reaction("F+D^!B+!A=Z")
            >>> r.sort()
            >>> r
            '!A+!B^D+F=Z'

        """
        # if only one lhs, nothing to do
        if len(self.lhs_species) == 1:
            return

        # we first need to split + and then ^
        splitted_ors = [x for x in self.lhs.split("+")]  # left species keeping ! sign

        # loop over split list searching for ANDs
        species = []
        for this in splitted_ors:
            species_ands = this.split("^")
            # sort the species within the ANDs
            species_ands = sorted(species_ands, key=lambda x: x.replace("!", ""))
            species_ands = "^".join(species_ands)
            species.append(species_ands)

        # now sort the ORs
        species = sorted(species, key=lambda x: x.replace("!", ""))
        # and finally rejoin them
        species = "+".join(species)

        new_reac = "=".join([species, self.rhs])
        if inplace is True:
            self.name = new_reac
        else:
            return new_reac

    def simplify(self, inplace=True):
        """Simplifies reaction if possible.

        ::

            >>> r = Reaction("A+A=B")
            >>> r.simplify()
            >>> r
            "A=B"

        Other cases (with ANDs) are not simplified.  Even though **A+A^B=C** truth table
        could be simplified to **A=C** but we will not simplified it for now.

        """
        lhs = "+".join(set(self.lhs.split("+")))
        name = "=".join([lhs, self.rhs])
        if inplace:
            self.name = name
        else:
            return name

    def _rename_one_species(self, lhs, k, v):
        symbols = ['+', '^', '!', '=']
        new_name = ''
        current_species = ''

        # LHS 
        for x in lhs:
            # each time there is a symbol found, we will read a new species
            if x in symbols:
                # the current species should now be added to the new_name
                if current_species == k:
                    new_name += v
                else:
                    new_name += current_species
                current_species = ''
                new_name += x
            else:
                current_species += x
        # RHS: in principle current_species should be the RHS
        if current_species == k:
            new_name += v
        else:
            new_name += current_species
        return new_name

    def rename_species(self, mapping={}):
        for k, v in mapping.items():
            self.name = self._rename_one_species(self.name, k, v)

    def __repr__(self):
        # str needs to be overwritten otherwise, _name is not used but the default __repr__
        # if one call sort(), then, calling the variable name raise the wrong values, 
        # _name but the internal attribute from the str object.
        return self._name

    def __eq__(self, other):
        # The reaction may not be sorted and user may not want it to be sorted,
        # so we create a new instance and sort it
        r1 = Reaction(self)
        r1.sort()

        # we also sort the input reaction creating an instance as well so that the input reaction
        # (if it is an object) will not be sorted inplace either
        r2 = Reaction(other)
        r2.sort()
        if r1.name == r2.name:
            return True
        else:
            return False


class Reactions(ReactionBase):
    """Data structure to handle list of :class:`Reaction` instances

    For the syntax of a reaction, see :class:`Reaction`. You can
    use the **=**, **!**, **+** and **^** characters.

    Reactions can be added using either string or instances of :class:`Reaction`::

        >>> from cno import Reaction, Reactions
        >>> r = Reactions()