"""
Jx | Copyright (c) Juan-Pablo Scaletti
"""

import typing as t
from collections.abc import Callable

import jinja2
from markupsafe import Markup

from .attrs import Attrs
from .exceptions import InvalidPropType, MaxRecursionDepthError, MissingRequiredArgument


MAX_COMPONENT_DEPTH = 100


class Component:
    __slots__ = (
        "relpath",
        "tmpl",
        "get_component",
        "required",
        "optional",
        "imports",
        "css",
        "js",
        "slots",
        "globals",
        "asset_resolver",
    )

    def __init__(
        self,
        *,
        relpath: str,
        tmpl: jinja2.Template,
        get_component: Callable[[str], "Component"],
        required: dict[str, type | None] | None = None,
        optional: dict[str, tuple[t.Any, type | None]] | None = None,
        imports: dict[str, str] | None = None,
        css: tuple[str, ...] = (),
        js: tuple[str, ...] = (),
        slots: tuple[str, ...] = (),
        asset_resolver: Callable[[str, str], str] | None = None,
    ) -> None:
        """
        Internal object that represents a Jx component.

        Arguments:
            relpath:
                The "name" of the component.
            tmpl:
                The jinja2.Template for the component.
            get_component:
                A callable that retrieves a component by its name/relpath.
            required:
                A dictionary of required attribute names mapped to their type (or None).
            optional:
                A dictionary of optional attributes mapped to (default_value, type or None).
            imports:
                A dictionary of imported component names as "name": "relpath" pairs.
            css:
                A tuple of CSS file URLs.
            js:
                A tuple of JS file URLs.
            slots:
                A tuple of slot names.
            asset_resolver:
                A callable that transforms asset URLs. Receives (url, prefix) and
                returns the resolved URL.

        """
        self.relpath = relpath
        self.tmpl = tmpl
        self.get_component = get_component

        self.required = required or {}
        self.optional = optional or {}
        self.imports = imports or {}
        self.css = css
        self.js = js
        self.slots = slots
        self.asset_resolver = asset_resolver

        self.globals: dict[str, t.Any] = {}

    def render(
        self,
        *,
        content: str | None = None,
        attrs: Attrs | dict[str, t.Any] | None = None,
        caller: Callable[[str], str] | None = None,
        **params: t.Any,
    ) -> Markup:
        # Check recursion depth
        depth = self.globals.get("_depth", 0)
        if depth > MAX_COMPONENT_DEPTH:
            raise MaxRecursionDepthError(MAX_COMPONENT_DEPTH)

        # Increment depth for child components
        self.globals = {**self.globals, "_depth": depth + 1}

        content = content if content is not None else caller("") if caller else ""
        attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
        params = {**attrs, **params}
        props, attrs = self.filter_attrs(params)

        globals = {**self.globals, "_get": self.get_child}
        globals.setdefault("attrs", Attrs(attrs))
        globals.setdefault("content", content)

        slots = {}
        if caller:
            for name in self.slots:
                body = caller(name)
                if body != content:
                    slots[name] = body
        props["_slots"] = slots

        html = self.tmpl.render({**props, **globals}).lstrip()
        return Markup(html)

    def filter_attrs(
        self, kw: dict[str, t.Any]
    ) -> tuple[dict[str, t.Any], dict[str, t.Any]]:
        props = {}

        for key, expected_type in self.required.items():
            if key not in kw:
                raise MissingRequiredArgument(self.relpath, key)
            value = kw.pop(key)
            if expected_type is not None and not isinstance(value, expected_type):
                raise InvalidPropType(self.relpath, key, expected_type, type(value))
            props[key] = value

        for key, (default, expected_type) in self.optional.items():
            value = kw.pop(key, default)
            if expected_type is not None and not isinstance(value, expected_type):
                raise InvalidPropType(self.relpath, key, expected_type, type(value))
            props[key] = value

        extra = kw.copy()
        return props, extra

    def get_child(self, name: str) -> "Component":
        relpath = self.imports[name]
        child = self.get_component(relpath)
        child.globals = self.globals
        return child

    def resolve_url(self, url: str) -> str:
        if not self.asset_resolver:
            return url
        prefix = ""
        if self.relpath.startswith("@"):
            prefix = self.relpath.split("/", 1)[0][1:]
        return self.asset_resolver(url, prefix)

    def collect_css(self, _visited: set[str] | None = None) -> list[str]:
        """
        Returns a list of CSS files for the component and its children.
        """
        resolved = [self.resolve_url(url) for url in self.css]
        urls = dict.fromkeys(resolved, 1)
        _visited = _visited or set()
        _visited.add(self.relpath)

        for name, relpath in self.imports.items():
            if relpath in _visited:
                continue
            co = self.get_child(name)
            for file in co.collect_css(_visited=_visited):
                if file not in urls:
                    urls[file] = 1
            _visited.add(relpath)

        return list(urls.keys())

    def collect_js(self, _visited: set[str] | None = None) -> list[str]:
        """
        Returns a list of JS files for the component and its children.
        """
        resolved = [self.resolve_url(url) for url in self.js]
        urls = dict.fromkeys(resolved, 1)
        _visited = _visited or set()
        _visited.add(self.relpath)

        for name, relpath in self.imports.items():
            if relpath in _visited:
                continue
            co = self.get_child(name)
            for file in co.collect_js(_visited=_visited):
                if file not in urls:
                    urls[file] = 1
            _visited.add(relpath)

        return list(urls.keys())

    def render_css(self) -> Markup:
        """
        Uses the `collect_css()` list to generate an HTML fragment
        with `<link rel="stylesheet" href="{url}">` tags.
        """
        html = []
        for url in self.collect_css():
            html.append(f'<link rel="stylesheet" href="{url}">')

        return Markup("\n".join(html))

    def render_js(self, module: bool = True, defer: bool = True) -> Markup:
        """
        Uses the `collected_js()` list to generate an HTML fragment
        with `<script type="module" src="{url}"></script>` tags.

        Arguments:
            module:
                Whether to render the script tags as modules, e.g.:
                `<script type="module" src="..."></script>`
            defer:
                Whether to add the `defer` attribute to the script tags,
                if `module` is `False` (all module scripts are also deferred), e.g.:
                `<script src="..." defer></script>`

        """
        html = []
        for url in self.collect_js():
            if module:
                tag = f'<script type="module" src="{url}"></script>'
            elif defer:
                tag = f'<script src="{url}" defer></script>'
            else:
                tag = f'<script src="{url}"></script>'
            html.append(tag)

        return Markup("\n".join(html))

    def render_assets(self, module: bool = True, defer: bool = False) -> Markup:
        """
        Calls `render_css()` and `render_js()` to generate
        an HTML fragment with `<link rel="stylesheet" href="{url}">`
        and `<script type="module" src="{url}"></script>` tags.

        Arguments:
            module:
                Whether to render the script tags as modules, e.g.:
                `<script type="module" src="..."></script>`
            defer:
                Whether to add the `defer` attribute to the script tags,
                if `module` is `False` (all module scripts are also deferred), e.g.:
                `<script src="..." defer></script>`

        """
        html_css = self.render_css()
        html_js = self.render_js()
        return Markup(("\n".join([html_css, html_js]).strip()))
