# ----------------------------------------------------------------------------
# Copyright (c) 2013--, scikit-bio development team.
#
# Distributed under the terms of the Modified BSD License.
#
# The full license is in the file COPYING.txt, distributed with this software.
# ----------------------------------------------------------------------------

import operator
import copy
import functools

from ._intersection import IntervalTree
from skbio.util._decorator import experimental, classonlymethod


class Interval:
    """Stores the bounds and metadata of an interval feature.

    This class stores an interval feature. An interval feature
    is defined as a sub-region of a biological sequence or sequence
    alignment that is a functional entity, e.g., a gene, a riboswitch,
    an exon, etc. It can span a single contiguous region or multiple
    non-contiguous regions (e.g. multiple exons in a transcript, or
    multiple genes in an operon).

    Parameters
    ----------
    interval_metadata : object
        A reference to the ``IntervalMetadata`` object that this
        ``Interval`` object is associated to.
    bounds : iterable of tuple of int
        Tuples representing start and end coordinates. It is *zero-based*
        numbering. It is always inclusive on start bound and exclusive on
        end bound.
    fuzzy : iterable of tuple of bool, optional
        Tuples representing the fuzziness of each bound coordinates.
        If this isn't specified, then the fuzziness of all bound
        coordinates are ``False``. If any of the coordinate fuzziness
        is ``True``, it indicates that the exact bound point of a
        interval feature is unknown. The bound may begin or end at
        some points outside the specified coordinates. This
        accommodates the location format [1]_ of INSDC.
    metadata : dict, optional
        Dictionary of attributes storing information of the feature
        such as "strand", "gene_name", or "product".

    See Also
    --------
    skbio.metadata.IntervalMetadata

    Notes
    -----
    While the construction of an ``Interval`` object automatically add
    itself to its associated ``IntervalMetadata`` object,
    ``IntervalMetadata.add`` is the typical/easier way to
    create and add it to ``IntervalMetadata``.

    References
    ----------
    .. [1] ftp://ftp.ebi.ac.uk/pub/databases/embl/doc/FT_current.html#3.4.3

    Examples
    --------
    Hypothetically, let's say we have a gene called "genA" with 10 nt
    as shown in the following diagram.  The second row represents the
    two exons (indicated by "=") on this gene:

    ::

        TGGATTCTGC
        -====--==-
        0123456789

    We can create an ``Interval`` object to represent the exons of the gene:

    >>> from skbio.metadata import Interval, IntervalMetadata
    >>> interval_metadata = IntervalMetadata(10)

    Remember the coordinates are inclusive in lower bound and exclusive on
    upper bound:

    >>> gene = Interval(interval_metadata,
    ...                 bounds=[(1, 5), (7, 9)],
    ...                 metadata={'name': 'genA'})
    >>> gene    # doctest: +ELLIPSIS
    Interval(interval_metadata=..., bounds=[(1, 5), (7, 9)], \
fuzzy=[(False, False), (False, False)], metadata={'name': 'genA'})

    """
    def __init__(self, interval_metadata, bounds,
                 fuzzy=None, metadata=None):
        if not isinstance(interval_metadata, IntervalMetadata):
            raise TypeError('You need to provide an IntervalMetadata'
                            'object, not %r' % interval_metadata)
        # Intervals
        self._interval_metadata = interval_metadata

        self._bounds_fuzzy_setter(bounds, fuzzy)

        # Metadata
        if metadata is None:
            metadata = {}
        self.metadata = metadata

        # add this interval feature to the associated IntervalMetadata
        self._add()

    def _add(self):
        """Add the current ``Interval`` to the IntervalMetadata object."""
        for bound in self.bounds:
            start, end = bound
            self._interval_metadata._interval_tree.add(start, end, self)
        self._interval_metadata._intervals.append(self)

    @experimental(as_of='0.5.1')
    def __eq__(self, other):
        '''Test if this ``Interval`` object is equal to another.

        The equality is performed by checking if the ``metadata``,
        ``bounds`` and ``fuzzy`` are equal. Since the ``bounds``
        and the ``fuzzy`` are sorted, the permutations of them during
        the ``Interval`` construction or assignment won't matter.

        Parameters
        ----------
        other : Interval
            Interval to test for equality against.

        Returns
        -------
        bool
            Indicates if the two objects are equal.
        '''
        return ((self.metadata == other.metadata) and
                (self.bounds == other.bounds) and
                (self.fuzzy == other.fuzzy))

    @experimental(as_of='0.5.1')
    def __ne__(self, other):
        '''Test if this ``Interval`` object is not equal to another.

        Parameters
        ----------
        other : Interval
            Interval to test for inequality against.

        Returns
        -------
        bool
            Indicates if the two objects are not equal.
        '''
        return not (self == other)

    @experimental(as_of='0.5.1')
    def __repr__(self):
        '''Return a string representation of this ``Interval`` object.

        Returns
        -------
        str
            String representation of this ``Interval`` object.
        '''
        if self.dropped:
            s = ('{}(dropped=True, bounds={!r}, '
                 'fuzzy={!r}, metadata={!r})')
            return s.format(self.__class__.__name__,
                            self.bounds, self.fuzzy, self.metadata)
        else:
            s = ('{}(interval_metadata=<{!r}>, bounds={!r}, '
                 'fuzzy={!r}, metadata={!r})')
            return s.format(self.__class__.__name__,
                            id(self._interval_metadata),
                            self.bounds, self.fuzzy, self.metadata)

    @experimental(as_of='0.5.1')
    def drop(self):
        '''Drop this ``Interval`` object from the interval metadata it links to.

        If the ``Interval`` object is dropped, you can still get values of
        ``bounds``, ``fuzzy``, and ``metadata`` attributes, but you
        can not change their values with the setters.

        See Also
        --------
        skbio.metadata.IntervalMetadata.drop
        '''
        if not self.dropped:
            self._interval_metadata.drop([self])

    def _bounds_fuzzy_setter(self, bounds=None, fuzzy=None):
        if self.dropped:
            raise RuntimeError('Cannot change `bounds` or `fuzzy` '
                               'on a dropped Interval object.')
        # Casts to `list`, validation, sorting, and setting of `bounds`
        # and `fuzzy` happen here.
        if bounds is not None:
            # check iterability
            try:
                # check iterability
                bounds = list(bounds)
            except TypeError:
                raise TypeError('Cannot give an non-iterable (%r) '
                                'to `bounds`.' % bounds)

            # check it is not empty
            if not bounds:
                raise ValueError('Cannot give empty `bounds`.')
            # check each contiguous span is in right format
            for bound in bounds:
                _assert_valid_bound(bound)

            spans = len(bounds)
        else:
            spans = len(self.bounds)

        if fuzzy is not None:
            try:
                fuzzy = list(fuzzy)
            except TypeError:
                raise TypeError(
                    'Cannot give a non-iterable (%r) '
                    'to `fuzzy`.' % fuzzy)

            if len(fuzzy) != spans:
                raise ValueError(
                    'The length of fuzzy must '
                    'be equal to the length of bounds.')

            for fuzzy_i in fuzzy:
                _assert_valid_fuzzy(fuzzy_i)

        if bounds is None:
            # `bounds` and `fuzzy` cannot both be omitted.
            if fuzzy is None:
                raise ValueError('Cannot give `None` to both `bounds` '
                                 'and `fuzzy`.')
            # If only `fuzzy` is provided, set `self.fuzzy` and don't
            # change `self.bounds`.
            else:
                self._fuzzy = fuzzy
        else:
            # If only `bounds` is provided, reset `self.fuzzy` to
            # all `False`.
            if fuzzy is None:
                bounds.sort()
                self._check_bounds(bounds)
                self._bounds = bounds
                # reset all the fuzzy to False!!
                del self.fuzzy

            # If both `bounds` and `fuzzy` are provided, set
            # `self.bounds` and `self.fuzzy`.
            else:
                bounds, fuzzy = [
                    list(e) for e in zip(*sorted(zip(bounds, fuzzy)))]
                self._check_bounds(bounds)
                self._bounds = bounds
                self._fuzzy = fuzzy

            self._interval_metadata._is_stale_tree = True

    def _check_bounds(self, bounds):
        '''input `bounds` must be sorted.'''
        upper_bound = self._interval_metadata.upper_bound
        lower_bound = self._interval_metadata.lower_bound
        if upper_bound is not None and bounds[-1][-1] > upper_bound:
            raise ValueError('Cannot set `bounds` (%r) with coordinate '
                             'larger than upper bound (%r)' %
                             (bounds, upper_bound))

        if bounds[0][0] < lower_bound:
            raise ValueError('Cannot set `bounds` (%r) with coordinate '
                             'smaller than lower bound (%r).' %
                             (bounds, lower_bound))

    @property
    @experimental(as_of='0.5.1')
    def fuzzy(self):
        '''The openness of each coordinate.

        This indicates that the exact bound of a interval feature
        is unknown. The bound may begin or end at some points outside
        the specified coordinates. This accommodates the bound format [1]_
        of INSDC.

        References
        ----------
        .. [1] ftp://ftp.ebi.ac.uk/pub/databases/embl/doc/FT_current.html#3.4.3
        '''
        return self._fuzzy

    @fuzzy.setter
    @experimental(as_of='0.5.1')
    def fuzzy(self, value):
        '''Set ``fuzzy``.

        The ``value`` should an iterable matching ``self.bounds``.
        '''
        self._bounds_fuzzy_setter(fuzzy=value)

    @fuzzy.deleter
    @experimental(as_of='0.5.1')
    def fuzzy(self):
        '''Delete ``fuzzy``.

        This set all fuzzy to be ``False``.
        '''
        if self.dropped:
            raise RuntimeError('Cannot change fuzzy on dropped '
                               'Interval object.')
        self._fuzzy = [(False, False)] * len(self.bounds)

    @property
    @experimental(as_of='0.5.1')
    def bounds(self):
        '''The coordinates of the interval feature.

        It should be a list of tuples of int pair. Each tuple stores
        the start and end coordinates of a span of the interval
        feature. The coordinates are *zero-based*. They are inclusive on
        the start and exclusive on the end.
        '''
        return self._bounds

    @bounds.setter
    @experimental(as_of='0.5.1')
    def bounds(self, value):
        '''Set ``bounds``.

        WARNING: setting ``bounds`` will reset ``fuzzy`` value to ``False``.
        This is not totally surprising because it is justifiable your old
        ``fuzzy`` don't fit the new bounds.
        '''
        self._bounds_fuzzy_setter(bounds=value)

    @property
    @experimental(as_of='0.5.1')
    def metadata(self):
        '''The metadata of the interval feature.

        It stores the metadata (eg. gene name, function, ID, etc.) of
        the interval feature as a ``dict``.
        '''
        return self._metadata

    @metadata.setter
    @experimental(as_of='0.5.1')
    def metadata(self, value):
        if self.dropped:
            raise RuntimeError('Cannot change metadata on dropped '
                               'Interval object.')
        if not isinstance(value, dict):
            raise TypeError("metadata must be a dict, not %r" % value)
        self._metadata = value

    @metadata.deleter
    @experimental(as_of='0.5.1')
    def metadata(self):
        '''Delete metadata.