"""
.. warning::

    The classes in this file are internal and may well be removed to an
    external kivy-pytest package or similar in the future. Use at your own
    risk.
"""
import random
import time
import math
import os
from collections import deque

from kivy.tests import UnitTestTouch

__all__ = ('UnitKivyApp', )


class AsyncUnitTestTouch(UnitTestTouch):

    def __init__(self, *largs, **kwargs):
        self.grab_exclusive_class = None
        self.is_touch = True
        super(AsyncUnitTestTouch, self).__init__(*largs, **kwargs)

    def touch_down(self, *args):
        self.eventloop._dispatch_input("begin", self)

    def touch_move(self, x, y):
        win = self.eventloop.window
        self.move({
            "x": x / (win.width - 1.0),
            "y": y / (win.height - 1.0)
        })
        self.eventloop._dispatch_input("update", self)

    def touch_up(self, *args):
        self.eventloop._dispatch_input("end", self)


_unique_value = object


class WidgetResolver(object):
    """It assumes that the widget tree strictly forms a DAG.
    """

    base_widget = None

    matched_widget = None

    _kwargs_filter = {}

    _funcs_filter = []

    def __init__(self, base_widget, **kwargs):
        self.base_widget = base_widget
        self._kwargs_filter = {}
        self._funcs_filter = []
        super(WidgetResolver, self).__init__(**kwargs)

    def __call__(self):
        if self.matched_widget is not None:
            return self.matched_widget

        if not self._kwargs_filter and not self._funcs_filter:
            return self.base_widget
        return None

    def match(self, **kwargs_filter):
        self._kwargs_filter.update(kwargs_filter)

    def match_funcs(self, funcs_filter=()):
        self._funcs_filter.extend(funcs_filter)

    def check_widget(self, widget):
        if not all(func(widget) for func in self._funcs_filter):
            return False

        for attr, val in self._kwargs_filter.items():
            if getattr(widget, attr, _unique_value) != val:
                return False

        return True

    def not_found(self, op):
        raise ValueError(
            'Cannot find widget matching <{}, {}> starting from base '
            'widget "{}" doing "{}" traversal'.format(
                self._kwargs_filter, self._funcs_filter, self.base_widget, op))

    def down(self, **kwargs_filter):
        self.match(**kwargs_filter)
        check = self.check_widget

        fifo = deque([self.base_widget])
        while fifo:
            widget = fifo.popleft()
            if check(widget):
                return WidgetResolver(base_widget=widget)

            fifo.extend(widget.children)

        self.not_found('down')

    def up(self, **kwargs_filter):
        self.match(**kwargs_filter)
        check = self.check_widget

        parent = self.base_widget
        while parent is not None:
            if check(parent):
                return WidgetResolver(base_widget=parent)

            new_parent = parent.parent
            # Window is its own parent oO
            if new_parent is parent:
                break
            parent = new_parent

        self.not_found('up')

    def family_up(self, **kwargs_filter):
        self.match(**kwargs_filter)
        check = self.check_widget

        base_widget = self.base_widget
        already_checked_base = None
        while base_widget is not None:
            fifo = deque([base_widget])
            while fifo:
                widget = fifo.popleft()
                # don't check the child we checked before moving up
                if widget is already_checked_base:
                    continue

                if check(widget):
                    return WidgetResolver(base_widget=widget)

                fifo.extend(widget.children)

            already_checked_base = base_widget
            new_base_widget = base_widget.parent
            # Window is its own parent oO
            if new_base_widget is base_widget:
                break
            base_widget = new_base_widget

        self.not_found('family_up')


class UnitKivyApp(object):
    """Base class to use with async test apps.

    .. warning::

        The classes in this file are internal and may well be removed to an
        external kivy-pytest package or similar in the future. Use at your own
        risk.
    """

    app_has_started = False

    app_has_stopped = False

    async_sleep = None

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        def started_app(*largs):
            self.app_has_started = True
        self.fbind('on_start', started_app)

        def stopped_app(*largs):
            self.app_has_stopped = True
        self.fbind('on_stop', stopped_app)

    def set_async_lib(self, async_lib):
        from kivy.clock import Clock
        if async_lib is not None:
            Clock.init_async_lib(async_lib)
        self.async_sleep = Clock._async_lib.sleep

    async def async_run(self, async_lib=None):
        self.set_async_lib(async_lib)
        return await super(UnitKivyApp, self).async_run(async_lib=async_lib)

    def resolve_widget(self, base_widget=None):
        if base_widget is None:
            from kivy.core.window import Window
            base_widget = Window
        return WidgetResolver(base_widget=base_widget)

    async def wait_clock_frames(self, n, sleep_time=1 / 60.):
        from kivy.clock import Clock
        frames_start = Clock.frames
        while Clock.frames < frames_start + n:
            await self.async_sleep(sleep_time)

    def get_widget_pos_pixel(self, widget, positions):
        from kivy.graphics import Fbo, ClearColor, ClearBuffers

        canvas_parent_index = -2
        if widget.parent is not None:
            canvas_parent_index = widget.parent.canvas.indexof(widget.canvas)
            if canvas_parent_index > -1:
                widget.parent.canvas.remove(widget.canvas)

        w, h = int(widget.width), int(widget.height)
        fbo = Fbo(size=(w, h), with_stencilbuffer=True)

        with fbo:
            ClearColor(0, 0, 0, 0)
            ClearBuffers()

        fbo.add(widget.canvas)
        fbo.draw()
        pixels = fbo.pixels
        fbo.remove(widget.canvas)

        if widget.parent is not None and canvas_parent_index > -1:
            widget.parent.canvas.insert(canvas_parent_index, widget.canvas)

        values = []
        for x, y in positions:
            x = int(x)
            y = int(y)
            i = y * w * 4 + x * 4
            values.append(tuple(pixels[i:i + 4]))

        return values

    async def do_touch_down_up(
            self, pos=None, widget=None, duration=.2, pos_jitter=None,
            widget_jitter=False, jitter_dt=1 / 15., end_on_pos=False):
        if widget is None:
            x, y = pos
        else:
            if pos is None:
                x, y = widget.to_window(*widget.center)
            else:
                x, y = widget.to_window(*pos, initial=False)
        touch = AsyncUnitTestTouch(x, y)

        ts = time.perf_counter()
        touch.touch_down()
        await self.wait_clock_frames(1)
        yield 'down', touch.pos

        if not pos_jitter and not widget_jitter:
            await self.async_sleep(duration)
            touch.touch_up()
            await self.wait_clock_frames(1)
            yield 'up', touch.pos

            return

        moved = False
        if pos_jitter:
            dx, dy = pos_jitter
        else:
            dx = widget.width / 2.
            dy = widget.height / 2.

        while time.perf_counter() - ts < duration:
            moved = True
            await self.async_sleep(jitter_dt)

            touch.touch_move(
                x + (random.random() * 2 - 1) * dx,
                y + (random.random() * 2 - 1) * dy
            )
            await self.wait_clock_frames(1)
            yield 'move', touch.pos

        if end_on_pos and moved:
            touch.touch_move(x, y)
            await self.wait_clock_frames(1)
            yield 'move', touch.pos

        touch.touch_up()
        await self.wait_clock_frames(1)
        yield 'up', touch.pos

    async def do_touch_drag(
            self, pos=None, widget=None,
            widget_loc=('center_x', 'center_y'), dx=0, dy=0,
            target_pos=None, target_widget=None, target_widget_offset=(0, 0),
            target_widget_loc=('center_x', 'center_y'), long_press=0,
            duration=.2, drag_n=5):
        """Initiates a touch down, followed by some dragging to a target
        location, ending with a touch up.

        `origin`: These parameters specify where the drag starts.
        - If ``widget`` is None, it starts at ``pos`` (in window coordinates).
          If ``dx``/``dy`` is used, it is in the window coordinate system also.
        - If ``pos`` is None, it starts on the ``widget`` as specified by
          ``widget_loc``. If ``dx``/``dy`` is used, it is in the ``widget``
          coordinate system.
        - If neither is None, it starts at ``pos``, but in the ``widget``'s
          coordinate system (:meth:`~kivy.uix.widget.Widget.to_window` is used
          on it). If ``dx``/``dy`` is used, it is in the ``widget``
          coordinate system.

        `target`: These parameters specify where the drag ends.
        - If ``target_pos`` and ``target_widget`` is None, then ``dx`` and
          ``dy`` is used relative to the position where the drag started.
        - If ``target_widget`` is None, it ends at ``target_pos``
          (in window coordinates).
        - If ``target_pos`` is None, it ends on the ``target_widget`` as
          specified by ``target_widget_loc``. ``target_widget_offset``, is an
          additional ``(x, y)`` offset relative to ``target_widget_loc``.
        - If neither is None, it starts at ``target_pos``, but in the
          ``target_widget``'s coordinate system
          (:meth:`~kivy.uix.widget.Widget.to_window` is used on it).

        When ``widget`` and/or ``target_widget`` are specified, ``widget_loc``
        and ``target_widget_loc``, respectively, indicate where on the widget
        the drag starts/ends. It is a a tuple with property names of the widget
        to loop up to get the value. The default is
        ``('center_x', 'center_y')`` so the drag would start/end in the
        widget's center.
        """
        if widget is None:
            x, y = pos
            tx, ty = x + dx, y + dy
        else:
            if pos is None:
                w_x = getattr(widget, widget_loc[0])
                w_y = getattr(widget, widget_loc[1])
                x, y = widget.to_window(w_x, w_y)
                tx, ty = widget.to_window(w_x + dx, w_y + dy)
            else:
                x, y = widget.to_window(*pos, initial=False)
                tx, ty = widget.to_window(
                    pos[0] + dx, pos[1] + dy, initial=False)

        if target_pos is not None:
            if target_widget is None:
                tx, ty = target_pos
            else:
                tx, ty = target_pos = target_widget.to_window(
                    *target_pos, initial=False)
        elif target_widget is not None:
            x_off, y_off = target_widget_offset
            w_x = getattr(target_widget, target_widget_loc[0]) + x_off
            w_y = getattr(target_widget, target_widget_loc[1]) + y_off
            tx, ty = target_pos = target_widget.to_window(w_x, w_y)
        else:
            target_pos = tx, ty

        touch = AsyncUnitTestTouch(x, y)

        touch.touch_down()
        await self.wait_clock_frames(1)
        if long_press:
            await self.async_sleep(long_press)
        yield 'down', touch.pos

        dx = (tx - x) / drag_n
        dy = (ty - y) / drag_n

        ts0 = time.perf_counter()
        for i in range(drag_n):
            await self.async_sleep(
                max(0., duration - (time.perf_counter() - ts0)) / (drag_n - i))

            touch.touch_move(x + (i + 1) * dx, y + (i + 1) * dy)
            await self.wait_clock_frames(1)
            yield 'move', touch.pos

        if touch.pos != target_pos:
            touch.touch_move(*target_pos)
            await self.wait_clock_frames(1)
            yield 'move', touch.pos

        touch.touch_up()
        await self.wait_clock_frames(1)
        yield 'up', touch.pos

    async def do_touch_drag_follow(
            self, pos=None, widget=None,
            widget_loc=('center_x', 'center_y'),
            target_pos=None, target_widget=None, target_widget_offset=(0, 0),
            target_widget_loc=('center_x', 'center_y'), long_press=0,
            duration=.2, drag_n=5, max_n=25):
        """Very similar to :meth:`do_touch_drag`, except it follows the target
        widget, even if the target widget moves as a result of the drag, the
        drag will follow it until it's on the target widget.

        `origin`: These parameters specify where the drag starts.
        - If ``widget`` is None, it starts at ``pos`` (in window coordinates).
        - If ``pos`` is None, it starts on the ``widget`` as specified by
          ``widget_loc``.
        - If neither is None, it starts at ``pos``, but in the ``widget``'s
          coordinate system (:meth:`~kivy.uix.widget.Widget.to_window` is used
          on it).

        `target`: These parameters specify where the drag ends.
        - If ``target_pos`` is None, it ends on the ``target_widget`` as
          specified by ``target_widget_loc``. ``target_widget_offset``, is an
          additional ``(x, y)`` offset relative to ``target_widget_loc``.
        - If ``target_pos`` is not None, it starts at ``target_pos``, but in
          the ``target_widget``'s coordinate system
          (:meth:`~kivy.uix.widget.Widget.to_window` is used on it).

        When ``widget`` and/or ``target_widget`` are specified, ``widget_loc``
        and ``target_widget_loc``, respectively, indicate where on the widget
        the drag starts/ends. It is a a tuple with property names of the widget
        to loop up to get the value. The default is
        ``('center_x', 'center_y')`` so the drag would start/end in the
        widget's center.
        """
        if widget is None:
            x, y = pos
        else:
            if pos is None:
                w_x = getattr(widget, widget_loc[0])
                w_y = getattr(widget, widget_loc[1])
                x, y = widget.to_window(w_x, w_y)
            else:
                x, y = widget.to_window(*pos, initial=False)

        if target_widget is None:
            raise ValueError('target_widget must be specified')

        def get_target():
            if target_pos is not None:
                return target_widget.to_window(*target_pos, initial=False)
            else:
                x_off, y_off = target_widget_offset
                wt_x = getattr(target_widget, target_widget_loc[0]) + x_off
                wt_y = getattr(target_widget, target_widget_loc[1]) + y_off
                return target_widget.to_window(wt_x, wt_y)

        touch = AsyncUnitTestTouch(x, y)

        touch.touch_down()
        await self.wait_clock_frames(1)
        if long_press:
            await self.async_sleep(long_press)
        yield 'down', touch.pos

        ts0 = time.perf_counter()
        tx, ty = get_target()
        i = 0
        while not (math.isclose(touch.x, tx) and math.isclose(touch.y, ty)):
            if i >= max_n:
                raise Exception(
                    'Exceeded the maximum number of iterations, '
                    'but {} != {}'.format(touch.pos, (tx, ty)))

            rem_i = max(1, drag_n - i)
            rem_t = max(0., duration - (time.perf_counter() - ts0)) / rem_i
            i += 1
            await self.async_sleep(rem_t)

            x, y = touch.pos
            touch.touch_move(x + (tx - x) / rem_i, y + (ty - y) / rem_i)
            await self.wait_clock_frames(1)
            yield 'move', touch.pos
            tx, ty = get_target()

        touch.touch_up()
        await self.wait_clock_frames(1)
        yield 'up', touch.pos

    async def do_touch_drag_path(
            self, path, axis_widget=None, long_press=0, duration=.2):
        """Drags the touch along the specified path.

        :parameters:

            `path`: list
                A list of position tuples the touch will follow. The first
                item is used for the touch down and the rest for the move.
            `axis_widget`: a Widget
                If None, the path coordinates is in window coordinates,
                otherwise, we will first transform the path coordinates
                to window coordinates using
                :meth:`~kivy.uix.widget.Widget.to_window` of the specified
                widget.
        """
        if axis_widget is not None:
            path = [axis_widget.to_window(*p, initial=False) for p in path]
        x, y = path[0]
        path = path[1:]

        touch = AsyncUnitTestTouch(x, y)

        touch.touch_down()
        await self.wait_clock_frames(1)
        if long_press:
            await self.async_sleep(long_press)
        yield 'down', touch.pos

        ts0 = time.perf_counter()
        n = len(path)
        for i, (x2, y2) in enumerate(path):
            await self.async_sleep(
                max(0., duration - (time.perf_counter() - ts0)) / (n - i))

            touch.touch_move(x2, y2)
            await self.wait_clock_frames(1)
            yield 'move', touch.pos

        touch.touch_up()
        await self.wait_clock_frames(1)
        yield 'up', touch.pos

    async def do_keyboard_key(
            self, key, modifiers=(), duration=.05, num_press=1):
        from kivy.core.window import Window
        if key == ' ':
            key = 'spacebar'
        key_lower = key.lower()
        key_code = Window._system_keyboard.string_to_keycode(key_lower)

        known_modifiers = {'shift', 'alt', 'ctrl', 'meta'}
        if set(modifiers) - known_modifiers:
            raise ValueError('Unknown modifiers "{}"'.
                             format(set(modifiers) - known_modifiers))

        special_keys = {
            27: 'escape',
            9: 'tab',
            8: 'backspace',
            13: 'enter',
            127: 'del',
            271: 'enter',
            273: 'up',
            274: 'down',
            275: 'right',
            276: 'left',
            278: 'home',
            279: 'end',
            280: 'pgup',
            281: 'pgdown',
            300: 'numlock',
            301: 'capslock',
            145: 'screenlock',
        }

        text = None
        try:
            text = chr(key_code)
            if key_lower != key:
                text = key
        except ValueError:
            pass

        dt = duration / num_press
        for i in range(num_press):
            await self.async_sleep(dt)

            Window.dispatch('on_key_down', key_code, 0, text, modifiers)
            if (key not in known_modifiers and
                    key_code not in special_keys and
                    not (known_modifiers & set(modifiers))):
                Window.dispatch('on_textinput', text)

            await self.wait_clock_frames(1)
            yield 'down', (key, key_code, 0, text, modifiers)

        Window.dispatch('on_key_up', key_code, 0)
        await self.wait_clock_frames(1)
        yield 'up', (key, key_code, 0, text, modifiers)
