import kmk.handlers.stock as handlers
from kmk.keys import make_key
from kmk.modules import Module


class Combo:
    timeout = 50
    per_key_timeout = False
    _timeout = None
    _remaining = []

    def __init__(self, match, result, timeout=None, per_key_timeout=None):
        '''
        match: tuple of keys (KC.A, KC.B)
        result: key KC.C
        '''
        self.match = match
        self.result = result
        if timeout:
            self.timeout = timeout
        if per_key_timeout:
            self.per_key_timeout = per_key_timeout

    def matches(self, key):
        raise NotImplementedError

    def reset(self):
        self._remaining = list(self.match)


class Chord(Combo):
    def matches(self, key):
        try:
            self._remaining.remove(key)
            return True
        except ValueError:
            return False


class Sequence(Combo):
    timeout = 1000
    per_key_timeout = True

    def matches(self, key):
        try:
            return key == self._remaining.pop(0)
        except IndexError:
            return False


class Combos(Module):
    def __init__(self, combos=[]):
        self.combos = combos
        self._active = []
        self._matching = []
        self._reset = set()
        self._key_buffer = []

        make_key(
            names=('LEADER',),
            on_press=handlers.passthrough,
            on_release=handlers.passthrough,
        )

    def during_bootup(self, keyboard):
        self.reset(keyboard)

    def before_matrix_scan(self, keyboard):
        return

    def after_matrix_scan(self, keyboard):
        return

    def before_hid_send(self, keyboard):
        return

    def after_hid_send(self, keyboard):
        return

    def on_powersave_enable(self, keyboard):
        return

    def on_powersave_disable(self, keyboard):
        return

    def process_key(self, keyboard, key, is_pressed, int_coord):
        if is_pressed:
            return self.on_press(keyboard, key, int_coord)
        else:
            return self.on_release(keyboard, key, int_coord)

    def on_press(self, keyboard, key, int_coord):
        # refill potential matches from timed-out matches
        if not self._matching:
            self._matching = list(self._reset)
            self._reset = set()

        # filter potential matches
        for combo in self._matching.copy():
            if combo.matches(key):
                continue
            self._matching.remove(combo)
            if combo._timeout:
                keyboard.cancel_timeout(combo._timeout)
            combo._timeout = keyboard.set_timeout(
                combo.timeout, lambda c=combo: self.reset_combo(keyboard, c)
            )

        if self._matching:
            # At least one combo matches current key: append key to buffer.
            self._key_buffer.append((int_coord, key, True))
            key = None

            # Start or reset individual combo timeouts.
            for combo in self._matching:
                if combo._timeout:
                    if combo.per_key_timeout:
                        keyboard.cancel_timeout(combo._timeout)
                    else:
                        continue
                combo._timeout = keyboard.set_timeout(
                    combo.timeout, lambda c=combo: self.on_timeout(keyboard, c)
                )
        else:
            # There's no matching combo: send and reset key buffer
            self.send_key_buffer(keyboard)
            self._key_buffer = []
            key = keyboard._find_key_in_map(int_coord)

        return key

    def on_release(self, keyboard, key, int_coord):
        for combo in self._active:
            if key in combo.match:
                # Deactivate combo if it matches current key.
                self.deactivate(keyboard, combo)
                self.reset_combo(keyboard, combo)
                key = combo.result
                break

        # Don't propagate key-release events for keys that have been buffered.
        # Append release events only if corresponding press is in buffer.
        else:
            pressed = self._key_buffer.count((int_coord, key, True))
            released = self._key_buffer.count((int_coord, key, False))
            if (pressed - released) > 0:
                self._key_buffer.append((int_coord, key, False))
                key = None

        return key

    def on_timeout(self, keyboard, combo):
        # If combo reaches timeout and has no remaining keys, activate it;
        # else, drop it from the match list.
        combo._timeout = None
        self._matching.remove(combo)

        if not combo._remaining:
            self.activate(keyboard, combo)
            if any([not pressed for (int_coord, key, pressed) in self._key_buffer]):
                # At least one of the combo keys has already been released:
                # "tap" the combo result.
                keyboard._send_hid()
                self.deactivate(keyboard, combo)
            self.reset(keyboard)
            self._key_buffer = []
        else:
            if not self._matching:
                # This was the last pending combo: flush key buffer.
                self.send_key_buffer(keyboard)
                self._key_buffer = []
            self.reset_combo(keyboard, combo)

    def send_key_buffer(self, keyboard):
        for (int_coord, key, is_pressed) in self._key_buffer:
            try:
                new_key = keyboard._coordkeys_pressed[int_coord]
            except KeyError:
                new_key = None
            if new_key is None:
                new_key = keyboard._find_key_in_map(int_coord)

            keyboard._coordkeys_pressed[int_coord] = new_key

            keyboard.process_key(new_key, is_pressed)
            keyboard._send_hid()

    def activate(self, keyboard, combo):
        combo.result.on_press(keyboard)
        self._active.append(combo)

    def deactivate(self, keyboard, combo):
        combo.result.on_release(keyboard)
        self._active.remove(combo)

    def reset_combo(self, keyboard, combo):
        combo.reset()
        if combo._timeout is not None:
            keyboard.cancel_timeout(combo._timeout)
            combo._timeout = None
        self._reset.add(combo)

    def reset(self, keyboard):
        self._matching = []
        for combo in self.combos:
            self.reset_combo(keyboard, combo)