import itertools
import contextlib
from copy import copy

from visidata import vd, options, VisiData, BaseSheet, UNLOADED

vd._undo_suppressed = False  # set while building a sheet's layout; GIL makes the flip atomic

BaseSheet.init('undone', list)  # list of CommandLogRow for redo after undo

vd.option('undo', True, 'enable undo/redo')

nonUndo = '''commit open-file reload-sheet'''.split()

def isUndoableCommand(longname):
    for n in nonUndo:
        if longname.startswith(n):
            return False
    return True

@VisiData.api
@contextlib.contextmanager
def suppressUndo(vd):
    'Do not record undos within this block (e.g. while constructing a sheet).'
    old = vd._undo_suppressed
    vd._undo_suppressed = True
    try:
        yield
    finally:
        vd._undo_suppressed = old

@VisiData.api
def addUndo(vd, undofunc, *args, **kwargs):
    'On undo of latest command, call ``undofunc(*args, **kwargs)``.'
    if vd.options.undo and not vd._undo_suppressed:
        # occurs when VisiData is just starting up.
        # very early in startup, modifyCommand does not yet exist
        if not vd.activeCommand:
            return
        r = vd.modifyCommand
        if not r or not isUndoableCommand(r.longname):
            return
        # some special commands, like open-file, do not have an undofuncs set
        # do not set undofuncs for non-logged commands
        if not vd.isLoggableCommand(vd.activeCommand):
            return
        if not r.undofuncs:
            r.undofuncs = []
        r.undofuncs.append((undofunc, args, kwargs))


@VisiData.api
def undo(vd, sheet):
    if not vd.options.undo:
        vd.fail("options.undo not enabled")

    cmdlogrows = itertools.dropwhile(lambda r: r.longname == 'set-option', sheet.cmdlog_sheet.rows)
    # skip the first remaining command, to exclude it from undo,
    # because it is always the command that created the sheet, or for
    # replayed cmdlogs, a no-op placeholder row
    for i, cmdlogrow in enumerate(reversed(list(cmdlogrows)[1:])):
        if cmdlogrow.undofuncs:
            for undofunc, args, kwargs, in cmdlogrow.undofuncs[::-1]:
                undofunc(*args, **kwargs)
            sheet.undone.append(cmdlogrow)
            row_idx = len(sheet.cmdlog_sheet.rows)-1 - i
            del sheet.cmdlog_sheet.rows[row_idx]

            vd.clearCaches()  # undofunc can invalidate the drawcache

            vd.moveToReplayContext(cmdlogrow, sheet)
            vd.status("%s undone" % cmdlogrow.longname)
            return

    vd.fail("nothing to undo on current sheet")


@VisiData.api
def redo(vd, sheet):
    sheet.undone or vd.fail("nothing to redo")
    cmdlogrow = sheet.undone.pop()
    vd.replayOne(cmdlogrow)
    vd.status("%s redone" % cmdlogrow.longname)

# undoers
def undoAttrFunc(objs, attrname):
    'Return closure that sets attrname on each obj to its former value.'
    oldvals = [(o, getattr(o, attrname)) for o in objs]
    def _undofunc():
        for o, v in oldvals:
            setattr(o, attrname, v)
    return _undofunc


class Fanout(list):
    'Fan out attribute changes to every element in a list.'
    def __getattr__(self, k):
        return Fanout([getattr(o, k) for o in self])

    def __setattr__(self, k, v):
        vd.addUndo(undoAttrFunc(self, k))
        for o in self:
            setattr(o, k, v)

    def __call__(self, *args, **kwargs):
        return Fanout([o(*args, **kwargs) for o in self])


def undoAttrCopyFunc(objs, attrname):
    'Return closure that sets attrname on each obj to its former value.'
    oldvals = [(o, copy(getattr(o, attrname))) for o in objs]
    def _undofunc():
        for o, v in oldvals:
            setattr(o, attrname, v)
    return _undofunc


@VisiData.api
def addUndoSetValues(vd, cols, rows):
    'Add undo function to reset values for *rows* in *cols*.'
    oldvals = [(c, r, c.getValue(r)) for c,r in itertools.product(cols, vd.Progress(rows, gerund='doing'))]
    def _undo():
        for c, r, v in oldvals:
            c.setValue(r, v, setModified=False)
    vd.addUndo(_undo)


BaseSheet.addCommand('U', 'undo-last', 'vd.undo(sheet)', 'Undo the most recent change (options.undo must be enabled)')
BaseSheet.addCommand('R', 'redo-last', 'vd.redo(sheet)', 'Redo the most recent undo (options.undo must be enabled)')

vd.addGlobals(
    undoAttrFunc=undoAttrFunc,
    Fanout=Fanout,
    undoAttrCopyFunc=undoAttrCopyFunc)

vd.addMenuItems('''
    Edit > Undo > undo-last
    Edit > Redo > redo-last
''')
