from __future__ import annotations

import copy
from typing import (
    TYPE_CHECKING,
    ClassVar,
    Dict,
    Iterator,
    List,
    Optional,
    TypeVar,
)

from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import MediaDefiningClass
from django.template import Context, TemplateSyntaxError
from django.template.base import Node, NodeList, Template
from django.template.loader import get_template
from django.utils.safestring import mark_safe

# Allow "component.AlreadyRegistered" instead of having to import these everywhere
from django_components.component_registry import (  # noqa
    AlreadyRegistered,
    ComponentRegistry,
    NotRegistered,
)

if TYPE_CHECKING:
    from django_components.templatetags.component_tags import (
        FillNode,
        SlotNode,
    )


T = TypeVar("T")


FILLED_SLOTS_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"


class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
    def __new__(mcs, name, bases, attrs):
        if "Media" in attrs:
            media = attrs["Media"]

            # Allow: class Media: css = "style.css"
            if hasattr(media, "css") and isinstance(media.css, str):
                media.css = [media.css]

            # Allow: class Media: css = ["style.css"]
            if hasattr(media, "css") and isinstance(media.css, list):
                media.css = {"all": media.css}

            # Allow: class Media: css = {"all": "style.css"}
            if hasattr(media, "css") and isinstance(media.css, dict):
                for media_type, path_list in media.css.items():
                    if isinstance(path_list, str):
                        media.css[media_type] = [path_list]

            # Allow: class Media: js = "script.js"
            if hasattr(media, "js") and isinstance(media.js, str):
                media.js = [media.js]

        return super().__new__(mcs, name, bases, attrs)


class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
    # Must be set on subclass OR subclass must implement get_template_name() with
    # non-null return.
    template_name: ClassVar[str]

    def __init__(self, component_name):
        self._component_name: str = component_name
        self._instance_fills: Optional[List[FillNode]] = None
        self._outer_context: Optional[dict] = None

    def get_context_data(self, *args, **kwargs):
        return {}

    # Can be overridden for dynamic templates
    def get_template_name(self, context):
        if not hasattr(self, "template_name") or not self.template_name:
            raise ImproperlyConfigured(
                f"Template name is not set for Component {self.__class__.__name__}"
            )

        return self.template_name

    def render_dependencies(self):
        """Helper function to access media.render()"""
        return self.media.render()

    def render_css_dependencies(self):
        """Render only CSS dependencies available in the media class."""
        return mark_safe("\n".join(self.media.render_css()))

    def render_js_dependencies(self):
        """Render only JS dependencies available in the media class."""
        return mark_safe("\n".join(self.media.render_js()))

    def get_declared_slots(
        self, context: Context, template: Optional[Template] = None
    ) -> List[SlotNode]:
        if template is None:
            template = self.get_template(context)
        return list(
            dfs_iter_slots_in_nodelist(template.nodelist, template.name)
        )

    def get_template(self, context, template_name: Optional[str] = None):
        if template_name is None:
            template_name = self.get_template_name(context)
        template = get_template(template_name).template
        return template

    def set_instance_fills(self, fills: Dict[str, FillNode]) -> None:
        self._instance_fills = fills

    def set_outer_context(self, context):
        self._outer_context = context

    @property
    def instance_fills(self):
        return self._instance_fills or {}

    @property
    def outer_context(self):
        return self._outer_context or {}

    def get_updated_fill_stacks(self, context):
        current_fill_stacks = context.get(FILLED_SLOTS_CONTEXT_KEY, None)
        updated_fill_stacks = (
            copy.deepcopy(current_fill_stacks)
            if current_fill_stacks is not None
            else {}
        )
        for name, fill in self.instance_fills.items():
            if name in updated_fill_stacks:
                updated_fill_stacks[name].append(fill)
            else:
                updated_fill_stacks[name] = [fill]
        return updated_fill_stacks

    def validate_fills_and_slots_(
        self,
        context,
        template: Template,
        fills: Optional[Dict[str, FillNode]] = None,
    ) -> None:
        if fills is None:
            fills = self.instance_fills
        all_slots: List[SlotNode] = self.get_declared_slots(context, template)
        slots: Dict[str, SlotNode] = {}
        # Each declared slot must have a unique name.
        for slot in all_slots:
            slot_name = slot.name
            if slot_name in slots:
                raise TemplateSyntaxError(
                    f"Encountered non-unique slot '{slot_name}' in template "
                    f"'{template.name}' of component '{self._component_name}'."
                )
            slots[slot_name] = slot
        # All fill nodes must correspond to a declared slot.
        unmatchable_fills = fills.keys() - slots.keys()
        if unmatchable_fills:
            msg = (
                f"Component '{self._component_name}' passed fill(s) "
                f"refering to undefined slot(s). Bad fills: {list(unmatchable_fills)}."
            )
            raise TemplateSyntaxError(msg)
        # Note: Requirement that 'required' slots be filled is enforced
        #  in SlotNode.render().

    def render(self, context):
        template_name = self.get_template_name(context)
        template = self.get_template(context, template_name)
        self.validate_fills_and_slots_(context, template)
        updated_fill_stacks = self.get_updated_fill_stacks(context)
        with context.update({FILLED_SLOTS_CONTEXT_KEY: updated_fill_stacks}):
            return template.render(context)

    class Media:
        css = {}
        js = []


def dfs_iter_slots_in_nodelist(
    nodelist: NodeList, template_name: str = None
) -> Iterator[SlotNode]:
    from django_components.templatetags.component_tags import SlotNode

    nodes: List[Node] = list(nodelist)
    while nodes:
        node = nodes.pop()
        if isinstance(node, SlotNode):
            yield node
        for nodelist_name in node.child_nodelists:
            nodes.extend(reversed(getattr(node, nodelist_name, [])))


# This variable represents the global component registry
registry = ComponentRegistry()


def register(name):
    """Class decorator to register a component.

    Usage:

    @register("my_component")
    class MyComponent(component.Component):
        ...
    """

    def decorator(component):
        registry.register(name=name, component=component)
        return component

    return decorator
