from __future__ import annotations

import typing
import warnings
from collections import deque
from collections.abc import Callable, Generator, Sequence
from contextlib import contextmanager
from typing import (
    TYPE_CHECKING,
    Any,
    ClassVar,
    NamedTuple,
    TypeAlias,
)

import numpy as np
import numpy.typing as npt

from napari.layers._data_protocols import LayerDataProtocol
from napari.layers._multiscale_data import MultiScaleData
from napari.layers._scalar_field.scalar_field import (
    ScalarFieldBase,
    ScalarFieldSlicingState,
)
from napari.layers.base import Layer, no_op
from napari.layers.base._base_mouse_bindings import (
    highlight_box_handles,
    transform_with_box,
)
from napari.layers.image._image_utils import guess_multiscale
from napari.layers.labels._labels_constants import (
    IsoCategoricalGradientMode,
    LabelColorMode,
    LabelsRendering,
    Mode,
)
from napari.layers.labels._labels_mouse_bindings import (
    BrushSizeOnMouseMove,
    draw,
    pick,
)
from napari.layers.labels._labels_utils import (
    expand_slice,
    get_contours,
    get_dtype,
    interpolate_coordinates,
)
from napari.layers.labels._slice import _LabelsSliceRequest
from napari.layers.utils.layer_utils import _FeatureTable
from napari.types import LayerDataType
from napari.utils._dtype import (
    get_dtype_limits,
    normalize_dtype,
    vispy_texture_dtype,
)
from napari.utils._indexing import elements_in_slice, index_in_slice
from napari.utils.colormaps import (
    direct_colormap,
    label_colormap,
)
from napari.utils.colormaps.colormap import (
    CyclicLabelColormap,
    LabelColormapBase,
    _normalize_label_colormap,
)
from napari.utils.colormaps.colormap_utils import shuffle_and_extend_colormap
from napari.utils.events import EmitterGroup, Event
from napari.utils.events.custom_types import Array
from napari.utils.misc import StringEnum
from napari.utils.naming import magic_name
from napari.utils.status_messages import format_feature_value

if TYPE_CHECKING:
    import pandas as pd

__all__ = ('Labels',)


class _MaskedPaintAtom(NamedTuple):
    """A single undoable mask-based edit of a Labels layer.

    Replay (see ``Labels._replay_masked_atom``) reads the bounding box with
    basic slicing, applies the masked update locally on a numpy array, and
    writes the bounding box back. Only basic indexing ever reaches the data
    backend, so replay behaves identically across numpy, zarr, tensorstore,
    dask and xarray (whose advanced-indexing semantics all differ).

    Attributes
    ----------
    slice_key : tuple of slice
        Bounding box of the edit in data coordinates.
    mask : ndarray of bool or None
        Changed pixels within the bounding box, or None when every pixel in
        the bounding box changed (the mask is dropped to save memory).
    old_values : ndarray
        The values under ``mask`` before the edit (1D), or a snapshot of the
        whole bounding box when ``mask`` is None.
    new_value : int
        The label that was painted.
    """

    slice_key: tuple[slice, ...]
    mask: npt.NDArray[np.bool_] | None
    old_values: np.ndarray
    new_value: int


# A single atom stored in the undo/redo history: either a mask-based edit
# (paint/fill/paint_polygon) or the legacy fancy-index 3-tuple of
# ``(indices, old_values, new_values)`` produced by ``data_setitem`` (where
# ``indices`` is a numpy multi-index and the new values may be a scalar).
HistoryAtom: TypeAlias = (
    _MaskedPaintAtom | tuple[Any, npt.NDArray, np.ndarray | int]
)


class Labels(ScalarFieldBase):
    """Labels (or segmentation) layer.

    An image-like layer where every pixel contains an integer ID
    corresponding to the region it belongs to.

    Parameters
    ----------
    data : array or list of array
        Labels data as an array or multiscale. Must be integer type or bools.
        In 2D, the displayed resolution is chosen automatically based on
        the viewport. In 3D, the lowest resolution scale is displayed by
        default. The resolution level can be locked via
        ``locked_data_level`` or the resolution control in the layer
        controls UI.
    affine : n-D array or napari.utils.transforms.Affine
        (N+1, N+1) affine transformation matrix in homogeneous coordinates.
        The first (N, N) entries correspond to a linear transform and
        the final column is a length N translation vector and a 1 or a napari
        `Affine` transform object. Applied as an extra transform on top of the
        provided scale, rotate, and shear values.
    axis_labels : tuple of str, optional
        Dimension names of the layer data.
        If not provided, axis_labels will be set to (..., '-2', '-1').
    blending : str
        One of a list of preset blending modes that determines how RGB and
        alpha values of the layer visual get mixed. Allowed values are
        {'opaque', 'translucent', and 'additive'}.
    cache : bool
        Whether slices of out-of-core datasets should be cached upon retrieval.
        Currently, this only applies to dask arrays.
    colormap : CyclicLabelColormap or DirectLabelColormap or None
        Colormap to use for the labels. If None, a random colormap will be
        used.
    depiction : str
        3D Depiction mode. Must be one of {'volume', 'plane'}.
        The default value is 'volume'.
    experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList
        Each dict defines a clipping plane in 3D in data coordinates.
        Valid dictionary keys are {'position', 'normal', and 'enabled'}.
        Values on the negative side of the normal are discarded if the plane is enabled.
    features : dict[str, array-like] or DataFrame
        Features table where each row corresponds to a label and each column
        is a feature. The first row corresponds to the background label.
    iso_gradient_mode : str
        Method for calulating the gradient (used to get the surface normal) in the
        'iso_categorical' rendering mode. Must be one of {'fast', 'smooth'}.
        'fast' uses a simple finite difference gradient in x, y, and z. 'smooth' uses an
        isotropic Sobel gradient, which is smoother but more computationally expensive.
        The default value is 'fast'.
    metadata : dict
        Layer metadata.
    multiscale : bool
        Whether the data is a multiscale image or not. Multiscale data is
        represented by a list of array like image data. If not specified by
        the user and if the data is a list of arrays that decrease in shape
        then it will be taken to be multiscale. The first image in the list
        should be the largest. In 2D, the displayed resolution is chosen
        automatically based on the viewport. In 3D, the lowest resolution
        scale is displayed by default. The resolution level can be locked
        via ``locked_data_level`` or the resolution control in the layer
        controls UI.
    name : str
        Name of the layer.
    opacity : float
        Opacity of the layer visual, between 0.0 and 1.0.
    plane : dict or SlicingPlane
        Properties defining plane rendering in 3D. Properties are defined in
        data coordinates. Valid dictionary keys are
        {'position', 'normal', 'thickness', and 'enabled'}.
    projection_mode : str
        How data outside the viewed dimensions but inside the thick Dims slice will
        be projected onto the viewed dimensions
    properties : dict {str: array (N,)} or DataFrame
        Properties for each label. Each property should be an array of length
        N, where N is the number of labels, and the first property corresponds
        to background.
    rendering : str
        3D Rendering mode used by vispy. Must be one {'translucent', 'iso_categorical'}.
        'translucent' renders without lighting. 'iso_categorical' uses isosurface
        rendering to calculate lighting effects on labeled surfaces.
        The default value is 'iso_categorical'.
    rotate : float, 3-tuple of float, or n-D array.
        If a float convert into a 2D rotation matrix using that value as an
        angle. If 3-tuple convert into a 3D rotation matrix, using a yaw,
        pitch, roll convention. Otherwise assume an nD rotation. Angles are
        assumed to be in degrees. They can be converted from radians with
        np.degrees if needed.
    scale : tuple of float
        Scale factors for the layer.
    shear : 1-D array or n-D array
        Either a vector of upper triangular values, or an nD shear matrix with
        ones along the main diagonal.
    translate : tuple of float
        Translation values for the layer.
    units : tuple of str or pint.Unit, optional
        Units of the layer data in world coordinates.
        If not provided, the default units are assumed to be pixels.
    visible : bool
        Whether the layer visual is currently being displayed.

    Attributes
    ----------
    data : array or list of array
        Integer label data as an array or multiscale. Can be N dimensional.
        Every pixel contains an integer ID corresponding to the region it
        belongs to. The label 0 is rendered as transparent. In 2D, the
        displayed resolution is chosen automatically based on the viewport.
        In 3D, the lowest resolution scale is displayed by default. The
        resolution level can be locked via ``locked_data_level`` or the
        resolution control in the layer controls UI.
    axis_labels : tuple of str
        Dimension names of the layer data.
    multiscale : bool
        Whether the data is a multiscale image or not. Multiscale data is
        represented by a list of array like image data. The first image in the
        list should be the largest. In 2D, the displayed resolution is chosen
        automatically based on the viewport. In 3D, the lowest resolution
        scale is displayed by default. The resolution level can be locked
        via ``locked_data_level`` or the resolution control in the layer
        controls UI.
    metadata : dict
        Labels metadata.
    num_colors : int
        Number of unique colors to use in colormap. DEPRECATED: set
        ``colormap`` directly, using `napari.utils.colormaps.label_colormap`.
    features : Dataframe-like
        Features table where each row corresponds to a label and each column
        is a feature. The first row corresponds to the background label.
    properties : dict {str: array (N,)}, DataFrame
        Properties for each label. Each property should be an array of length
        N, where N is the number of labels, and the first property corresponds
        to background.
    color : dict of int to str or array
        Custom label to color mapping. Values must be valid color names or RGBA
        arrays. While there is no limit to the number of custom labels, the
        the layer will render incorrectly if they map to more than 1024 distinct
        colors. DEPRECATED: set ``colormap`` directly, using
        `napari.utils.colormaps.DirectLabelColormap`.
    seed : float
        Seed for colormap random generator. DEPRECATED: set ``colormap``
        directly, using `napari.utils.colormaps.label_colormap`.
    opacity : float
        Opacity of the labels, must be between 0 and 1.
    contiguous : bool
        If `True`, the fill bucket changes only connected pixels of same label.
    n_edit_dimensions : int
        The number of dimensions across which labels will be edited.
    contour : int
        If greater than 0, displays contours of labels instead of shaded regions
        with a thickness equal to its value. Must be >= 0.
    brush_size : float
        Size of the paint brush in data coordinates.
    iso_gradient_mode : str
        Method for calulating the gradient (used to get the surface normal) in the
        'iso_categorical' rendering mode. Must be one of {'fast', 'smooth'}.
        'fast' uses a simple finite difference gradient in x, y, and z. 'smooth' uses an
        isotropic Sobel gradient, which is smoother but more computationally expensive.
    selected_label : int
        Index of selected label. Can be greater than the current maximum label.
    mode : str
        Interactive mode. The normal, default mode is PAN_ZOOM, which
        allows for normal interactivity with the canvas.

        In PICK mode the cursor functions like a color picker, setting the
        clicked on label to be the current label. If the background is picked it
        will select the background label `0`.

        In PAINT mode the cursor functions like a paint brush changing any
        pixels it brushes over to the current label. If the background label
        `0` is selected than any pixels will be changed to background and this
        tool functions like an eraser. The size and shape of the cursor can be
        adjusted in the properties widget.

        In FILL mode the cursor functions like a fill bucket replacing pixels
        of the label clicked on with the current label. It can either replace
        all pixels of that label or just those that are contiguous with the
        clicked on pixel. If the background label `0` is selected than any
        pixels will be changed to background and this tool functions like an
        eraser.

        In ERASE mode the cursor functions similarly to PAINT mode, but to
        paint with background label, which effectively removes the label.
    plane : SlicingPlane
        Properties defining plane rendering in 3D.
    experimental_clipping_planes : ClippingPlaneList
        Clipping planes defined in data coordinates, used to clip the volume.
    units: tuple of pint.Unit
        Units of the layer data in world coordinates.

    Notes
    -----
    _selected_color : 4-tuple or None
        RGBA tuple of the color of the selected label, or None if the
        background label `0` is selected.
    """

    events: EmitterGroup
    _colormap: LabelColormapBase

    _modeclass = Mode

    _drag_modes: ClassVar[
        dict[Mode, Callable[[Labels, Event], Generator | None]]
    ] = {  # type: ignore[assignment]
        Mode.PAN_ZOOM: no_op,
        Mode.TRANSFORM: transform_with_box,
        Mode.PICK: pick,
        Mode.PAINT: draw,
        Mode.FILL: draw,
        Mode.ERASE: draw,
        Mode.POLYGON: no_op,  # the overlay handles mouse events in this mode
    }

    brush_size_on_mouse_move = BrushSizeOnMouseMove(min_brush_size=1)

    _move_modes: ClassVar[
        dict[StringEnum, Callable[[Labels, Event], None]]
    ] = {  # type: ignore[assignment]
        Mode.PAN_ZOOM: no_op,
        Mode.TRANSFORM: highlight_box_handles,
        Mode.PICK: no_op,
        Mode.PAINT: brush_size_on_mouse_move,
        Mode.FILL: no_op,
        Mode.ERASE: brush_size_on_mouse_move,
        Mode.POLYGON: no_op,  # the overlay handles mouse events in this mode
    }

    _cursor_modes: ClassVar[dict[Mode, str]] = {  # type: ignore[assignment]
        Mode.PAN_ZOOM: 'standard',
        Mode.TRANSFORM: 'standard',
        Mode.PICK: 'cross',
        Mode.PAINT: 'circle',
        Mode.FILL: 'cross',
        Mode.ERASE: 'circle',
        Mode.POLYGON: 'cross',
    }

    _history_limit = 100

    def __init__(
        self,
        data,
        *,
        affine=None,
        axis_labels=None,
        blending='translucent',
        cache=True,
        colormap=None,
        depiction='volume',
        experimental_clipping_planes=None,
        features=None,
        iso_gradient_mode=IsoCategoricalGradientMode.FAST.value,
        metadata=None,
        multiscale=None,
        name=None,
        opacity=0.7,
        plane=None,
        projection_mode='none',
        properties=None,
        rendering='iso_categorical',
        rotate=None,
        scale=None,
        shear=None,
        translate=None,
        units=None,
        visible=True,
    ) -> None:
        if name is None and data is not None:
            name = magic_name(data)

        self._seed = 0.5
        # We use 50 colors (49 + transparency) by default for historical
        # consistency. This may change in future versions.
        self._random_colormap = label_colormap(
            49, self._seed, background_value=0
        )
        self._original_random_colormap = self._random_colormap
        self._direct_colormap = direct_colormap(
            {0: 'transparent', None: 'black'}
        )
        self._colormap = self._random_colormap
        self._color_mode = LabelColorMode.AUTO
        self._show_selected_label = False
        self._contour = 0

        data = self._ensure_int_labels(data)

        super().__init__(
            data,
            affine=affine,
            axis_labels=axis_labels,
            blending=blending,
            cache=cache,
            depiction=depiction,
            experimental_clipping_planes=experimental_clipping_planes,
            rendering=rendering,
            metadata=metadata,
            multiscale=multiscale,
            name=name,
            scale=scale,
            shear=shear,
            plane=plane,
            opacity=opacity,
            projection_mode=projection_mode,
            rotate=rotate,
            translate=translate,
            units=units,
            visible=visible,
        )

        self.events.add(
            brush_shape=Event,
            brush_size=Event,
            colormap=Event,
            contiguous=Event,
            contour=Event,
            features=Event,
            iso_gradient_mode=Event,
            labels_update=Event,
            n_edit_dimensions=Event,
            paint=Event,
            preserve_labels=Event,
            properties=Event,
            selected_label=Event,
            show_selected_label=Event,
        )

        from napari.components.overlays.labels_brush_stroke import (
            LabelsBrushStrokeOverlay,
        )
        from napari.components.overlays.labels_polygon import (
            LabelsPolygonOverlay,
        )

        self._overlays.update(
            {
                'polygon': LabelsPolygonOverlay(visible=True),
                'brush_stroke': LabelsBrushStrokeOverlay(visible=True),
            }
        )

        self._feature_table = _FeatureTable.from_layer(
            features=features, properties=properties
        )
        self._label_index = self._make_label_index()

        self._n_edit_dimensions = 2
        self._contiguous = True
        self._brush_size = 10

        self._iso_gradient_mode = IsoCategoricalGradientMode(iso_gradient_mode)

        self._selected_label = 1
        self.colormap.selection = self._selected_label
        self.colormap.use_selection = self._show_selected_label
        self._prev_selected_label = None
        self._selected_color = self.get_color(self._selected_label)
        self._updated_slice: tuple[slice, ...] | None = None
        if colormap is not None:
            self._set_colormap(colormap)

        self._status = self.mode
        self._preserve_labels = False

        # Each history undo step is a list of atoms.
        self._undo_history: deque[list[HistoryAtom]]
        self._redo_history: deque[list[HistoryAtom]]
        self._staged_history: list[HistoryAtom]
        self._block_history: bool

    def _slice_dtype(self):
        """Calculate dtype of data view based on data dtype and current colormap"""
        return self.colormap._data_to_texture(
            np.zeros(0, dtype=normalize_dtype(self.dtype))
        ).dtype

    def _post_init(self):
        self._reset_history()
        # Trigger generation of view slice and thumbnail
        self.refresh()
        self._reset_editable()

    @property
    def rendering(self):
        """Return current rendering mode.

        Selects a preset rendering mode in vispy that determines how
        lablels are displayed.  Options include:

        * ``translucent``: voxel colors are blended along the view ray until
          the result is opaque.
        * ``iso_categorical``: isosurface for categorical data.
          Cast a ray until a non-background value is encountered. At that
          location, lighning calculations are performed to give the visual
          appearance of a surface.

        Returns
        -------
        str
            The current rendering mode
        """
        return str(self._rendering)

    @rendering.setter
    def rendering(self, rendering):
        self._rendering = LabelsRendering(rendering)
        self.events.rendering()

    @property
    def iso_gradient_mode(self) -> str:
        """Return current gradient mode for isosurface rendering.

        Selects the finite-difference gradient method for the isosurface shader. Options include:
            * ``fast``: use a simple finite difference gradient along each axis
            * ``smooth``: use an isotropic Sobel gradient, smoother but more
              computationally expensive

        Returns
        -------
        str
            The current gradient mode
        """
        return str(self._iso_gradient_mode)

    @iso_gradient_mode.setter
    def iso_gradient_mode(self, value: IsoCategoricalGradientMode | str):
        self._iso_gradient_mode = IsoCategoricalGradientMode(value)
        self.events.iso_gradient_mode()

    @property
    def contiguous(self):
        """bool: fill bucket changes only connected pixels of same label."""
        return self._contiguous

    @contiguous.setter
    def contiguous(self, contiguous):
        self._contiguous = contiguous
        self.events.contiguous()

    @property
    def n_edit_dimensions(self):
        return self._n_edit_dimensions

    @n_edit_dimensions.setter
    def n_edit_dimensions(self, n_edit_dimensions):
        self._n_edit_dimensions = n_edit_dimensions
        self.events.n_edit_dimensions()

    @property
    def contour(self) -> int:
        """int: displays contours of labels instead of shaded regions."""
        return self._contour

    @contour.setter
    def contour(self, contour: int) -> None:
        if contour < 0:
            raise ValueError('contour value must be >= 0')
        self._contour = int(contour)
        self.events.contour()
        self.refresh(extent=False)

    @property
    def brush_size(self):
        """float: Size of the paint in world coordinates."""
        return self._brush_size

    @brush_size.setter
    def brush_size(self, brush_size):
        self._brush_size = int(brush_size)
        self.cursor_size = self._calculate_cursor_size()
        self.events.brush_size()

    def _calculate_cursor_size(self) -> int:
        # Convert from brush size in data coordinates to
        # cursor size in world coordinates
        scale = self._data_to_world.scale
        min_scale = np.min(
            [abs(scale[d]) for d in self._slice_input.displayed]
        )
        return abs(self.brush_size * min_scale)

    def new_colormap(self, seed: int | None = None):
        if seed is None:
            seed = int(np.random.default_rng().integers(2**32 - 1))

        orig = self._original_random_colormap
        new_cmap = shuffle_and_extend_colormap(
            self._original_random_colormap, seed
        )
        # Sync from the layer (source of truth) before assignment, so
        # `events.colormap` listeners observe the correct `use_selection`.
        new_cmap.use_selection = self._show_selected_label
        new_cmap.selection = self._selected_label
        self.colormap = new_cmap
        self._original_random_colormap = orig

    @property
    def colormap(self) -> LabelColormapBase:
        return self._colormap

    @colormap.setter
    def colormap(self, colormap: LabelColormapBase):
        self._set_colormap(colormap)

    def _set_colormap(self, colormap):
        colormap = _normalize_label_colormap(colormap)
        if isinstance(colormap, CyclicLabelColormap):
            self._random_colormap = colormap
            self._original_random_colormap = colormap
            self._colormap = self._random_colormap
            color_mode = LabelColorMode.AUTO
        else:
            self._direct_colormap = colormap
            # `self._direct_colormap.color_dict` may contain just the default None and background label
            # colors, in which case we need to be in AUTO color mode. Otherwise,
            # `self._direct_colormap.color_dict` contains colors for all labels, and we should be in DIRECT
            # mode.

            # For more information
            # - https://github.com/napari/napari/issues/2479
            # - https://github.com/napari/napari/issues/2953
            if self._is_default_colors(self._direct_colormap.color_dict):
                color_mode = LabelColorMode.AUTO
                self._colormap = self._random_colormap
            else:
                color_mode = LabelColorMode.DIRECT
                self._colormap = self._direct_colormap
        self._cached_labels = None  # invalidate the cached color mapping
        self._selected_color = self.get_color(self.selected_label)
        self._color_mode = color_mode
        self.events.colormap()  # Will update the LabelVispyColormap shader
        self.events.selected_label()
        self.refresh(extent=False)

    @ScalarFieldBase.data.setter  # type: ignore[attr-defined]
    def data(self, data: LayerDataProtocol | MultiScaleData) -> None:
        data = self._ensure_int_labels(data)
        ScalarFieldBase.data.fset(self, data)  # type: ignore[attr-defined]
        self.events.features()

    @property
    def features(self):
        """Dataframe-like features table.

        It is an implementation detail that this is a `pandas.DataFrame`. In the future,
        we will target the currently-in-development Data API dataframe protocol [1]_.
        This will enable us to use alternate libraries such as xarray or cuDF for
        additional features without breaking existing usage of this.

        If you need to specifically rely on the pandas API, please coerce this to a
        `pandas.DataFrame` using `features_to_pandas_dataframe`.

        References
        ----------
        .. [1] https://data-apis.org/dataframe-protocol/latest/API.html
        """
        return self._feature_table.values

    @features.setter
    def features(
        self,
        features: dict[str, np.ndarray] | pd.DataFrame,
    ) -> None:
        self._feature_table.set_values(features)
        self._label_index = self._make_label_index()
        self.events.properties()
        self.events.features()

    @property
    def properties(self) -> dict[str, np.ndarray]:
        """dict {str: array (N,)}, DataFrame: Properties for each label."""
        return self._feature_table.properties()

    @properties.setter
    def properties(self, properties: dict[str, Array]):
        self.features = properties

    def _make_label_index(self) -> dict[int, int]:
        features = self._feature_table.values
        label_index = {}
        if 'index' in features:
            label_index = {i: k for k, i in enumerate(features['index'])}
        elif features.shape[1] > 0:
            label_index = {i: i for i in range(features.shape[0])}
        return label_index

    def _is_default_colors(self, color: dict) -> bool:
        """Returns True if color contains only default colors, otherwise False.

        Default colors are black for `None` and transparent for
        `self.colormap.background_value`.

        Parameters
        ----------
        color : Dict
            Dictionary of label value to color array

        Returns
        -------
        bool
            True if color contains only default colors, otherwise False.
        """
        return (
            {None, self.colormap.background_value} == set(color.keys())
            and np.allclose(color[None], [0, 0, 0, 1])
            and np.allclose(
                color[self.colormap.background_value], [0, 0, 0, 0]
            )
        )

    def _ensure_int_labels(self, data):
        """Ensure data is integer by converting from bool if required, raising an error otherwise."""
        looks_multiscale, data = guess_multiscale(data)
        if not looks_multiscale:
            data = [data]
        int_data = []
        for data_level in data:
            # normalize_dtype turns e.g. tensorstore or torch dtypes into
            # numpy dtypes
            if np.issubdtype(normalize_dtype(data_level.dtype), np.floating):
                raise TypeError(
                    f'Only integer types are supported for Labels layers, but data contains {data_level.dtype}.'
                )
            if data_level.dtype == bool:
                int_data.append(data_level.view(np.uint8))
            else:
                int_data.append(data_level)
        data = int_data
        if not looks_multiscale:
            data = data[0]
        return data

    def _get_state(self) -> dict[str, Any]:
        """Get dictionary of layer state.

        Returns
        -------
        state : dict of str to Any
            Dictionary of layer state.
        """
        state = self._get_base_state()
        state.update(
            {
                'multiscale': self.multiscale,
                'properties': self.properties,
                'rendering': self.rendering,
                'iso_gradient_mode': self.iso_gradient_mode,
                'depiction': self.depiction,
                'plane': self.plane.model_dump(),
                'experimental_clipping_planes': [
                    plane.model_dump()
                    for plane in self.experimental_clipping_planes
                ],
                'data': self.data,
                'features': self.features,
                'colormap': self.colormap,
            }
        )
        return state

    def _validate_label_in_range(self, label: int) -> None:
        """Raise if ``label`` is outside the layer dtype's representable range."""
        layer_dtype = get_dtype(self)
        dtype_lims = get_dtype_limits(layer_dtype)
        if dtype_lims[0] > label or dtype_lims[1] < label:
            raise WrongSelectedLabelError(
                dtype=layer_dtype,
                value=label,
                lower_bound=dtype_lims[0],
                upper_bound=dtype_lims[1],
            )

    def _validate_non_painted_coord(
        self, slice_coord: list[int], dims_to_paint: list[int]
    ) -> None:
        """Resolve negative indexes and bounds-check non-painted dims."""
        for i, coord in enumerate(slice_coord):
            if i in dims_to_paint:
                continue
            resolved = coord + self.data.shape[i] if coord < 0 else coord
            if not 0 <= resolved < self.data.shape[i]:
                raise IndexError(
                    f'Coordinate {coord} for non-painted dimension {i} is out '
                    f'of bounds for axis with size {self.data.shape[i]}.'
                )
            slice_coord[i] = resolved

    @property
    def selected_label(self):
        """int: Index of selected label."""
        return self._selected_label

    @selected_label.setter
    def selected_label(self, selected_label):
        if selected_label == self.selected_label:
            return
        self._validate_label_in_range(selected_label)
        # when setting the label to the background, store the previous
        # otherwise, clear it
        if selected_label == self.colormap.background_value:
            self._prev_selected_label = self.selected_label
        else:
            self._prev_selected_label = None
        self.colormap.selection = selected_label
        self._selected_label = selected_label
        self._selected_color = self.get_color(selected_label)

        self.events.selected_label()

        if self.show_selected_label:
            self.refresh(extent=False)

    def swap_selected_and_background_labels(self) -> None:
        """Swap between the selected label and the background label."""
        if self.selected_label != self.colormap.background_value:
            self.selected_label = self.colormap.background_value
        else:
            self.selected_label = self._prev_selected_label

    @property
    def show_selected_label(self):
        """Whether to filter displayed labels to only the selected label or not"""
        return self._show_selected_label

    @show_selected_label.setter
    def show_selected_label(self, show_selected):
        self._show_selected_label = show_selected
        self.colormap.use_selection = show_selected
        self.colormap.selection = self.selected_label
        self.events.show_selected_label(show_selected_label=show_selected)
        self.refresh(extent=False)

    # Only overriding to change the docstring
    @property
    def mode(self):
        """MODE: Interactive mode. The normal, default mode is PAN_ZOOM, which
        allows for normal interactivity with the canvas.

        In PICK mode the cursor functions like a color picker, setting the
        clicked on label to be the current label. If the background is picked it
        will select the background label `0`.

        In PAINT mode the cursor functions like a paint brush changing any
        pixels it brushes over to the current label. If the background label
        `0` is selected than any pixels will be changed to background and this
        tool functions like an eraser. The size and shape of the cursor can be
        adjusted in the properties widget.

        In FILL mode the cursor functions like a fill bucket replacing pixels
        of the label clicked on with the current label. It can either replace
        all pixels of that label or just those that are contiguous with the
        clicked on pixel. If the background label `0` is selected than any
        pixels will be changed to background and this tool functions like an
        eraser.

        In ERASE mode the cursor functions similarly to PAINT mode, but to
        paint with background label, which effectively removes the label.
        """
        return super().mode

    # Only overriding to change the docstring of the setter above
    @mode.setter
    def mode(self, mode):
        # See https://github.com/python/mypy/issues/16426 for type ignore reason
        Layer.mode.fset(self, mode)  # type: ignore[attr-defined]

    def _mode_setter_helper(self, mode):
        mode = super()._mode_setter_helper(mode)
        if mode == self._mode:
            return mode

        self._overlays['polygon'].enabled = mode == Mode.POLYGON
        self._overlays['brush_stroke'].enabled = mode == Mode.PAINT
        if mode in {Mode.PAINT, Mode.ERASE}:
            self.cursor_size = self._calculate_cursor_size()

        return mode

    @property
    def preserve_labels(self):
        """Defines if painting should preserve existing labels.

        Default to false to allow paint on existing labels. When
        set to true, existing labels will be preserved during painting.
        """
        return self._preserve_labels

    @preserve_labels.setter
    def preserve_labels(self, preserve_labels: bool):
        self._preserve_labels = preserve_labels
        self.events.preserve_labels(preserve_labels=preserve_labels)

    def _reset_editable(self) -> None:
        self.editable = not self.multiscale

    def _on_editable_changed(self) -> None:
        if not self.editable:
            self.mode = Mode.PAN_ZOOM
            self._reset_history()

    @staticmethod
    def _to_vispy_texture_dtype(data):
        """Convert data to a dtype that can be used as a VisPy texture.

        Labels layers allow all integer dtypes for data, but only a subset
        are supported by VisPy textures. For now, we convert all data to
        float32 as it can represent all input values (though not losslessly,
        see https://github.com/napari/napari/issues/6084).
        """
        return vispy_texture_dtype(data)

    def _partial_labels_refresh(self) -> None:
        """Prepares and displays only an updated part of the labels."""

        if self._updated_slice is None or not self._slicing_state.loaded:
            return

        dims_displayed = self._slice_input.displayed
        raw_displayed = self._slice.image.raw

        # Keep only the dimensions that correspond to the current view
        updated_slice = tuple(
            self._updated_slice[index] for index in dims_displayed
        )

        offset = [axis_slice.start for axis_slice in updated_slice]

        if self.contour > 0:
            colors_sliced = self._raw_to_displayed(
                raw_displayed, data_slice=updated_slice
            )
        else:
            colors_sliced = self._slice.image.view[updated_slice]
        # The next line is needed to make the following tests pass in
        # napari/_vispy/_tests/:
        # - test_vispy_labels_layer.py::test_labels_painting
        # - test_vispy_labels_layer.py::test_labels_fill_slice
        # See https://github.com/napari/napari/pull/6112/files#r1291613760
        # and https://github.com/napari/napari/issues/6185
        self._slice.image.view[updated_slice] = colors_sliced

        self.events.labels_update(data=colors_sliced, offset=offset)
        self._updated_slice = None

    def _calculate_contour(
        self, labels: np.ndarray, data_slice: tuple[slice, ...]
    ) -> np.ndarray | None:
        """Calculate the contour of a given label array within the specified data slice.

        Parameters
        ----------
        labels : np.ndarray
            The label array.
        data_slice : Tuple[slice, ...]
            The slice of the label array on which to calculate the contour.

        Returns
        -------
        Optional[np.ndarray]
            The calculated contour as a boolean mask array.
            Returns None if the contour parameter is less than 1,
            or if the label array has more than 2 dimensions.
        """
        if self.contour < 1:
            return None
        if labels.ndim > 2:
            warnings.warn('Contours are not displayed during 3D rendering')
            return None

        contour_offset = max(1, int(self.contour))
        expanded_slice = expand_slice(data_slice, labels.shape, contour_offset)
        sliced_labels = get_contours(
            labels[expanded_slice],
            self.contour,
            self.colormap.background_value,
        )

        # Remove the border that was added to compute thick contours locally.
        delta_slice = tuple(
            slice(s1.start - s2.start, s1.stop - s2.start)
            for s1, s2 in zip(data_slice, expanded_slice, strict=False)
        )
        return sliced_labels[delta_slice]

    def _raw_to_displayed(
        self, raw, data_slice: tuple[slice, ...] | None = None
    ) -> np.ndarray:
        """Determine displayed image from a saved raw image and a saved seed.

        This function ensures that the 0 label gets mapped to the 0 displayed
        pixel.

        Parameters
        ----------
        raw : array or int
            Raw integer input image.

        data_slice : numpy array slice
            Slice that specifies the portion of the input image that
            should be computed and displayed.
            If None, the whole input image will be processed.

        Returns
        -------
        mapped_labels : array
            Encoded colors mapped between 0 and 1 to be displayed.
        """

        if data_slice is None:
            data_slice = tuple(slice(0, size) for size in raw.shape)

        labels = raw  # for readability

        sliced_labels = self._calculate_contour(labels, data_slice)

        # lookup function -> self._as_type

        if sliced_labels is None:
            sliced_labels = labels[data_slice]

        return self.colormap._data_to_texture(sliced_labels)

    def _update_thumbnail(self):
        """Update the thumbnail with current data and colormap.

        This is overridden from _ImageBase because we don't need to do things
        like adjusting gamma or changing the data based on the contrast
        limits.
        """
        from scipy import ndimage as ndi

        if not self._slicing_state.loaded or self._slice.empty:
            # ASYNC_TODO: Do not compute the thumbnail until we are loaded.
            # Is there a nicer way to prevent this from getting called?
            return

        image = self._slice.thumbnail.raw
        if self._slice_input.ndisplay == 3 and self.ndim > 2:
            # we are only using the current slice so `image` will never be
            # bigger than 3. If we are in this clause, it is exactly 3, so we
            # use max projection. For labels, ideally we would use "first
            # nonzero projection", but we leave that for a future PR. (TODO)
            image = np.max(image, axis=0)
        imshape = np.array(image.shape[:2])
        thumbshape = np.array(self._thumbnail_shape[:2])

        raw_zoom_factor = np.min(thumbshape / imshape)
        new_shape = np.clip(
            raw_zoom_factor * imshape, a_min=1, a_max=thumbshape
        )
        zoom_factor = tuple(new_shape / imshape)

        downsampled = ndi.zoom(image, zoom_factor, prefilter=False, order=0)
        color_array = self.colormap.map(downsampled)
        color_array[..., 3] *= self.opacity

        self.thumbnail = color_array

    def get_color(self, label):
        """Return the color corresponding to a specific label."""
        if label == self.colormap.background_value:
            col = None
        elif label is None or (
            self.show_selected_label and label != self.selected_label
        ):
            col = self.colormap.map(self.colormap.background_value)
        else:
            col = self.colormap.map(label)
        return col

    def _reset_history(self, event: Event | None = None) -> None:
        self._undo_history = deque(maxlen=self._history_limit)
        self._redo_history = deque(maxlen=self._history_limit)
        self._staged_history = []
        self._block_history = False

    @contextmanager
    def block_history(self):
        """Context manager to group history-editing operations together.

        While in the context, history atoms are grouped together into a
        "staged" history. When exiting the context, that staged history is
        committed to the undo history queue, and an event is emitted
        containing the change.
        """
        prev = self._block_history
        self._block_history = True
        try:
            yield
            self._commit_staged_history()
        finally:
            self._block_history = prev

    def _commit_staged_history(self):
        """Save staged history to undo history and clear it."""
        if self._staged_history:
            self._append_to_undo_history(self._staged_history)
            self._staged_history = []

    def _begin_stroke(self):
        """Start grouping edits that span multiple events into one undo item.

        Unlike `block_history`, a stroke spans discrete mouse events and so
        cannot be expressed as a single `with` block.
        """
        self._block_history = True

    def _commit_stroke(self):
        """Commit a stroke started with `_begin_stroke` as one undo item."""
        self._block_history = False
        self._commit_staged_history()

    def _abort_stroke(self) -> None:
        """Discard the staged (uncommitted) edits of an in-progress stroke."""
        for atom in reversed(self._staged_history):
            if isinstance(atom, _MaskedPaintAtom):
                self._replay_masked_atom(atom, undoing=True)
                continue
            indices, prev_values, _ = atom
            self.data[indices] = prev_values
        self._staged_history = []
        self._block_history = False
        self.refresh()

    def _append_to_undo_history(self, item):
        """Append item to history and emit paint event.

        Parameters
        ----------
        item : list of HistoryAtoms
            They are applied together as a single undoable step.
        """
        self._undo_history.append(item)
        self.events.paint(value=item)

    def _save_history(self, value):
        """Save a history "atom" to the undo history.

        A history "atom" is a single change operation to the array. A history
        *item* is a collection of atoms that were applied together to make a
        single change. For example, when dragging and painting, at each mouse
        callback we create a history "atom", but we save all those atoms in
        a single history item, since we would want to undo one drag in one
        undo operation.

        Parameters
        ----------
        value : _MaskedPaintAtom or 3-tuple of arrays
            The change to record. The mask-based editing methods (paint,
            fill, paint_polygon) store a ``_MaskedPaintAtom``;
            ``data_setitem`` stores a 3-tuple containing:

            - a numpy multi-index, pointing to the array elements that were
              changed
            - the values corresponding to those elements before the change
            - the value(s) after the change
        """
        self._redo_history.clear()
        if self._block_history:
            self._staged_history.append(value)
        else:
            self._append_to_undo_history([value])

    def _load_history(self, before, after, undoing=True):
        """Load a history item and apply it to the array.

        Parameters
        ----------
        before : list of history items
            The list of elements from which we want to load.
        after : list of history items
            The list of element to which to append the loaded element. In the
            case of an undo operation, this is the redo queue, and vice versa.
        undoing : bool
            Whether we are undoing (default) or redoing. When redoing, each
            atom is replayed forwards, applying its "after change" value
            (``new_value`` for a ``_MaskedPaintAtom``, or the third element
            of the legacy 3-tuple).

        See Also
        --------
        Labels._save_history
        """
        if len(before) == 0:
            return

        history_item = before.pop()
        after.append(list(reversed(history_item)))
        for atom in reversed(history_item):
            if isinstance(atom, _MaskedPaintAtom):
                self._replay_masked_atom(atom, undoing)
                continue
            prev_indices, prev_values, next_values = atom
            self.data[prev_indices] = prev_values if undoing else next_values

        self.refresh()

    def _replay_masked_atom(
        self, atom: _MaskedPaintAtom, undoing: bool
    ) -> None:
        """Replay a mask-based edit via read-modify-write.

        Only basic slicing reaches the data backend; the masked update is
        applied locally on a numpy array, so replay does not depend on any
        backend's advanced-indexing semantics.
        """
        values = atom.old_values if undoing else atom.new_value
        if atom.mask is None:
            # The whole bounding box changed: assign directly.
            self.data[atom.slice_key] = values
            return
        region = np.asarray(self.data[atom.slice_key])
        region[atom.mask] = values
        self.data[atom.slice_key] = region

    def undo(self) -> None:
        self._load_history(
            self._undo_history, self._redo_history, undoing=True
        )

    def redo(self) -> None:
        self._load_history(
            self._redo_history, self._undo_history, undoing=False
        )

    def fill(
        self,
        coord: Sequence[float],
        new_label: int,
        refresh: bool = True,
    ) -> None:
        """Replace an existing label with a new label.

        This replaces the label at the cursor position with `new_label`.
        If `contiguous` is True, only the orthogonally connected component is
        replaced using ``skimage.segmentation.flood(..., connectivity=1)``.
        Otherwise, all pixels with the same label are replaced.

        Parameters
        ----------
        coord : sequence of float
            Position of mouse cursor in image coordinates.
            Note: Floats are rounded to the nearest integer before indexing.
        new_label : int
            Value of the new label to be filled in.
        refresh : bool
            Whether to refresh view slice or not. Set to False to batch paint
            calls.
        """
        self._validate_label_in_range(new_label)
        _, dims_to_paint = self._get_shape_and_dims_to_paint()

        slice_coord = [int(np.round(c)) for c in coord]
        self._validate_non_painted_coord(slice_coord, dims_to_paint)

        fill_info = self._get_flood_mask_and_bbox(
            slice_coord, new_label, dims_to_paint
        )

        if fill_info is None:
            return

        mask, min_vals, max_vals, region_data = fill_info
        slice_key = self._build_slice_key(
            slice_coord, dims_to_paint, min_vals, max_vals
        )

        self._paint_region_with_mask(
            slice_key,
            mask,
            new_label,
            dims_to_paint,
            refresh,
            region_data=region_data,
        )

    def _get_flood_mask_and_bbox(
        self,
        coord: Sequence[float],
        new_label: int,
        dims_to_paint: list[int],
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None:
        """Compute the mask and bounding box for a flood fill operation.

        Parameters
        ----------
        coord : Sequence[float]
            Position of mouse cursor in image coordinates.
        new_label : int
            Value of the new label to be filled in.
        dims_to_paint : list[int]
            The dimensions across which the fill will be performed.

        Returns
        -------
        tuple | None
            A 4-tuple containing:
            - mask : np.ndarray
                Boolean mask indicating the region to be filled.
            - min_vals : np.ndarray
                Minimum (inclusive) indices for each painted dimension.
            - max_vals : np.ndarray
                Maximum (exclusive) indices for each painted dimension.
            - region_data : np.ndarray
                The data within the bounding box.
            Returns None if the fill operation is invalid.
        """
        from skimage.segmentation import flood

        int_coord = tuple(np.round(coord).astype(int))
        # If requested fill location is outside data shape then return
        if np.any(np.less(int_coord, 0)) or np.any(
            np.greater_equal(int_coord, self.data.shape)
        ):
            return None

        # If requested new label doesn't change old label then return
        old_label = np.asarray(self.data[int_coord]).item()
        if old_label == new_label:
            return None

        if self.preserve_labels:
            source_label = self._get_preserve_labels_source_label(new_label)
            if old_label != source_label:
                return None

        # Create the slice to extract the full working volume/plane
        data_slice_list = list(int_coord)
        for dim in dims_to_paint:
            data_slice_list[dim] = slice(None)
        data_slice = tuple(data_slice_list)

        labels = np.asarray(self.data[data_slice])

        # Coordinate of the seed point relative to the extracted labels
        slice_coord = tuple(int_coord[d] for d in dims_to_paint)

        if self.contiguous:
            mask = flood(labels, slice_coord, connectivity=1)
        else:
            mask = labels == old_label

        # Calculate bounding box of the mask to minimize update size.
        # When the fill already spans the full extracted region, skip the
        # bbox scan and reuse the full slice directly.
        mask_is_full = mask.all()
        if mask_is_full:
            bbox_slices = tuple(slice(None) for _ in dims_to_paint)
            cropped_mask = mask
            min_vals = np.zeros(mask.ndim, dtype=int)
            max_vals = np.array(mask.shape)
        else:
            min_vals, max_vals = self._compute_mask_bbox(mask)

            bbox_slices = tuple(
                slice(min_v, max_v)
                for min_v, max_v in zip(min_vals, max_vals, strict=True)
            )
            cropped_mask = mask[bbox_slices]

        return cropped_mask, min_vals, max_vals, labels[bbox_slices]

    def _get_preserve_labels_source_label(self, new_label: int) -> int:
        """Return the existing label value that preserve_labels allows to change.

        Painting with a non-background label may only replace background.
        Painting with the background label may only replace the previously
        selected label, falling back to the current selected label when no
        previous label is stored.

        Parameters
        ----------
        new_label : int
            The label value that will be written.

        Returns
        -------
        int
            The only existing label value that may be overwritten.
        """
        if new_label == self.colormap.background_value:
            if self._prev_selected_label is not None:
                return self._prev_selected_label
            return self.selected_label
        return self.colormap.background_value

    def _draw(self, new_label, last_cursor_coord, coordinates):
        """Paint into coordinates, accounting for mode and cursor movement.

        The draw operation depends on the current mode of the layer.

        Parameters
        ----------
        new_label : int
            value of label to paint
        last_cursor_coord : sequence
            last painted cursor coordinates
        coordinates : sequence
            new cursor coordinates
        """
        if coordinates is None:
            return
        interp_coord = interpolate_coordinates(
            last_cursor_coord, coordinates, self.brush_size
        )
        for c in interp_coord:
            if (
                self._slice_input.ndisplay == 3
                and self.data[tuple(np.round(c).astype(int))] == 0
            ):
                continue
            if self._mode in [Mode.PAINT, Mode.ERASE]:
                self.paint(c, new_label, refresh=False)
            elif self._mode == Mode.FILL:
                self.fill(c, new_label, refresh=False)
        self._partial_labels_refresh()

    def paint(
        self,
        coord: Sequence[float],
        new_label: int,
        refresh: bool = True,
    ) -> None:
        """Paint over existing labels with a new label.

        This uses the selected brush shape and size, painting either on the
        visible slice or in n dimensions depending on `n_edit_dimensions`.

        Parameters
        ----------
        coord : Sequence[float]
            Position of mouse cursor in image coordinates.
        new_label : int
            Value of the new label to be filled in.
        refresh : bool
            Whether to refresh view slice or not. Set to False to batch paint
            calls.
        """
        self._validate_label_in_range(new_label)
        shape, dims_to_paint = self._get_shape_and_dims_to_paint()

        slice_coord = [int(np.round(c)) for c in coord]
        self._validate_non_painted_coord(slice_coord, dims_to_paint)

        brush_info = self._get_brush_mask_and_bbox(
            slice_coord, dims_to_paint, shape
        )

        if brush_info is None:
            return

        mask, min_vals, max_vals = brush_info
        slice_key = self._build_slice_key(
            slice_coord, dims_to_paint, min_vals, max_vals
        )

        self._paint_region_with_mask(
            slice_key, mask, new_label, dims_to_paint, refresh
        )

    def _get_brush_mask_and_bbox(
        self,
        coord: Sequence[float],
        dims_to_paint: list[int],
        shape: list[int],
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
        """Compute the mask and bounding box for a brush painting operation.

        Parameters
        ----------
        coord : Sequence[float]
            Position of mouse cursor in image coordinates.
        dims_to_paint : list[int]
            The dimensions across which the painting will be performed.
        shape : list[int]
            The shape of the data being painted.

        Returns
        -------
        tuple | None
            A 3-tuple containing:
            - mask : np.ndarray
                Boolean mask indicating the brush region.
            - min_vals : np.ndarray
                Minimum (inclusive) indices for each painted dimension.
            - max_vals : np.ndarray
                Maximum (exclusive) indices for each painted dimension.
            Returns None if the brush operation is invalid.
        """
        paint_scale = np.array(
            [self.scale[i] for i in dims_to_paint], dtype=float
        )

        if self.n_edit_dimensions < self.ndim:
            coord_paint = np.array([coord[i] for i in dims_to_paint])
        else:
            coord_paint = np.array(coord)

        # Ensure circle doesn't have spurious point on edge by keeping radius as 0.5
        radius = np.floor(self.brush_size / 2) + 0.5

        # Radius in pixels for each dimension (accounting for scale)
        # Use floor to match old sphere_indices behavior: points where dist <= radius
        # means integer coordinates from -floor(radius) to +floor(radius)
        normalized_scale = np.abs(paint_scale)
        normalized_scale = normalized_scale / np.min(normalized_scale)
        radius_pixels = np.floor(radius / normalized_scale).astype(int)

        center = np.round(coord_paint).astype(int)

        min_vals = np.maximum(center - radius_pixels, 0)
        max_vals = np.minimum(center + radius_pixels + 1, shape)

        if np.any(min_vals >= max_vals):
            return None

        bbox_shape = tuple(max_vals - min_vals)
        center_in_bbox = center - min_vals

        brush_mask = self._create_brush_mask(
            bbox_shape, radius, center_in_bbox, normalized_scale
        )

        if not np.any(brush_mask):
            return None

        return brush_mask, min_vals, max_vals

    def paint_polygon(
        self,
        points: list | np.ndarray,
        new_label: int,
    ) -> None:
        """Paint a polygon over existing labels with a new label.

        Uses a bounding box optimization: extracts a rectangular region around
        the polygon, paints within that region in-place, then writes back.
        This approach works for both contiguous and non-contiguous dimension orderings.

        Parameters
        ----------
        points : list or array
            List of coordinates of the vertices of a polygon.
        new_label : int
            Value of the new label to be filled in.
        """
        self._validate_label_in_range(new_label)
        shape, dims_to_paint = self._get_shape_and_dims_to_paint()

        points = np.array(points, dtype=int)
        slice_coord = points[0].tolist()
        self._validate_non_painted_coord(slice_coord, dims_to_paint)
        polygon_info = self._get_polygon_mask_and_bbox(
            points, dims_to_paint, shape
        )

        if polygon_info is None:
            return

        mask, min_vals, max_vals = polygon_info
        slice_key = self._build_slice_key(
            slice_coord, dims_to_paint, min_vals, max_vals
        )

        self._paint_region_with_mask(slice_key, mask, new_label, dims_to_paint)

    def _get_polygon_mask_and_bbox(
        self,
        points: np.ndarray,
        dims_to_paint: list[int],
        shape: list[int],
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
        """Compute the mask and bounding box for a polygon painting operation.

        Parameters
        ----------
        points : np.ndarray
            Integer array of coordinates of the vertices of a polygon.
        dims_to_paint : list[int]
            The dimensions across which the painting will be performed.
        shape : list[int]
            The shape of the data being painted.

        Returns
        -------
        tuple | None
            A 3-tuple containing:
            - mask : np.ndarray
                Boolean mask indicating the polygon region.
            - min_vals : np.ndarray
                Minimum (inclusive) indices for each painted dimension.
            - max_vals : np.ndarray
                Maximum (exclusive) indices for each painted dimension.
            Returns None if the polygon operation is invalid.

        Raises
        ------
        NotImplementedError
            If ``dims_to_paint`` is not 2D, because polygon painting is 2D only.
        """
        if len(dims_to_paint) != 2:
            raise NotImplementedError(
                'Polygon painting is implemented only in 2D.'
            )

        points2d = points[:, dims_to_paint]

        min_vals = np.min(points2d, axis=0)
        max_vals = np.max(points2d, axis=0) + 1  # exclusive

        min_vals = np.maximum(min_vals, 0)
        max_vals = np.minimum(max_vals, shape)

        if np.any(min_vals >= max_vals):
            return None

        bbox_shape = list(max_vals - min_vals)
        points2d_shifted = points2d - min_vals

        polygon_mask = self._create_polygon_mask(points2d_shifted, bbox_shape)

        if not np.any(polygon_mask):
            return None

        return polygon_mask, min_vals, max_vals

    @staticmethod
    def _create_polygon_mask(
        points2d: np.ndarray, shape: list[int]
    ) -> np.ndarray:
        """Create a boolean mask from polygon points using PIL rasterization.

        Parameters
        ----------
        points2d : ndarray
            2D polygon vertices in (row, col) format, relative to the mask
            coordinate system.
        shape : list of int
            Shape of the mask to create [height, width].

        Returns
        -------
        ndarray
            Boolean mask with True inside polygon.
        """
        from PIL import Image, ImageDraw

        # PIL uses (x, y) = (col, row), so reverse the points
        img = Image.new('L', (shape[1], shape[0]), 0)
        draw = ImageDraw.Draw(img)
        points_pil = [tuple(p[::-1]) for p in points2d]
        draw.polygon(points_pil, outline=1, fill=1)
        return np.array(img, dtype=bool)

    @staticmethod
    def _create_brush_mask(
        shape: tuple[int, ...],
        radius: float,
        center: np.ndarray,
        normalized_scale: np.ndarray,
    ) -> np.ndarray:
        """Create boolean mask for nD circular/spherical/ellipsoidal brush.

        This method generates a boolean mask for brushes in arbitrary dimensions,
        supporting 2D circles, 3D spheres, 4D hyperspheres, etc. with anisotropic
        scaling (ellipsoids). The mask is created by computing the scaled distance
        from the center point and comparing against the radius.

        Parameters
        ----------
        shape : tuple of int
            Shape of the mask to create (matches bounding box shape)
        radius : float
            Brush radius in data coordinates
        center : ndarray
            Center position within the mask (already adjusted for bounding box offset)
            Shape: (n_edit_dimensions,)
        normalized_scale : ndarray
            Scale factors normalized so the smallest painted axis has scale 1.
            These are used to support ellipsoids with anisotropic scaling.
            Shape: (n_edit_dimensions,)

        Returns
        -------
        ndarray
            Boolean mask with True inside brush shape, False outside
            Shape matches input `shape` parameter
        """
        ndim = len(shape)

        grids = np.ogrid[tuple(slice(0, s) for s in shape)]

        # Calculate squared distance from center in scaled space
        dist_sq = sum(
            ((grids[i] - center[i]) * normalized_scale[i]) ** 2
            for i in range(ndim)
        )

        return dist_sq <= radius**2

    @staticmethod
    def _compute_mask_bbox(mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
        """Compute the bounding box of True values in a boolean mask.

        Uses np.argmax on axis projections to find the first and last True
        values along each dimension, avoiding intermediate index arrays.

        Parameters
        ----------
        mask : np.ndarray
            Boolean mask array. Must contain at least one True value.

        Returns
        -------
        min_vals : np.ndarray
            Minimum (inclusive) index for each dimension.
        max_vals : np.ndarray
            Maximum (exclusive) index for each dimension.
        """
        min_vals = np.empty(mask.ndim, dtype=int)
        max_vals = np.empty(mask.ndim, dtype=int)

        for i in range(mask.ndim):
            axes = tuple(j for j in range(mask.ndim) if j != i)
            any_on_axis = np.any(mask, axis=axes)
            # argmax finds the first True; reverse search finds the last
            min_vals[i] = np.argmax(any_on_axis)
            max_vals[i] = len(any_on_axis) - np.argmax(any_on_axis[::-1])

        return min_vals, max_vals

    @staticmethod
    def _build_slice_key(
        slice_coord: list[int],
        dims_to_paint: list[int],
        min_vals: np.ndarray,
        max_vals: np.ndarray,
    ) -> tuple[slice, ...]:
        """Build an N-dimensional slice key with bounding-box slices for painted dims.

        Parameters
        ----------
        slice_coord : list[int]
            Base coordinate list for the volume slice; non-painted dims
            become length-1 slices around their coordinate.
        dims_to_paint : list[int]
            Dimensions that will be painted (these receive the bbox bounds)
        min_vals, max_vals : ndarray
            Minimum (inclusive) and maximum (exclusive) bounds for each painted dimension

        Returns
        -------
        tuple of slice
            N-dimensional slice key. Every entry is a slice with explicit
            bounds, so indexing with it never drops dimensions.
        """
        slice_key_list = [
            slice(int(coord), int(coord) + 1) for coord in slice_coord
        ]
        for i, dim in enumerate(dims_to_paint):
            slice_key_list[dim] = slice(int(min_vals[i]), int(max_vals[i]))
        return tuple(slice_key_list)

    def _expand_updated_slice_for_contour(
        self, updated_slice: tuple[slice, ...]
    ) -> tuple[slice, ...]:
        """Expand dirty slices so contour refresh includes the full contour halo."""
        if self.contour < 1:
            return updated_slice

        contour_offset = max(1, int(self.contour))
        return expand_slice(updated_slice, self.data.shape, contour_offset)

    def _accumulate_updated_slice(
        self, updated_slice: tuple[slice, ...]
    ) -> None:
        """Merge a newly dirtied region into the pending partial refresh."""
        updated_slice = self._expand_updated_slice_for_contour(updated_slice)

        if self._updated_slice is None:
            self._updated_slice = updated_slice
            return

        self._updated_slice = tuple(
            slice(min(s1.start, s2.start), max(s1.stop, s2.stop))
            for s1, s2 in zip(self._updated_slice, updated_slice, strict=True)
        )

    def _paint_region_with_mask(
        self,
        slice_key: tuple[slice, ...],
        mask: np.ndarray,
        new_label: int,
        dims_to_paint: list[int],
        refresh: bool = True,
        region_data: np.ndarray | None = None,
    ) -> None:
        """Universal painting method using a boolean mask within a bounding box.

        This method extracts the common pattern shared by brush painting, fill
        and polygon painting: extract a region, apply a mask, write back, update
        caches. It manages cache updates for non-shared memory backends, respects
        the preserve_labels setting (handled by _apply_mask_to_data), and updates
        undo/redo history.

        Parameters
        ----------
        slice_key : tuple of slice
            N-dimensional slice key in volume space specifying the bounding box
            region; length-1 slices for non-painted dims. Example:
            (slice(5, 6), slice(100, 200), slice(150, 250)) for a 3D volume
            where dims_to_paint=[1, 2].
        mask : ndarray
            Boolean mask indicating which pixels to paint within the bounding
            box, with one axis per painted dimension.
        new_label : int
            Label value to paint.
        dims_to_paint : list of int
            Indices of dimensions being painted (e.g., [1, 2] for YX in a ZYX volume).
        refresh : bool, optional
            Whether to refresh the display after painting. Default is True.
        region_data : np.ndarray | None, optional
            Pre-loaded data for the region defined by slice_key, with one axis
            per painted dimension. If None (default), data will be read from
            self.data[slice_key]. Providing this avoids redundant reads when
            data is already available (e.g. in fill).
        """
        # slice_key consists solely of slices, so the extracted region keeps
        # the full data dimensionality; give the painted-dims mask (and a
        # pre-loaded region, if any) matching length-1 axes.
        extra_axes = tuple(
            dim for dim in range(self.ndim) if dim not in dims_to_paint
        )
        if extra_axes:
            mask = np.expand_dims(mask, extra_axes)
            if region_data is not None:
                region_data = np.expand_dims(region_data, extra_axes)

        if region_data is None:
            region_data = np.asarray(self.data[slice_key])

        effective_mask = self._apply_mask_to_data(
            region_data, mask, new_label, slice_key
        )

        if effective_mask is None:
            return

        # For numpy-backed data, region_data is a view into self.data, so
        # _apply_mask_to_data already wrote through and this assignment is a
        # no-op; for copy-returning backends (zarr, tensorstore, dask, ...)
        # this is the actual write-back.
        self.data[slice_key] = region_data

        # Update caches (raw and view) for non-shared memory backends
        # This handles mapping the N-D painted region to the currently displayed slice
        self._refresh_caches_from_region(
            region_data, slice_key, effective_mask, new_label
        )

        # Accumulate updated slices for batch painting (refresh=False)
        self._accumulate_updated_slice(slice_key)

        if refresh:
            self._partial_labels_refresh()

    def _apply_mask_to_data(
        self,
        data: np.ndarray,
        mask: np.ndarray,
        new_label: int,
        volume_slice: tuple[slice, ...],
    ) -> np.ndarray | None:
        """Paint within a data region using a boolean mask.

        This method operates on the data array in-place, using boolean mask
        indexing. It handles preserve_labels logic and optimization to skip
        unchanged pixels. The return value is the effective mask of pixels that
        were actually painted.

        Parameters
        ----------
        data : np.ndarray
            The data to paint (e.g., extracted region/bounding box around polygon).
            This array is modified IN PLACE.
        mask : np.ndarray
            Boolean mask indicating which pixels to paint, with shape matching data.
        new_label : int
            Label value to paint.
        volume_slice : tuple of slice
            Slice key in volume space for undo/redo history.

        Returns
        -------
        np.ndarray | None
            Boolean mask of the pixels that were painted, or None if the
            operation made no changes.

        Notes
        -----
        - Saves to undo/redo history
        - Does NOT update caches or refresh display (caller's responsibility)
        """
        effective_mask = np.array(mask, copy=True)

        if self.preserve_labels:
            source_label = self._get_preserve_labels_source_label(new_label)
            keep_mask = data == source_label
            if not np.any(keep_mask):
                return None
            effective_mask &= keep_mask

        effective_mask &= data != new_label
        if not np.any(effective_mask):
            return None

        self._save_history(
            self._history_atom_for_mask_paint(
                volume_slice, data, effective_mask, new_label
            )
        )
        data[effective_mask] = new_label

        return effective_mask

    @staticmethod
    def _history_atom_for_mask_paint(
        volume_slice: tuple[slice, ...],
        region_data: np.ndarray,
        mask: np.ndarray,
        new_label: int,
    ) -> _MaskedPaintAtom:
        """Build the undo history atom for a bbox+mask edit.

        When every pixel in the bbox changed, the mask is dropped and a
        snapshot of the old bbox is stored instead (redo only needs the
        scalar label). Otherwise the mask and the old values under it are
        stored. The atom takes ownership of ``mask``, which must not be
        mutated afterwards.
        """
        if mask.all():
            return _MaskedPaintAtom(
                volume_slice, None, region_data.copy(), new_label
            )
        return _MaskedPaintAtom(
            volume_slice, mask, region_data[mask], new_label
        )

    def _refresh_caches_from_region(
        self,
        region_data: np.ndarray,
        slice_key: tuple[slice, ...],
        mask: np.ndarray,
        new_label: int,
    ) -> None:
        """Update raw and view caches with new data from a painted region.

        Checks if the painted region intersects with the currently displayed slice.
        If so, extracts the visible sub-region and updates the caches. This method
        handles painting on non-shared memory backends (zarr, tensorstore, etc.)
        that require manual cache updates.

        Parameters
        ----------
        region_data : np.ndarray
            The data of the painted region, with full data dimensionality.
        slice_key : tuple of slice
            The slices used to extract region_data from the full volume;
            length-1 slices for non-painted dims.
        mask : np.ndarray
            Boolean mask indicating modified pixels in region_data.
            Used to optimize texture updates.
        new_label : int
            The new label value that was painted. Used to optimize texture updates.
        """
        # If the slice has not loaded yet (e.g. async slicing), the caches
        # are placeholders; the pending slice load will pick up the painted
        # data directly, so there is nothing to patch here.
        if not self._slicing_state.loaded or self._slice.empty:
            return

        # Invariant: for numpy-backed data, both raw and view caches are
        # already views into self.data, so manual cache patching would be
        # redundant.
        if isinstance(self.data, np.ndarray) and np.shares_memory(
            self.data, self._slice.image.view
        ):
            return

        update_slices = self._get_update_slices(slice_key)
        if update_slices is None:
            return

        region_slices, view_slices = update_slices

        # Extract visible data from region_data using computed slices
        visible_data = region_data[tuple(region_slices)]
        visible_mask = mask[tuple(region_slices)]

        visible_data, visible_mask = self._align_data_to_view(
            visible_data, visible_mask
        )

        # Update raw cache (always safe to do if not sharing memory, updates display source)
        if not (
            isinstance(self.data, np.ndarray)
            and np.shares_memory(self.data, self._slice.image.raw)
        ):
            self._slice.image.raw[tuple(view_slices)] = visible_data

        # Contours are recomputed from the raw cache during
        # _partial_labels_refresh (via _raw_to_displayed), so patching the
        # texture view cache here would be redundant and immediately stale.
        if self.contour > 0:
            return

        # Update texture view cache by compute new color only once
        new_color = self.colormap._data_to_texture(
            np.array([new_label], dtype=visible_data.dtype)
        )[0]

        # Update cache in-place for changed pixels only
        if visible_mask.all():
            self._slice.image.view[tuple(view_slices)] = new_color
        else:
            self._slice.image.view[tuple(view_slices)][visible_mask] = (
                new_color
            )

    def _get_update_slices(
        self, slice_key: tuple[slice, ...]
    ) -> tuple[list[slice | int], list[slice]] | None:
        """Calculate slices for extracting region data and updating the view.

        Returns
        -------
        tuple[list[slice | int], list[slice]] | None
            A tuple (region_slices, view_slices). region_slices extracts
            the currently visible part of the painted region (integer
            indices collapse the non-displayed dims); view_slices addresses
            that part within the displayed slice caches.
            Returns None if the painted region is not currently visible.
        """
        displayed_dims = self._slice_input.displayed
        pt_not_disp = self._get_pt_not_disp()

        # Two coordinate systems: region_slices indexes region_data (full ndim,
        # so non-displayed dims collapse to an int that picks the visible plane);
        # view_slices indexes the 2D display caches (one entry per displayed dim,
        # all slices).
        region_slices: list[slice | int] = [slice(None)] * self.ndim
        view_slices: list[slice] = [slice(None)] * len(displayed_dims)

        for d in range(self.ndim):
            axis_slice = slice_key[d]
            if d in displayed_dims:
                view_slices[displayed_dims.index(d)] = axis_slice
            else:
                # Non-displayed dims must contain the current slice position
                current_pos = pt_not_disp[d]
                if not (axis_slice.start <= current_pos < axis_slice.stop):
                    return None
                region_slices[d] = int(current_pos - axis_slice.start)

        return region_slices, view_slices

    def _align_data_to_view(
        self,
        visible_data: np.ndarray,
        visible_mask: np.ndarray,
    ) -> tuple[np.ndarray, np.ndarray]:
        """Transpose data from ascending dimension order to the displayed order.

        ``displayed`` may not be sorted (e.g. a transposed view), so the
        extracted region (whose axes are in ascending dimension order) is
        permuted to match the order the display caches expect.
        """
        displayed_dims = self._slice_input.displayed
        sorted_dims = sorted(displayed_dims)
        if list(displayed_dims) != sorted_dims:
            perm = [sorted_dims.index(d) for d in displayed_dims]
            visible_data = np.transpose(visible_data, perm)
            visible_mask = np.transpose(visible_mask, perm)
        return visible_data, visible_mask

    def _get_shape_and_dims_to_paint(self) -> tuple[list, list]:
        dims_to_paint = sorted(self._get_dims_to_paint())
        shape = list(self.data.shape)

        if self.n_edit_dimensions < self.ndim:
            shape = [shape[i] for i in dims_to_paint]

        return shape, dims_to_paint

    def _get_dims_to_paint(self) -> list:
        return list(self._slice_input.order[-self.n_edit_dimensions :])

    def _get_pt_not_disp(self) -> dict[int, int]:
        """
        Get indices of current visible slice.
        """
        slice_input = self._slice.slice_input
        point = np.round(
            self.world_to_data(slice_input.world_slice.point)
        ).astype(int)
        return {dim: point[dim] for dim in slice_input.not_displayed}

    def data_setitem(self, indices, value, refresh=True):
        """Set `indices` in `data` to `value`, while writing to edit history.

        Parameters
        ----------
        indices : tuple of arrays of int
            Indices in data to overwrite. Must be a tuple of arrays of length
            equal to the number of data dimensions. (Fancy indexing in [2]_).
        value : int or array of int
            New label value(s). If more than one value, must match or
            broadcast with the given indices.
        refresh : bool, default True
            whether to refresh the view, by default True

        References
        ----------
        .. [2] https://numpy.org/doc/stable/user/basics.indexing.html
        """
        changed_indices = self.data[indices] != value
        indices = tuple(x[changed_indices] for x in indices)

        if isinstance(value, Sequence):
            value = np.asarray(value, dtype=self._slice.image.raw.dtype)
        else:
            value = self._slice.image.raw.dtype.type(value)

        # Resize value array to remove unchanged elements
        if isinstance(value, np.ndarray):
            value = value[changed_indices]

        if not indices or indices[0].size == 0:
            return

        self._save_history(
            (
                indices,
                np.array(self.data[indices], copy=True),
                value,
            )
        )

        # update the labels image
        self.data[indices] = value

        pt_not_disp = self._get_pt_not_disp()
        displayed_indices = index_in_slice(
            indices, pt_not_disp, self._slice.slice_input.order
        )
        if isinstance(value, np.ndarray):
            visible_values = value[elements_in_slice(indices, pt_not_disp)]
        else:
            visible_values = value

        if not (  # if not a numpy array or numpy-backed xarray
            isinstance(self.data, np.ndarray)
            or isinstance(getattr(self.data, 'data', None), np.ndarray)
        ):
            # In the absence of slicing, the current slice becomes
            # invalidated by data_setitem; only in the special case of a NumPy
            # array, or a NumPy-array-backed Xarray, is the slice a view and
            # therefore updated automatically.
            # For other types, we update it manually here.
            self._slice.image.raw[displayed_indices] = visible_values

        # tensorstore and xarray do not return their indices in
        # np.ndarray format, so they need to be converted explicitly
        if not isinstance(self.data, np.ndarray):
            indices = [np.array(x).flatten() for x in indices]

        updated_slice = tuple(
            slice(int(axis_indices.min()), int(axis_indices.max()) + 1)
            for axis_indices in indices
        )

        if self.contour == 0:
            # update data view
            self._slice.image.view[displayed_indices] = (
                self.colormap._data_to_texture(visible_values)
            )

        self._accumulate_updated_slice(updated_slice)

        if refresh is True:
            self._partial_labels_refresh()

    def _calculate_value_from_ray(self, values):
        non_bg = values != self.colormap.background_value
        if not np.any(non_bg):
            return None
        return values[np.argmax(np.ravel(non_bg))]

    def get_status(
        self,
        position: npt.ArrayLike | None = None,
        *,
        view_direction: npt.ArrayLike | None = None,
        dims_displayed: list[int] | None = None,
        world: bool = False,
        value: Any | None = None,
    ) -> dict[str, str]:
        """Status message information of the data at a coordinate position.

        Parameters
        ----------
        position : tuple
            Position in either data or world coordinates.
        view_direction : Optional[np.ndarray]
            A unit vector giving the direction of the ray in nD world coordinates.
            The default value is None.
        dims_displayed : Optional[List[int]]
            A list of the dimensions currently being displayed in the viewer.
            The default value is None.
        world : bool
            If True the position is taken to be in world coordinates
            and converted into data coordinates. False by default.

        Returns
        -------
        status : dict
            Dict containing a information that can be used in a status update.
        """
        status = super().get_status(
            position,
            view_direction=view_direction,
            dims_displayed=dims_displayed,
            world=world,
        )

        # if this labels layer has properties
        properties = self._get_properties(
            position,
            view_direction=np.asarray(view_direction),
            dims_displayed=dims_displayed,
            world=world,
        )
        if properties:
            status['coordinates'] += '; ' + ', '.join(properties)
            status['value'] += '; ' + ', '.join(properties)

        return status

    def _get_tooltip_text(
        self,
        position,
        *,
        view_direction: np.ndarray | None = None,
        dims_displayed: list[int] | None = None,
        world: bool = False,
    ):
        """
        tooltip message of the data at a coordinate position.

        Parameters
        ----------
        position : tuple
            Position in either data or world coordinates.
        view_direction : Optional[np.ndarray]
            A unit vector giving the direction of the ray in nD world coordinates.
            The default value is None.
        dims_displayed : Optional[List[int]]
            A list of the dimensions currently being displayed in the viewer.
            The default value is None.
        world : bool
            If True the position is taken to be in world coordinates
            and converted into data coordinates. False by default.

        Returns
        -------
        msg : string
            String containing a message that can be used as a tooltip.
        """
        value = self.get_value(
            position,
            view_direction=view_direction,
            dims_displayed=dims_displayed,
            world=world,
        )
        if value is None:
            return ''

        properties = self._get_properties(
            position,
            view_direction=view_direction,
            dims_displayed=dims_displayed,
            world=world,
        )

        if not properties:
            return f'{value}'

        return f'{value}\n' + '\n'.join(properties)

    def _get_properties(
        self,
        position,
        *,
        view_direction: np.ndarray | None = None,
        dims_displayed: list[int] | None = None,
        world: bool = False,
    ) -> list:
        if len(self._label_index) == 0 or self.features.shape[1] == 0:
            return []

        value = self.get_value(
            position,
            view_direction=view_direction,
            dims_displayed=dims_displayed,
            world=world,
        )
        # if the cursor is not outside the image or on the background
        if value is None:
            return []

        label_value: int = typing.cast(
            int, value[1] if self.multiscale else value
        )
        if label_value not in self._label_index:
            return ['[No Properties]']

        idx = self._label_index[label_value]
        return [
            f'{k}: {format_feature_value(v[idx])}'
            for k, v in self.features.items()
            if k != 'index'
            and len(v) > idx
            and v[idx] is not None
            and not (isinstance(v[idx], float) and np.isnan(v[idx]))
        ]

    def _get_layer_slicing_state(
        self, data: LayerDataType, cache: bool
    ) -> _LabelsSlicingState:
        return _LabelsSlicingState(self, data, cache)


class _LabelsSlicingState(ScalarFieldSlicingState):
    layer: Labels
    _slice_request_class = _LabelsSliceRequest


class WrongSelectedLabelError(ValueError):
    """Raised when a label value is out of range for the layer's data dtype.

    Raised both when setting ``selected_label`` and when painting
    (``paint``/``fill``/``paint_polygon``) with a value the data dtype cannot
    represent.
    """

    def __init__(
        self,
        dtype: np.dtype,
        value: int,
        lower_bound: float,
        upper_bound: float,
        message: str = '',
    ):
        self.dtype = dtype
        self.value = value
        self.lower_bound = lower_bound
        self.upper_bound = upper_bound
        text = f'The value {value} is out of bounds for dtype {dtype} that allow for range [{int(lower_bound)}, {int(upper_bound)}].'
        if message:
            text = f'{message} {text}'
        self.text = text
        super().__init__(text)
