"""
implementation of page objects, element(s) and sections
"""

import contextlib
import inspect
import threading
import types
import weakref
from collections import OrderedDict
from collections.abc import Sequence
from functools import wraps

import selenium.webdriver.common.by
from selenium.common.exceptions import (
    NoSuchElementException,
    NoSuchFrameException,
    StaleElementReferenceException,
    TimeoutException,
)
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import WebDriverWait

from holmium.core.conditions import BaseCondition

from .enhancers import get_enhancers
from .facets import CopyOnCreateFacetCollectionMeta, ElementFacet, Faceted
from .logger import log

# pylint: disable=unnecessary-lambda,too-few-public-methods,too-many-arguments


class Locators(selenium.webdriver.common.by.By):
    """
    proxy class to access locator types
    """

    pass


class ElementList(list):
    """
    proxy to a standard list which would be stored in
    a :class:`holmium.core.Page`.
    """

    def __init__(self, instance, *args, **kwargs):
        self.instance = weakref.ref(instance)
        list.__init__(self, *args, **kwargs)

    def __getitem__(self, index):
        return list.__getitem__(self, index).__get__(
            self.instance(), self.instance().__class__
        )

    def __iter__(self):
        for idx in range(len(self)):
            yield self[idx]


class ElementDict(dict):
    """
    proxy to a standard dict which would be stored in
    a :class:`holmium.core.Page`.
    """

    def __init__(self, instance, *args, **kwargs):
        self.instance = weakref.ref(instance)
        dict.__init__(self, *args, **kwargs)

    def __getitem__(self, key):
        return dict.__getitem__(self, key).__get__(
            self.instance(), self.instance().__class__
        )

    def values(self):
        return [self[key] for key in self]

    def items(self):
        return [(key, self[key]) for key in self.keys()]


class Registry(CopyOnCreateFacetCollectionMeta):
    """
    simple meta class to keep track of all page objects registered
    """

    pages = {}

    def __new__(mcs, *args, **kwargs):
        page = super().__new__(mcs, *args, **kwargs)
        Registry.pages[args[0]] = page
        return page


class Page(Faceted, metaclass=Registry):
    """
    Base class for all page objects to extend from.
    void Instance methods implemented by subclasses are provisioned
    with fluent wrappers to facilitate with writing code such as::

        class Google(Page):
            def query(self):
                ....

            def submit(self):
                ....

            def get_results(self):
                ....

        assert len(Google().query("page objects").submit().get_results()) > 0

    """

    local = threading.local()

    def __init__(self, driver, url=None, iframe=None):
        # pylint: disable=too-many-branches
        super().__init__()
        self.driver = driver
        self.touched = False
        self.initialized = False
        if url:
            self.home = url
        elif driver.current_url:
            self.home = driver.current_url
        else:
            self.home = None

        self.iframe = iframe

        def update_element(element, name):
            """
            check if the element is a facet
            and register it.
            """
            if issubclass(element.__class__, ElementGetter):
                element.iframe = self.iframe
                if element.is_facet:
                    facet = ElementFacet(element, name, debug=element.is_debug_facet)
                    facet.register(self)
                return True
            return False

        for element in inspect.getmembers(self.__class__):
            if issubclass(element[1].__class__, list):
                hit = True
                for item in element[1]:
                    hit &= update_element(item, element[0])
                if hit:
                    self.__setattr__(element[0], ElementList(self, element[1]))
            elif issubclass(element[1].__class__, dict):
                hit = True
                for item in element[1].values():
                    hit &= update_element(item, element[0])
                if hit:
                    self.__setattr__(element[0], ElementDict(self, element[1]))
            else:
                update_element(element[1], element[0])

        if url:
            self.driver.get(url)
        self.initialized = True

    @contextlib.contextmanager
    def scope(self):
        """
        context manager to manage the current webdriver
        in use.
        """
        Page.local.driver = object.__getattribute__(self, "driver")
        yield

    @classmethod
    def get_driver(cls):
        """
        returns the thread local driver
        """
        return cls.local.driver

    def go_home(self):
        """
        returns the page object to the url it was initialized with
        """
        self.driver.get(self.home)

    def __getattribute__(self, key):
        """
        to enable fluent access to page objects, instance methods that
        don't return a value, instead return the page object instance.
        """

        def attr_getter(key):
            return object.__getattribute__(self, key)

        def attr_setter(key, value):
            return object.__setattr__(self, key, value)

        with attr_getter("scope")():
            if not attr_getter("touched") and attr_getter("initialized"):
                attr_getter("evaluate")()
                attr_setter("touched", True)
            attr = attr_getter(key)
            # check if home url is set, else update.
            if not attr_getter("home"):
                log.debug("home url not set, attempting to update.")
                attr_setter("home", attr_getter("driver").current_url)

            if isinstance(attr, types.MethodType):

                @wraps(attr)
                def wrap(*args, **kwargs):
                    """
                    fluent wrapper
                    """
                    resp = attr(*args, **kwargs)
                    if resp is None:
                        resp = self
                    return resp

                return wrap
            return attr


class ElementGetter:
    """
    internal class to encapsulate the logic used by
    :class:`holmium.core.Element`
    & :class:`holmium.core.Elements`
    """

    # pylint: disable=too-many-instance-attributes
    def __init__(
        self,
        locator_type,
        query_string,
        base_element=None,
        timeout=0,
        value=lambda el: el,
        only_if=lambda el: el is not None,
        facet=False,
        filter_by=lambda el: el is not None,
    ):
        """
        :param holmium.core.Locators locator_type: selenium locator to use when
         locating the element
        :param str query_string: the value to pass to the locator
        :param holmium.core.Element base_element: a reference to another i
         element under which to locate this element.
        :param int timeout: time to implicitely wait for the element
        :param lambda value: transform function for the value of the element.
         The located :class:`selenium.webdriver.remote.webelement.WebElement`
         instance is passed as the only argument to the function.
        :param function only_if: extra validation function that is called
         repeatedly until :attr:`timeout` has elapsed. If not provided the
         default function used checks that the element is present.
         The located :class:`selenium.webdriver.remote.webelement.WebElement`
         instance is passed as the only argument to the function.
        :param bool facet: flag to  treat this element as a facet.
        :param function filter_by: condition function that determines if the
         located :class:`selenium.webdriver.remote.webelement.WebElement`, the
         only argument passed to the function, should be returned. If not
         provided, the default function used checks that the element is
         present.
        """
        self.query_string = query_string
        self.locator_type = locator_type
        self.timeout = timeout
        self.driver = None
        self.iframe = None
        self.base_element = base_element
        self.value_mapper = value
        self.root_fn = lambda: Page.get_driver()
        self.only_if = only_if
        log.debug(
            "locator:%s, query_string:%s, timeout:%d"
            % (locator_type, query_string, timeout)
        )
        self.is_facet = facet
        self.is_debug_facet = False
        self.filter_by = filter_by

    @property
    def root(self):
        "returns the root webelement"
        return self.root_fn()

    @root.setter
    def root(self, root_fn):
        "sets the root element extraction function"
        self.root_fn = root_fn

    def _get_element(self, method=None):
        """
        extracts the webelement(s)
        :param function method: the method used to query the webdriver
        """
        if self.base_element:
            if isinstance(self.base_element, types.LambdaType):
                _ = self.base_element()
                _meth = getattr(_, method.__name__)
            elif isinstance(self.base_element, Element):
                _meth = getattr(
                    self.base_element.__get__(self, self.__class__), method.__name__
                )
            elif isinstance(self.base_element, WebElement):
                _meth = getattr(self.base_element, "find_element")
            else:
                raise TypeError(
                    "invalid base_element type (%s) used" % (type(self.base_element))
                )
        else:
            _meth = method
        log.debug(
            "looking up locator:%s, query_string:%s, timeout:%d"
            % (self.locator_type, self.query_string, self.timeout)
        )

        if self.iframe:
            Page.local.driver.switch_to.default_content()
            Page.local.driver.switch_to.frame(self.iframe)

        if self.timeout:
            try:

                def callback(_):
                    """
                    timeout & only_if explicit wait.
                    """
                    return _meth(self.locator_type, self.query_string) and (
                        BaseCondition.get_current() or self.only_if
                    )(_meth(self.locator_type, self.query_string))

                WebDriverWait(
                    self.root,
                    self.timeout,
                    ignored_exceptions=[
                        StaleElementReferenceException,
                    ],
                ).until(callback)
            except TimeoutException:
                log.debug(
                    "unable to find element %s after waiting for %d seconds"
                    % (self.query_string, self.timeout)
                )
                raise
        retval = _meth(self.locator_type, self.query_string)
        if isinstance(retval, list):
            return [el for el in retval if self.filter_by(el)]
        elif self.filter_by(retval):
            return retval
        else:
            return None

    @classmethod
    def enhance(cls, element):
        """
        incase a higher level abstraction for a WebElement is available
        we will use that in Pages. (e.g. a select element is converted into
        :class:`selenium.webdriver.support.ui.Select`)
        """
        if element is None:
            return None
        for enhancer in get_enhancers():
            if enhancer.matches(element):
                return enhancer(element)
        return element


class Element(ElementGetter):
    """
    Utility to get a :class:`selenium.webdriver.remote.webelement.WebElement`
    by querying via one of :class:`holmium.core.Locators`

    :param holmium.core.Locators locator_type: selenium locator to use when
     locating the element
    :param str query_string: the value to pass to the locator
    :param holmium.core.Element base_element: a reference to another element
     under which to locate this element.
    :param int timeout: time to implicitely wait for the element
    :param lambda value: transform function for the value of the element.
     The located :class:`selenium.webdriver.remote.webelement.WebElement`
     instance is passed as the only argument to the function.
    :param function only_if: extra validation function that is called
     repeatedly until :attr:`timeout` elapses. If not provided the default
     function used checks that the element is present. The located
     :class:`selenium.webdriver.remote.webelement.WebElement` instance is
     passed as the only argument to the function.
    :param bool facet: flag to  treat this element as a facet.
    :param function filter_by: condition function that determines if the
     located :class:`selenium.webdriver.remote.webelement.WebElement`, the
     only argument passed to the function, should be returned. If not
     provided, the default function used checks that the element is present.
    """

    def __get__(self, instance, owner):
        if not instance:
            return self
        try:
            return (
                self.value_mapper(
                    self.enhance(self._get_element(self.root.find_element))
                )
                if self.root
                else None
            )
        except (
            NoSuchElementException,
            TimeoutException,
            StaleElementReferenceException,
        ):
            return None


class Elements(ElementGetter):
    """
    Utility to get a collection of
    :class:`selenium.webdriver.remote.webelement.WebElement` objects by
    querying via one of :class:`holmium.core.Locators`

    :param holmium.core.Locators locator_type: selenium locator to use when
     locating the element
    :param str query_string: the value to pass to the locator
    :param holmium.core.Element base_element: a reference to another element
     under which to locate this element.
    :param int timeout: time to implicitely wait for the element
    :param lambda value: transform function for each element in the collection.
     The located :class:`selenium.webdriver.remote.webelement.WebElement`
     instance is passed as the only argument to the function.
    :param function only_if: extra validation function that is called
     repeatedly until :attr:`timeout` elapses. If not provided the default
     function used checks that the element collection is not empty. The list
     of located :class:`selenium.webdriver.remote.webelement.WebElement`
     instances is passed as the only argument to the function.
    :param bool facet: flag to  treat this element as a facet.
    :param function filter_by: condition function determines which elements are
     included in the collection. If not provided the default function used
     includes all elements identified by :attr:`query_string`.
     A :class:`selenium.webdriver.remote.webelement.WebElement` instance is
     passed as the only argument to the function.
    """

    # pylint: disable=incomplete-protocol,line-too-long

    def __init__(
        self,
        locator_type,
        query_string=None,
        base_element=None,
        timeout=0,
        value=lambda el: el,
        only_if=lambda els: len(els) > 0,
        facet=False,
        filter_by=lambda el: el is not None,
    ):
        super().__init__(
            locator_type,
            query_string,
            base_element=base_element,
            timeout=timeout,
            facet=facet,
            value=value,
            only_if=only_if,
            filter_by=filter_by,
        )

    def __getitem__(self, idx):
        return lambda: self.__get__(self, self.__class__)[idx]

    def __get__(self, instance, owner):
        if not instance:
            return self
        try:
            return (
                [
                    self.value_mapper(self.enhance(el))
                    for el in self._get_element(self.root.find_elements)
                ]
                if self.root
                else []
            )
        except (
            NoSuchElementException,
            TimeoutException,
            StaleElementReferenceException,
        ):
            return []


class ElementMap(Elements):
    """
    Used to create dynamic dictionaries based on an element locator specified
    by one of :class:`holmium.core.Locators`.

    The wrapped dictionary is an :class:`collections.OrderedDict` instance.

    :param holmium.core.Locators locator_type: selenium locator to use when
     locating the element
    :param str query_string: the value to pass to the locator
    :param holmium.core.Element base_element: a reference to another element
     under which to locate this element.
    :param int timeout: time to implicitely wait for the element
    :param bool facet: flag to  treat this element as a facet.
    :param lambda key: transform function for mapping a key to a WebElement in
     the collection. The located
     :class:`selenium.webdriver.remote.webelement.WebElement` instance is
     passed as the only argument to the function.
    :param lambda value: transform function for the value when accessed via the
     key. The located :class:`selenium.webdriver.remote.webelement.WebElement`
     instance is passed as the only argument to the function.
    :param function only_if: extra validation function that is called
     repeatedly until :attr:`timeout` elapses. If not provided the default
     function used checks that the element collection is not empty. The list
     of located :class:`selenium.webdriver.remote.webelement.WebElement`
     instances is passed as the only argument to the function.
    :param function filter_by: condition function determines which elements are
     included in the collection. If not provided the default function used
     includes all elements identified by :attr:`query_string`.
     A :class:`selenium.webdriver.remote.webelement.WebElement` instance is
     passed as the only argument to the function.
    """

    # pylint: disable=incomplete-protocol,line-too-long

    def __init__(
        self,
        locator_type,
        query_string=None,
        base_element=None,
        timeout=0,
        key=lambda el: el.text,
        value=lambda el: el,
        only_if=lambda els: len(els) > 0,
        facet=False,
        filter_by=lambda el: el is not None,
    ):
        super().__init__(
            locator_type,
            query_string,
            base_element,
            timeout,
            facet=facet,
            only_if=only_if,
            filter_by=filter_by,
        )
        self.key_mapper = key
        self.value_mapper = value

    def __get__(self, instance, owner):
        if not instance:
            return self
        try:
            return (
                OrderedDict(
                    (self.key_mapper(el), self.value_mapper(self.enhance(el)))
                    for el in self._get_element(self.root.find_elements)
                )
                if self.root
                else {}
            )
        except (
            NoSuchElementException,
            TimeoutException,
            StaleElementReferenceException,
        ):
            return {}

    def __getitem__(self, key):
        return lambda: self.__get__(self, self.__class__)[key]


class Section(Faceted):
    """
    Base class to encapsulate reusable page sections::

        class MySection(Section):
            things = Elements( .... )

        class MyPage(Page):
            section_1 =  MySection(Locators.CLASS_NAME, "section")
            section_2 =  MySection(Locators.ID, "unique_section")

    """

    def __init__(self, locator_type, query_string, iframe=None, timeout=0):
        super().__init__()
        self.touched = False
        self.locator_type = locator_type
        self.query_string = query_string
        self.iframe = iframe
        self.timeout = timeout
        self.__root_val = None
        self.element_members = {}
        for element in inspect.getmembers(self.__class__):
            if issubclass(element[1].__class__, ElementGetter):
                self.element_members[element[0]] = element[1]
                if element[1].is_facet:
                    facet = ElementFacet(
                        element[1], element[0], debug=element[1].is_debug_facet
                    )
                    facet.register(self)

    def __get__(self, instance, owner):
        for element in self.element_members.values():
            element.root = lambda: self.root
        return self

    def __getattribute__(self, item):
        def attr_getter(key):
            return super(Section, self).__getattribute__(key)

        def attr_setter(key, value):
            return super(Section, self).__setattr__(key, value)

        members = attr_getter("element_members")
        touched = attr_getter("touched")
        if not touched and item in members:
            attr_getter("evaluate")()
            attr_setter("touched", True)
        return attr_getter(item)

    @property
    def root(self):
        """
        returns the element the section is rooted at
        """
        if self.iframe:
            try:
                Page.get_driver().switch_to.default_content()
                Page.get_driver().switch_to.frame(self.iframe)
            except NoSuchFrameException:
                log.error("unable to switch to iframe %s" % self.iframe)
        try:
            if not self.__root_val:
                WebDriverWait(Page.get_driver(), self.timeout).until(
                    lambda _: Page.get_driver().find_element(
                        self.locator_type, self.query_string
                    )
                )
            return self.__root_val or Page.get_driver().find_element(
                self.locator_type, self.query_string
            )
        except (
            NoSuchElementException,
            TimeoutException,
            StaleElementReferenceException,
        ):
            return None

    @root.setter
    def root(self, val):
        """
        sets the root of the section
        """
        self.__root_val = val


class Sections(Section, Sequence):
    """
    Base class for an Iterable view of a collection of
    :class:`holmium.core.Section` objects.
    """

    def __init__(self, locator_type, query_string, iframe=None, timeout=0):
        super().__init__(locator_type, query_string, iframe, timeout)

    def __getelements__(self):
        if self.timeout:
            try:
                WebDriverWait(Page.get_driver(), self.timeout).until(
                    lambda _: Page.get_driver().find_elements(
                        self.locator_type, self.query_string
                    )
                )
            except TimeoutException:
                log.debug(
                    "unable to find element %s after waiting for %d seconds"
                    % (self.query_string, self.timeout)
                )
        return Page.get_driver().find_elements(self.locator_type, self.query_string)

    def __iter__(self):
        for element in self.__getelements__():
            self.root = element
            yield self

    def __len__(self):
        return len(self.__getelements__())

    def __getitem__(self, idx):
        if idx < 0:
            idx = len(self) + idx
        if idx < 0 or idx >= len(self):
            raise IndexError("Sections index (%d) out of range" % idx)
        for i, _ in enumerate(self):
            if i == idx:
                break
        return self
