"""Classes and functions for styling layout elements using style sheets.

This module provides a CSS-like styling system for layout elements, supporting:
- Style sheets with selectors (type, class, id, child, descendant, compound)
- Style collections that group related style properties
- CSS parsing from files or strings
- Style application to layout elements

Example:
    >>> from momapy.styling import StyleSheet
    >>> # Load a CSS file
    >>> stylesheet = StyleSheet.from_file("styles.css")
    >>> # Apply to a layout element
    >>> styled_element = apply_style_sheet(element, stylesheet)
"""

import abc
import collections.abc
import dataclasses
import pyparsing
import copy


import momapy.coloring
import momapy.drawing
import momapy.core


class StyleCollection(dict):
    """A dictionary-based collection of style properties.

    StyleCollection maps attribute names to their values, similar to a CSS
    declaration block. It is used within StyleSheets to store styles for
    specific selectors.

    Example:
        >>> styles = StyleCollection({"fill": "red", "stroke_width": 2})
        >>> styles["fill"]
        'red'
    """

    pass


class StyleSheet(dict):
    """A dictionary-based stylesheet mapping selectors to style collections.

    StyleSheet extends dict to map Selector objects to StyleCollection objects.
    Supports merging stylesheets using the | operator.

    Example:
        >>> from momapy.styling import StyleSheet, TypeSelector
        >>> ss = StyleSheet({TypeSelector("Rectangle"): StyleCollection({"fill": "blue"})})
        >>> # Merge stylesheets
        >>> combined = ss | another_stylesheet
    """

    def __or__(self, other):
        d = copy.deepcopy(self)
        for key, value in other.items():
            if key in d:
                d[key] |= value
            else:
                d[key] = value
        return StyleSheet(d)

    def __ior__(self, other):
        return self.__or__(other)

    @classmethod
    def from_file(cls, file_path: str) -> "StyleSheet":
        """Parse and return a StyleSheet from a CSS file.

        Args:
            file_path: Path to the CSS file to parse.

        Returns:
            A StyleSheet containing the parsed selectors and style collections.

        Raises:
            pyparsing.ParseException: If the CSS file is malformed.
        """
        style_sheet = _css_document.parse_file(file_path, parse_all=True)[0]
        return style_sheet

    @classmethod
    def from_string(cls, s: str) -> "StyleSheet":
        """Parse and return a StyleSheet from a CSS string.

        Args:
            s: CSS string to parse.

        Returns:
            A StyleSheet containing the parsed selectors and style collections.

        Raises:
            pyparsing.ParseException: If the CSS string is malformed.

        Example:
            >>> css = "Rectangle { fill: red; }"
            >>> ss = StyleSheet.from_string(css)
        """
        style_sheet = _css_document.parse_string(s, parse_all=True)[0]
        return style_sheet

    @classmethod
    def from_files(cls, file_paths: collections.abc.Collection[str]) -> "StyleSheet":
        """Parse and combine multiple CSS files into a single StyleSheet.

        Args:
            file_paths: Collection of paths to CSS files.

        Returns:
            A merged StyleSheet containing all parsed styles.
        """
        style_sheets = []
        for file_path in file_paths:
            style_sheet = StyleSheet.from_file(file_path)
            style_sheets.append(style_sheet)
        style_sheet = combine_style_sheets(style_sheets)
        return style_sheet


def combine_style_sheets(
    style_sheets: collections.abc.Collection[StyleSheet],
) -> StyleSheet:
    """Merge multiple StyleSheets into a single StyleSheet.

    Args:
        style_sheets: Collection of StyleSheets to merge.

    Returns:
        A combined StyleSheet, or None if the input is empty.
    """
    if not style_sheets:
        return None
    output_style_sheet = style_sheets[0]
    for style_sheet in style_sheets[1:]:
        output_style_sheet |= style_sheet
    return output_style_sheet


def apply_style_collection(
    layout_element: (momapy.core.LayoutElement | momapy.core.LayoutElementBuilder),
    style_collection: StyleCollection,
    strict: bool = True,
) -> momapy.core.LayoutElement | momapy.core.LayoutElementBuilder:
    """Apply a StyleCollection to a layout element.

    Args:
        layout_element: The element or builder to apply styles to.
        style_collection: The styles to apply.
        strict: If True, raises AttributeError for invalid attributes.
            If False, silently ignores them.

    Returns:
        The modified layout element or builder.

    Raises:
        AttributeError: If strict=True and an attribute doesn't exist on the element.
    """
    if not isinstance(layout_element, momapy.builder.Builder):
        layout_element = momapy.builder.builder_from_object(layout_element)
        is_builder = False
    else:
        is_builder = True
    for attribute, value in style_collection.items():
        if hasattr(layout_element, attribute):
            setattr(layout_element, attribute, value)
        else:
            if strict:
                raise AttributeError(
                    f"{type(layout_element)} object has no attribute '{attribute}'"
                )
    if is_builder:
        return layout_element
    return momapy.builder.object_from_builder(layout_element)


def apply_style_sheet(
    map_or_layout_element: (
        momapy.core.Map
        | momapy.core.LayoutElement
        | momapy.core.MapBuilder
        | momapy.core.LayoutElementBuilder
    ),
    style_sheet: StyleSheet,
    strict: bool = True,
    ancestors: collections.abc.Collection[
        momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
    ] = None,
) -> (
    momapy.core.Map
    | momapy.core.LayoutElement
    | momapy.core.LayoutElementBuilder
    | momapy.core.MapBuilder
):
    """Apply a StyleSheet to a layout element or map layout recursively.

    Args:
        map_or_layout_element: The map, element, or builder to style.
        style_sheet: The stylesheet to apply.
        strict: If True, raises errors for invalid attributes.
        ancestors: Internal list of ancestor elements for selector matching.

    Returns:
        The modified map, layout element, or builder.
    """
    if not isinstance(map_or_layout_element, momapy.builder.Builder):
        map_or_layout_element = momapy.builder.builder_from_object(
            map_or_layout_element
        )
        is_builder = False
    else:
        is_builder = True
    if isinstance(map_or_layout_element, momapy.core.MapBuilder):
        layout_element = map_or_layout_element.layout
    else:
        layout_element = map_or_layout_element
    if style_sheet is not None:
        if ancestors is None:
            ancestors = []
        for selector, style_collection in style_sheet.items():
            if selector.select(layout_element, ancestors):
                apply_style_collection(
                    layout_element=layout_element,
                    style_collection=style_collection,
                    strict=strict,
                )
        ancestors = ancestors + [layout_element]
        for child in layout_element.children():
            apply_style_sheet(
                map_or_layout_element=child,
                style_sheet=style_sheet,
                strict=strict,
                ancestors=ancestors,
            )
    if is_builder:
        return map_or_layout_element
    return momapy.builder.object_from_builder(map_or_layout_element)


@dataclasses.dataclass(frozen=True)
class Selector(object):
    """Abstract base class for CSS-like selectors.

    Selectors determine whether a layout element matches specific criteria.
    All selector types inherit from this class and implement the select() method.
    """

    @abc.abstractmethod
    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ) -> bool:
        """Check if the layout element matches this selector.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements.

        Returns:
            True if the element matches, False otherwise.
        """
        pass


@dataclasses.dataclass(frozen=True)
class TypeSelector(Selector):
    """Selector that matches elements by their exact class name.

    Attributes:
        class_name: The exact class name to match (e.g., "Rectangle").

    Example:
        >>> selector = TypeSelector("Rectangle")
        >>> selector.match(some_rectangle)  # True if type is Rectangle
    """

    class_name: str = dataclasses.field(
        metadata={"description": "The name of the class"}
    )

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if the object's class name matches exactly.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements (unused for type matching).

        Returns:
            True if the object's class name or builder name matches.
        """
        obj_cls_name = type(obj).__name__
        return (
            obj_cls_name == self.class_name
            or obj_cls_name == f"{self.class_name}Builder"
        )


@dataclasses.dataclass(frozen=True)
class ClassSelector(Selector):
    """Selector that matches elements by class (including subclasses).

    Attributes:
        class_name: The class name to match (matches subclasses too).

    Example:
        >>> selector = ClassSelector("Shape")
        >>> selector.match(some_rectangle)  # True if Rectangle is a Shape subclass
    """

    class_name: str = dataclasses.field(
        metadata={"description": "The name of the class"}
    )

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if the object is an instance of the specified class.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements (unused for class matching).

        Returns:
            True if the object is an instance of class_name or its subclasses.
        """
        for cls in type(obj).__mro__:
            cls_name = cls.__name__
            if cls_name == self.class_name or cls_name == f"{self.class_name}Builder":
                return True
        return False


@dataclasses.dataclass(frozen=True)
class IdSelector(Selector):
    """Selector that matches elements by their id attribute.

    Attributes:
        id_: The identifier to match.

    Example:
        >>> selector = IdSelector("main_node")
        >>> selector.match(element)  # True if element.id_ == "main_node"
    """

    id_: str = dataclasses.field(metadata={"description": "The id"})

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if the object has the specified id.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements (unused for id matching).

        Returns:
            True if the object has an id_ attribute matching the selector.
        """
        return hasattr(obj, "id_") and obj.id_ == self.id_


@dataclasses.dataclass(frozen=True)
class ChildSelector(Selector):
    """Selector that matches elements that are direct children of a parent.

    Attributes:
        parent_selector: The selector for the parent element.
        child_selector: The selector for the child element.

    Example:
        >>> selector = ChildSelector(TypeSelector("Group"), TypeSelector("Rectangle"))
        >>> selector.match(rect, [group])  # True if rect is direct child of group
    """

    parent_selector: Selector = dataclasses.field(
        metadata={"description": "The parent selector"}
    )
    child_selector: Selector = dataclasses.field(
        metadata={"description": "The child selector"}
    )

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if the object is a direct child matching the criteria.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements.

        Returns:
            True if the object matches child_selector and its immediate
            parent matches parent_selector.
        """
        if not ancestors:
            return False
        return self.child_selector.select(
            obj, ancestors
        ) and self.parent_selector.select(ancestors[-1], ancestors[:-1])


@dataclasses.dataclass(frozen=True)
class DescendantSelector(Selector):
    """Selector that matches elements that are descendants of an ancestor.

    Attributes:
        ancestor_selector: The selector for any ancestor element.
        descendant_selector: The selector for the descendant element.

    Example:
        >>> selector = DescendantSelector(TypeSelector("Group"), TypeSelector("Text"))
        >>> selector.match(text, [subgroup, group])  # True if text is somewhere inside group
    """

    ancestor_selector: Selector = dataclasses.field(
        metadata={"description": "The ancestor selector"}
    )
    descendant_selector: Selector = dataclasses.field(
        metadata={"description": "The descendant selector"}
    )

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if the object is a descendant matching the criteria.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements.

        Returns:
            True if the object matches descendant_selector and any ancestor
            matches ancestor_selector.
        """
        if not ancestors:
            return False
        return self.descendant_selector.select(obj, ancestors) and any(
            [
                self.ancestor_selector.select(ancestor, ancestors[:i])
                for i, ancestor in enumerate(ancestors)
            ]
        )


@dataclasses.dataclass(frozen=True)
class OrSelector(Selector):
    """Selector that matches if any of its component selectors match (OR logic).

    Attributes:
        selectors: Tuple of selectors, any of which can match.

    Example:
        >>> selector = OrSelector((TypeSelector("Rectangle"), TypeSelector("Circle")))
        >>> selector.match(some_shape)  # True if shape is Rectangle OR Circle
    """

    selectors: tuple[Selector, ...] = dataclasses.field(
        metadata={"description": "The tuple of disjunct selectors"}
    )

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if any selector in the tuple matches.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements.

        Returns:
            True if any selector matches the object.
        """
        return any([selector.select(obj, ancestors) for selector in self.selectors])


@dataclasses.dataclass(frozen=True)
class CompoundSelector(Selector):
    """Selector that matches only if all of its component selectors match (AND logic).

    Attributes:
        selectors: Tuple of selectors, all of which must match.

    Example:
        >>> selector = CompoundSelector((TypeSelector("Rectangle"), ClassSelector("Colored")))
        >>> selector.match(element)  # True if element is Rectangle AND Colored
    """

    selectors: tuple[Selector, ...] = dataclasses.field(
        metadata={"description": "The tuple of conjunct selectors"}
    )

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if all selectors in the tuple match.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements.

        Returns:
            True if all selectors match the object.
        """
        return all([selector.select(obj, ancestors) for selector in self.selectors])


@dataclasses.dataclass(frozen=True)
class NotSelector(Selector):
    """Selector that matches if none of its component selectors match (NOT logic).

    Attributes:
        selectors: Tuple of selectors, none of which should match.

    Example:
        >>> selector = NotSelector((TypeSelector("Hidden"),))
        >>> selector.match(element)  # True if element is NOT of type Hidden
    """

    selectors: tuple[Selector, ...] = dataclasses.field(
        metadata={"description": "The tuple of negated conjunct selectors"}
    )

    def select(
        self,
        obj: momapy.core.LayoutElement | momapy.core.LayoutElementBuilder,
        ancestors: collections.abc.Collection[
            momapy.core.LayoutElement | momapy.core.LayoutElementBuilder
        ],
    ):
        """Check if none of the selectors in the tuple match.

        Args:
            obj: The layout element or builder to test.
            ancestors: List of ancestor elements.

        Returns:
            True if no selector matches the object.
        """
        return not any([selector.select(obj, ancestors) for selector in self.selectors])


_css_import_keyword = pyparsing.Literal("@import")
_css_unset_value = pyparsing.Literal("unset")
_css_none_value = pyparsing.Literal("none")
_css_float_value = pyparsing.Combine(
    pyparsing.Word(pyparsing.nums)
    + pyparsing.Literal(".")
    + pyparsing.Word(pyparsing.nums)
)
_css_string_value = pyparsing.quoted_string
_css_color_name_value = pyparsing.Word(pyparsing.alphas + "_")
_css_color_value = _css_color_name_value
_css_int_value = pyparsing.Word(pyparsing.nums)
_css_drop_shadow_filter_value = (
    pyparsing.Literal("drop-shadow(")
    + _css_float_value
    + pyparsing.Literal(",")
    + _css_float_value
    + pyparsing.Literal(",")
    + _css_float_value
    + pyparsing.Literal(",")
    + _css_float_value
    + pyparsing.Literal(",")
    + _css_color_value
    + pyparsing.Literal(")")
)
_css_filter_value = _css_drop_shadow_filter_value
_css_simple_value = (
    _css_drop_shadow_filter_value
    | _css_unset_value
    | _css_none_value
    | _css_float_value
    | _css_string_value
    | _css_color_value
    | _css_int_value
)
_css_list_value = pyparsing.Group(
    pyparsing.delimited_list(_css_simple_value, ",", min=2)
)
_css_attribute_value = _css_list_value | _css_simple_value
_css_attribute_name = pyparsing.Word(
    pyparsing.alphas + "_", pyparsing.alphanums + "_" + "-"
)


_css_import_statement = _css_import_keyword + _css_string_value + pyparsing.Literal(";")
_css_style = (
    _css_attribute_name
    + pyparsing.Literal(":")
    + _css_attribute_value
    + pyparsing.Literal(";")
)
_css_style_collection = (
    pyparsing.Literal("{")
    + pyparsing.Group(_css_style[1, ...])
    + pyparsing.Literal("}")
)
_css_id = pyparsing.Word(pyparsing.printables, exclude_chars=",")
_css_id_selector = pyparsing.Literal("#") + _css_id
_css_class_name = pyparsing.Word(pyparsing.alphas + "_", pyparsing.alphanums + "_")
_css_type_selector = _css_class_name.copy()
_css_class_selector = pyparsing.Literal(".") + _css_class_name
_css_elementary_selector = _css_class_selector | _css_type_selector | _css_id_selector
_css_child_selector = (
    _css_elementary_selector + pyparsing.Literal(">") + _css_elementary_selector
)
_css_descendant_selector = (
    _css_elementary_selector
    + pyparsing.OneOrMore(pyparsing.White())
    + _css_elementary_selector
)
_css_or_selector = pyparsing.Group(
    pyparsing.delimited_list(_css_elementary_selector, ",", min=2)
)
_css_selector = (
    _css_child_selector
    | _css_descendant_selector
    | _css_or_selector
    | _css_elementary_selector
)
_css_rule = _css_selector + _css_style_collection
_css_style_sheet = pyparsing.Group(_css_rule[1, ...])
_css_document = _css_import_statement[...] + _css_style_sheet[...]


@_css_unset_value.set_parse_action
def _resolve_css_unset_value(results):
    return results[0]


@_css_none_value.set_parse_action
def _resolve_css_none_value(results):
    return momapy.drawing.NoneValue


@_css_float_value.set_parse_action
def _resolve_css_float_value(results):
    return float(results[0])


@_css_string_value.set_parse_action
def _resolve_css_string_value(results):
    return str(results[0][1:-1])


@_css_int_value.set_parse_action
def _resolve_css_int_value(results):
    return int(results[0])


@_css_color_name_value.set_parse_action
def _resolve_css_color_name_value(results):
    if not momapy.coloring.has_color(results[0]):
        raise ValueError(f"{results[0]} is not a valid color name")
    return getattr(momapy.coloring, results[0])


@_css_drop_shadow_filter_value.set_parse_action
def _resolve_css_drop_shadow_filter_value(results):
    filter_effect = momapy.builder.get_or_make_builder_cls(
        momapy.drawing.DropShadowEffect
    )(
        dx=results[1],
        dy=results[3],
        std_deviation=results[5],
        flood_opacity=results[7],
        flood_color=results[9],
    )
    filter = momapy.builder.get_or_make_builder_cls(momapy.drawing.Filter)(
        effects=momapy.core.TupleBuilder([filter_effect])
    )
    return filter


# Issue: the function cannot return None (pyparsing bug?) otherwise it simply
# does not apply the function
@_css_simple_value.set_parse_action
def _resolve_css_simple_value(results):
    return results[0]


@_css_list_value.set_parse_action
def _resolve_css_list_value(results):
    return [momapy.core.TupleBuilder(results[0])]


@_css_attribute_value.set_parse_action
def _resolve_css_attribute_value(results):
    # see above
    if results[0] == "unset":
        results[0] = None
    return results


@_css_import_statement.set_parse_action
def _resolve_css_import_statement(results):
    file_path = results[1]
    style_sheet = StyleSheet.from_file(file_path)
    return style_sheet


@_css_attribute_name.set_parse_action
def _resolve_css_attribute_name(results):
    return results[0].replace("-", "_")


@_css_style.set_parse_action
def _resolve_css_style(results):
    return (
        results[0],
        results[2],
    )


@_css_style_collection.set_parse_action
def _resolve_css_style_collection(results):
    return StyleCollection(dict(list(results[1])))


@_css_id.set_parse_action
def _resolve_css_id(results):
    return results[0]


@_css_id_selector.set_parse_action
def _resolve_id_selector(results):
    return IdSelector(results[1])


@_css_class_name.set_parse_action
def _resolve_css_class_name(results):
    return results[0]


@_css_type_selector.set_parse_action
def _resolve_css_type_selector(results):
    return TypeSelector(results[0])


@_css_class_selector.set_parse_action
def _resolve_css_class_selector(results):
    return ClassSelector(results[1])


@_css_elementary_selector.set_parse_action
def _resolve_css_elementary_selector(results):
    return results[0]


@_css_child_selector.set_parse_action
def _resolve_css_child_selector(results):
    return ChildSelector(results[0], results[2])


@_css_descendant_selector.set_parse_action
def _resolve_css_descendant_selector(results):
    return DescendantSelector(results[0], results[2])


@_css_or_selector.set_parse_action
def _resolve_css_or_selector(results):
    return OrSelector(tuple(results[0]))


@_css_rule.set_parse_action
def _resolve_css_rule(results):
    return (
        results[0],
        results[1],
    )


@_css_style_sheet.set_parse_action
def _resolve_css_style_sheet(results):
    return StyleSheet(dict(list(results[0])))


@_css_document.set_parse_action
def _resolve_css_document(results):
    style_sheets = [style_sheet for style_sheet in results if style_sheet is not None]
    return combine_style_sheets(style_sheets)
