"""
This plugin provides a Vim emulation layer for GPS.
It does so outside of the GPS keybindings facility, which means
that it won't affect your regular keybindings.

This plugin provides a very small subset of the facilities of vim
for the moment. What works :

- Insertion mode
- Basic commands (word and line movement, deletions, replace)
- Visual mode (both line and character)

Notable features that are not yet implemented:

- Visual block mode
- Repeats
- Macros
- Text objects
- Paragraph and sentences moves
- Much much more
"""

# TODO:
# * Commands : Sentences and paragraphs movements {}()
# * Make w and b work as in vim  OK
# * Goto line
# * Repeats
# * Bind "/" to isearch or implement own search
# * Bind "*" to search whole word

from gi.repository import Gtk
import GPS
from pygps import get_widgets_by_type, send_key_event
from collections import defaultdict
from functools import partial
import re

# UTILS #


def qualifier_decorator(_type):
    def decorator(klass):
        def _action_type(self):
            return _type

        klass.qualifiers = klass.qualifiers.union([_type])
        return klass

    decorator._type = _type

    return decorator


movement_command = qualifier_decorator("movement_command")
composed_movement = qualifier_decorator("composed_movement")
write_command = qualifier_decorator("write_command")
insert_command = qualifier_decorator("insert_command")


def with_col(loc, col):
    return GPS.EditorLocation(loc.buffer(), loc.line(), col)


def forward_vim_word(loc, fwd=True):
    loc = loc if fwd else loc.forward_char(-1)

    def isword(c):
        return c == "_" or c.isalpha()

    def cat(c):
        if isword(c):
            return 1
        elif c.isspace():
            return 2
        else:
            return 3

    offset = 1 if fwd else -1
    fw_ws = partial(forward_until, pred=lambda c: not c.isspace(), backwards=not fwd)

    if loc.get_char().isspace():
        if fwd:
            return fw_ws(loc)
        loc = fw_ws(loc)

    c = cat(loc.get_char())
    while c == cat(loc.get_char()):
        loc = loc.forward_char(offset)

    return fw_ws(loc) if fwd else loc.forward_char(1)


GPS.EditorLocation.forward_vim_word = forward_vim_word


def forward_until(loc, pred, skip_first_char=False, stop_at_eol=False, backwards=False):
    step = -1 if backwards else 1
    cur_loc = loc

    if skip_first_char:
        cur_loc = cur_loc.forward_char(step)

    while not pred(cur_loc.get_char()):
        if cur_loc.get_char() == "\n" and stop_at_eol:
            return loc

        if cur_loc == cur_loc.forward_char(step):
            return loc

        cur_loc = cur_loc.forward_char(step)
    return cur_loc


def get_visual_locs(vim_state):
    return [(vim_state.visual_start_pos, vim_state.view.cursor())]


def get_visual_box_locs(vim_state):
    pass


def get_visual_line_locs(vim_state):
    cur = vim_state.view.cursor()
    if vim_state.visual_start_pos > cur:
        a = cur.beginning_of_line()
        b = vim_state.visual_start_pos.end_of_line()
    else:
        a = vim_state.visual_start_pos.beginning_of_line()
        b = cur.end_of_line()
    return [(a, b)]


GPS.EditorLocation.with_col = with_col
GPS.EditorLocation.forward_until = forward_until


# CONSTANTS #

KEY_ESC = 65307

NormalState = 0
InsertState = 1
ReplaceState = 12
VisualState = 2
VisualStateLine = 3
VisualStateBox = 4

visual_state_locs_funcs = {
    VisualState: get_visual_locs,
    VisualStateLine: get_visual_line_locs,
    VisualStateBox: get_visual_box_locs,
}

Mov_Char = 0
Mov_Line = 1
Mov_Word = 2


def create_action(action_tuple, vim_state):
    if isinstance(action_tuple, tuple):
        args = action_tuple[1:]
        args_prepared = [
            create_action(arg, vim_state) if isinstance(arg, tuple) else arg
            for arg in args
        ]
        inst = action_tuple[0](*args_prepared)
    else:
        inst = action_tuple()

    inst.vim_state = vim_state

    if inst.is_a(insert_command):
        inst.key_presses = []

    return inst


class KeyMap(object):
    def get_action(keyval, vim_state):
        return NoAction()


class CharActionKeyMap(KeyMap):
    def __init__(self, keymap):
        self.keymap = keymap

    def get_action(self, keyval, vim_state):
        action = self.keymap.get(chr(keyval), (NoAction,))
        return create_action(action, vim_state)


class CharCharKeyMap(KeyMap):
    def get_action(self, keyval, vim_state):
        return create_action((CharAction, chr(keyval)), vim_state)


def is_command_state(state):
    return state in [NormalState, VisualState, VisualStateLine, VisualStateBox]


def is_visual_state(state):
    return is_visual_state(state) and state != NormalState


def is_insert_state(state):
    return state in [InsertState, ReplaceState]


override_keys_map = {65106: 94}


class VimState(object):
    def __init__(self, view, gtk_view):
        self.view = view
        self.buffer = view.buffer()
        self.selection_overlay = self.buffer.create_overlay(
            name="vim_selection_overlay"
        )
        self.selection_overlay.set_property("background", "#777")
        self.gtk_view = gtk_view
        self.state = NormalState
        self.column_memory = None
        self.action_stack = []
        self.keymaps_stack = [CharActionKeyMap(basic_actions)]
        self.last_write_command = None
        self.last_movement_command = None
        self.current_insert_command = None

        switch_state(self, NormalState)

    def yank(self, beginning, end, is_line=False):
        yanks[current_yank_buffer] = (self.buffer.get_chars(beginning, end), is_line)

    def delete(self, start, end, is_line=False):
        self.yank(start, end, is_line)
        self.buffer.delete(start, end)

    def get_selection_locs(self):
        return visual_state_locs_funcs[self.state](self)

    def extend_selection(self):
        locs_pairs = self.get_selection_locs()
        for loc_a, loc_b in locs_pairs:
            self.buffer.remove_overlay(self.selection_overlay)
            self.buffer.apply_overlay(self.selection_overlay, loc_a, loc_b)

    def init_visual(self):
        self.visual_start_pos = self.view.cursor()
        self.extend_selection()

    def is_in_command_state(self):
        return is_command_state(self.state)

    def on_key_pressed(self, view, event):
        _, key = event.get_keyval()

        if key in override_keys_map:
            key = override_keys_map[key]

        # Escape always returns back to normal mode
        if key == KEY_ESC:
            self.action_stack = []
            self.current_insert_command = None
            switch_state(self, NormalState)

        # Bind actions if we are in normal mode
        elif self.is_in_command_state():
            if key in range(256):
                action = self.keymaps_stack[-1].get_action(key, self)
                remember_action = action.apply()
                if remember_action:
                    if remember_action.is_a(composed_movement):
                        self.last_movement_command = remember_action
                    elif remember_action.is_a(write_command) or remember_action.is_a(
                        insert_command
                    ):
                        self.last_write_command = remember_action
                    if remember_action.is_a(insert_command):
                        self.current_insert_command = remember_action

            # Extend the state's selection if we are in visual
            if self.state in [VisualState, VisualStateLine, VisualStateBox]:
                self.extend_selection()

            return True

        elif is_insert_state(self.state):
            if self.current_insert_command:
                self.current_insert_command.key_presses.append(
                    (key, event.hardware_keycode, event.string)
                )
                self.last_ev = event

            return False

    def column(self):
        if self.column_memory:
            return self.column_memory
        else:
            return self.view.cursor().column()

    def set_column_memory(self):
        self.column_memory = self.view.cursor().column()


yanks = {}
current_yank_buffer = "default_yank_buffer"


class BaseAction(object):
    def cursor(self):
        return self.vim_state.view.cursor()

    qualifiers = set()

    def replay(self):
        self.apply_action()
        if self.is_a(insert_command):
            for key, hw_kc, string in self.key_presses:
                send_key_event(
                    key, hardware_keycode=hw_kc, window=self.vim_state.gtk_view
                )

    def apply(self):
        if self.vim_state.action_stack == []:
            self.apply_action()
            return self
        else:
            try:
                action = self.vim_state.action_stack.pop().compose(self)
                action.apply_action()
                return action
            except Exception as e:
                raise e
                self.vim_state.action_stack = []

    def is_a(self, qualifier):
        return qualifier._type in self.qualifiers


class PasteAction(BaseAction):
    def __init__(self, next_line=True):
        self.next_line = next_line

    def apply_action(self):
        try:
            text, is_line = yanks[current_yank_buffer]
        except KeyError:
            # We have nothing to paste
            return
        pos = self.cursor()
        if self.next_line:
            pos = pos.forward_line(1).beginning_of_line()
        else:
            pos = pos.beginning_of_line()
        self.vim_state.buffer.insert(pos, text)
        self.vim_state.view.goto(pos)


class CharAction(BaseAction):
    def __init__(self, char):
        self.char = char

    def get_char(self):
        return self.char


class ComposedAction(BaseAction):
    def apply(self):
        self.vim_state.action_stack.append(self)

    def compose(self, expected_action):
        self.expected_action = expected_action
        if self.vim_state.action_stack == []:
            return self
        else:
            return self.vim_state.action_stack.pop().compose(self)


class NoAction(BaseAction):
    def apply_action(self):
        pass


class SwitchState(BaseAction):
    def __init__(self, state):
        self.state = state

    def apply_action(self):
        self.vim_state.state = self.state
        if self.state == NormalState:
            self.vim_state.buffer.remove_overlay(self.vim_state.selection_overlay)
        if is_command_state(self.state) or self.state == ReplaceState:
            self.vim_state.gtk_view.set_overwrite(True)
        elif self.state == InsertState:
            self.vim_state.gtk_view.set_overwrite(False)
        self.vim_state.set_column_memory()
        self.vim_state.keymaps_stack = [CharActionKeyMap(modes_keymaps[self.state])]


@insert_command
class Insert(BaseAction):
    def apply_action(self):
        self.vim_state.state = InsertState
        self.vim_state.gtk_view.set_overwrite(False)
        self.vim_state.set_column_memory()


@movement_command
class Movement(BaseAction):
    def get_start_location(self):
        return self.cursor()

    def get_end_location(self):
        raise Exception("Not implemented")

    def is_line(self):
        return False

    def apply_action(self):
        loc = self.get_end_location()
        self.vim_state.view.goto(loc)
        self.vim_state.set_column_memory()


is_keyword_re = re.compile("[\w_0-9]")


def is_symbol_char(char):
    return not is_keyword_char(char) and not char.isspace()


def is_keyword_char(char):
    return is_keyword_re.match(char) is not None


class SimpleMovement(Movement):
    def __init__(self, movement_type, repeat=1):
        self.type = movement_type
        self.repeat = repeat

    def get_end_location(self, internal=False):
        cur = self.cursor()

        if self.type == Mov_Char:
            return cur.forward_char(self.repeat)

        elif self.type == Mov_Line:
            res = cur.forward_line(self.repeat)
            eol = res.end_of_line()
            return res.with_col(min(eol.column(), self.vim_state.column()))

        elif self.type == Mov_Word:
            loc = cur.forward_vim_word(self.repeat > 0)
            if not internal:
                return loc.forward_char(-1 if self.repeat > 0 else 1)
            return loc

    def apply_action(self):
        loc = self.get_end_location(True)

        self.vim_state.view.goto(loc)
        if self.type != Mov_Line:
            self.vim_state.set_column_memory()


class LineAction(ComposedAction):
    def apply(self):
        if self.vim_state.action_stack != [] and isinstance(
            self.vim_state.action_stack[-1], type(self)
        ):
            BaseAction.apply(self)
        else:
            ComposedAction.apply(self)


@write_command
class Deletion(LineAction):
    def apply_action(self):
        a = self.expected_action
        if isinstance(a, Deletion):
            start_loc = self.cursor().beginning_of_line()
            end_loc = self.cursor().end_of_line()
            is_line = True
        else:
            try:
                start_loc = a.get_start_location()
                end_loc = a.get_end_location()
                is_line = a.is_line()
            except Exception:
                return

        self.vim_state.delete(start_loc, end_loc, is_line)


class YankAction(BaseAction):
    def apply_action(self):
        # Implement Y action
        start_loc = self.cursor().beginning_of_line()
        end_loc = self.cursor().end_of_line()
        is_line = True
        self.vim_state.yank(start_loc, end_loc, is_line)


@write_command
class Yank(LineAction):
    def apply_action(self):
        # Implement y? action
        a = self.expected_action
        if isinstance(a, Yank):
            start_loc = self.cursor().beginning_of_line()
            end_loc = self.cursor().end_of_line()
            is_line = True
        else:
            try:
                start_loc = a.get_start_location()
                end_loc = a.get_end_location()
                is_line = a.is_line()
            except Exception:
                return

        self.vim_state.yank(start_loc, end_loc, is_line)


@write_command
@insert_command
class Replace(LineAction):
    def apply_action(self):
        cur = self.cursor()
        if not self.expected_action:
            start_loc = cur.beginning_of_line().forward_until(lambda c: not c.isspace())
            end_loc = cur.end_of_line().forward_char(-1)
        else:
            start_loc = self.cursor()
            end_loc = self.expected_action.get_end_location()
        self.vim_state.delete(start_loc, end_loc)
        switch_state(self.vim_state, InsertState)


class Undo(BaseAction):
    def apply_action(self):
        self.vim_state.view.buffer().undo()


@write_command
@insert_command
class OpenLine(BaseAction):
    def __init__(self, before=False):
        self.before = before

    def apply_action(self):
        cur = self.cursor()
        if self.before:
            insert_loc = cur.forward_line(-1).end_of_line()
        else:
            insert_loc = cur.end_of_line()
        self.vim_state.view.goto(insert_loc)
        self.vim_state.buffer.insert(insert_loc, "\n")
        GPS.execute_action("format selection")
        switch_state(self.vim_state, InsertState)


@movement_command
@composed_movement
class UntilCharMovement(ComposedAction):
    def __init__(self, backwards=False, stop_before=False):
        self.backwards = backwards
        self.stop_before = stop_before

    def is_line(self):
        return False

    def apply(self):
        self.vim_state.keymaps_stack.append(CharCharKeyMap())
        ComposedAction.apply(self)

    def compose(self, expected_action):
        self.vim_state.keymaps_stack.pop()
        return ComposedAction.compose(self, expected_action)

    def apply_action(self):
        self.vim_state.view.goto(self.get_end_location())
        self.vim_state.set_column_memory()

    def get_start_location(self):
        return self.cursor()

    def get_end_location(self):
        loc = self.cursor().forward_until(
            lambda x: x == self.expected_action.get_char(),
            skip_first_char=True,
            stop_at_eol=True,
            backwards=self.backwards,
        )

        if self.stop_before:
            loc = loc.forward_char(1 if self.backwards else -1)

        return loc


class ChainedAction(BaseAction):
    def __init__(self, *actions):
        self.actions = actions

    def apply(self):
        for action in self.actions:
            action.apply()


class ReplayAction(BaseAction):
    def apply(self):
        self.vim_state.last_write_command.replay()


class ReplayMove(BaseAction):
    def apply(self):
        self.vim_state.last_movement_command.replay()


class SwitchToVisual(BaseAction):
    def __init__(self, state):
        self.visual_state = state

    def apply_action(self):
        switch_state(self.vim_state, self.visual_state)
        self.vim_state.init_visual()


class VisualDeletion(BaseAction):
    def apply_action(self):
        for loc_a, loc_b in self.vim_state.get_selection_locs():
            self.vim_state.delete(
                loc_a, loc_b, is_line=self.vim_state.state == VisualStateLine
            )
        switch_state(self.vim_state, NormalState)


class VisualYank(BaseAction):
    def apply_action(self):
        for loc_a, loc_b in self.vim_state.get_selection_locs():
            self.vim_state.yank(
                loc_a, loc_b, is_line=self.vim_state.state == VisualStateLine
            )
        switch_state(self.vim_state, NormalState)


class EOLMovement(Movement):
    def get_end_location(self):
        return self.cursor().end_of_line().forward_char(-1)


class BOLMovement(Movement):
    def get_end_location(self):
        return (
            self.cursor().beginning_of_line().forward_until(lambda c: not c.isspace())
        )


class HardBOLMovement(Movement):
    def get_end_location(self):
        return self.cursor().beginning_of_line()


class ConflateLines(BaseAction):
    def apply_action(self):
        self.vim_state.view.goto(self.cursor().end_of_line().forward_char(-1))
        self.vim_state.buffer.delete(
            self.cursor().forward_char(),
            self.cursor()
            .forward_char()
            .forward_until(lambda c: not c.isspace())
            .forward_char(-1),
        )
        self.vim_state.buffer.insert(self.cursor().forward_char(), " ")
        self.vim_state.view.goto(self.cursor().forward_char(-1))


def switch_state(vim_state, state):
    sw = create_action((SwitchState, state), vim_state)
    sw.apply_action()


basic_actions = {
    ".": (ReplayAction,),
    ";": (ReplayMove,),
    "i": (Insert,),
    "a": (ChainedAction, (SimpleMovement, Mov_Char, 1), (Insert,)),
    "x": (ChainedAction, (Deletion,), (SimpleMovement, Mov_Char, 0)),
    "h": (SimpleMovement, Mov_Char, -1),
    "l": (SimpleMovement, Mov_Char, 1),
    "k": (SimpleMovement, Mov_Line, -1),
    "j": (SimpleMovement, Mov_Line, 1),
    "w": (SimpleMovement, Mov_Word, 1),
    "b": (SimpleMovement, Mov_Word, -1),
    "f": (UntilCharMovement, False, False),
    "F": (UntilCharMovement, True, False),
    "t": (UntilCharMovement, False, True),
    "T": (UntilCharMovement, True, True),
    "D": (
        ChainedAction,
        (Deletion,),
        (UntilCharMovement, False, True),
        (CharAction, "\n"),
        (SimpleMovement, Mov_Char, -1),
    ),
    "C": (
        ChainedAction,
        (Replace,),
        (UntilCharMovement, False, True),
        (CharAction, "\n"),
    ),
    "d": (Deletion,),
    "y": (Yank,),
    "Y": (YankAction,),
    "c": (Replace,),
    "u": (Undo,),
    "o": (OpenLine, False),
    "O": (OpenLine, True),
    "v": (SwitchToVisual, VisualState),
    "V": (SwitchToVisual, VisualStateLine),
    "p": (PasteAction, True),
    "P": (PasteAction, False),
    "$": (EOLMovement,),
    "^": (BOLMovement,),
    "0": (HardBOLMovement,),
    "J": (ConflateLines,),
}


visual_actions = dict(
    list(basic_actions.items())
    + list(
        {
            "d": (VisualDeletion,),
            "D": (VisualDeletion,),
            "y": (VisualYank,),
            "Y": (VisualYank,),
        }.items()
    )
)


modes_keymaps = defaultdict(
    dict,
    {
        NormalState: basic_actions,
        VisualState: visual_actions,
        VisualStateLine: visual_actions,
        VisualStateBox: visual_actions,
    },
)


def on_file_edited(hn, f):
    def key_pressed_proxy(view, event):
        return view.vim_state.on_key_pressed(view, event)

    buffer = GPS.EditorBuffer.get(f)
    view = buffer.current_view()
    gtk_view = get_widgets_by_type(Gtk.TextView, view.pywidget())[0]
    gtk_view.vim_state = VimState(view, gtk_view)
    buffer.vim_state = gtk_view.vim_state
    gtk_view.connect("key-press-event", key_pressed_proxy)


GPS.Hook("file_edited").add(on_file_edited)
