import difflib
import re
from enum import Enum
from typing import List, Optional

from gi.repository import Gtk

from gourmand.gdebug import debug


class UndoMode(Enum):
    ADD = "add"
    DELETE = "delete"


class TooManyChanges(Exception):
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return repr(self.value)


class UndoableObject:
    """An UndoableObject.

    It must be provided an action, an inverse and a history.
    Alternatively, it can be supplied a `get_reapply_action_args` or
    `get_reundo_action_args` which will allow the action to be "reapplied" with
    new arguments.
    For example, if the action is setting a text attribute, reapply might set
    that attribute for the currently highlighted text.
    """

    def __init__(
        self,
        action: callable,
        inverse: callable,
        history: "UndoableObject",
        action_args: Optional[List] = None,
        undo_action_args: Optional[List] = None,
        get_reapply_action_args: Optional[List] = None,
        get_reundo_action_args: Optional[List] = None,
        reapply_name=None,
        reundo_name=None,
        is_undo: Optional[bool] = False,
        widget: Gtk.Widget = None,
    ):
        """
        history is an object derived from this class.

        widget is used to keep track of where this action began, for GUIs to
        check if actions have changed values from their "saved" state.

        is_undo is used as a flag to know whether this is an undo of a previous
        UndoableObject.
        """
        if action_args is None:
            action_args = []
        if undo_action_args is None:
            undo_action_args = []
        self.history = history
        self.action = action
        self.inverse_action = inverse
        self.get_reapply_action_args = get_reapply_action_args
        self.undo_action_args = undo_action_args
        self.get_reundo_action_args = get_reundo_action_args
        self.reapply_name = reapply_name
        self.reundo_name = reundo_name
        self.is_undo = is_undo
        self.widget = widget
        if self.get_reapply_action_args:
            self.reapplyable = True
        else:
            self.reapplyable = False
        self.action_args = action_args

    def perform(self):
        self.action(*self.action_args)
        self.history.append(self)

    def inverse(self):
        u = UndoableObject(
            self.inverse_action,
            self.action,
            self.history,
            action_args=self.undo_action_args,
            undo_action_args=self.action_args,
            get_reapply_action_args=self.get_reundo_action_args,
            get_reundo_action_args=self.get_reapply_action_args,
            is_undo=not self.is_undo,
            widget=self.widget,
        )
        self.history.remove(self)
        u.perform()

    def reapply(self):
        if self.get_reapply_action_args:
            args, undoargs = self.get_reapply_action_args()
            u = UndoableObject(
                self.action,
                self.inverse_action,
                self.history,
                action_args=args,
                undo_action_args=undoargs,
                get_reapply_action_args=self.get_reapply_action_args,
                reapply_name=self.reundo_name,
                reundo_name=self.reapply_name,
            )
            u.perform()

    def __repr__(self):
        return "<Undo.UndoableObject %s widget=%s>" % (self.is_undo, self.widget)


class UndoableTextChange(UndoableObject):
    def __init__(self, set_text_action, history, initial_text="", text="", txt_id=None, is_undo=False):
        self.txt_id = txt_id
        self.blob_matcher = re.compile(r"\s+\S+\s+")
        self.initial_text = initial_text if initial_text is not None else ""
        self.text = text
        self._set_text = set_text_action
        UndoableObject.__init__(
            self, lambda *args: self._set_text(self.text), lambda *args: self._set_text(self.initial_text), history, is_undo=is_undo, widget=self.txt_id
        )
        self.mode = self.determine_mode()
        try:
            self.cindex, self.clen = self.find_change(self.text)
        except TooManyChanges:
            debug("Too many changes - assume 0,0", 0)
            self.cindex, self.clen = 0, 0

    def determine_mode(self, current: Optional[str] = "", initial: Optional[str] = "") -> UndoMode:
        current = current if current else self.text
        initial = initial if initial else self.initial_text

        if len(current) > len(initial):
            return UndoMode.ADD
        return UndoMode.DELETE

    def find_change(self, text2=None, initial_text=None):
        if initial_text is None:
            initial_text = self.initial_text
        if not self.mode:
            self.text = text2
            self.mode = self.determine_mode()
        if text2 is None:
            text2 = self.text
        blocks = difflib.SequenceMatcher(None, initial_text, text2).get_matching_blocks()
        # we only are interested in similar blocks at different positions
        # (which tell us where the changes happened).
        ch_blocks = [x for x in blocks if x[0] != x[1] and x[2] != 0]
        if ch_blocks and len(ch_blocks) > 1:
            raise TooManyChanges("More than one block changed from '%s' to '%s': %s" % (initial_text, text2, ch_blocks))
        if ch_blocks:
            i, j, n = ch_blocks[0]
            change_length = j - i
            change_index = i
            return [change_index, change_length]
        else:
            if self.mode is UndoMode.DELETE:
                return [len(initial_text), len(initial_text) - len(text2)]
            else:
                return [len(initial_text), len(text2) - len(initial_text)]

    def add_text(self, new_text):
        mode = self.determine_mode(new_text, self.text)
        contmode = self.determine_mode(new_text, self.text)
        if mode == contmode == self.mode:
            try:
                # This whole thing is in a try/except box because
                # there are multiple places where "Too many changes"
                # could crop up as an error.
                cindex, clen = self.find_change(new_text)
                if (
                    (cindex == self.cindex)
                    or (self.mode == UndoMode.ADD and cindex == self.cindex)
                    or (self.mode == UndoMode.DELETE and cindex == self.cindex - clen)
                ):
                    if self.mode == UndoMode.ADD:
                        changed_text = new_text[cindex : cindex + (self.clen + clen)]
                    else:
                        changed_text = ""
                    # Now we make sure the addition is at the end or middle of our new text...
                    relative_cindex, relative_clen = self.find_change(new_text, self.text)
                    # We only consider a word at a time to be a continuous change...
                    if (
                        # Adding text to the end
                        (
                            relative_cindex == cindex + (clen - relative_clen)
                            or
                            # Adding text to the beginning
                            (relative_cindex == self.clen or relative_cindex == self.cindex)
                        )
                        and not self.blob_matcher.search(changed_text)
                    ):
                        self.text = new_text
                        self.cindex, self.clen = self.find_change(new_text)
                        ## End here if we've worked
                        return
            except TooManyChanges:
                pass  # We'll go to the end of the method & create a new action
        # If the mode has changed or we have too many changes to
        # handle simply, we just create a new undo action for this new
        # change
        self.new_action(new_text)

    def new_action(self, new_text):
        self.history.append(
            UndoableTextChange(
                self._set_text,
                self.history,
                initial_text=self.text,
                text=new_text,
                txt_id=self.txt_id,
            )
        )

    def inverse(self):
        # self._set_text(self.initial_text)
        u = UndoableTextChange(
            self._set_text,
            self.history,
            initial_text=self.text,
            text=self.initial_text,
            txt_id=self.txt_id,
            is_undo=not self.is_undo,
        )
        self.history.remove(self)
        u.perform()

    def perform(self):
        self._set_text(self.text)
        self.history.append(self)

    def __repr__(self):
        return (
            "<Undo.UndoableTextChange "
            + self.mode.name
            + " "
            + repr(self.txt_id)
            + repr(self.initial_text)
            + "=>"
            + repr(self.text)
            + "clen"
            + str(self.clen)
            + "cindex"
            + str(self.cindex)
            + ">"
        )

    def __eq__(self, obj):
        """We are equal too objects that represent the same txt_id and
        are either the same action as us or the inverse of us
        """
        if not hasattr(obj, "txt_id"):
            return False
        else:
            return self.txt_id == obj.txt_id and (
                (self.initial_text == obj.initial_text and self.text == obj.text) or (self.initial_text == obj.text and self.text == obj.initial_text)
            )


class UndoableTextContainer:
    def __init__(self, container: Gtk.Widget, history):
        self.history = history
        self.container = container
        self.setup_widgets()
        self.txt = None
        if isinstance(container, Gtk.ComboBoxText):
            self.txt = self.container.get_active_text()
        self._setting = False

    def change_event_cb(self, *args):
        # if the last change is the same as us...
        if self._setting:
            return
        txt = self.get_text()
        if txt == self.txt:
            return
        if len(self.history) >= 1 and hasattr(self.history[-1], "txt_id") and self.history[-1].txt_id == self.container:
            self.change = self.history[-1]
            self.history[-1].add_text(txt)
        else:
            self.change = UndoableTextChange(self._set_text, self.history, initial_text=self.txt, text=txt, txt_id=self.container)
            self.history.append(self.change)
        self.txt = txt

    def setup_widgets(self):
        pass

    def get_text(self):
        raise NotImplementedError

    def _set_text(self, txt):
        self._setting = True  # Set flag so we don't trigger our own
        # callbacks
        orig = self.get_text()  # Get current text for comparison
        # (helps with placement of cursor)
        if len(txt) > len(orig):  # If we're adding
            try:
                index, length = self.change.find_change(txt, orig)
                cursor_index = index + length
            except TooManyChanges:
                # If we changed more than one block, there is no obvious
                # place to put the cursor, so we put it at the end
                cursor_index = len(txt)
        else:  # If we're Removing
            try:
                # We get the *index* of the inverse of our action
                # i.e. If we go from Thhis -> This, then we reverse to
                # This -> Thhis (which is the same as where it would
                # have been after hitting backspace) and we put it
                # there.
                index, length = self.change.find_change(orig, txt)
                cursor_index = index
            except TooManyChanges:
                cursor_index = len(txt)
        self.set_text(txt, cursor_index)
        self._setting = False  # Unset flag

    def set_text(self, txt, cursor_index):
        raise NotImplementedError


class UndoableEntry(UndoableTextContainer):
    def __init__(self, entry: Gtk.Widget, history):
        self.entry = entry
        if isinstance(entry, Gtk.ComboBoxText):
            self.get_text = entry.get_active_text
        elif isinstance(entry, Gtk.Entry):
            self.get_text = entry.get_text
        UndoableTextContainer.__init__(self, self.entry, history)

    def setup_widgets(self):
        self.entry.connect("changed", self.change_event_cb)

    def set_text(self, txt, cursor_index):
        self.entry.grab_focus()
        self.entry.set_text(txt)
        self.entry.set_position(cursor_index)


class UndoableGenericWidget:
    """Wrap a widget in an Undo class.

    We take a set_method, a get_method, and a signal, as well as a
    history list to append our Undo objects to.
    """

    def __init__(self, widget, history, set_method="set_value", get_method="get_value", signal="changed"):
        self.w = widget
        self.set_method = set_method
        self.get_method = get_method
        self.im_doing_the_setting = False
        self.history = history
        self.last_value = self.get()
        self.w.connect(signal, self.changecb)

    def set(self, val):
        self.im_doing_the_setting = True
        getattr(self.w, self.set_method)(val)
        self.im_doing_the_setting = False

    def get(self):
        return getattr(self.w, self.get_method)()

    def changecb(self, *args):
        if self.im_doing_the_setting:
            # If we are doing the setting, presumably this is from one
            # of the Undo objects we created, in which case we don't
            # want to add a new item to the Undo queue
            return
        old_val = self.last_value
        new_val = self.get()
        if old_val == new_val:
            return  # Ignore redundant changes...
        if new_val != old_val:
            # We don't perform because we're being called after the action has happened.
            # We simply append ourselves to the history list.
            u = UndoableObject(lambda *args: self.set(new_val), lambda *args: self.set(old_val), self.history, widget=self.w)
            self.history.append(u)
            self.last_value = new_val


class UndoableTextView(UndoableTextContainer):
    def __init__(self, textview, history):
        self.tv = textview
        UndoableTextContainer.__init__(self, self.tv, history)

    def setup_widgets(self):
        self.buffer = self.tv.get_buffer()
        self.buffer.connect("changed", self.change_event_cb)
        self.buffer.connect("apply-tag", self.change_event_cb)
        self.buffer.connect("remove-tag", self.change_event_cb)

    def set_text(self, text, cursor_index):
        self.buffer.set_text(text)
        self.tv.grab_focus()
        self.buffer.place_cursor(self.buffer.get_iter_at_offset(cursor_index))

    def get_text(self):
        return self.buffer.get_text(self.buffer.get_start_iter(), self.buffer.get_end_iter())


class UndoHistoryList(list):
    """An UndoHistoryList."""

    def __init__(self, undo_widget, redo_widget, reapply_widget=None, signal="activate"):
        self.undo_widget = undo_widget
        self.redo_widget = redo_widget
        self.reapply_widget = reapply_widget
        self.action_hooks = []
        list.__init__(self)
        self.gui_update()
        if signal:
            if self.undo_widget:
                self.undo_widget.connect(signal, self.undo)
            if self.redo_widget:
                self.redo_widget.connect(signal, self.redo)
            if self.reapply_widget:
                self.reapply_widget.connect(signal, self.reapply)

    def add_action_hook(self, hook):
        """Add action hook

        Hook will be a callback as follows

        hook(undo_history,action,type)

        where undo_history is this instance of UndoHistory and
        type is 'perform','undo','redo', or 'reapply'
        """
        self.action_hooks.append(hook)

    def undo(self, *args):
        index = -1
        if len(self) == 0:
            return False
        try:
            while self[index].is_undo:
                index -= 1
        except IndexError:
            debug("All %s available action are .is_undo=True" % len(self), 0)
            print("There is nothing to undo!")
            print("All %s actions are undos" % len(self))
            print(self, index)
            raise
        action = self[index]
        action.inverse()
        for h in self.action_hooks:
            h(self, action, "undo")

    def redo(self, *args):
        if len(self) == 0:
            return False
        index = -1
        try:
            while not self[index].is_undo:
                index = index - 1
        except IndexError:
            debug("All %s available actions are is_undo=False" % len(self), 0)
            print("There is nothing to redo!")
            print("All %s available actions are is_undo=False" % len(self))
            raise
        action = self[index]
        action.inverse()
        for h in self.action_hooks:
            h(self, action, "redo")

    def reapply(self, *args):
        debug("Reapplying", 0)
        action = self[-1]
        action.reapply()
        for h in self.action_hooks:
            h(self, action, "reapply")

    def set_sensitive(self, w: Gtk.Action, is_sensitive: bool):
        # FIXME: this function should not exist.
        # It's only here to deal with the case where self.reapply_widget is
        # None. This function is only used within this object.
        debug("set_sensitive", 0)
        if not w:
            return
        w.set_sensitive(is_sensitive)

    def gui_update(self):
        debug("gui_update", 0)
        if len(self) >= 1:
            undoables = [x.is_undo for x in self]
            if False in undoables:
                self.set_sensitive(self.undo_widget, True)
                debug("Sensitizing undo_widget", 0)
            else:
                self.set_sensitive(self.undo_widget, False)
                debug("Desensizing undo_widget", 0)
            if True in undoables:
                debug("Sensitizing redo_widget", 0)
                self.set_sensitive(self.redo_widget, True)
            else:
                debug("Desensitizing redo widget", 0)
                self.set_sensitive(self.redo_widget, False)
            if self[-1].reapplyable:
                debug('Sensitizing "reapply" widgets', 0)
                self.set_sensitive(self.reapply_widget, True)
                if self[-1].reapply_name:
                    if isinstance(self.reapply_widget, Gtk.MenuItem):
                        alabel = self.reapply_widget.get_children()[0]
                        alabel.set_text_with_mnemonic(self[-1].reapply_name)
                        alabel.set_use_markup(True)
        else:
            debug("Nothing to undo, desensitizing widgets", 0)
            self.set_sensitive(self.redo_widget, False)
            self.set_sensitive(self.undo_widget, False)
            self.set_sensitive(self.reapply_widget, False)

    def append(self, obj):
        debug("Appending %s" % obj, 0)
        list.append(self, obj)
        if not obj.is_undo:  # Is this necessary? Not sure...
            for h in self.action_hooks:
                h(self, obj, "perform")
        self.gui_update()

    def remove(self, obj):
        debug("Removing %s" % obj, 0)
        list.remove(self, obj)
        self.gui_update()


class MultipleUndoLists:
    """For tabbed interfaces and other places where it makes sense for one
    Action (menu item, etc.) to control multiple UndoLists (since presumably
    nobody wants to undo things they can't see)"""

    def __init__(self, undo_widget, redo_widget, reapply_widget=None, signal="activate", get_current_id=None):
        self.undo_widget = undo_widget
        self.redo_widget = redo_widget
        self.reapply_widget = reapply_widget
        self.signal = signal
        self.get_current_id = get_current_id
        self.histories = {}
        self.action_hooks = []
        if signal:
            if self.undo_widget:
                self.undo_widget.connect(signal, self.undo)
            if self.redo_widget:
                self.redo_widget.connect(signal, self.redo)
            if self.reapply_widget:
                self.reapply_widget.connect(signal, self.reapply)

        # attempts to implement the following programatically are failing me...
        # it feels awful to write each of these methods out here, but here goes...

    def add_action_hook(self, hook):
        self.action_hooks.append(hook)
        for h in self.get_all_histories():
            h.add_action_hook(hook)

    def __add__(self, *args, **kwargs):
        return self.get_history().__add__(*args, **kwargs)

    def __contains__(self, *args, **kwargs):
        return self.get_history().__contains__(*args, **kwargs)

    def __delitem__(self, *args, **kwargs):
        return self.get_history().__delitem__(*args, **kwargs)

    def __doc__(self, *args, **kwargs):
        return self.get_history().__doc__(*args, **kwargs)

    def __eq__(self, *args, **kwargs):
        return self.get_history().__eq__(*args, **kwargs)

    def __ge__(self, *args, **kwargs):
        return self.get_history().__ge__(*args, **kwargs)

    def __getitem__(self, *args, **kwargs):
        return self.get_history().__getitem__(*args, **kwargs)

    def __gt__(self, *args, **kwargs):
        return self.get_history().__gt__(*args, **kwargs)

    def __hash__(self, *args, **kwargs):
        return self.get_history().__hash__(*args, **kwargs)

    def __iadd__(self, *args, **kwargs):
        return self.get_history().__iadd__(*args, **kwargs)

    def __imul__(self, *args, **kwargs):
        return self.get_history().__imul__(*args, **kwargs)

    def __iter__(self, *args, **kwargs):
        return self.get_history().__iter__(*args, **kwargs)

    def __le__(self, *args, **kwargs):
        return self.get_history().__le__(*args, **kwargs)

    def __len__(self, *args, **kwargs):
        return self.get_history().__len__(*args, **kwargs)

    def __lt__(self, *args, **kwargs):
        return self.get_history().__lt__(*args, **kwargs)

    def __mul__(self, *args, **kwargs):
        return self.get_history().__mul__(*args, **kwargs)

    def __ne__(self, *args, **kwargs):
        return self.get_history().__ne__(*args, **kwargs)

    def __new__(self, *args, **kwargs):
        return self.get_history().__new__(*args, **kwargs)

    def __reduce__(self, *args, **kwargs):
        return self.get_history().__reduce__(*args, **kwargs)

    def __reduce_ex__(self, *args, **kwargs):
        return self.get_history().__reduce_ex__(*args, **kwargs)

    def __repr__(self, *args, **kwargs):
        return self.get_history().__repr__(*args, **kwargs)

    def __rmul__(self, *args, **kwargs):
        return self.get_history().__rmul__(*args, **kwargs)

    def __setitem__(self, *args, **kwargs):
        return self.get_history().__setitem__(*args, **kwargs)

    def __str__(self, *args, **kwargs):
        return self.get_history().__str__(*args, **kwargs)

    def append(self, *args, **kwargs):
        return self.get_history().append(*args, **kwargs)

    def count(self, *args, **kwargs):
        return self.get_history().count(*args, **kwargs)

    def extend(self, *args, **kwargs):
        return self.get_history().extend(*args, **kwargs)

    def index(self, *args, **kwargs):
        return self.get_history().index(*args, **kwargs)

    def insert(self, *args, **kwargs):
        return self.get_history().insert(*args, **kwargs)

    def pop(self, *args, **kwargs):
        return self.get_history().pop(*args, **kwargs)

    def remove(self, *args, **kwargs):
        return self.get_history().remove(*args, **kwargs)

    def reverse(self, *args, **kwargs):
        return self.get_history().reverse(*args, **kwargs)

    def sort(self, *args, **kwargs):
        return self.get_history().sort(*args, **kwargs)

    def redo(self, *args, **kwargs):
        return self.get_history().redo(*args, **kwargs)

    def undo(self, *args, **kwargs):
        return self.get_history().undo(*args, **kwargs)

    def reapply(self, *args, **kwargs):
        return self.get_history().reapply(*args, **kwargs)

    def get_all_histories(self):
        return list(self.histories.values())

    def get_history(self):
        hid = self.get_current_id()
        if hid in self.histories:
            # debug('Returning history %s for id %s'%([repr(i) for i in self.histories[hid]],hid),0)
            return self.histories[hid]
        else:
            # debug('Creating new history for id %s'%hid,0)
            self.histories[hid] = self.make_history()
            return self.histories[hid]

    def make_history(self):
        uhl = UndoHistoryList(self.undo_widget, self.redo_widget, None, None)
        for h in self.action_hooks:
            uhl.add_action_hook(h)
        return uhl

    def switch_context(self, hid):
        # set sensitivity for current context
        debug("switching context...", 0)
        self.get_history().gui_update()


if __name__ == "__main__":
    # txt = raw_input('Text: ')
    # history = []
    # utc = UndoableTextChange(None,None,history,text=txt)
    # history.append(utc)
    # while txt:
    #    txt = raw_input('Text: ')
    #    history[-1].add_text(txt)
    from gi.repository import Gtk

    w = Gtk.Window()
    e = Gtk.Entry()
    sb = Gtk.SpinButton()
    adj = sb.get_adjustment()
    adj.upper = 100
    adj.lower = -100
    adj.step_increment = 1
    adj.page_increment = 10
    tv = Gtk.TextView()
    ub = Gtk.Button(stock=Gtk.STOCK_UNDO)
    rb = Gtk.Button(stock=Gtk.STOCK_REDO)
    sc = Gtk.Button("show changes")
    vb = Gtk.VBox()
    bb = Gtk.HButtonBox()
    bb.add(ub)
    bb.add(rb)
    bb.add(sc)
    vb.add(bb)
    vb.add(e)
    vb.add(tv)
    vb.add(sb)
    sb.show()
    w.add(vb)
    uhl = UndoHistoryList(ub, rb, signal="clicked")
    UndoableTextView(tv, uhl)
    UndoableEntry(e, uhl)
    UndoableGenericWidget(sb, uhl)
    w.show_all()
    w.connect("delete-event", lambda *args: Gtk.main_quit())

    def show_changes(*args):
        for c in uhl:
            if hasattr(c, "initial_text"):
                print(c, " initial: ", c.initial_text, " current: ", c.text)
            else:
                print(c)

    ub.connect("clicked", lambda *args: debug("Undo clicked!", 0))
    sc.connect("clicked", show_changes)
    rb.connect("clicked", lambda *args: debug("Redo clicked!", 0))
    Gtk.main()
