# Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
"""BagBuilderBase - Abstract base class for Bag builders with validation.

Provides domain-specific methods for creating nodes in a Bag with
validation support.

Exports:
    element: Decorator to mark methods as element handlers
    abstract: Decorator to define abstract elements (for inheritance only)
    component: Decorator to mark methods as component handlers (composite structures)
    BagBuilderBase: Abstract base class for all builders
    SchemaBuilder: Builder for creating schemas programmatically
    Regex: Regex pattern constraint for string validation
    Range: Range constraint for numeric validation

Decorators:
    @element: Pure schema elements. Body MUST be empty (...). No logic allowed.
    @abstract: Define sub_tags for inheritance. Cannot be instantiated directly.
    @component: Composite structures with required handler body. Body is called
        only at compile time (lazy expansion). Components can use a different
        builder internally.

Schema conventions:
    - Elements stored by name: 'div', 'span'
    - Abstracts prefixed with '@': '@flow', '@phrasing'
    - Use inherits_from='@abstract' to inherit sub_tags (only for @element)

Validation parameters:
    sub_tags: Controls which children are allowed (with cardinality).
    parent_tags: Controls where the element can be placed (comma-separated list).

sub_tags semantics:
    None     -> no validation (sub_tags not specified)
    ""       -> leaf element (no children allowed)
    "*"      -> accepts any children (container)
    "foo"    -> accepts only "foo" children

sub_tags cardinality syntax (when not "*"):
    foo      -> any number (0..N)
    foo[1]   -> exactly 1
    foo[3]   -> exactly 3
    foo[0:]  -> 0 or more
    foo[:2]  -> 0 to 2
    foo[1:3] -> 1 to 3

compile_* parameters:
    Both @element and @abstract support compile_* parameters for code generation.
    Parameters can be passed as:
    - compile_kwargs dict: compile_kwargs={'module': 'x', 'class': 'Y'}
    - Individual kwargs: compile_module='x', compile_class='Y'
    - Mixed: both approaches are merged (individual kwargs override dict)

    When using inherits_from, compile_kwargs are inherited from the abstract
    and merged with the element's own compile_kwargs (element overrides abstract).

Constraint classes for use with Annotated:
    Regex: regex pattern for strings
    Range: min/max value constraints for numbers (ge, le, gt, lt)

Type hints supported:
    - Basic types: int, str, bool, float, Decimal
    - Literal['a', 'b'] for enum-like constraints
    - list[T], dict[K, V], tuple[...], set[T] for generics
    - X | None for optional
    - Annotated[T, validator...] for validators

Example - @element:
    >>> from genro_builders import BuilderBag
    >>> from genro_builders.builders import BagBuilderBase, element

    >>> class MyBuilder(BagBuilderBase):
    ...     @element(sub_tags='item')
    ...     def container(self): ...
    ...
    ...     @element(parent_tags='container')  # can only be inside container
    ...     def item(self): ...

Example - @component (lazy expansion):
    >>> from genro_builders import BuilderBag
    >>> from genro_builders.builders import BagBuilderBase, element, component

    >>> class FormBuilder(BagBuilderBase):
    ...     @element()
    ...     def input(self): ...
    ...
    ...     @component(sub_tags='')  # closed component, returns parent
    ...     def login_form(self, component: Bag, **kwargs):
    ...         # Body called only at compile time, not at creation
    ...         component.input(name='username')
    ...         component.input(name='password')

SchemaBuilder Example:
    >>> from genro_builders import BuilderBag
    >>> from genro_builders.builders import SchemaBuilder
    >>>
    >>> schema = BuilderBag(builder=SchemaBuilder)
    >>> schema.item('@container', sub_tags='child', compile_module='textual.containers')
    >>> schema.item('vertical', inherits_from='@container', compile_class='Vertical')
    >>> schema.item('br', sub_tags='')  # void element
    >>> schema.builder._compile('schema.msgpack')
"""

from __future__ import annotations

import inspect
import re
import sys
import types
import warnings
from abc import ABC
from collections.abc import Callable
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Annotated,
    Any,
    Literal,
    get_args,
    get_origin,
    get_type_hints,
)

from genro_bag import Bag

if TYPE_CHECKING:
    from genro_bag import BagNode


# =============================================================================
# Empty body detection (internal, needed by decorators)
# =============================================================================


def _ref_empty_body(self): ...


def _ref_empty_body_with_docstring(self):
    """docstring"""
    ...


_EMPTY_BODY_BYTECODE = _ref_empty_body.__code__.co_code
_EMPTY_BODY_DOCSTRING_BYTECODE = _ref_empty_body_with_docstring.__code__.co_code


def _is_empty_body(func: Callable) -> bool:
    """Check if function body is empty (just ... or docstring + ...)."""
    code = func.__code__.co_code
    return code in (_EMPTY_BODY_BYTECODE, _EMPTY_BODY_DOCSTRING_BYTECODE)


# =============================================================================
# Decorators (Public API)
# =============================================================================


def element(
    tags: str | tuple[str, ...] | None = None,
    sub_tags: str | tuple[str, ...] | None = None,
    parent_tags: str | None = None,
    inherits_from: str | None = None,
    compile_kwargs: dict[str, Any] | None = None,
    **kwargs: Any,
) -> Callable:
    """Decorator to mark a method as element handler.

    Args:
        tags: Tag names this method handles. If None, uses method name.
        sub_tags: Valid child tags with cardinality. Syntax:
            'a,b,c'     -> a, b, c each exactly once
            'a[],b[]'   -> a and b any number of times
            'a[2],b[0:]' -> a exactly twice, b zero or more
            '' (empty)  -> no children allowed (void element)
        parent_tags: Valid parent tags (comma-separated). If specified,
            element can only be placed inside one of these parents.
        inherits_from: Abstract element name to inherit sub_tags from.
        compile_kwargs: Dict of compilation parameters (module, class, etc.).
        **kwargs: Additional compile_* parameters are extracted and merged
            into compile_kwargs. E.g., compile_module='x' -> {'module': 'x'}.

    Example:
        @element(sub_tags='header,content[],footer')
        def page(self): ...

        @element(
            sub_tags='child',
            compile_kwargs={'module': 'textual.containers'},
            compile_class='Vertical',  # merged into compile_kwargs
        )
        def container(self): ...

        @element(parent_tags='ul,ol')  # can only be inside ul or ol
        def li(self): ...
    """
    # Extract compile_* from kwargs and merge with compile_kwargs
    merged_compile = dict(compile_kwargs) if compile_kwargs else {}
    for key, value in kwargs.items():
        if key.startswith("compile_"):
            merged_compile[key[8:]] = value  # strip "compile_" prefix

    def decorator(func: Callable) -> Callable:
        # Elements MUST have empty body (ellipsis only)
        if not _is_empty_body(func):
            raise ValueError(
                f"@element '{func.__name__}' must have empty body (...) - "
                "use @component for elements with logic"
            )

        func._decorator = {  # type: ignore[attr-defined]
            k: v
            for k, v in {
                "tags": tags,
                "sub_tags": sub_tags,
                "parent_tags": parent_tags,
                "inherits_from": inherits_from,
                "compile_kwargs": merged_compile if merged_compile else None,
            }.items()
            if v is not None
        }
        return func

    return decorator


def abstract(
    sub_tags: str | tuple[str, ...] = "",
    parent_tags: str | tuple[str, ...] | None = None,
    inherits_from: str | None = None,
    compile_kwargs: dict[str, Any] | None = None,
    **kwargs: Any,
) -> Callable:
    """Decorator to define an abstract element (for inheritance only).

    Abstract elements are stored with '@' prefix and cannot be instantiated.
    They define sub_tags/parent_tags that can be inherited by concrete elements.

    Args:
        sub_tags: Valid child tags with cardinality (see element decorator).
        parent_tags: Valid parent tags with cardinality.
        inherits_from: Comma-separated list of abstract names to inherit from.
        compile_kwargs: Dict of compilation parameters (module, class, etc.).
        **kwargs: Additional compile_* parameters are extracted and merged
            into compile_kwargs. E.g., compile_module='x' -> {'module': 'x'}.

    Example:
        @abstract(sub_tags='span,a,em,strong')
        def phrasing(self): ...

        @element(inherits_from='@phrasing')
        def p(self): ...

        @abstract(
            sub_tags='child',
            compile_module='textual.containers',
        )
        def base_container(self): ...
    """
    # Extract compile_* from kwargs and merge with compile_kwargs
    merged_compile = dict(compile_kwargs) if compile_kwargs else {}
    for key, value in kwargs.items():
        if key.startswith("compile_"):
            merged_compile[key[8:]] = value  # strip "compile_" prefix

    def decorator(func: Callable) -> Callable:
        result: dict[str, Any] = {
            "abstract": True,
            "sub_tags": sub_tags,
            "inherits_from": inherits_from or "",
        }
        if parent_tags is not None:
            result["parent_tags"] = parent_tags
        if merged_compile:
            result["compile_kwargs"] = merged_compile
        func._decorator = result  # type: ignore[attr-defined]
        return func

    return decorator


def component(
    tags: str | tuple[str, ...] | None = None,
    sub_tags: str | tuple[str, ...] | None = None,
    parent_tags: str | None = None,
    builder: type[BagBuilderBase] | None = None,
    based_on: str | None = None,
    compile_kwargs: dict[str, Any] | None = None,
    **kwargs: Any,
) -> Callable:
    """Decorator to mark a method as component handler.

    Components are composite structures that receive a new Bag, populate it,
    and return it. The populated bag becomes the node's value.

    Unlike @element, @component REQUIRES a method body (ellipsis not allowed).
    The handler receives a fresh Bag as first parameter (after self) and
    should populate it with child elements.

    Args:
        tags: Tag names this component handles. If None, uses method name.
        sub_tags: Valid child tags AFTER the component is created. Controls
            return behavior of the component call:
            - '' (empty string): Closed/leaf component, returns parent bag
              (for chaining at same level)
            - defined or None: Open container, returns internal bag
              (for adding children to the component)
        parent_tags: Valid parent tags (comma-separated). If specified,
            component can only be placed inside one of these parents.
        builder: Optional builder class for the component's internal bag.
            If not specified, uses the same builder class as parent.
        compile_kwargs: Dict of compilation parameters (module, class, etc.).
        **kwargs: Additional compile_* parameters are extracted and merged
            into compile_kwargs.

    Handler signature:
        def handler(self, component: Bag, **kwargs) -> None:
            # 'component' is the component's internal Bag to populate
            # Body is called ONLY at compile time (lazy expansion)

    Example - Closed component (sub_tags=''):
        @component(sub_tags='')
        def login_form(self, component: Bag, **kwargs):
            # This body is NOT called when login_form() is invoked
            # It's called only during compile to expand the component
            component.input(name='username')
            component.input(name='password')
            component.button('Login')

        # Usage: returns parent for chaining (node registered but not expanded)
        page.login_form()
        page.other_element()  # continues at same level

    Example - Open component (sub_tags defined):
        @component(sub_tags='item')
        def mylist(self, component: Bag, title='', **kwargs):
            # Body called at compile time
            component.header(title=title)

        # Usage: returns internal bag for adding children
        lst = page.mylist(title='My List')
        lst.item('First')  # Added to component's internal bag
        lst.item('Second')
    """
    # Extract compile_* from kwargs and merge with compile_kwargs
    merged_compile = dict(compile_kwargs) if compile_kwargs else {}
    for key, value in kwargs.items():
        if key.startswith("compile_"):
            merged_compile[key[8:]] = value

    def decorator(func: Callable) -> Callable:
        # Components MUST have a real body (not ellipsis)
        if _is_empty_body(func):
            raise ValueError(
                f"@component '{func.__name__}' must have a body - ellipsis (...) not allowed"
            )

        func._decorator = {  # type: ignore[attr-defined]
            k: v
            for k, v in {
                "component": True,
                "tags": tags,
                "sub_tags": sub_tags,
                "parent_tags": parent_tags,
                "builder": builder,
                "based_on": based_on,
                "compile_kwargs": merged_compile if merged_compile else None,
            }.items()
            if v is not None
        }
        return func

    return decorator


# =============================================================================
# Validator classes (Annotated metadata)
# =============================================================================


@dataclass(frozen=True)
class Regex:
    """Regex pattern constraint for string validation."""

    pattern: str
    flags: int = 0

    def __call__(self, value: Any) -> None:
        if not isinstance(value, str):
            raise TypeError("Regex validator requires a str")
        if re.fullmatch(self.pattern, value, self.flags) is None:
            raise ValueError(f"must match pattern '{self.pattern}'")


@dataclass(frozen=True)
class Range:
    """Range constraint for numeric validation (Pydantic-style: ge, le, gt, lt)."""

    ge: float | None = None
    le: float | None = None
    gt: float | None = None
    lt: float | None = None

    def __call__(self, value: Any) -> None:
        if not isinstance(value, (int, float, Decimal)):
            raise TypeError("Range validator requires int, float or Decimal")
        if self.ge is not None and value < self.ge:
            raise ValueError(f"must be >= {self.ge}")
        if self.le is not None and value > self.le:
            raise ValueError(f"must be <= {self.le}")
        if self.gt is not None and value <= self.gt:
            raise ValueError(f"must be > {self.gt}")
        if self.lt is not None and value >= self.lt:
            raise ValueError(f"must be < {self.lt}")


# =============================================================================
# BagBuilderBase
# =============================================================================


class BagBuilderBase(ABC):
    """Abstract base class for Bag builders.

    A builder provides domain-specific methods for creating nodes in a Bag.
    Define elements using decorators:
        - @element: Pure schema elements (body MUST be empty)
        - @abstract: Define sub_tags for inheritance (cannot be instantiated)
        - @component: Composite structures (body called at compile time only)

    Schema conventions:
        - Elements: stored directly by name (e.g., 'div', 'span')
        - Abstracts: prefixed with '@' (e.g., '@flow', '@phrasing')
        - Components: stored by name, marked with is_component=True

    Validation parameters:
        - sub_tags: Controls which children are allowed under this element
        - parent_tags: Controls where this element can be placed

    Schema loading priority:
        1. schema_path passed to constructor (builder_schema_path='...')
        2. schema_path class attribute
        3. @element and @component decorated methods

    Usage:
        >>> bag = Bag(builder=MyBuilder)
        >>> bag.div()  # looks up 'div' in schema, calls handler
        >>> # With custom schema:
        >>> bag = Bag(builder=MyBuilder, builder_schema_path='custom.bag.mp')
    """

    _class_schema: Bag  # Schema built from decorators at class definition
    _schema_path: str | Path | None = None  # Default schema path (class attribute)
    _compiler_class: type | None = None  # Default compiler class for this builder

    # -------------------------------------------------------------------------
    # Initialization
    # -------------------------------------------------------------------------

    def __init_subclass__(cls, **kwargs: Any) -> None:
        """Build _class_schema Bag from @element and @component decorated methods."""
        super().__init_subclass__(**kwargs)

        schema_path = getattr(cls, "_schema_path", None)
        if schema_path is not None:
            cls._class_schema = Bag().fill_from(schema_path)
        else:
            # Inherit parent schema via MRO, then extend with own elements
            parent_schema = None
            for base in cls.__mro__[1:]:
                if hasattr(base, "_class_schema"):
                    parent_schema = base._class_schema
                    break
            cls._class_schema = Bag(source=parent_schema) if parent_schema else Bag()

        for tag_list, method_name, obj, decorator_info in _pop_decorated_methods(cls):
            if method_name:
                setattr(cls, method_name, obj)

            is_component = decorator_info.get("component", False)
            # For components: distinguish between sub_tags="" (closed) and absent (open)
            # Use sentinel to detect if sub_tags was explicitly set
            sub_tags = decorator_info.get("sub_tags") if is_component else decorator_info.get("sub_tags", "")
            parent_tags = decorator_info.get("parent_tags")
            inherits_from = decorator_info.get("inherits_from", "")
            compile_kwargs = decorator_info.get("compile_kwargs")
            component_builder = decorator_info.get("builder")
            based_on = decorator_info.get("based_on")
            documentation = obj.__doc__
            call_args_validations = _extract_validators_from_signature(obj)

            for tag in tag_list:
                if is_component:
                    cls._class_schema.set_item(
                        tag,
                        None,
                        handler_name=method_name,
                        is_component=True,
                        component_builder=component_builder,
                        based_on=based_on,
                        sub_tags=sub_tags,
                        parent_tags=parent_tags,
                        compile_kwargs=compile_kwargs,
                        documentation=documentation,
                        call_args_validations=call_args_validations,
                    )
                else:
                    # Element: no adapter_name (body must be empty)
                    cls._class_schema.set_item(
                        tag,
                        None,
                        sub_tags=sub_tags,
                        parent_tags=parent_tags,
                        inherits_from=inherits_from,
                        compile_kwargs=compile_kwargs,
                        documentation=documentation,
                        call_args_validations=call_args_validations,
                    )

        # Check for name collisions with Bag and BagBuilderBase methods
        _rename_colliding_schema_tags(cls, cls._class_schema)

    def __init__(self, bag: Bag, schema_path: str | Path | None = None) -> None:
        """Bind builder to bag. Enables node.parent navigation.

        Args:
            bag: The Bag instance this builder is attached to.
            schema_path: Optional path to load schema from. If not provided,
                uses the class-level schema (_class_schema).
        """
        self._bag = bag
        self._bag.set_backref()

        if schema_path is not None:
            self._schema = Bag().fill_from(schema_path)
        else:
            self._schema = type(self)._class_schema

    # -------------------------------------------------------------------------
    # Bag delegation
    # -------------------------------------------------------------------------

    def _bag_call(self, bag: Bag, name: str) -> Any:
        """Handle attribute access from a Bag.

        Called by Bag.__getattr__ to resolve attribute requests. Decides whether
        the name is a schema element (returns wrapped callable) or a builder
        attribute (returns directly).

        Args:
            bag: The Bag requesting the attribute.
            name: The attribute name requested.

        Returns:
            - For schema elements: a callable that creates elements in the bag
            - For builder methods/properties: the attribute value directly

        Raises:
            AttributeError: If name is not in schema and not a builder attribute.
        """
        # Check if it's in the schema
        if name in self._schema:
            handler = self.__getattr__(name)
            return lambda node_value=None, node_label=None, node_position=None, **attr: handler(
                bag,
                _tag=name,
                node_value=node_value,
                node_label=node_label,
                node_position=node_position,
                **attr,
            )

        # Check if it's a builder attribute (method, property, etc.)
        # Use object.__getattribute__ to avoid recursion
        try:
            return object.__getattribute__(self, name)
        except AttributeError:
            pass

        raise AttributeError(f"'{type(self).__name__}' has no element or attribute '{name}'")

    # -------------------------------------------------------------------------
    # Element dispatch
    # -------------------------------------------------------------------------

    def __getattr__(self, name: str) -> Any:
        """Look up tag in _schema and return handler with validation."""
        if name.startswith("_"):
            raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")

        def wrapper(destination_bag: Bag, *args: Any, node_tag: str = name, **kwargs: Any) -> Any:
            try:
                info = self._get_schema_info(node_tag)
            except KeyError as err:
                raise AttributeError(f"'{type(self).__name__}' has no element '{node_tag}'") from err

            # Validazione sui kwargs originali, PRIMA del method
            node_value = args[0] if args else kwargs.get("node_value")
            self._validate_call_args(info, node_value, kwargs)

            # Check if this is a component
            if info.get("is_component"):
                return self._handle_component(destination_bag, info, node_tag, kwargs)

            # Element: no adapter, registra direttamente con kwargs originali
            kwargs.pop("node_value", None)
            return self._add_element(destination_bag, node_value, node_tag=node_tag, **kwargs)

        return wrapper

    def _handle_component(
        self,
        destination_bag: Bag,
        info: dict,
        node_tag: str,
        kwargs: dict,
    ) -> Bag:
        """Handle component invocation - lazy registration with resolver.

        Registers the component node with a ComponentResolver. The handler
        body is NOT called here — it will be called lazily when the node
        is accessed with static=False (during expand or compile).

        Args:
            destination_bag: The parent Bag where component will be added.
            info: Schema info for the component.
            node_tag: The tag name for the component.
            kwargs: Arguments passed to the component (stored as attributes).

        Returns:
            destination_bag for chaining at the same level.
        """
        from .component_resolver import ComponentResolver

        # Extract internal kwargs that need special handling
        kwargs.pop("node_value", None)
        node_label = kwargs.pop("node_label", None)
        node_position = kwargs.pop("node_position", None)

        # Register node with kwargs as attributes, no expansion
        node = self._add_element(
            destination_bag,
            node_value=None,
            node_tag=node_tag,
            node_label=node_label,
            node_position=node_position,
            **kwargs,
        )

        # Attach resolver for lazy expansion
        handler_name = info.get("handler_name")
        handler = getattr(self, handler_name) if handler_name else None
        builder_class = info.get("component_builder") or type(self)
        based_on = info.get("based_on")

        resolver = ComponentResolver(
            handler=handler,
            builder_class=builder_class,
            based_on=based_on,
            builder=self,
        )
        node.resolver = resolver

        return destination_bag

    def _add_element(
        self,
        build_where: Bag,
        node_value: Any = None,
        node_label: str | None = None,
        node_tag: str = "",
        **attr: Any,
    ) -> BagNode:
        """Add an element node to the bag.

        Called by the wrapper after optional adapter transformation.

        Args:
            build_where: The destination Bag where the node will be created.
            node_value: Node content (positional). Becomes node.value.
            node_label: Optional explicit label for the node.
            node_tag: The tag name for the element.
            **attr: Node attributes.
        """
        return self._child(build_where, node_tag, node_value, node_label=node_label, **attr)

    def _child(
        self,
        build_where: Bag,
        node_tag: str,
        node_value: Any = None,
        node_label: str | None = None,
        node_position: str | int | None = None,
        **attr: Any,
    ) -> BagNode:
        """Create a child node in the target Bag with validation.

        Raises ValueError if validation fails, KeyError if parent tag not in schema.
        """
        parent_node = build_where._parent_node
        if parent_node and parent_node.node_tag:
            parent_info = self._get_schema_info(parent_node.node_tag)
            self._accept_child(parent_node, parent_info, node_tag, node_position)

        child_info = self._get_schema_info(node_tag)
        self._validate_parent_tags(child_info, parent_node)

        node_label = node_label or self._auto_label(build_where, node_tag)
        child_node = build_where.set_item(
            node_label, node_value, _attributes=dict(attr),
            node_position=node_position, node_tag=node_tag,
        )

        if parent_node and parent_node.node_tag:
            self._validate_sub_tags(parent_node, parent_info)

        self._validate_sub_tags(child_node, child_info)

        return child_node

    def _auto_label(self, build_where: Bag, node_tag: str) -> str:
        """Generate unique label for a node: tag_0, tag_1, ..."""
        n = 0
        while f"{node_tag}_{n}" in build_where._nodes:
            n += 1
        return f"{node_tag}_{n}"

    def _validate_call_args(
        self,
        info: dict,
        node_value: Any,
        attr: dict[str, Any],
    ) -> None:
        """Validate attributes and node_value. Raises ValueError if invalid."""
        call_args_validations = info.get("call_args_validations")
        if not call_args_validations:
            return

        errors: list[str] = []
        all_args = dict(attr)
        if node_value is not None:
            all_args["node_value"] = node_value

        for attr_name, (base_type, validators, default) in call_args_validations.items():
            attr_value = all_args.get(attr_name)

            # Required check
            if default is inspect.Parameter.empty and attr_value is None:
                errors.append(f"required attribute '{attr_name}' is missing")
                continue

            if attr_value is None:
                continue

            # Type check
            if not _check_type(attr_value, base_type):
                errors.append(
                    f"'{attr_name}': expected {base_type}, got {type(attr_value).__name__}"
                )
                continue

            # Validator checks (Regex, Range, etc.)
            for v in validators:
                try:
                    v(attr_value)
                except Exception as e:
                    errors.append(f"'{attr_name}': {e}")

        if errors:
            raise ValueError("Validation failed: " + "; ".join(errors))

    def _validate_children_tags(
        self,
        node_tag: str,
        sub_tags_compiled: dict[str, tuple[int, int]] | str,
        children_tags: list[str],
    ) -> list[str]:
        """Validate a list of child tags against sub_tags spec.

        Args:
            node_tag: Tag of parent node (for error messages)
            sub_tags_compiled: Compiled sub_tags: "*" for any, or dict {tag: (min, max)}
            children_tags: List of child tags to validate

        Returns:
            List of invalid_reasons (missing required tags)

        Raises:
            ValueError: if tag not allowed or max exceeded
        """
        # Wildcard "*" accepts any children - no validation needed
        if sub_tags_compiled == "*":
            return []

        bounds = {tag: list(minmax) for tag, minmax in sub_tags_compiled.items()}
        for tag in children_tags:
            minmax = bounds.get(tag)
            if minmax is None:
                raise ValueError(f"'{tag}' not allowed as child of '{node_tag}'")
            minmax[1] -= 1
            if minmax[1] < 0:
                raise ValueError(f"Too many '{tag}' in '{node_tag}'")
            minmax[0] -= 1

        # Warnings for missing required elements (min > 0 after decrement)
        return [tag for tag, (n_min, _) in bounds.items() if n_min > 0]

    def _validate_sub_tags(self, node: BagNode, info: dict) -> None:
        """Validate sub_tags constraints on node's existing children.

        Gets children_tags from node's actual children, calls _validate_children_tags,
        and sets node._invalid_reasons.

        Args:
            node: The node to validate.
            info: Schema info dict from get_schema_info().
        """
        node_tag = node.node_tag
        if not node_tag:
            node._invalid_reasons = []
            return

        sub_tags_compiled = info.get("sub_tags_compiled")
        if sub_tags_compiled is None:
            node._invalid_reasons = []
            return

        # Wildcard "*" accepts any children - no validation needed
        if sub_tags_compiled == "*":
            node._invalid_reasons = []
            return

        children_tags = [n.node_tag for n in node.value.nodes] if isinstance(node.value, Bag) else []

        node._invalid_reasons = self._validate_children_tags(
            node_tag, sub_tags_compiled, children_tags
        )

    def _accept_child(
        self,
        target_node: BagNode,
        info: dict,
        child_tag: str,
        node_position: str | int | None,
    ) -> None:
        """Check if target_node can accept child_tag at node_position.

        Builds children_tags = current tags + new tag, calls _validate_children_tags.
        Raises ValueError if not valid.
        """
        sub_tags_compiled = info.get("sub_tags_compiled")
        if sub_tags_compiled is None:
            return

        # Wildcard "*" accepts any children - no validation needed
        if sub_tags_compiled == "*":
            return

        # Build children_tags = current + new
        children_tags = (
            [n.node_tag for n in target_node.value.nodes] if isinstance(target_node.value, Bag) else []
        )

        # Insert new tag at correct position
        idx = (
            target_node.value._nodes._parse_position(node_position)
            if isinstance(target_node.value, Bag)
            else 0
        )
        children_tags.insert(idx, child_tag)

        self._validate_children_tags(target_node.node_tag, sub_tags_compiled, children_tags)

    def _validate_parent_tags(
        self,
        child_info: dict,
        parent_node: BagNode | None,
    ) -> None:
        """Validate that child can be placed in parent based on parent_tags.

        Args:
            child_info: Schema info for the child element.
            parent_node: The parent node (None if adding to root).

        Raises:
            ValueError: If parent_tags is specified and parent is not in the list.
        """
        parent_tags_compiled = child_info.get("parent_tags_compiled")
        if parent_tags_compiled is None:
            return

        parent_tag = parent_node.node_tag if parent_node else None
        if parent_tag not in parent_tags_compiled:
            valid_parents = ", ".join(sorted(parent_tags_compiled))
            raise ValueError(
                f"Element cannot be placed here: parent_tags requires one of [{valid_parents}], "
                f"but parent is '{parent_tag or 'root'}'"
            )

    def _command_on_node(
        self,
        node: BagNode,
        child_tag: str,
        node_position: str | int | None = None,
        node_value: Any = None,
        **attrs: Any,
    ) -> Any:
        """Add a child to a node.

        Uses _bag_call for schema elements (handles components and tag renames).
        Falls back to self._child() for unknown tags (provides validation errors).
        """
        from .builder_bag import BuilderBag

        if not isinstance(node.value, Bag):
            node.value = BuilderBag()
            node.value.builder = self

        if child_tag in self._schema:
            callable_handler = self._bag_call(node.value, child_tag)
            return callable_handler(
                node_value=node_value,
                node_position=node_position,
                **attrs,
            )

        # Tag not in schema: use _child() which will validate and raise
        return self._child(
            node.value,
            child_tag,
            node_value=node_value,
            node_position=node_position,
            **attrs,
        )

    # -------------------------------------------------------------------------
    # Schema access
    # -------------------------------------------------------------------------

    def __contains__(self, name: str) -> bool:
        """Check if element exists in schema."""
        return self._schema.get_node(name) is not None

    def _get_schema_info(self, name: str) -> dict:
        """Return info dict for an element.

        Returns dict with keys:
            - adapter_name: str | None
            - sub_tags: str | None
            - sub_tags_compiled: dict[str, tuple[int, int]] | None
            - call_args_validations: dict | None

        Raises KeyError if element not in schema.
        """
        schema_node = self._schema.get_node(name)
        if schema_node is None:
            raise KeyError(f"Element '{name}' not found in schema")

        cached = schema_node.attr.get("_cached_info")  # type: ignore[union-attr]
        if cached is not None:
            return cached  # type: ignore[no-any-return]

        result = dict(schema_node.attr)  # type: ignore[union-attr]
        inherits_from = result.pop("inherits_from", None)

        if inherits_from:
            # Support multiple inheritance: "alfa,beta" -> ["alfa", "beta"]
            # Parents are processed left-to-right, later parents override earlier ones
            parents = [p.strip() for p in inherits_from.split(",")]
            for parent in parents:
                abstract_attrs = self._schema.get_attr(parent)
                if abstract_attrs:
                    for k, v in abstract_attrs.items():
                        # Skip inherits_from from abstract - don't propagate it
                        if k == "inherits_from":
                            continue
                        if k == "compile_kwargs":
                            # Merge compile_kwargs: abstract base + element overrides
                            inherited = v or {}
                            current = result.get("compile_kwargs") or {}
                            result["compile_kwargs"] = {**inherited, **current}
                        elif k not in result or not result[k]:
                            result[k] = v

        sub_tags = result.get("sub_tags")
        if sub_tags is not None:
            result["sub_tags_compiled"] = _parse_sub_tags_spec(sub_tags)

        parent_tags = result.get("parent_tags")
        if parent_tags is not None:
            result["parent_tags_compiled"] = _parse_parent_tags_spec(parent_tags)

        schema_node.attr["_cached_info"] = result  # type: ignore[union-attr]
        return result

    def __iter__(self):
        """Iterate over schema nodes."""
        return iter(self._schema)

    def __repr__(self) -> str:
        """Show builder schema summary."""
        count = sum(1 for _ in self)
        return f"<{type(self).__name__} ({count} elements)>"

    def __str__(self) -> str:
        """Show schema structure."""
        return str(self._schema)

    # -------------------------------------------------------------------------
    # Validation check
    # -------------------------------------------------------------------------

    def _check(self, bag: Bag | None = None) -> list[tuple[str, BagNode, list[str]]]:
        """Return report of invalid nodes."""
        if bag is None:
            bag = self._bag
        invalid_nodes: list[tuple[str, BagNode, list[str]]] = []
        self._walk_check(bag, "", invalid_nodes)
        return invalid_nodes

    def _walk_check(
        self,
        bag: Bag,
        path: str,
        invalid_nodes: list[tuple[str, BagNode, list[str]]],
    ) -> None:
        """Walk tree collecting invalid nodes."""
        for node in bag:
            node_path = f"{path}.{node.label}" if path else node.label

            if node._invalid_reasons:
                invalid_nodes.append((node_path, node, node._invalid_reasons.copy()))

            node_value = node.get_value(static=True)
            if isinstance(node_value, Bag):
                self._walk_check(node_value, node_path, invalid_nodes)

    # -------------------------------------------------------------------------
    # Compiler access
    # -------------------------------------------------------------------------

    @property
    def _compiler(self) -> Any:
        """Return compiler instance for this builder.

        Requires _compiler_class to be defined on the builder subclass.

        Raises:
            ValueError: If _compiler_class is not defined.
        """
        if self._compiler_class is None:
            raise ValueError(f"{type(self).__name__} has no _compiler_class defined")
        return self._compiler_class(self)

    def _compile(self, **kwargs: Any) -> Any:
        """Compile the bag via the compiler, then render to output string.

        If _compiler_class is defined, compiles source into a target bag
        and then renders it using the compiler's render method.

        Without _compiler_class, falls back to XML/JSON serialization (string).

        Args:
            **kwargs: Extra parameters. 'destination' writes output to file.
                'format' selects legacy format ('xml' or 'json').

        Returns:
            Rendered output string.
        """
        if self._compiler_class is not None:
            from .binding import BindingManager
            from .builder_bag import BuilderBag

            compiler = self._compiler
            target = BuilderBag(builder=type(self))
            data = kwargs.pop("data", None) or Bag()
            binding = kwargs.pop("binding", None) or BindingManager()
            compiler.compile(self._bag, target, data, binding)

            destination = kwargs.get("destination")
            if hasattr(compiler, "render"):
                result = compiler.render(target)
            else:
                parts = list(compiler._walk_compile(target))
                result = "\n".join(p for p in parts if p)

            if destination is not None:
                from pathlib import Path
                Path(destination).write_text(result)

            return result
        format_ = kwargs.get("format", "xml")
        if format_ == "xml":
            return self._bag.to_xml()
        elif format_ == "json":
            return self._bag.to_tytx(transport="json")  # type: ignore[return-value]
        else:
            raise ValueError(f"Unknown format: {format_}")

    # -------------------------------------------------------------------------
    # Schema documentation
    # -------------------------------------------------------------------------

    def _schema_to_md(self, title: str | None = None) -> str:
        """Generate Markdown documentation for the builder schema.

        Creates a formatted Markdown document with tables for abstract
        and concrete elements, including all schema information.

        Args:
            title: Optional title for the document. Defaults to class name.

        Returns:
            Markdown string with schema documentation.
        """
        from .builder_bag import BuilderBag
        from .builders.markdown import MarkdownBuilder

        doc = BuilderBag(builder=MarkdownBuilder)
        builder_name = title or type(self).__name__

        doc.h1(f"Schema: {builder_name}")

        # Collect abstracts and elements
        abstracts: list[tuple[str, dict]] = []
        elements: list[tuple[str, dict]] = []

        for node in self._schema:
            name = node.label
            info = self._get_schema_info(name)
            if name.startswith("@"):
                abstracts.append((name[1:], info))
            else:
                elements.append((name, info))

        # Abstract elements section
        if abstracts:
            doc.h2("Abstract Elements")
            table = doc.table()
            header = table.tr()
            header.th("Name")
            header.th("Sub Tags")
            header.th("Documentation")

            for name, info in sorted(abstracts):
                row = table.tr()
                row.td(f"`@{name}`")
                row.td(f"`{info.get('sub_tags') or '-'}`")
                row.td(info.get("documentation") or "-")

        # Concrete elements section
        if elements:
            doc.h2("Elements")
            table = doc.table()
            header = table.tr()
            header.th("Name")
            header.th("Inherits")
            header.th("Sub Tags")
            header.th("Call Args")
            header.th("Compile")
            header.th("Documentation")

            for name, info in sorted(elements):
                row = table.tr()
                row.td(f"`{name}`")

                inherits = info.get("inherits_from")
                row.td(f"`{inherits}`" if inherits else "-")

                sub_tags = info.get("sub_tags")
                row.td(f"`{sub_tags}`" if sub_tags else "-")

                call_args = info.get("call_args_validations")
                if call_args:
                    args_str = ", ".join(call_args.keys())
                    row.td(f"`{args_str}`")
                else:
                    row.td("-")

                compile_kwargs = info.get("compile_kwargs") or {}
                compile_parts = []
                if "template" in compile_kwargs:
                    # Escape backticks in template for markdown display
                    tmpl = compile_kwargs["template"].replace("`", "\\`")
                    tmpl = tmpl.replace("\n", "\\n")
                    compile_parts.append(f"template: {tmpl}")
                if "callback" in compile_kwargs:
                    compile_parts.append(f"callback: {compile_kwargs['callback']}")
                # Other compile_kwargs (module, class, etc.)
                for k, v in compile_kwargs.items():
                    if k not in ("template", "callback"):
                        compile_parts.append(f"{k}: {v}")
                if compile_parts:
                    row.td("`" + ", ".join(compile_parts) + "`")
                else:
                    row.td("-")

                row.td(info.get("documentation") or "-")

        return doc.builder._compile()

    # -------------------------------------------------------------------------
    # Value rendering (for compile)
    # -------------------------------------------------------------------------

    def _render_value(self, node: BagNode) -> str:
        """Render node value applying format and template transformations.

        Applies transformations in order:
        1. value_format (node attr) - format the raw value
        2. value_template (node attr) - apply runtime template
        3. compile_callback (schema) - call method to modify context in place
        4. compile_format (schema) - format from decorator
        5. compile_template (schema) - structural template from decorator

        Template placeholders available:
        - {node_value}: the node value
        - {node_label}: the node label
        - {attr_name}: any node attribute (e.g., {lang}, {href})

        Args:
            node: The BagNode to render.

        Returns:
            Rendered string value.
        """
        node_value = node.get_value(static=True)
        node_value = "" if node_value is None else str(node_value)

        # Build template context: node_value, node_label, and all attributes
        # Start with default values from schema for optional parameters
        tag = node.node_tag or node.label
        info = self._get_schema_info(tag)
        call_args = info.get("call_args_validations") or {}
        template_ctx: dict[str, Any] = {}
        for param_name, (default, _validators, _type) in call_args.items():
            if default is not None:
                template_ctx[param_name] = default
        # Override with actual node attributes
        template_ctx.update(node.attr)
        template_ctx["node_value"] = node_value
        template_ctx["node_label"] = node.label
        template_ctx["_node"] = node  # For callbacks needing full node access

        # 1. value_format from node attr (runtime)
        value_format = node.attr.get("value_format")
        if value_format:
            try:
                node_value = value_format.format(node_value)
                template_ctx["node_value"] = node_value
            except (ValueError, KeyError):
                pass

        # 2. value_template from node attr (runtime)
        value_template = node.attr.get("value_template")
        if value_template:
            node_value = value_template.format(**template_ctx)
            template_ctx["node_value"] = node_value

        # 3-5. compile_callback, compile_format and compile_template from schema
        compile_kwargs = info.get("compile_kwargs") or {}

        # 3. compile_callback - call method to modify context in place
        compile_callback = compile_kwargs.get("callback")
        if compile_callback:
            method = getattr(self, compile_callback)
            method(template_ctx)
            node_value = template_ctx["node_value"]

        # 4. compile_format from schema
        compile_format = compile_kwargs.get("format")
        if compile_format:
            try:
                node_value = compile_format.format(node_value)
                template_ctx["node_value"] = node_value
            except (ValueError, KeyError):
                pass

        # 5. compile_template from schema
        compile_template = compile_kwargs.get("template")
        if compile_template:
            node_value = compile_template.format(**template_ctx)

        return node_value

    # -------------------------------------------------------------------------
    # Call args validation (internal)
    # -------------------------------------------------------------------------

    def _get_call_args_validations(self, tag: str) -> dict[str, tuple[Any, list, Any]] | None:
        """Return attribute spec for a tag from schema."""
        schema_node = self._schema.node(tag)
        if schema_node is None:
            return None
        return schema_node.attr.get("call_args_validations")


# =============================================================================
# Type hint parsing utilities (internal)
# =============================================================================


def _split_annotated(tp: Any) -> tuple[Any, list]:
    """Split Annotated type into base type and validators.

    Handles Optional[Annotated[T, ...]] which appears as Union[Annotated[T, ...], None].
    """
    if get_origin(tp) is Annotated:
        base, *meta = get_args(tp)
        validators = [m for m in meta if callable(m)]
        return base, validators

    # Handle Optional[Annotated[...]] -> Union[Annotated[...], None]
    from typing import Union

    if get_origin(tp) is Union:
        args = get_args(tp)
        # Check if it's Optional (Union with NoneType)
        non_none_args = [a for a in args if a is not type(None)]
        if len(non_none_args) == 1:
            inner = non_none_args[0]
            if get_origin(inner) is Annotated:
                base, *meta = get_args(inner)
                validators = [m for m in meta if callable(m)]
                return base, validators

    return tp, []


def _check_type(value: Any, tp: Any) -> bool:
    """Check if value matches the type annotation."""
    tp, _ = _split_annotated(tp)

    origin = get_origin(tp)
    args = get_args(tp)

    if tp is Any:
        return True

    if tp is type(None):
        return value is None

    if origin is Literal:
        return value in args

    if origin is types.UnionType:
        return any(_check_type(value, t) for t in args)

    try:
        from typing import Union

        if origin is Union:
            return any(_check_type(value, t) for t in args)
    except ImportError:
        pass

    if origin is None:
        try:
            return isinstance(value, tp)
        except TypeError:
            return True

    if origin is list:
        if not isinstance(value, list):
            return False
        if not args:
            return True
        t_item = args[0]
        return all(_check_type(v, t_item) for v in value)

    if origin is dict:
        if not isinstance(value, dict):
            return False
        if not args:
            return True
        k_t, v_t = args[0], args[1] if len(args) > 1 else Any
        return all(_check_type(k, k_t) and _check_type(v, v_t) for k, v in value.items())

    if origin is tuple:
        if not isinstance(value, tuple):
            return False
        if not args:
            return True
        if len(args) == 2 and args[1] is Ellipsis:
            return all(_check_type(v, args[0]) for v in value)
        return len(value) == len(args) and all(
            _check_type(v, t) for v, t in zip(value, args, strict=True)
        )

    if origin is set:
        if not isinstance(value, set):
            return False
        if not args:
            return True
        t_item = args[0]
        return all(_check_type(v, t_item) for v in value)

    try:
        return isinstance(value, origin)
    except TypeError:
        return True


def _extract_validators_from_signature(fn: Callable) -> dict[str, tuple[Any, list, Any]]:
    """Extract type hints with validators from function signature."""
    skip_params = {
        "self",
        "build_where",
        "node_tag",
        "node_label",
        "node_position",
        "component",  # first param of @component methods
        "comp",  # short form for component param
    }

    try:
        hints = get_type_hints(fn, include_extras=True)
    except Exception:
        return {}

    result = {}
    sig = inspect.signature(fn)

    for name, param in sig.parameters.items():
        if name in skip_params:
            continue
        if param.kind in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL):
            continue

        tp = hints.get(name)
        if tp is None:
            continue

        base, validators = _split_annotated(tp)
        result[name] = (base, validators, param.default)

    return result


# =============================================================================
# Sub-tags and parent-tags validation utilities (internal)
# =============================================================================


def _parse_parent_tags_spec(spec: str) -> set[str]:
    """Parse parent_tags spec into set of valid parent tag names.

    Simple comma-separated list of tag names (no cardinality).

    Args:
        spec: Comma-separated list of tag names, e.g. "div, span, section".

    Returns:
        Set of valid parent tag names.
    """
    return {tag.strip() for tag in spec.split(",") if tag.strip()}


def _parse_sub_tags_spec(spec: str) -> dict[str, tuple[int, int]] | str:
    """Parse sub_tags spec into dict of {tag: (min, max)} or "*" for any.

    Semantics:
        ""       -> leaf element (no children allowed) - returns empty dict
        "*"      -> accepts any children (no validation) - returns "*"
        "foo"    -> accepts only "foo" children

    Cardinality syntax:
        foo      -> any number 0..N (min=0, max=sys.maxsize)
        foo[1]   -> exactly 1 (min=1, max=1)
        foo[3]   -> exactly 3 (min=3, max=3)
        foo[0:]  -> 0 or more (min=0, max=sys.maxsize)
        foo[:2]  -> 0 to 2 (min=0, max=2)
        foo[1:3] -> 1 to 3 (min=1, max=3)
        foo[]    -> ERROR (invalid syntax)

    Returns:
        "*" if spec is "*" (accepts any children)
        dict of {tag: (min, max)} otherwise
    """
    # Handle wildcard - accepts any children
    if spec == "*":
        return "*"

    result: dict[str, tuple[int, int]] = {}
    for item in spec.split(","):
        item = item.strip()
        if not item:
            continue
        # Try [min:max] format first
        match = re.match(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d*):(\d*)\]$", item)
        if match:
            tag = match.group(1)
            min_val = int(match.group(2)) if match.group(2) else 0
            max_val = int(match.group(3)) if match.group(3) else sys.maxsize
            result[tag] = (min_val, max_val)
            continue
        # Try [n] format (exactly n)
        match = re.match(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]$", item)
        if match:
            tag = match.group(1)
            n = int(match.group(2))
            result[tag] = (n, n)
            continue
        # Check for invalid [] format
        match = re.match(r"([a-zA-Z_][a-zA-Z0-9_]*)\[\]$", item)
        if match:
            raise ValueError(
                f"Invalid sub_tags syntax: '{item}' - use 'foo' for 0..N or 'foo[n]' for exact count"
            )
        # Plain tag name (0..N)
        match = re.match(r"([a-zA-Z_][a-zA-Z0-9_]*)$", item)
        if match:
            tag = match.group(1)
            result[tag] = (0, sys.maxsize)
    return result


def _decorated_method_info(
    name: str, obj: Any,
) -> tuple[list[str], str | None, Any, dict]:
    """Build (tag_list, method_name, obj, decorator_info) for a decorated method."""
    decorator_info = obj._decorator
    if decorator_info.get("abstract"):
        return [f"@{name}"], None, obj, decorator_info
    elif decorator_info.get("component"):
        tag_list: list[str] = [] if name.startswith("_") else [name]
        tags_raw = decorator_info.get("tags")
        if tags_raw:
            if isinstance(tags_raw, str):
                tag_list.extend(t.strip() for t in tags_raw.split(",") if t.strip())
            else:
                tag_list.extend(tags_raw)
        handler_name = f"_comp_{tag_list[0]}"
        return tag_list, handler_name, obj, decorator_info
    else:
        tag_list = [] if name.startswith("_") else [name]
        tags_raw = decorator_info.get("tags")
        if tags_raw:
            if isinstance(tags_raw, str):
                tag_list.extend(t.strip() for t in tags_raw.split(",") if t.strip())
            else:
                tag_list.extend(tags_raw)
        return tag_list, None, obj, decorator_info


def _pop_decorated_methods(cls: type):
    """Remove and yield decorated methods from cls and its mixin bases.

    Collects @element, @abstract, and @component methods from:
    1. The class itself (cls.__dict__) — removed with delattr
    2. Mixin bases in MRO that are not BagBuilderBase subclasses

    Methods defined directly on cls take priority over mixin methods.
    Mixin methods are NOT removed from their defining class.
    """
    seen: set[str] = set()

    for name, obj in list(cls.__dict__.items()):
        if hasattr(obj, "_decorator"):
            seen.add(name)
            delattr(cls, name)
            yield _decorated_method_info(name, obj)

    for base in cls.__mro__:
        if base is cls or base is BagBuilderBase or base is object:
            continue
        if issubclass(base, BagBuilderBase):
            continue
        for name, obj in list(base.__dict__.items()):
            if name in seen:
                continue
            if hasattr(obj, "_decorator"):
                seen.add(name)
                yield _decorated_method_info(name, obj)


def _rename_colliding_schema_tags(cls: type, schema: Bag) -> None:
    """Rename schema tags that collide with Bag or BagBuilderBase methods.

    Tags that collide are renamed to 'el_<tag>' and a warning is emitted.
    """
    # Get all method names from Bag and BagBuilderBase
    bag_methods = set(dir(Bag))
    builder_methods = set(dir(BagBuilderBase))
    reserved_names = bag_methods | builder_methods

    # Get all tag names from schema (excluding @abstract tags)
    schema_tags = {node.label for node in schema.nodes if not node.label.startswith("@")}

    # Find collisions
    collisions = schema_tags & reserved_names
    if not collisions:
        return

    # Rename colliding tags
    renamed = []
    for tag in collisions:
        new_tag = f"el_{tag}"
        node = schema.get_node(tag)
        if node is not None:
            # Get node data and attributes
            node_value = node.value
            node_attrs = dict(node.attr)
            # Remove old entry and add new one
            schema.pop(tag)
            schema.set_item(new_tag, node_value, **node_attrs)
            renamed.append(f"'{tag}' -> '{new_tag}'")

    # Emit warning
    if renamed:
        warnings.warn(
            f"Builder {cls.__name__}: schema tags renamed to avoid collision with "
            f"Bag/BagBuilderBase methods: {', '.join(sorted(renamed))}",
            UserWarning,
            stacklevel=3,
        )


# =============================================================================
# SchemaBuilder
# =============================================================================


class SchemaBuilder(BagBuilderBase):
    """Builder for creating builder schemas programmatically.

    Use SchemaBuilder to define schemas at runtime instead of using decorators.
    Creates schema nodes with the structure expected by BagBuilderBase.

    Note: SchemaBuilder cannot define @component - components require code
    handlers and must be defined using the @component decorator.

    Schema conventions:
        - Elements: stored by name (e.g., 'div', 'span')
        - Abstracts: prefixed with '@' (e.g., '@flow', '@phrasing')
        - Use inherits_from='@abstract' to inherit sub_tags

    Usage:
        schema = Bag(builder=SchemaBuilder)
        schema.item('@flow', sub_tags='p,span')
        schema.item('div', inherits_from='@flow')
        schema.item('li', parent_tags='ul,ol')  # li only inside ul or ol
        schema.item('br', sub_tags='')  # void element
        schema.builder._compile('schema.msgpack')
    """

    def item(
        self,
        name: str,
        sub_tags: str | None = None,
        parent_tags: str | None = None,
        inherits_from: str | None = None,
        call_args_validations: dict[str, tuple[Any, list, Any]] | None = None,
        compile_kwargs: dict[str, Any] | None = None,
        documentation: str | None = None,
        **kwargs: Any,
    ) -> BagNode:
        """Define a schema item (element definition).

        Args:
            name: Element name to define (e.g., 'div', '@flow').
            sub_tags: Valid child tags with cardinality syntax.
            parent_tags: Comma-separated list of valid parent tags for this element.
            inherits_from: Abstract element name to inherit sub_tags from.
            call_args_validations: Validation spec for element attributes.
            compile_kwargs: Dict of compilation parameters (module, class, etc.).
            documentation: Documentation string for the element.
            **kwargs: Additional compile_* parameters are extracted and merged
                into compile_kwargs. E.g., compile_module='x' -> {'module': 'x'}.

        Returns:
            The created BagNode.
        """
        # Extract compile_* from kwargs and merge with compile_kwargs
        merged_compile = dict(compile_kwargs) if compile_kwargs else {}
        for key, value in list(kwargs.items()):
            if key.startswith("compile_"):
                merged_compile[key[8:]] = value  # strip "compile_" prefix
                del kwargs[key]

        # Build attributes dict, excluding None values
        attrs: dict[str, Any] = {}
        if sub_tags is not None:
            attrs["sub_tags"] = sub_tags
        if parent_tags is not None:
            attrs["parent_tags"] = parent_tags
        if inherits_from is not None:
            attrs["inherits_from"] = inherits_from
        if call_args_validations is not None:
            attrs["call_args_validations"] = call_args_validations
        if merged_compile:
            attrs["compile_kwargs"] = merged_compile
        if documentation is not None:
            attrs["documentation"] = documentation

        return self._bag.set_item(name, None, **attrs)

    def _compile(self, destination: str | Path) -> None:  # type: ignore[override]
        """Save schema to MessagePack file for later loading by builders.

        Args:
            destination: Path to the output .msgpack file.
        """
        msgpack_data = self._bag.to_tytx(transport="msgpack")
        Path(destination).write_bytes(msgpack_data)  # type: ignore[arg-type]
