class Tag:
    """
    The most basic building block of a PuePy app. A Tag is a single HTML element. This is also the base class of
    `Component`, which is then the base class of `Page`.
    Attributes:
        default_classes (list): Default classes for the tag.
        default_attrs (dict): Default attributes for the tag.
        default_role (str): Default role for the tag.
        page (Page): The page the tag is on.
        router (Router or None): The router the application is using, if any.
        parent (Tag): The parent tag, component, or page.
        application (Application): The application instance.
        element: The rendered element on the DOM. Raises ElementNotInDom if not found.
        children (list): The children of the tag.
        refs (dict): The refs of the tag.
        tag_name (str): The name of the tag.
        ref (str): The reference of the tag.
    """
    stack = []
    population_stack = []
    origin_stack = [[]]
    component_stack = []
    default_classes = []
    default_attrs = {}
    default_role = None
    document = document
    # noinspection t
    def __init__(
        self,
        tag_name,
        ref,
        page: "Page" = None,
        parent=None,
        parent_component=None,
        origin=None,
        children=None,
        **kwargs,
    ):
        # Kept so we can garbage collect them later
        self._added_event_listeners = []
        # Ones manually added, which we persist when reconfigured
        self._manually_added_event_listeners = {}
        # The rendered element
        self._rendered_element = None
        # Child nodes and origin refs
        self.children = []
        self.refs = {}
        self.tag_name = tag_name
        self.ref = ref
        # Attrs that webcomponents create that we need to preserve
        self._retained_attrs = {}
        # Add any children passed to constructor
        if children:
            self.add(*children)
        # Configure self._page
        if isinstance(page, Page):
            self._page = page
        elif isinstance(self, Page):
            self._page = self
        elif page:
            raise Exception(f"Unknown page type {type(page)}")
        else:
            raise Exception("No page passed")
        if "id" in kwargs:
            self._element_id = kwargs["id"]
        elif self._page and self._page.application:
            self._element_id = self._page.application.element_id_generator.get_id_for_element(self)
        else:
            self._element_id = f"ppauto-{id(self)}"
        if isinstance(parent, Tag):
            self.parent = parent
            parent.add(self)
        elif parent:
            raise Exception(f"Unknown parent type {type(parent)}: {repr(parent)}")
        else:
            self.parent = None
        if isinstance(parent_component, Component):
            self.parent_component = parent_component
        elif parent_component:
            raise Exception(f"Unknown parent_component type {type(parent_component)}: {repr(parent_component)}")
        else:
            self.parent_component = None
        self.origin = origin
        self._children_generated = False
        self._configure(kwargs)
    def __del__(self):
        if not is_server_side:
            while self._added_event_listeners:
                remove_event_listener(*self._added_event_listeners.pop())
    @property
    def application(self):
        return self._page._application
    def _configure(self, kwargs):
        self._kwarg_event_listeners = _extract_event_handlers(kwargs)
        self._handle_bind(kwargs)
        self._handle_attrs(kwargs)
    def _handle_bind(self, kwargs):
        if "bind" in kwargs:
            self.bind = kwargs.pop("bind")
            input_type = kwargs.get("type")
            tag_name = self.tag_name.lower()
            if "value" in kwargs and not (tag_name == "input" and input_type == "radio"):
                raise Exception("Cannot specify both 'bind' and 'value'")
        else:
            self.bind = None
    def _handle_attrs(self, kwargs):
        self.attrs = self._retained_attrs.copy()
        for k, v in kwargs.items():
            if hasattr(self, f"set_{k}"):
                getattr(self, f"set_{k}")(v)
            else:
                self.attrs[k] = v
    def populate(self):
        """To be overwritten by subclasses, this method will define the composition of the element"""
        pass
    def precheck(self):
        """
        Before doing too much work, decide whether rendering this tag should raise an error or not. This may be useful,
        especially on a Page, to check if the user is authorized to view the page, for example:
        Examples:
            ``` py
            def precheck(self):
                if not self.application.state["authenticated_user"]:
                    raise exceptions.Unauthorized()
            ```
        """
        pass
    def generate_children(self):
        """
        Runs populate, but first adds self to self.population_stack, and removes it after populate runs.
        That way, as populate is executed, self.population_stack can be used to figure out what the innermost populate()
        method is being run and thus, where to send bind= parameters.
        """
        self.origin_stack.append([])
        self._refs_pending_removal = self.refs.copy()
        self.refs = {}
        self.population_stack.append(self)
        try:
            self.precheck()
            self.populate()
        finally:
            self.population_stack.pop()
            self.origin_stack.pop()
    def render(self):
        attrs = self.get_default_attrs()
        attrs.update(self.attrs)
        element = self._create_element(attrs)
        self._render_onto(element, attrs)
        self.post_render(element)
        return element
    def _create_element(self, attrs):
        if "xmlns" in attrs:
            element = self.document.createElementNS(attrs.get("xmlns"), self.tag_name)
        else:
            element = self.document.createElement(self.tag_name)
        element.setAttribute("id", self.element_id)
        if is_server_side:
            element.setIdAttribute("id")
        self.configure_element(element)
        return element
    def configure_element(self, element):
        pass
    def post_render(self, element):
        pass
    @property
    def element_id(self):
        return self._element_id
    @property
    def element(self):
        el = self.document.getElementById(self.element_id)
        if el:
            return el
        else:
            raise ElementNotInDom(self.element_id)
    # noinspection t
    def _render_onto(self, element, attrs):
        self._rendered_element = element
        # Handle classes
        classes = self.get_render_classes(attrs)
        if classes:
            # element.className = " ".join(classes)
            element.setAttribute("class", " ".join(classes))
        # Add attributes
        for key, value in attrs.items():
            if key not in ("class_name", "classes", "class"):
                if hasattr(self, f"handle_{key}_attr"):
                    getattr(self, f"handle_{key}_attr")(element, value)
                else:
                    if key.endswith("_"):
                        attr = key[:-1]
                    else:
                        attr = key
                    attr = attr.replace("_", "-")
                    if isinstance(value, bool) or value is None:
                        if value:
                            element.setAttribute(attr, attr)
                    elif isinstance(value, (str, int, float)):
                        element.setAttribute(attr, value)
                    else:
                        element.setAttribute(attr, str(value))
        if "role" not in attrs and self.default_role:
            element.setAttribute("role", self.default_role)
        # Add event handlers
        self._add_listeners(element, self._kwarg_event_listeners)
        self._add_listeners(element, self._manually_added_event_listeners)
        # Add bind
        if self.bind and self.origin:
            input_type = _element_input_type(element)
            if type(self.bind) in [list, tuple]:
                value = self.origin.state
                for key in self.bind:
                    value = value[key]
            else:
                value = self.origin.state[self.bind]
            if input_type == "checkbox":
                if is_server_side and value:
                    element.setAttribute("checked", value)
                else:
                    element.checked = bool(value)
                    element.setAttribute("checked", value)
                event_type = "change"
            elif input_type == "radio":
                is_checked = value == element.value
                if is_server_side and is_checked:
                    element.setAttribute("checked", is_checked)
                else:
                    element.checked = is_checked
                    element.setAttribute("checked", is_checked)
                event_type = "change"
            else:
                if is_server_side:
                    element.setAttribute("value", value)
                else:
                    element.value = value
                    element.setAttribute("value", value)
                event_type = "input"
            self.add_event_listener(element, event_type, self.on_bind_input)
        elif self.bind:
            raise Exception("Cannot specify bind a valid parent component")
        self.render_children(element)
    def _add_listeners(self, element, listeners):
        for key, value in listeners.items():
            key = key.replace("_", "-")
            if isinstance(value, (list, tuple)):
                for handler in value:
                    self.add_event_listener(element, key, handler)
            else:
                self.add_event_listener(element, key, value)
    def render_children(self, element):
        for child in self.children:
            if isinstance(child, Slot):
                if child.children:  # If slots don't have any children, don't bother.
                    element.appendChild(child.render())
            elif isinstance(child, Tag):
                element.appendChild(child.render())
            elif isinstance(child, html):
                element.insertAdjacentHTML("beforeend", str(child))
            elif isinstance(child, str):
                element.appendChild(self.document.createTextNode(child))
            elif child is None:
                pass
            elif getattr(child, "nodeType", None) is not None:
                # DOM element
                element.appendChild(child)
            else:
                self.render_unknown_child(element, child)
    def render_unknown_child(self, element, child):
        """
        Called when the child is not a Tag, Slot, or html. By default, it raises an error.
        """
        raise Exception(f"Unknown child type {type(child)} onto {self}")
    def get_render_classes(self, attrs):
        class_names, python_css_classes = merge_classes(
            set(self.get_default_classes()),
            attrs.pop("class_name", []),
            attrs.pop("classes", []),
            attrs.pop("class", []),
        )
        self.page.python_css_classes.update(python_css_classes)
        return class_names
    def get_default_classes(self):
        """
        Returns a shallow copy of the default_classes list.
        This could be overridden by subclasses to provide a different default_classes list.
        Returns:
            (list): A shallow copy of the default_classes list.
        """
        return self.default_classes.copy()
    def get_default_attrs(self):
        return self.default_attrs.copy()
    def add_event_listener(self, element, event, listener):
        """
        Just an internal wrapper around add_event_listener (JS function) that keeps track of what we added, so
        we can garbage collect it later.
        Should probably not be used outside this class.
        """
        self._added_event_listeners.append((element, event, listener))
        if not is_server_side:
            add_event_listener(element, event, listener)
    def mount(self, selector_or_element):
        self.update_title()
        if not self._children_generated:
            with self:
                self.generate_children()
        if isinstance(selector_or_element, str):
            element = self.document.querySelector(selector_or_element)
        else:
            element = selector_or_element
        if not element:
            raise RuntimeError(f"Element {selector_or_element} not found")
        element.innerHTML = ""
        element.appendChild(self.render())
        self.recursive_call("on_ready")
        self.add_python_css_classes()
    def add_python_css_classes(self):
        """
        This is only done at the page level.
        """
        pass
    def recursive_call(self, method, *args, **kwargs):
        """
        Recursively call a specified method on all child Tag objects.
        Args:
            method (str): The name of the method to be called on each Tag object.
            *args: Optional arguments to be passed to the method.
            **kwargs: Optional keyword arguments to be passed to the method.
        """
        for child in self.children:
            if isinstance(child, Tag):
                child.recursive_call(method, *args, **kwargs)
        getattr(self, method)(*args, **kwargs)
    def on_ready(self):
        pass
    def _retain_implicit_attrs(self):
        """
        Retain attributes set elsewhere
        """
        try:
            for attr in self.element.attributes:
                if attr.name not in self.attrs and attr.name != "id":
                    self._retained_attrs[attr.name] = attr.value
        except ElementNotInDom:
            pass
    def on_redraw(self):
        pass
    def on_bind_input(self, event):
        input_type = _element_input_type(event.target)
        if input_type == "checkbox":
            self.set_bind_value(self.bind, event.target.checked)
        elif input_type == "radio":
            if event.target.checked:
                self.set_bind_value(self.bind, event.target.value)
        elif input_type == "number":
            value = event.target.value
            try:
                if "." in str(value):
                    value = float(value)
                else:
                    value = int(value)
            except (ValueError, TypeError):
                pass
            self.set_bind_value(self.bind, value)
        else:
            self.set_bind_value(self.bind, event.target.value)
    def set_bind_value(self, bind, value):
        if type(bind) in (list, tuple):
            nested_dict = self.origin.state
            for key in bind[:-1]:
                nested_dict = nested_dict[key]
            with self.origin.state.mutate(bind[0]):
                nested_dict[bind[-1]] = value
        else:
            self.origin.state[self.bind] = value
    @property
    def page(self):
        if self._page:
            return self._page
        elif isinstance(self, Page):
            return self
    @property
    def router(self):
        if self.application:
            return self.application.router
    @property
    def parent(self):
        return self._parent
    @parent.setter
    def parent(self, new_parent):
        existing_parent = getattr(self, "_parent", None)
        if new_parent == existing_parent:
            if new_parent and self not in new_parent.children:
                existing_parent.children.append(self)
            return
        if existing_parent and self in existing_parent.children:
            existing_parent.children.remove(self)
        if new_parent and self not in new_parent.children:
            new_parent.children.append(self)
        self._parent = new_parent
    def add(self, *children):
        for child in children:
            if isinstance(child, Tag):
                child.parent = self
            else:
                self.children.append(child)
    def redraw(self):
        if self in self.page.redraw_list:
            self.page.redraw_list.remove(self)
        try:
            element = self.element
        except ElementNotInDom:
            return
        if is_server_side:
            old_active_element_id = None
        else:
            old_active_element_id = self.document.activeElement.id if self.document.activeElement else None
            self.recursive_call("_retain_implicit_attrs")
        self.children = []
        attrs = self.get_default_attrs()
        attrs.update(self.attrs)
        self.update_title()
        with self:
            self.generate_children()
        staging_element = self._create_element(attrs)
        self._render_onto(staging_element, attrs)
        patch_dom_element(staging_element, element)
        if old_active_element_id is not None:
            el = self.document.getElementById(old_active_element_id)
            if el:
                el.focus()
        self.recursive_call("on_redraw")
    def trigger_event(self, event, detail=None, **kwargs):
        """
                Triggers an event to be consumed by code using this class.
                Args:
                    event (str): The name of the event to trigger. If the event name contains underscores, a warning message is printed suggesting to use dashes instead.
                    detail (dict, optional): Additional data to be sent with the event. This should be a dictionary where the keys and values will be converted to JavaScript objects.
                    **kwargs: Additional keyword arguments. These arguments are not used in the implementation of the method and are ignored.
        ß"""
        if "_" in event:
            print("Triggering event with underscores. Did you mean dashes?: ", event)
        # noinspection PyUnresolvedReferences
        from pyscript.ffi import to_js
        # noinspection PyUnresolvedReferences
        from js import Object, Map
        if detail:
            event_object = to_js({"detail": Map.new(Object.entries(to_js(detail)))})
        else:
            event_object = to_js({})
        self.element.dispatchEvent(CustomEvent.new(event, event_object))
    def update_title(self):
        """
        To be overridden by subclasses (usually pages), this method should update the Window title as needed.
        Called on mounting or redraw.
        """
        pass
    def __enter__(self):
        self.stack.append(self)
        self.origin_stack[0].append(self)
        return self
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.stack.pop()
        self.origin_stack[0].pop()
        return False
    def __str__(self):
        return self.tag_name
    def __repr__(self):
        return f"<{self} ({id(self)})>"