"""Graph-operations mixin for AcherionDesigner.

Provides system-node sync, node CRUD, connection management, and all
methods that mutate the graph model. Intended to be inherited by
AcherionDesigner only.
"""
from typing import Any, cast

import acherion.node_behaviors as acherion_node_behaviors

from acherion.constants import (
    _GRID_SNAP_SIZE,
    _GROUP_COLOURS,
    _MANUAL_X,
    _MANUAL_Y,
    _NODE_STEP,
    _NODE_TOP,
    _SOURCE_X,
)
from acherion.events import (
    AcherionExternalEvent,
    default_acherion_external_events,
    normalize_acherion_external_events,
)
from acherion.registry import (
    get_acherion_node_definition,
    is_acherion_system_sink_kind,
    is_acherion_system_source_kind,
)
from acherion.model import (
    AcherionNode,
    _default_node,
    _system_node_id,
)


_AcherionLayoutMetric = dict[str, float | int]
_AcherionLayoutMetrics = dict[str, _AcherionLayoutMetric]
_AcherionLayoutBounds = dict[str, float]
_AcherionLayoutTargets = dict[str, tuple[int, int]]
_AcherionLayoutCommandResult = tuple[bool, str | _AcherionLayoutTargets]

_LAYOUT_COMMAND_SPECS: dict[str, tuple[str, object]] = {
    'align_top': ('align', ('y', 'top')),
    'align_middle': ('align', ('y', 'center')),
    'align_bottom': ('align', ('y', 'bottom')),
    'align_left': ('align', ('x', 'left')),
    'align_center': ('align', ('x', 'center')),
    'align_right': ('align', ('x', 'right')),
    'straighten_connections': ('straighten', None),
    'distribute_horizontally': ('distribute', 'x'),
    'distribute_vertically': ('distribute', 'y'),
    'stack_horizontally': ('stack', 'x'),
    'stack_vertically': ('stack', 'y'),
}

_LAYOUT_COMMAND_MESSAGES: dict[str, str] = {
    'align_top': 'Aligned selected nodes to the top edge.',
    'align_middle': 'Aligned selected nodes to the vertical middle.',
    'align_bottom': 'Aligned selected nodes to the bottom edge.',
    'align_left': 'Aligned selected nodes to the left edge.',
    'align_center': 'Aligned selected nodes to the horizontal center.',
    'align_right': 'Aligned selected nodes to the right edge.',
    'straighten_connections': 'Straightened the selected node layout.',
    'distribute_horizontally': 'Distributed selected nodes horizontally.',
    'distribute_vertically': 'Distributed selected nodes vertically.',
    'stack_horizontally': 'Stacked selected nodes horizontally.',
    'stack_vertically': 'Stacked selected nodes vertically.',
}


class _GraphOpsMixin:
    """Graph-mutation methods for AcherionDesigner."""

    _CENTER_ADD_OFFSET = 48
    _CENTER_ADD_MAX_ATTEMPTS = 64
    _STACK_GAP = _GRID_SNAP_SIZE

    _selected_connection_id: str | None = None

    @staticmethod
    def _snap_grid_value(value: int | float) -> int:
        return int(round(float(value) / _GRID_SNAP_SIZE) * _GRID_SNAP_SIZE)

    def _snap_grid_point(
        self: Any,
        x: int | float,
        y: int | float,
    ) -> tuple[int, int]:
        return (
            self._snap_grid_value(x),
            self._snap_grid_value(y),
        )

    # --- node predicates ---------------------------------------------------

    @staticmethod
    def _is_system_node(node: AcherionNode) -> bool:
        return bool(node.params.get('system_node'))

    def _is_system_source_node(self: Any, node: AcherionNode) -> bool:
        return self._is_system_node(node) and is_acherion_system_source_kind(
            node.kind
        )

    def _is_system_sink_node(self: Any, node: AcherionNode) -> bool:
        return self._is_system_node(node) and is_acherion_system_sink_kind(
            node.kind
        )

    @staticmethod
    def _pure_node_id(source_id: str) -> str:
        """Strip '@pin_index' suffix from a source id."""
        return source_id.split('@')[0] if source_id and '@' in source_id else (source_id or '')

    @staticmethod
    def _source_pin_index(source_id: str) -> int:
        """Return the output pin index encoded in a source id (default 0)."""
        if source_id and '@' in source_id:
            try:
                return int(source_id.split('@', 1)[1])
            except ValueError:
                return 0
        return 0

    def _manual_nodes(self: Any) -> list[AcherionNode]:
        return [
            n for n in self._graph.nodes if not self._is_system_node(n)
        ]

    def _external_events(self: Any) -> list[AcherionExternalEvent]:
        events: dict[str, AcherionExternalEvent]
        if self._host is not None:
            events = normalize_acherion_external_events(
                self._host.external_events()
            )
        else:
            events = default_acherion_external_events()
        for node in self._manual_nodes():
            definition = get_acherion_node_definition(node.kind)
            if definition is None:
                continue
            for event in definition.external_events(self, node):
                events[event.event_key] = event
        return list(normalize_acherion_external_events(events).values())

    def _sync_manual_schema_keys(self: Any) -> None:
        if self._host is not None:
            self._host.sync_manual_schema_keys(self)

    def _source_nodes(self: Any) -> list[AcherionNode]:
        return [
            n for n in self._graph.nodes if self._is_system_source_node(n)
        ]

    def _sink_nodes(self: Any) -> list[AcherionNode]:
        return [
            n for n in self._graph.nodes if self._is_system_sink_node(n)
        ]

    def _rebuild_graph(
        self: Any,
        manual_nodes: list[AcherionNode],
    ) -> None:
        self._graph.nodes = (
            self._source_nodes() + manual_nodes + self._sink_nodes()
        )
        self._ensure_function_box_entries()
        self._sync_function_box_ports()

    # --- position seeding --------------------------------------------------

    def _take_matching_node(  # type: ignore[return]
        self,
        *,
        consumed_ids: set[str],
        kind: str,
        key: str,
        param_key: str,
    ) -> AcherionNode | None:
        owner = cast(Any, self)
        expected_id = _system_node_id(kind, key)
        for node in owner._graph.nodes:
            if node.node_id in consumed_ids:
                continue
            if node.node_id == expected_id:
                consumed_ids.add(node.node_id)
                matched_node: AcherionNode = node
                return matched_node
            if node.kind != kind:
                continue
            if str(node.params.get(param_key) or '').strip() != key:
                continue
            consumed_ids.add(node.node_id)
            matched_node = node
            return matched_node
        return None

    def _seed_position(
        self: Any,
        node: AcherionNode,
        *,
        group: str,
        index: int,
    ) -> None:
        if bool(node.params.get('manual_position')):
            return
        if group == 'source':
            node.params.setdefault('dock', 'left')
            node.params.setdefault('y', _NODE_TOP + (index * _NODE_STEP))
            return
        if group == 'sink':
            node.params.setdefault('dock', 'right')
            node.params.setdefault('y', _NODE_TOP + (index * _NODE_STEP))
            return
        node.params.setdefault('dock', 'free')
        node.params.setdefault('x', _MANUAL_X)
        node.params.setdefault('y', _MANUAL_Y + (index * _NODE_STEP))

    def _rects_overlap(
        self: Any,
        *,
        left: int,
        top: int,
        width: int,
        height: int,
        other_left: int,
        other_top: int,
        other_width: int,
        other_height: int,
        padding: int = 24,
    ) -> bool:
        return not (
            (left + width + padding) <= other_left
            or left >= (other_left + other_width + padding)
            or (top + height + padding) <= other_top
            or top >= (other_top + other_height + padding)
        )

    def _visible_node_collision(
        self: Any,
        node: AcherionNode,
        *,
        left: int,
        top: int,
    ) -> bool:
        width = self._node_width(node)
        height = self._node_height(node)
        owner = cast(Any, self)
        for other_node in owner._graph.nodes:
            if other_node.node_id == node.node_id:
                continue
            if (
                owner._is_function_input(other_node)
                or owner._is_function_output(other_node)
                or owner._is_function_entry(other_node)
            ):
                continue
            if self._rects_overlap(
                left=left,
                top=top,
                width=width,
                height=height,
                other_left=self._node_world_left(other_node),
                other_top=self._node_world_top(other_node),
                other_width=self._node_width(other_node),
                other_height=self._node_height(other_node),
            ):
                return True
        return False

    def _resolve_centered_manual_position(
        self: Any,
        node: AcherionNode,
        *,
        center_x: int,
        center_y: int,
    ) -> tuple[int, int]:
        width = self._node_width(node)
        height = self._node_height(node)
        base_left = center_x - max(1, width // 2)
        base_top = center_y - max(1, height // 2)
        for attempt in range(self._CENTER_ADD_MAX_ATTEMPTS):
            offset = attempt * self._CENTER_ADD_OFFSET
            candidate_left = base_left + offset
            candidate_top = base_top + offset
            if not self._visible_node_collision(
                node,
                left=candidate_left,
                top=candidate_top,
            ):
                return candidate_left, candidate_top
        return base_left, base_top

    # --- system node sync --------------------------------------------------

    def _sync_system_nodes(self: Any) -> None:  # noqa: C901
        if self._host is not None:
            self._host.sync_system_nodes(self)
        return

    # --- node CRUD ---------------------------------------------------------

    def _add_node(
        self: Any,
        kind: str,
        center_x: int | None = None,
        center_y: int | None = None,
    ) -> None:
        manual_nodes = self._manual_nodes()
        new_node = _default_node(kind)
        if center_x is not None and center_y is not None:
            left, top = self._resolve_centered_manual_position(
                new_node,
                center_x=center_x,
                center_y=center_y,
            )
            new_node.params['x'] = left
            new_node.params['y'] = top
            new_node.params['dock'] = 'free'
            new_node.params['manual_position'] = True
            new_node.params['parent_function'] = self._containing_function_box_id(
                center_x,
                center_y,
            )
        else:
            self._seed_position(
                new_node, group='manual', index=len(manual_nodes)
            )
            target_box_id = self._selected_function_box_target()
            if target_box_id:
                self._place_node_inside_function_box(new_node, target_box_id)
        self._ensure_custom_function_entry(new_node)
        manual_nodes.append(new_node)
        self._rebuild_graph(manual_nodes)
        self._notify_change()

    def _add_node_at_position(
        self: Any, kind: str, x: int, y: int
    ) -> None:
        """Add a node of kind at a specific canvas position from a drag-drop."""
        manual_nodes = self._manual_nodes()
        new_node = _default_node(kind)
        x, y = self._snap_grid_point(x, y)
        new_node.params['x'] = x
        new_node.params['y'] = y
        new_node.params['dock'] = 'free'
        new_node.params['manual_position'] = True
        center_x = x + max(1, self._node_width(new_node) // 2)
        center_y = y + max(1, self._node_height(new_node) // 2)
        new_node.params['parent_function'] = self._containing_function_box_id(
            center_x,
            center_y,
        )
        self._ensure_custom_function_entry(new_node)
        manual_nodes.append(new_node)
        self._rebuild_graph(manual_nodes)
        self._notify_change()

    def _move_node(self: Any, node_id: str, direction: int) -> None:
        manual_nodes = self._manual_nodes()
        old_index = next(
            (
                i for i, n in enumerate(manual_nodes)
                if n.node_id == node_id
            ),
            -1,
        )
        if old_index < 0:
            return
        new_index = max(0, min(len(manual_nodes) - 1, old_index + direction))
        if old_index == new_index:
            return
        node = manual_nodes.pop(old_index)
        manual_nodes.insert(new_index, node)
        self._rebuild_graph(manual_nodes)
        self._notify_change()

    def _selected_layout_nodes(self: Any) -> list[AcherionNode]:
        selected_ids = set(self._selected_node_ids)
        if not selected_ids:
            return []
        selected_nodes = [
            node for node in self._graph.nodes
            if node.node_id in selected_ids and not self._is_function_entry(node)
        ]
        selected_box_ids = {
            node.node_id
            for node in selected_nodes
            if self._is_function_box(node)
        }
        return [
            node
            for node in selected_nodes
            if not any(
                box_id != node.node_id
                and self._node_has_function_ancestor(node.node_id, box_id)
                for box_id in selected_box_ids
            )
        ]

    def _layout_metrics(
        self: Any,
        node: AcherionNode,
    ) -> _AcherionLayoutMetric:
        left = int(self._node_world_left(node))
        top = int(self._node_world_top(node))
        width = int(self._node_width(node))
        height = int(self._node_height(node))
        return {
            'left': left,
            'top': top,
            'right': left + width,
            'bottom': top + height,
            'width': width,
            'height': height,
            'center_x': left + (width / 2.0),
            'center_y': top + (height / 2.0),
        }

    def _function_box_descendants(
        self: Any,
        box_id: str,
    ) -> list[AcherionNode]:
        return [
            node
            for node in self._manual_nodes()
            if node.node_id != box_id
            and self._node_has_function_ancestor(node.node_id, box_id)
        ]

    @staticmethod
    def _median(values: list[float]) -> float:
        ordered = sorted(values)
        mid = len(ordered) // 2
        if len(ordered) % 2:
            return ordered[mid]
        return (ordered[mid - 1] + ordered[mid]) / 2.0

    def _apply_layout_positions(
        self: Any,
        nodes: list[AcherionNode],
        targets: _AcherionLayoutTargets,
    ) -> bool:
        if not targets:
            return False
        current_positions = {
            node.node_id: (
                int(self._node_world_left(node)),
                int(self._node_world_top(node)),
            )
            for node in self._graph.nodes
        }
        resolved_targets = {
            node_id: self._snap_grid_point(left, top)
            for node_id, (left, top) in targets.items()
        }
        for node in nodes:
            if not self._is_function_box(node):
                continue
            current_left, current_top = current_positions.get(
                node.node_id,
                (0, 0),
            )
            target_left, target_top = resolved_targets.get(
                node.node_id,
                (current_left, current_top),
            )
            delta_x = target_left - current_left
            delta_y = target_top - current_top
            if delta_x == 0 and delta_y == 0:
                continue
            for child in self._function_box_descendants(node.node_id):
                child_left, child_top = current_positions.get(
                    child.node_id,
                    (
                        int(self._node_world_left(child)),
                        int(self._node_world_top(child)),
                    ),
                )
                resolved_targets[child.node_id] = self._snap_grid_point(
                    child_left + delta_x,
                    child_top + delta_y,
                )

        changed = False
        moved_nodes: list[tuple[AcherionNode, int, int]] = []
        for node_id, (left, top) in resolved_targets.items():
            node = self._node_by_id(node_id)
            if node is None:
                continue
            current_left, current_top = current_positions.get(
                node_id,
                (left, top),
            )
            if current_left == left and current_top == top:
                continue
            node.params['x'] = left
            node.params['y'] = top
            node.params['dock'] = 'free'
            node.params['manual_position'] = True
            moved_nodes.append((node, left, top))
            changed = True
        if not changed:
            return False
        for moved_node, left, top in moved_nodes:
            self._assign_node_to_containing_function_box(moved_node, left, top)
        self._notify_change()
        return True

    @staticmethod
    def _layout_selection_bounds(
        metrics: _AcherionLayoutMetrics,
    ) -> _AcherionLayoutBounds:
        left = min(float(data['left']) for data in metrics.values())
        top = min(float(data['top']) for data in metrics.values())
        right = max(float(data['right']) for data in metrics.values())
        bottom = max(float(data['bottom']) for data in metrics.values())
        return {
            'left': left,
            'top': top,
            'right': right,
            'bottom': bottom,
            'center_x': (left + right) / 2.0,
            'center_y': (top + bottom) / 2.0,
        }

    @staticmethod
    def _ordered_layout_nodes(
        nodes: list[AcherionNode],
        metrics: _AcherionLayoutMetrics,
        *,
        primary: str,
        secondary: str,
    ) -> list[AcherionNode]:
        return sorted(
            nodes,
            key=lambda node: (
                float(metrics[node.node_id][primary]),
                float(metrics[node.node_id][secondary]),
            ),
        )

    def _layout_axis_center_targets(
        self: Any,
        nodes: list[AcherionNode],
        metrics: _AcherionLayoutMetrics,
        *,
        axis: str,
        center_value: float,
    ) -> _AcherionLayoutTargets:
        targets: _AcherionLayoutTargets = {}
        for node in nodes:
            data = metrics[node.node_id]
            if axis == 'y':
                targets[node.node_id] = (
                    int(data['left']),
                    self._snap_grid_value(
                        center_value - (float(data['height']) / 2.0)
                    ),
                )
                continue
            targets[node.node_id] = (
                self._snap_grid_value(
                    center_value - (float(data['width']) / 2.0)
                ),
                int(data['top']),
            )
        return targets

    def _layout_align_targets(
        self: Any,
        nodes: list[AcherionNode],
        metrics: _AcherionLayoutMetrics,
        bounds: _AcherionLayoutBounds,
        *,
        axis: str,
        anchor: str,
    ) -> _AcherionLayoutTargets:
        if anchor == 'center':
            return self._layout_axis_center_targets(
                nodes,
                metrics,
                axis=axis,
                center_value=float(bounds['center_y' if axis == 'y' else 'center_x']),
            )

        targets: _AcherionLayoutTargets = {}
        for node in nodes:
            data = metrics[node.node_id]
            if axis == 'y':
                anchor_values = {
                    'top': float(bounds['top']),
                    'bottom': float(bounds['bottom']) - float(data['height']),
                }
                targets[node.node_id] = (
                    int(data['left']),
                    self._snap_grid_value(anchor_values[anchor]),
                )
                continue
            anchor_values = {
                'left': float(bounds['left']),
                'right': float(bounds['right']) - float(data['width']),
            }
            targets[node.node_id] = (
                self._snap_grid_value(anchor_values[anchor]),
                int(data['top']),
            )
        return targets

    def _layout_straighten_targets(
        self: Any,
        nodes: list[AcherionNode],
        metrics: _AcherionLayoutMetrics,
        bounds: _AcherionLayoutBounds,
    ) -> _AcherionLayoutTargets:
        horizontal = (
            (float(bounds['right']) - float(bounds['left']))
            >= (float(bounds['bottom']) - float(bounds['top']))
        )
        if horizontal:
            target_center = self._median([
                float(data['center_y']) for data in metrics.values()
            ])
            return self._layout_axis_center_targets(
                nodes,
                metrics,
                axis='y',
                center_value=target_center,
            )
        target_center = self._median([
            float(data['center_x']) for data in metrics.values()
        ])
        return self._layout_axis_center_targets(
            nodes,
            metrics,
            axis='x',
            center_value=target_center,
        )

    def _layout_distribute_targets(
        self: Any,
        nodes: list[AcherionNode],
        metrics: _AcherionLayoutMetrics,
        bounds: _AcherionLayoutBounds,
        *,
        axis: str,
    ) -> _AcherionLayoutCommandResult:
        if len(nodes) < 3:
            if axis == 'x':
                return (
                    False,
                    'Select at least three nodes to distribute horizontally.',
                )
            return (
                False,
                'Select at least three nodes to distribute vertically.',
            )

        if axis == 'x':
            ordered = self._ordered_layout_nodes(
                nodes,
                metrics,
                primary='left',
                secondary='top',
            )
            total_size = sum(
                float(metrics[node.node_id]['width']) for node in ordered
            )
            gap = max(
                0.0,
                (float(bounds['right']) - float(bounds['left']) - total_size)
                / (len(ordered) - 1),
            )
            cursor = float(bounds['left'])
            targets: _AcherionLayoutTargets = {}
            for node in ordered:
                data = metrics[node.node_id]
                targets[node.node_id] = (
                    self._snap_grid_value(cursor),
                    int(data['top']),
                )
                cursor += float(data['width']) + gap
            return (True, targets)

        ordered = self._ordered_layout_nodes(
            nodes,
            metrics,
            primary='top',
            secondary='left',
        )
        total_size = sum(
            float(metrics[node.node_id]['height']) for node in ordered
        )
        gap = max(
            0.0,
            (float(bounds['bottom']) - float(bounds['top']) - total_size)
            / (len(ordered) - 1),
        )
        cursor = float(bounds['top'])
        targets = {}
        for node in ordered:
            data = metrics[node.node_id]
            targets[node.node_id] = (
                int(data['left']),
                self._snap_grid_value(cursor),
            )
            cursor += float(data['height']) + gap
        return (True, targets)

    def _layout_stack_targets(
        self: Any,
        nodes: list[AcherionNode],
        metrics: _AcherionLayoutMetrics,
        bounds: _AcherionLayoutBounds,
        *,
        axis: str,
    ) -> _AcherionLayoutTargets:
        if axis == 'x':
            ordered = self._ordered_layout_nodes(
                nodes,
                metrics,
                primary='left',
                secondary='top',
            )
            cursor = float(bounds['left'])
            targets: _AcherionLayoutTargets = {}
            for node in ordered:
                data = metrics[node.node_id]
                targets[node.node_id] = self._snap_grid_point(
                    cursor,
                    float(bounds['center_y']) - (float(data['height']) / 2.0),
                )
                cursor += float(data['width']) + self._STACK_GAP
            return targets

        ordered = self._ordered_layout_nodes(
            nodes,
            metrics,
            primary='top',
            secondary='left',
        )
        cursor = float(bounds['top'])
        targets = {}
        for node in ordered:
            data = metrics[node.node_id]
            targets[node.node_id] = self._snap_grid_point(
                float(bounds['center_x']) - (float(data['width']) / 2.0),
                cursor,
            )
            cursor += float(data['height']) + self._STACK_GAP
        return targets

    def _layout_selected_nodes(
        self: Any,
        command: str,
    ) -> tuple[bool, str]:
        nodes = self._selected_layout_nodes()
        if len(nodes) < 2:
            return (False, 'Select at least two nodes first.')

        metrics: _AcherionLayoutMetrics = {
            node.node_id: self._layout_metrics(node)
            for node in nodes
        }
        bounds = self._layout_selection_bounds(metrics)
        command_spec = _LAYOUT_COMMAND_SPECS.get(command)
        if command_spec is None:
            return (False, 'Unknown layout command.')

        mode, config = command_spec
        dispatchers = {
            'align': lambda raw: (
                True,
                self._layout_align_targets(
                    nodes,
                    metrics,
                    bounds,
                    axis=cast(tuple[str, str], raw)[0],
                    anchor=cast(tuple[str, str], raw)[1],
                ),
            ),
            'straighten': lambda _raw: (
                True,
                self._layout_straighten_targets(nodes, metrics, bounds),
            ),
            'distribute': lambda raw: self._layout_distribute_targets(
                nodes,
                metrics,
                bounds,
                axis=cast(str, raw),
            ),
            'stack': lambda raw: (
                True,
                self._layout_stack_targets(
                    nodes,
                    metrics,
                    bounds,
                    axis=cast(str, raw),
                ),
            ),
        }
        ok, payload = dispatchers[mode](config)
        if not ok:
            return (False, cast(str, payload))
        targets = cast(_AcherionLayoutTargets, payload)

        if not self._apply_layout_positions(nodes, targets):
            return (True, 'Selection already matches that layout.')
        return (
            True,
            _LAYOUT_COMMAND_MESSAGES.get(
                command,
                'Updated selected node layout.',
            ),
        )

    def _delete_node(self: Any, node_id: str) -> None:
        node = self._node_by_id(node_id)
        if node is not None and self._is_function_entry(node):
            return
        manual_nodes = self._manual_nodes()
        if any(node.node_id == node_id and self._is_function_box(node) for node in manual_nodes):
            child_ids = {
                node.node_id for node in manual_nodes
                if self._function_parent_id(node) == node_id
            }
            child_ids.add(node_id)
            self._delete_nodes_batch(child_ids)
            return
        manual_nodes = [
            n for n in self._manual_nodes() if n.node_id != node_id
        ]
        self._rebuild_graph(manual_nodes)
        self._cleanup_custom_function_entries()
        for node in manual_nodes:
            if self._is_function_box(node):
                self._prune_box_io_metadata(node)
        self._cleanup_empty_groups()
        self._notify_change()

    def _delete_nodes_batch(self: Any, node_ids: set[str]) -> None:
        if not node_ids:
            return
        manual_nodes = self._manual_nodes()
        expanded_ids = {
            node_id for node_id in node_ids
            if not any(
                node.node_id == node_id and self._is_function_entry(node)
                for node in manual_nodes
            )
        }
        for node in manual_nodes:
            if node.node_id in node_ids and self._is_function_box(node):
                expanded_ids.update(
                    child.node_id
                    for child in manual_nodes
                    if self._function_parent_id(child) == node.node_id
                )
        manual_nodes = [
            n for n in manual_nodes
            if n.node_id not in expanded_ids
        ]
        self._rebuild_graph(manual_nodes)
        self._cleanup_custom_function_entries()
        for node in manual_nodes:
            if self._is_function_box(node):
                self._prune_box_io_metadata(node)
        self._cleanup_empty_groups()
        self._notify_change()

    # --- group management -------------------------------------------------

    def _next_group_colour(self: Any) -> str:
        """Return next unused colour from the palette."""
        used = set(self._graph.groups.values())
        for colour in _GROUP_COLOURS:
            if colour not in used:
                return str(colour)
        return str(_GROUP_COLOURS[
            len(self._graph.groups) % len(_GROUP_COLOURS)
        ])

    def _create_group(self: Any, name: str, node_ids: set[str]) -> None:
        """Create a named group and assign selected nodes to it."""
        name = name.strip()
        if not name or name in self._graph.groups:
            return
        self._graph.groups[name] = self._next_group_colour()
        for node in self._graph.nodes:
            if node.node_id in node_ids:
                node.params['group'] = name
        self._notify_change()

    def _add_nodes_to_group(self: Any, name: str, node_ids: set[str]) -> None:
        """Move nodes into an existing group."""
        if name not in self._graph.groups:
            return
        for node in self._graph.nodes:
            if node.node_id in node_ids:
                node.params['group'] = name
        self._notify_change()

    def _remove_nodes_from_group(self: Any, node_ids: set[str]) -> None:
        """Remove nodes from their current group; prune empty groups."""
        for node in self._graph.nodes:
            if node.node_id in node_ids:
                node.params.pop('group', None)
        self._cleanup_empty_groups()
        self._notify_change()

    def _cleanup_empty_groups(self: Any) -> None:
        """Delete groups that have no member nodes."""
        members: set[str] = {
            str(n.params['group'])
            for n in self._graph.nodes
            if n.params.get('group')
        }
        self._graph.groups = {
            k: v
            for k, v in self._graph.groups.items()
            if k in members
        }

    def _extract_function_from_selection(
        self: Any,
        name: str,
        *,
        return_node_id: str | None = None,
    ) -> tuple[bool, str]:
        """Extract selected nodes into a composite function box."""
        selected_ids = set(self._selected_node_ids)
        if not selected_ids:
            return (False, 'Select nodes first.')

        selected_nodes = [
            node for node in self._manual_nodes()
            if node.node_id in selected_ids
        ]
        if len(selected_nodes) != len(selected_ids):
            return (False, 'Only manual nodes can be extracted into a function.')

        if any(self._is_function_box(node) for node in selected_nodes):
            return (False, 'Selecting a function box for extraction is not supported.')
        if any(
            self._is_function_input(node) or self._is_function_output(node)
            for node in selected_nodes
        ):
            return (False, 'Function input/output nodes cannot be re-extracted.')
        if any(self._is_function_entry(node) for node in selected_nodes):
            return (False, 'Function entry nodes cannot be re-extracted.')
        parent_ids = {
            self._function_parent_id(node) for node in selected_nodes
        }
        if len(parent_ids) > 1:
            return (
                False,
                'Select nodes from the same function scope.',
            )
        common_parent_id = next(iter(parent_ids), '')

        unsupported_kinds = {
            'for_each',
            'collect',
            'branch_route',
        }
        bad_kinds = sorted({
            node.kind
            for node in selected_nodes
            if (
                node.kind in unsupported_kinds
                or acherion_node_behaviors.function_box_unsupported(node)
            )
        })
        if bad_kinds:
            return (
                False,
                'Selection contains unsupported node kinds: '
                + ', '.join(bad_kinds),
            )

        function_slug = self._sanitize_identifier(name, 'function_box')
        manual_nodes = self._manual_nodes()
        insert_index = min(
            index
            for index, node in enumerate(manual_nodes)
            if node.node_id in selected_ids
        )
        box = _default_node('function_box')
        box.title = name.strip() or function_slug.replace('_', ' ').title()
        box.params['function_name'] = function_slug
        box.params['parent_function'] = common_parent_id
        box.params['manual_position'] = True
        box.params['dock'] = 'free'
        box.params['x'] = min(
            self._node_world_left(node) for node in selected_nodes
        ) - 48
        box.params['y'] = min(
            self._node_world_top(node) for node in selected_nodes
        ) - 72

        for node in selected_nodes:
            node.params['parent_function'] = box.node_id
            node.params.pop('group', None)

        new_manual_nodes = manual_nodes[:]
        new_manual_nodes.insert(insert_index, box)

        self._rebuild_graph(new_manual_nodes)
        self._selected_node_ids = {box.node_id}
        self._notify_change()
        return (
            True,
            f'Created function box {function_slug}. Boundary wires now define its inputs and outputs.',
        )



