from contextlib import contextmanager
from functools import wraps
from selenium.common.exceptions import MoveTargetOutOfBoundsException, WebDriverException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys

try:
    from selenium.common.exceptions import ElementClickInterceptedException
except ImportError:
    class ElementClickInterceptedException(WebDriverException):
        pass

from capybara.exceptions import ReadOnlyElementError, UnselectNotAllowed
from capybara.driver.node import Node as Base
from capybara.helpers import normalize_whitespace


class Node(Base):
    @property
    def tag_name(self):
        return self.native.tag_name

    @property
    def visible(self):
        return self.native.is_displayed()

    @property
    def value(self):
        if self.tag_name == "select" and self.multiple:
            options = self.native.find_elements_by_xpath(".//option")
            selected_options = filter(lambda opt: opt.is_selected(), options)
            return [opt.get_attribute("value") for opt in selected_options]
        else:
            return self.native.get_attribute("value")

    def style(self, styles):
        return {style: self.native.value_of_css_property(style) for style in styles}

    @property
    def selected(self):
        return self.native.is_selected()

    @property
    def multiple(self):
        val = self["multiple"]
        return bool(val) and val != "false"

    @property
    def path(self):
        path = [self] + list(reversed(list(self._find_xpath("ancestor::*"))))

        result = []
        while path:
            node = path.pop(0)
            parent = path[0] if path else None

            if parent:
                siblings = parent._find_xpath(node.tag_name)
                index = next(i for i, elem in enumerate(siblings) if elem == node)

                if index == 0:
                    try:
                        next(siblings)
                    except StopIteration:
                        index = None
            else:
                index = None

            if index is None:
                result.insert(0, node.tag_name)
            else:
                result.insert(0, "{tag}[{i}]".format(tag=node.tag_name, i=index + 1))

        return "/" + "/".join(result)

    @property
    def all_text(self):
        text = self.driver.browser.execute_script("return arguments[0].textContent", self.native)
        return normalize_whitespace(text)

    @property
    def visible_text(self):
        return normalize_whitespace(self.native.text)

    def __getitem__(self, name):
        return self.native.get_attribute(name)

    def _find_css(self, css):
        cls = type(self)
        return (cls(self.driver, element)
                for element in self.native.find_elements_by_css_selector(css))

    def _find_xpath(self, xpath):
        cls = type(self)
        return (cls(self.driver, element)
                for element in self.native.find_elements_by_xpath(xpath))

    def click(self, *keys, **offset):
        try:
            if not any(keys) and not self._has_coords(offset):
                self.native.click()
            else:
                @self._scroll_if_needed
                def click():
                    with self._action_with_modifiers(keys, offset) as a:
                        a.click() if self._has_coords(offset) else a.click(self.native)
                click()

        except WebDriverException as e:
            if (
                # Marionette
                isinstance(e, ElementClickInterceptedException) or

                # Chrome
                "Other element would receive the click" in e.msg
            ):
                self._scroll_to_center()

            raise

    def double_click(self, *keys, **offset):
        @self._scroll_if_needed
        def double_click():
            with self._action_with_modifiers(keys, offset) as a:
                a.double_click() if self._has_coords(offset) else a.double_click(self.native)
        double_click()

    def drag_to(self, element):
        ActionChains(self.driver.browser).drag_and_drop(self.native, element.native).perform()

    def hover(self):
        ActionChains(self.driver.browser).move_to_element(self.native).perform()

    def right_click(self, *keys, **offset):
        @self._scroll_if_needed
        def right_click():
            with self._action_with_modifiers(keys, offset) as a:
                a.context_click() if self._has_coords(offset) else a.context_click(self.native)
        right_click()

    def select_option(self):
        if not self.selected and not self.disabled:
            self.native.click()

    def send_keys(self, *args):
        self.native.send_keys(*args)

    def set(self, value, clear=None):
        tag_name = self.tag_name
        type_attr = self["type"]

        if tag_name == "input" and type_attr == "radio":
            self.click()
        elif tag_name == "input" and type_attr == "checkbox":
            current = self.native.get_attribute("checked") == "true"

            if current ^ value:
                self.click()
        elif tag_name == "textarea" or tag_name == "input":
            if self.readonly:
                raise ReadOnlyElementError()

            if clear == "backspace":
                # Clear field by sending the correct number of backspace keys.
                backspaces = [Keys.BACKSPACE] * len(self.value)
                self.native.send_keys(*([Keys.END] + backspaces + [value]))
            else:
                # Clear field by JavaScript assignment of the value property.
                self.driver.browser.execute_script("arguments[0].value = ''", self.native)
                self.native.send_keys(value)
        elif self["isContentEditable"]:
            self.native.click()
            script = """
                var range = document.createRange();
                var sel = window.getSelection();
                range.selectNodeContents(arguments[0]);
                sel.removeAllRanges();
                sel.addRange(range);
            """
            self.driver.browser.execute_script(script, self.native)
            self.native.send_keys(value)

    def unselect_option(self):
        if not self._select_node.multiple:
            raise UnselectNotAllowed("Cannot unselect option from single select box.")

        if self.selected:
            self.native.click()

    def __eq__(self, other):
        return self.native == other.native

    def __hash__(self):
        return hash(self.native)

    @property
    def checked(self):
        return self.selected

    @property
    def selected(self):
        return self.native.is_selected()

    @property
    def disabled(self):
        if not self.native.is_enabled():
            return True

        if self.tag_name == "fieldset":
            return any(self._find_xpath("ancestor-or-self::fieldset[@disabled]"))

        return False

    @property
    def readonly(self):
        val = self["readonly"]
        return bool(val) and val != "false"

    @property
    def _select_node(self):
        return next(self._find_xpath("./ancestor::select"))

    @staticmethod
    def _has_coords(options):
        return bool(options.get('x')) and bool(options.get('y'))

    def _scroll_if_needed(self, fn):
        @wraps(fn)
        def wrapper():
            try:
                fn()
            except MoveTargetOutOfBoundsException:
                self._scroll_to_center()
                fn()

        return wrapper

    def _scroll_to_center(self):
        try:
            self.driver.execute_script("""
                arguments[0].scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'})
            """, self)
        except:
            # Swallow error if scrollIntoView with options isn't supported.
            pass

    @contextmanager
    def _action_with_modifiers(self, keys, offset):
        actions = ActionChains(self.driver.browser)
        if self._has_coords(offset):
            actions.move_to_element_with_offset(self.native, offset['x'], offset['y'])
        self._modifiers_down(actions, keys)
        yield actions
        self._modifiers_up(actions, keys)
        actions.perform()

    @staticmethod
    def _modifiers_down(actions, keys):
        for key in keys:
            actions.key_down(key)

    @staticmethod
    def _modifiers_up(actions, keys):
        for key in keys:
            actions.key_up(key)
