import logging
from collections import defaultdict
from functools import wraps
from typing import Any, Callable, Dict, Iterable, List, Optional, Union

from webview.dom import DOMEventHandler, ManipulationMode, _dnd_state
from webview.dom.classlist import ClassList
from webview.dom.propsdict import DOMPropType, PropsDict
from webview.errors import JavascriptException
from webview.event import EventContainer

logger = logging.getLogger('pywebview')


def _ignore_window_document(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        if args[0]._node_id in ('window', 'document'):
            return None

        return func(*args, **kwargs)

    return wrapper


def _exists(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not args[0]._exists:
            logger.warning(f'Element with id {args[0]._node_id} has been removed')
            return

        try:
            return func(*args, **kwargs)
        except JavascriptException as e:
            if e.args[0].get('cause') == 'ELEMENT_NOT_FOUND':
                logger.warning(f'Element with id {args[0]._node_id} has been removed')
            else:
                logger.exception(e)
            return

    return wrapper


class Element:
    def __init__(self, window, node_id) -> None:
        self._window = window
        self.events = EventContainer()
        self._node_id = node_id
        self._query_command = rf"""
            var element;

            if ('{self._node_id}' === 'document') {{
                element = document;
            }} else if ('{self._node_id}' === 'window') {{
                element = window;
            }} else if ('{self._node_id}' === 'body') {{
                element = document.body;
            }} else {{
                element = document.querySelector('[data-pywebview-id="{self._node_id}"]');
            }}

            if (!element) {{
                throw new Error('Element with pywebview-id {self._node_id} not found', {{ cause: 'ELEMENT_NOT_FOUND' }});
            }}
        """.replace('\n', '')
        self._event_handlers = defaultdict(list)
        self._event_handler_ids = {}
        self._exists = True
        self._classes = ClassList(self)
        self._style = PropsDict(self, DOMPropType.Style)
        self._attributes = PropsDict(self, DOMPropType.Attribute)

        self.__original_display = None
        self.__generate_events()

    @property
    @_exists
    @_ignore_window_document
    def tag(self) -> str:
        return self._window.evaluate_js(f'{self._query_command}; element.tagName').lower()

    @property
    @_exists
    @_ignore_window_document
    def id(self) -> Optional[str]:
        return self._window.evaluate_js(f'{self._query_command}; element.id')

    @id.setter
    @_exists
    @_ignore_window_document
    def id(self, id: str) -> None:
        self._window.run_js(f"{self._query_command}; element.id = '{id}'")

    @property
    @_exists
    @_ignore_window_document
    def classes(self) -> ClassList:
        return self._classes

    @classes.setter
    def classes(self, classes: Iterable) -> None:
        self._classes = ClassList(self, classes)

    @property
    @_exists
    @_ignore_window_document
    def attributes(self) -> Dict[str, Any]:
        return self._attributes

    @attributes.setter
    @_exists
    @_ignore_window_document
    def attributes(self, attributes: Dict[str, Any]) -> None:
        self._attributes = PropsDict(self, DOMPropType.Attribute, attributes)

    @property
    @_exists
    def node(self) -> Dict[str, Any]:
        return self._window.evaluate_js(
            f'{self._query_command}; var r2 = pywebview._processElements([element])[0]; r2'
        )

    @property
    @_exists
    @_ignore_window_document
    def style(self) -> Dict[str, Any]:
        return self._style

    @style.setter
    @_exists
    @_ignore_window_document
    def style(self, style: Dict[str, Any]) -> None:
        self._style = PropsDict(self, DOMPropType.Style, style)

    @property
    @_exists
    @_ignore_window_document
    def tabindex(self) -> int:
        return self._window.evaluate_js(f'{self._query_command}; element.tabIndex')

    @tabindex.setter
    @_exists
    @_ignore_window_document
    def tabindex(self, tab_index: int) -> None:
        self._window.run_js(f'{self._query_command}; element.tabIndex = {tab_index}')

    @property
    @_exists
    @_ignore_window_document
    def text(self) -> str:
        return self._window.evaluate_js(f'{self._query_command}; element.textContent')

    @text.setter
    @_exists
    @_ignore_window_document
    def text(self, text: str) -> None:
        self._window.run_js(f"{self._query_command}; element.textContent = '{text}'")

    @property
    @_exists
    @_ignore_window_document
    def value(self) -> str:
        return self._window.evaluate_js(f'{self._query_command}; element.value')

    @property
    @_exists
    @_ignore_window_document
    def visible(self) -> bool:
        return self._window.evaluate_js(f'{self._query_command}; element.offsetParent !== null')

    @property
    @_exists
    @_ignore_window_document
    def focused(self) -> bool:
        return self._window.evaluate_js(
            f'{self._query_command}; document.activeElement === element'
        )

    @value.setter
    @_exists
    @_ignore_window_document
    def value(self, value: str) -> None:
        self._window.run_js(
            f"{self._query_command}; if ('value' in element) {{ element.value = '{value}' }}"
        )

    @_exists
    @_ignore_window_document
    def blur(self) -> None:
        self._window.run_js(f'{self._query_command}; element.blur()')

    @_exists
    @_ignore_window_document
    def focus(self) -> None:
        self._window.run_js(f'{self._query_command}; element.focus()')

    @property
    @_exists
    @_ignore_window_document
    def children(self) -> List['Element']:
        children = self._window.evaluate_js(
            f"""
            {self._query_command};
            var children = element.children;
            var nodeIds = []
            for (var i = 0; i < children.length; i++) {{
                var nodeId = pywebview._getNodeId(children[i]);
                nodeIds.push(nodeId);
            }}

            nodeIds
        """
        )
        return [Element(self._window, node_id) for node_id in children]

    @property
    @_exists
    @_ignore_window_document
    def parent(self) -> Union['Element', None]:
        node_id = self._window.evaluate_js(
            f"""
            {self._query_command};
            var parent = element.parentElement;
            parent ? pywebview._getNodeId(parent) : null
        """
        )
        return Element(self._window, node_id) if node_id else None

    @property
    @_exists
    @_ignore_window_document
    def next(self) -> Union['Element', None]:
        node_id = self._window.evaluate_js(
            f"""
            {self._query_command};
            var nextSibling = element.nextElementSibling;
            nextSibling ? pywebview._getNodeId(nextSibling) : null
        """
        )
        return Element(self._window, node_id) if node_id else None

    @property
    @_exists
    @_ignore_window_document
    def previous(self) -> Union['Element', None]:
        node_id = self._window.evaluate_js(
            f"""
            {self._query_command};
            var previousSibling = element.previousElementSibling;
            previousSibling ? pywebview._getNodeId(previousSibling) : null
        """
        )
        return Element(self._window, node_id) if node_id else None

    @_exists
    @_ignore_window_document
    def hide(self) -> None:
        self.__original_display = self._window.evaluate_js(
            f'{self._query_command}; element.style.display'
        )
        self._window.run_js(f"{self._query_command}; element.style.display = 'none'")

    @_exists
    @_ignore_window_document
    def show(self) -> None:
        current_display = self._window.evaluate_js(f'{self._query_command}; element.style.display')

        if current_display == 'none':
            display = self.__original_display or 'block'
            self._window.run_js(f"{self._query_command}; element.style.display = '{display}'")

    @_exists
    @_ignore_window_document
    def toggle(self) -> None:
        current_display = self._window.evaluate_js(f'{self._query_command}; element.style.display')

        if current_display == 'none':
            self.show()
        else:
            self.hide()

    @_exists
    @_ignore_window_document
    def append(self, html: str, mode=ManipulationMode.LastChild) -> 'Element':
        return self._window.dom.create_element(html, self, mode)

    @_exists
    @_ignore_window_document
    def empty(self) -> None:
        self._window.run_js(f"{self._query_command}; element.innerHTML = ''")

    @_exists
    @_ignore_window_document
    def remove(self) -> None:
        self._window.run_js(f'{self._query_command}; element.remove()')

        if self._node_id in self._window.dom._elements:
            self._window.dom._elements.pop(self._node_id)

        handler_ids = ','.join(
            [f'"{handler_id}"' for handler_id in self._event_handler_ids.values()]
        )
        self._window.run_js(
            f"""
            var handlerIds = [{handler_ids}];
            handlerIds.forEach(function(handlerId) {{
                delete pywebview._eventHandlers[handler_id]
            }})
        """
        )
        self._event_handler_ids = {}
        self._event_handlers = defaultdict(list)
        self._exists = False

    @_exists
    @_ignore_window_document
    def copy(
        self, target: Union[str, 'Element'] = None, mode=ManipulationMode.LastChild, id: str = None
    ) -> 'Element':
        if isinstance(target, str):
            target = self._window.dom.get_element(target)
        elif target is None:
            target = self.parent

        if id:
            id_command = f'newElement.id = "{id}"'
        else:
            id_command = 'newElement.removeAttribute("id")'

        node_id = self._window.evaluate_js(
            f"""
            {self._query_command};
            var target = document.querySelector('[data-pywebview-id=\"{target._node_id}\"]');
            var newElement = element.cloneNode(true);
            newElement.removeAttribute('data-pywebview-id');
            {id_command};

            var nodeId = pywebview._getNodeId(newElement);
            pywebview._insertNode(newElement, target, '{mode.value}')
            nodeId;
        """
        )

        new_element = Element(self._window, node_id)
        for event, handlers in self._event_handlers.items():
            for handler in handlers:
                new_element.on(event, handler)

        return new_element

    @_exists
    @_ignore_window_document
    def move(self, target: Union[str, 'Element'], mode=ManipulationMode.LastChild) -> 'Element':
        if isinstance(target, str):
            target = self._window.dom.get_element(target)

        self._window.run_js(
            f"""
            {self._query_command};
            var target = document.querySelector('[data-pywebview-id=\"{target._node_id}\"]');
            pywebview._insertNode(element, target, '{mode.value}')
        """
        )
        return self

    @_exists
    def on(self, event: str, callback: Union[Callable, DOMEventHandler]) -> None:
        if self._node_id not in self._window.dom._elements:
            self._window.dom._elements[self._node_id] = self

        if isinstance(callback, DOMEventHandler):
            prevent_default = 'e.preventDefault();' if callback.prevent_default else ''
            stop_propagation = 'e.stopPropagation();' if callback.stop_propagation else ''
            debounce = callback.debounce
            callback = callback.callback
        else:
            prevent_default = ''
            stop_propagation = ''
            debounce = 0

        callback_func = f"window.pywebview._jsApiCallback('pywebviewEventHandler', {{ event: e, nodeId: '{self._node_id}' }}, 'eventHandler')"
        debounced_func = (
            f'pywebview._debounce(function() {{ {callback_func} }}, {debounce})'
            if debounce > 0
            else callback_func
        )

        handler_id = self._window.evaluate_js(
            f"""
            {self._query_command};
            var handlerId = null

            if (element) {{
                handlerId = Math.random().toString(36).substr(2, 11);
                pywebview._eventHandlers[handlerId] = function(e) {{
                    {prevent_default}
                    {stop_propagation}
                    {debounced_func};
                }}

                element.addEventListener('{event}', pywebview._eventHandlers[handlerId]);
            }};
            handlerId;
        """
        )

        if handler_id:
            self._event_handlers[event].append(callback)
            self._event_handler_ids[callback] = handler_id

        if event == 'drop':
            _dnd_state['num_listeners'] += 1

    @_exists
    def off(self, event: str, callback: Callable) -> None:
        handler_id = self._event_handler_ids.get(callback)

        if not handler_id:
            return

        self._window.run_js(
            f"""
            {self._query_command};
            var callback = pywebview._eventHandlers['{handler_id}'];
            if (element) {{
                element.removeEventListener('{event}', callback);
                delete pywebview._eventHandlers['{handler_id}'];
            }}
        """
        )

        del self._event_handler_ids[callback]
        del self._window.dom._elements[self._node_id]

        for handler in self._event_handlers[event]:
            if handler == callback:
                self._event_handlers[event].remove(handler)

        if event == 'drop':
            _dnd_state['num_listeners'] = max(0, _dnd_state['num_listeners'] - 1)

    @_exists
    def __generate_events(self):
        from webview.dom.event import DOMEvent

        events = self._window.evaluate_js(
            f"""
            {self._query_command};
            Object
                .getOwnPropertyNames(element)
                .concat(
                    Object.getOwnPropertyNames(
                        Object.getPrototypeOf(
                            Object.getPrototypeOf(element)
                        )
                    )
                )
                .filter(function(i){{
                    return !i.indexOf('on') && (element[i] == null || typeof element[i]=='function');
                }})
                .map(function(f) {{ return f.substr(2) }});
        """
        )

        for event in events:
            setattr(self.events, event, DOMEvent(event, self))

    @_exists
    def __str__(self) -> str:
        return repr(self)

    @_exists
    def __repr__(self) -> str:
        if self.node['nodeName'] == '#document':
            return 'document'
        elif self.node['nodeName'] == '#window':
            return 'window'
        else:
            return self.node['outerHTML']

    @_exists
    def __eq__(self, other: 'Element') -> bool:
        return hasattr(other, '_node_id') and self._node_id == other._node_id
