import enum
import inspect
import time
from contextlib import contextmanager
from decorator import decorator
from schrodinger.application.msv.utils import DEBUG_MODE
from schrodinger.Qt import QtWidgets
# NO_COMMAND can be returned from a do_command decorated method to indicate that
# no command should be added to the undo stack.  See do_command for more
# information.
NO_COMMAND = object()
# Command IDs for commands that should be mergeable.  Using this enum allows us
# to avoid id conflicts.
CommandType = enum.IntEnum("CommandType",
                           ("MoveTab", "AddGaps", "SelectResidues"))
[docs]class UndoStack(QtWidgets.QUndoStack):
    """
    Custom QUndoStack for MSV2.
    :ivar in_macro: Whether currently inside a macro.
    """
[docs]    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.in_macro = False 
[docs]    def beginMacro(self, desc):
        self.in_macro = True
        super().beginMacro(desc) 
[docs]    def endMacro(self):
        super().endMacro()
        # TODO in_macro will be incorrect if macros are nested
        self.in_macro = False  
[docs]class Command(QtWidgets.QUndoCommand):
[docs]    def __init__(self, redo, undo, description, command_id=-1, desc_merge=None):
        """
        :param redo: A method that redoes some action
        :type  redo: callable
        :param undo: A function that undoes the actions of `redo`
        :type  undo: callable
        :param description: A short summary of what `redo` accomplishes
        :type  description: str
        :param command_id: An id to assign the command.
        :type  command_id: int
        :param desc_merge: A function that takes two command descriptions
            and merges them.
        :type  desc_merge: callable
        """
        QtWidgets.QUndoCommand.__init__(self, description)
        self._redo = [redo]
        self._undo = [undo]
        self.command_id = command_id
        self.desc_merge = desc_merge
        self.redo_return_value = None 
[docs]    def redo(self):
        for func in self._redo:
            self.redo_return_value = func() 
[docs]    def undo(self):
        for func in self._undo:
            func() 
[docs]    def id(self):
        return self.command_id 
[docs]    def mergeWith(self, other_command):
        # See Qt documentation for method documentation
        self._redo.extend(other_command._redo)
        self._undo = other_command._undo + self._undo
        if self.desc_merge is not None:
            new_desc = self.desc_merge(self.text(), other_command.text())
            self.setText(new_desc)
        return True  
[docs]class TimeBasedCommand(Command):
    """
    A class for undo commands that can be can be merged together if they have
    the same command_id and occur within a certain time interval.
    """
[docs]    def __init__(self,
                 redo,
                 undo,
                 description,
                 command_id=-1,
                 desc_merge=None,
                 merge_time_interval=1):
        """
        :param redo: A method that redoes some action
        :type  redo: callable
        :param undo: A function that undoes the actions of `redo`
        :type  undo: callable
        :param description: A short summary of what `redo` accomplishes
        :type  description: str
        :param command_id: An id to assign the command.
        :type  command_id: int
        :param desc_merge: A function that takes two command descriptions
            and merges them.
        :type  desc_merge: callable
        :param merge_time_interval: The max time in seconds that two commands
            with the same ID can occur in while still merging. Defaults to 1.
        :type  merge_time: float
        """
        super().__init__(redo, undo, description, command_id, desc_merge)
        self.merge_time_interval = merge_time_interval
        self.timestamp = time.time() 
[docs]    def mergeWith(self, other_command):
        # See Qt documentation for method documentation
        if other_command.timestamp - self.timestamp > self.merge_time_interval:
            return False
        self.timestamp = other_command.timestamp
        return super().mergeWith(other_command)  
[docs]def do_command(meth=None,
               *,
               command_id=-1,
               command_class=Command,
               **dec_kwargs):
    """
    Decorates a method returning parameters for a `Command`, constructs the
    `Command`, and pushes it onto the command stack if there is one or calls
    `redo` if there isn't. Also validates the `Command` parameters if DEBUG
    mode is enabled.
    The decorated method must return a tuple of at least three values:
        - The redo method (`callable`)
        - The undo method (`callable`)
        - A description of the command (`str`)
    The returned tuple may also include additional values, which will be passed
    to the command class's `__init__` method (see `command_class` param below).
    Alternatively, the decorated method may return `NO_COMMAND`, in which case
    no command will be added to the undo stack.  In this case, no changes should
    be made to the alignment.  This is useful if the arguments passed to the
    method do not require any changes to the alignment.
    When the decorated method is called, it will return the value returned by
    the redo method.
    The decorator itself takes two optional keyword-only arguments.
    :param command_id: The command ID.  If given, then any sequential commands
        with the same command ID will be merged as they are pushed onto the undo
        stack.  If no command ID is given, then no command merging will occur.
        (Note that a non-default `command_class` may alter this command merging
        behavior.)
    :type command_id: int
    :param command_class: The command class to use.  Must be `Command` or a
        `Command` subclass.
    :type command_class: Type[Command]
    Any additional keyword arguments passed to the decorator will be passed to
    the command class's `__init__` method.
    """
    @decorator
    def do_command_dec(meth, self, *args, **kwargs):
        retval = meth(self, *args, **kwargs)
        if retval is NO_COMMAND:
            return None
        redo, undo, desc, *command_args = retval
        if DEBUG_MODE:
            if (not callable(undo) or not callable(redo) or
                    not isinstance(desc, str)):
                msg = ('%s must return a tuple with callable redo and undo '
                       'objects and a description string.' % meth.__name__)
                raise ValueError(msg)
        command = command_class(redo, undo, desc, command_id, *command_args,
                                **dec_kwargs)
        undo_stack = getattr(self, 'undo_stack', None)
        if undo_stack is None:
            command.redo()
        else:
            undo_stack.push(command)
        return command.redo_return_value
    if meth is None:
        return do_command_dec
    else:
        return do_command_dec(meth) 
[docs]@contextmanager
def compress_command(stack, description):
    """
    Enables compression of commands on undo stack, so they can be revoked
    using a single undo call.
    :type stack: `QUndoStack`
    :param stack: Undo stack to compress commands on.
    :type description: str
    :param description: Description of the compressed command.
    """
    try:
        stack.beginMacro(description)
        yield
    finally:
        stack.endMacro() 
[docs]@decorator
def from_command_only(meth, *args, **kwargs):
    """
    When running in DEBUG mode, enforces the requirement that the decorated
    method only be called from a command object
    """
    if DEBUG_MODE:
        info = inspect.stack()[2][3]
        if info not in ['redo', 'undo']:
            msg = ("%s was called by %s but can only be called from a command" %
                   (meth.__name__, info))
            raise RuntimeError(msg)
    return meth(*args, **kwargs) 
[docs]def revert_command(stack):
    """
    Undo and discard the last command on the undo stack.
    :param stack: The undo stack
    :type stack: QtWidgets.QUndoStack
    """
    cur_index = stack.index()
    if cur_index < 1:
        return
    cmd = stack.command(cur_index - 1)
    cmd.setObsolete(True)
    cmd.undo()  # TODO remove after QTBUG-67633 is fixed
    stack.undo()