# -----------------------------------------------------------------------------
# Name:         derivation.py
# Purpose:      Class for storing and managing Stream-based derivations
#
# Authors:      Christopher Ariza
#               Joséphine Wolf Oberholtzer
#               Michael Scott Asato Cuthbert
#
# Copyright:    Copyright © 2011-2014 Michael Scott Asato Cuthbert
# License:      BSD, see license.txt
# ----------------------------------------------------------------------------
'''
This module defines objects for tracking the derivation of one
:class:`~music21.stream.Stream` from another.
'''
from __future__ import annotations

import weakref
from collections.abc import Generator
import functools
import typing as t
import unittest

from music21 import common
from music21.common.objects import SlottedObjectMixin
from music21 import environment


if t.TYPE_CHECKING:
    from music21 import base


environLocal = environment.Environment('derivation')


def derivationMethod(function):
    '''
    This decorator can be used for creating a function that returns a new
    derivation.  But is currently unused, since it does not take into account `inPlace=True`.
    `Stream.cloneEmpty(derivationMethod='derivationMethod')` is preferred for
    Streams.

    >>> from copy import deepcopy
    >>> @derivation.derivationMethod
    ... def allGreen(n):
    ...     n2 = deepcopy(n)
    ...     n2.style.color = 'green'
    ...     return n2

    >>> n = note.Note('C#')
    >>> n2 = allGreen(n)
    >>> n2.style.color
    'green'

    >>> n2.name = 'D-'
    >>> n2.derivation
    <Derivation of <music21.note.Note D-> from <music21.note.Note C#> via 'allGreen'>
    '''
    @functools.wraps(function)
    def wrapper(self, *arguments, **keywords):
        result = function(self, *arguments, **keywords)
        result.derivation.origin = self
        result.derivation.method = function.__name__
        return result
    return wrapper


class Derivation(SlottedObjectMixin):
    '''
    A Derivation object keeps track of which Streams (or perhaps other Music21Objects)
    a Stream or other music21 object has come from and how.

    Derivation is automatically updated by many methods:

    >>> import copy
    >>> sOrig = stream.Stream(id='orig')
    >>> sNew = copy.deepcopy(sOrig)
    >>> sNew.id = 'copy'
    >>> sNew.derivation
    <Derivation of <music21.stream.Stream copy>
        from <music21.stream.Stream orig> via '__deepcopy__'>

    >>> sNew.derivation.client
    <music21.stream.Stream copy>
    >>> sNew.derivation.client is sNew
    True
    >>> sNew.derivation.origin
    <music21.stream.Stream orig>
    >>> sNew.derivation.method
    '__deepcopy__'

    >>> s1 = stream.Stream()
    >>> s1.id = 'DerivedStream'
    >>> d1 = derivation.Derivation(s1)

    >>> s2 = stream.Stream()
    >>> s2.id = 'OriginalStream'

    >>> d1.method = 'manual'
    >>> d1.origin = s2
    >>> d1
    <Derivation of <music21.stream.Stream DerivedStream> from
        <music21.stream.Stream OriginalStream> via 'manual'>
    >>> d1.origin is s2
    True

    >>> d1.client is s1
    True

    >>> import copy
    >>> d2 = copy.deepcopy(d1)
    >>> d2.origin is s2
    True

    >>> d1.method = 'measure'
    >>> d1.method
    'measure'

    Deleting the origin stream does not change the Derivation, since origin is held by strong ref:

    >>> import gc  # Garbage collection
    >>> del s2
    >>> unused = gc.collect()  # ensure Garbage collection is run
    >>> d1
    <Derivation of <music21.stream.Stream DerivedStream>
        from <music21.stream.Stream OriginalStream> via 'measure'>

    But deleting the client stream changes the Derivation, since client is held by weak ref,
    and will also delete the origin (so long as client was ever set)

    >>> del s1
    >>> unused = gc.collect()  # ensure Garbage collection is run
    >>> d1
    <Derivation of None from None via 'measure'>
    '''

    # CLASS VARIABLES #

    __slots__ = (
        '_client',
        '_clientId',
        '_method',
        '_origin',
        '_originId',
    )

    # INITIALIZER #

    def __init__(self, client: base.Music21Object|None = None):
        # store a reference to the Music21Object that has this Derivation object as a property
        self._client: weakref.ReferenceType|None = None
        self._clientId: int|None = None  # store python-id to optimize w/o unwrapping
        self._method: str|None = None
        # origin could be stored as a weak ref -- the place where the client was derived from.
        self._origin: base.Music21Object|None = None
        self._originId: int|None = None  # store id to optimize w/o unwrapping

        # set client; can handle None
        self.client = client

    # SPECIAL METHODS #

    def __deepcopy__(self, memo=None):
        '''
        Manage deepcopying by creating a new reference to the same object. If
        the origin no longer exists, than origin is set to None
        '''
        new = type(self)()
        new.client = self.client
        new.origin = self.origin
        return new

    def __repr__(self):
        '''
        representation of the Derivation
        '''
        klass = self.__class__.__name__
        via = f' via {self.method!r}' if self.method else ''
        return f'<{klass} of {self.client} from {self.origin}{via}>'

    def __getstate__(self):
        # unwrap weakref for pickling
        self._client = common.unwrapWeakref(self._client)
        return SlottedObjectMixin.__getstate__(self)

    def __setstate__(self, state):
        SlottedObjectMixin.__setstate__(self, state)
        self._client = common.wrapWeakref(self._client)

    # PUBLIC METHODS #

    # PUBLIC PROPERTIES #

    @property
    def client(self) -> base.Music21Object|None:
        c = common.unwrapWeakref(self._client)
        if c is None and self._clientId is not None:
            self._clientId = None
            self._client = None
            self._origin = None
            self._originId = None
        return c

    @client.setter
    def client(self, client: base.Music21Object|None):
        # client is the Stream that this derivation lives on
        if client is None:
            self._clientId = None
            self._client = None
        else:
            self._clientId = id(client)
            self._client = common.wrapWeakref(client)  # type: ignore

    def chain(self) -> Generator[base.Music21Object, None, None]:
        '''
        Iterator/Generator

        Yields the Streams which this Derivation's client Stream was derived
        from. This provides a way to obtain all Streams that the client passed
        through, such as those created by
        :meth:`~music21.stream.Stream.getElementsByClass` or
        :meth:`~music21.stream.Stream.flatten`.

        >>> s1 = stream.Stream()
        >>> s1.id = 's1'
        >>> s1.repeatAppend(note.Note(), 10)
        >>> s1.repeatAppend(note.Rest(), 10)
        >>> s2 = s1.notesAndRests.stream()
        >>> s2.id = 's2'
        >>> s3 = s2.getElementsByClass(note.Note).stream()
        >>> s3.id = 's3'
        >>> for y in s3.derivation.chain():
        ...     print(y)
        <music21.stream.Stream s2>
        <music21.stream.Stream s1>

        >>> list(s3.derivation.chain()) == [s2, s1]
        True
        '''
        orig: base.Music21Object | None = self.origin
        while orig is not None:
            yield orig
            orig = orig.derivation.origin  # pylint: disable=no-member

    @property
    def method(self) -> str|None:
        '''
        Returns or sets the string of the method that was used to generate this
        Stream.

        >>> s = stream.Stream()
        >>> s.derivation.method is None
        True

        >>> sNotes = s.notes.stream()
        >>> sNotes.derivation.method
        'notes'

        Some examples are 'getElementsByClass' etc.

        >>> s = stream.Stream()
        >>> s.id = 'lonelyStream'
        >>> s.append(clef.TrebleClef())
        >>> s.append(note.Note())
        >>> sNotes = s.notes.stream()
        >>> sNotes.derivation
        <Derivation of <music21.stream.Stream lonelyStream>
            from <music21.stream.Stream lonelyStream> via 'notes'>

        >>> derived = sNotes.derivation
        >>> derived.method
        'notes'

        >>> derived.method = 'blah'
        >>> derived.method
        'blah'

        >>> derived is sNotes.derivation
        True
        >>> sNotes.derivation.method
        'blah'
        '''
        return self._method

    @method.setter
    def method(self, method: str|None):
        self._method = method

    @property
    def origin(self) -> base.Music21Object|None:
        return self._origin

    @origin.setter
    def origin(self, origin: base.Music21Object|None):
        # for now, origin is not a weak ref
        if origin is None:
            self._originId = None
            self._origin = None
        else:
            self._originId = id(origin)
            self._origin = origin
            # self._origin = common.wrapWeakref(origin)

    @property
    def originId(self) -> int|None:
        '''
        Return the Python id (=memory location) of the origin.
        (Same as id(derivation.origin).  Not the same as derivation.origin.ind)
        '''
        return self._originId

    @property
    def rootDerivation(self) -> base.Music21Object|None:
        r'''
        Return a reference to the oldest source of this Stream; that is, chain
        calls to :attr:`~music21.stream.Stream.derivesFrom` until we get to a
        Stream that cannot be further derived.

        >>> s1 = stream.Stream()
        >>> s1.repeatAppend(note.Note(), 10)
        >>> s1.repeatAppend(note.Rest(), 10)
        >>> s2 = s1.notesAndRests.stream()
        >>> s3 = s2.getElementsByClass(note.Note).stream()
        >>> s3.derivation.rootDerivation is s1
        True
        '''
        derivationChain = list(self.chain())
        if derivationChain:
            return derivationChain[-1]
        else:
            return None

# -----------------------------------------------------------------------------


class Test(unittest.TestCase):
    pass


# -----------------------------------------------------------------------------
# define presented order in documentation

_DOC_ORDER = [Derivation]

if __name__ == '__main__':
    # sys.arg test options will be used in mainTest()
    import music21
    music21.mainTest(Test)
