import ast
import warnings
from collections.abc import Mapping
from contextlib import suppress
from typing import TYPE_CHECKING

import numpy as np
from qtpy.QtCore import (
    QEvent,
    QObject,
    QRunnable,
    QSize,
    Qt,
    QThreadPool,
    Signal,
)
from qtpy.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap
from qtpy.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QCheckBox,
    QColorDialog,
    QFormLayout,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QMessageBox,
    QTableWidget,
    QTableWidgetItem,
    QToolButton,
    QVBoxLayout,
    QWidget,
)
from napari.utils.colormaps import CyclicLabelColormap

if TYPE_CHECKING:
    import napari
    from napari.layers import Labels, Layer


def _format_scalar(value: object) -> str:
    if isinstance(value, np.generic):
        value = value.item()
    if isinstance(value, float):
        return f"{value:.6g}"
    return str(value)


def _format_vector(values: object) -> str:
    array = np.asarray(values)
    if array.ndim == 0:
        return _format_scalar(array.item())
    return "(" + ", ".join(_format_scalar(item) for item in array.tolist()) + ")"


def _format_matrix(values: object) -> str:
    array = np.asarray(values)
    if array.ndim < 2:
        return _format_vector(array)
    rows = (_format_vector(row) for row in array.tolist())
    return "[" + ", ".join(rows) + "]"


def _parse_vector_text(text: str, expected_size: int) -> tuple[float, ...]:
    parsed = ast.literal_eval(text.strip())
    values = np.asarray(parsed, dtype=float)
    if values.ndim == 0:
        values = values.reshape(1)
    flat = values.ravel()
    if flat.size != expected_size:
        raise ValueError(f"expected {expected_size} values")
    return tuple(float(item) for item in flat.tolist())


def _parse_int_vector_text(text: str, expected_size: int) -> tuple[int, ...]:
    parsed = ast.literal_eval(text.strip())
    values = np.asarray(parsed, dtype=float)
    if values.ndim == 0:
        values = values.reshape(1)
    flat = values.ravel()
    if flat.size != expected_size:
        raise ValueError(f"expected {expected_size} values")
    rounded = np.rint(flat)
    if not np.allclose(flat, rounded):
        raise ValueError("expected integer values")
    return tuple(int(item) for item in rounded.tolist())


def _parse_matrix_text(text: str, expected_size: int) -> np.ndarray:
    parsed = ast.literal_eval(text.strip())
    matrix = np.asarray(parsed, dtype=float)
    if matrix.shape != (expected_size, expected_size):
        raise ValueError(f"expected a {expected_size}x{expected_size} matrix")
    return matrix


def _get_primary_array(layer: "Layer") -> np.ndarray:
    data = layer.data[0] if getattr(layer, "multiscale", False) else layer.data
    return np.asarray(data)


def _get_direction_matrix(layer: "Layer") -> np.ndarray:
    ndim = _layer_ndim(layer)
    default = np.eye(max(ndim, 1))
    affine = getattr(layer, "affine", None)
    if affine is None:
        return default

    affine_matrix = np.asarray(getattr(affine, "affine_matrix", None))
    if affine_matrix.ndim != 2 or affine_matrix.shape[0] < 2:
        return default
    matrix = affine_matrix[:-1, :-1]
    if matrix.shape[0] == matrix.shape[1] and matrix.shape[0] > 0:
        return matrix
    return default


def _compute_label_statistics(
    data: np.ndarray,
) -> tuple[str, str, str, str, list[tuple[str, str]]]:
    flat = data.ravel()
    total_pixels = int(flat.size)

    background_voxels = int(np.count_nonzero(flat == 0))
    background_percentage = (
        0.0 if total_pixels == 0 else (background_voxels / total_pixels) * 100
    )

    foreground = flat[flat != 0]
    foreground_pixels = int(foreground.size)
    foreground_percentage = (
        0.0 if total_pixels == 0 else (foreground_pixels / total_pixels) * 100
    )

    if foreground_pixels == 0:
        return (
            f"{background_voxels} ({background_percentage:.2f}%)",
            "0",
            "0",
            f"{foreground_pixels} ({foreground_percentage:.2f}%)",
            [],
        )

    label_ids, counts = np.unique(foreground, return_counts=True)
    max_class_id_text = str(int(np.max(label_ids)))
    classes_present_text = str(int(label_ids.size))
    rows = []
    for label_id, count in zip(label_ids.tolist(), counts.tolist(), strict=False):
        percentage = (count / foreground_pixels) * 100
        rows.append((str(label_id), f"{count} ({percentage:.2f}%)"))

    return (
        f"{background_voxels} ({background_percentage:.2f}%)",
        max_class_id_text,
        classes_present_text,
        f"{foreground_pixels} ({foreground_percentage:.2f}%)",
        rows,
    )


def _compute_label_class_overview(
    data: np.ndarray,
) -> tuple[str, str, list[tuple[str, str]]]:
    foreground = np.asarray(data).ravel()
    foreground = foreground[foreground != 0]
    if foreground.size == 0:
        return "0", "0", []

    label_ids = np.unique(foreground)
    max_class_id_text = str(int(np.max(label_ids)))
    classes_present_text = str(int(label_ids.size))
    rows = [(str(int(label_id)), "-") for label_id in label_ids.tolist()]
    return max_class_id_text, classes_present_text, rows


def _compute_image_statistics(
    data: np.ndarray,
    is_rgb: bool,
) -> list[tuple[str, str, str, str, str, str]]:
    array = np.asarray(data)
    if is_rgb and array.ndim >= 1:
        channel_labels = ["R", "G", "B", "A"]
        channel_arrays = [
            (
                (
                    channel_labels[channel_index]
                    if channel_index < len(channel_labels)
                    else f"C{channel_index}"
                ),
                array[..., channel_index],
            )
            for channel_index in range(array.shape[-1])
        ]
    else:
        channel_arrays = [("All", array)]

    rows = []
    for channel_name, channel_array in channel_arrays:
        flattened = np.asarray(channel_array)
        non_zero = int(np.count_nonzero(flattened))
        total = int(flattened.size)
        non_zero_percentage = 0.0 if total == 0 else (non_zero / total) * 100
        rows.append(
            (
                channel_name,
                f"{_format_scalar(np.min(flattened))} to {_format_scalar(np.max(flattened))}",
                (
                    f"{_format_scalar(float(np.mean(flattened)))} / "
                    f"{_format_scalar(float(np.std(flattened)))}"
                ),
                _format_scalar(float(np.median(flattened))),
                (
                    f"{_format_scalar(float(np.percentile(flattened, 1)))} / "
                    f"{_format_scalar(float(np.percentile(flattened, 99)))}"
                ),
                f"{non_zero} ({non_zero_percentage:.2f}%)",
            )
        )
    return rows


def _layer_ndim(layer: "Layer") -> int:
    ndim = getattr(layer, "ndim", None)
    if isinstance(ndim, int) and ndim > 0:
        return ndim

    scale = getattr(layer, "scale", None)
    if scale is not None:
        scale_array = np.asarray(scale)
        if scale_array.size > 0:
            return int(scale_array.size)

    extent = getattr(layer, "extent", None)
    if extent is not None:
        world = np.asarray(getattr(extent, "world", []), dtype=float)
        if world.ndim == 2 and world.shape[1] > 0:
            return int(world.shape[1])

    return 1


def _layer_data_shape_text(layer: "Layer") -> str:
    data = getattr(layer, "data", None)
    if data is None:
        return "-"

    if getattr(layer, "multiscale", False):
        with suppress(IndexError, TypeError):
            data = data[0]

    if isinstance(data, tuple):
        parts = []
        for index, item in enumerate(data):
            with suppress(TypeError, ValueError):
                parts.append(f"{index}:{tuple(np.asarray(item).shape)}")
        if parts:
            return "[" + ", ".join(parts) + "]"
        return "-"

    with suppress(TypeError, ValueError):
        return str(tuple(np.asarray(data).shape))
    return "-"


def _layer_vector_attr_text(layer: "Layer", attr_name: str) -> str:
    value = getattr(layer, attr_name, None)
    if value is None:
        return "-"
    with suppress(TypeError, ValueError):
        return _format_vector(value)
    return str(value)


def _wrap_continuous_text(text: str, max_chunk: int = 24) -> str:
    if len(text) <= max_chunk:
        return text
    if any(character.isspace() for character in text):
        return text
    return "\n".join(
        text[index : index + max_chunk] for index in range(0, len(text), max_chunk)
    )


def _style_value_label(label: QLabel) -> None:
    label.setWordWrap(True)
    label.setTextInteractionFlags(Qt.TextSelectableByMouse)
    label.setStyleSheet("font-family: Consolas, 'Courier New', monospace;")


def _style_value_input(widget: QLineEdit) -> None:
    widget.setStyleSheet("font-family: Consolas, 'Courier New', monospace;")


def _to_qcolor(color: object) -> QColor | None:
    if color is None:
        return None
    array = np.asarray(color, dtype=float)
    if array.size < 3:
        return None
    if np.all(array[:3] <= 1.0):
        array = array * 255.0
    red, green, blue = [int(np.clip(channel, 0, 255)) for channel in array[:3]]
    alpha = 255
    if array.size >= 4:
        alpha_value = array[3]
        if alpha_value <= 1.0:
            alpha_value *= 255.0
        alpha = int(np.clip(alpha_value, 0, 255))
    return QColor(red, green, blue, alpha)


def _make_color_icon(color: QColor) -> QIcon:
    pixmap = QPixmap(10, 10)
    pixmap.fill(Qt.GlobalColor.transparent)
    painter = QPainter(pixmap)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing)
    painter.setPen(Qt.PenStyle.NoPen)
    painter.setBrush(QBrush(color))
    painter.drawEllipse(0, 0, 9, 9)
    painter.end()
    return QIcon(pixmap)


def _make_copy_icon() -> QIcon:
    pixmap = QPixmap(14, 14)
    pixmap.fill(Qt.GlobalColor.transparent)
    painter = QPainter(pixmap)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
    pen = QPen(QColor(90, 90, 90))
    pen.setWidth(1)
    painter.setPen(pen)
    painter.setBrush(Qt.GlobalColor.transparent)
    painter.drawRect(2, 1, 8, 9)
    painter.drawRect(4, 3, 8, 9)
    painter.end()
    return QIcon(pixmap)


def _make_paste_icon() -> QIcon:
    pixmap = QPixmap(14, 14)
    pixmap.fill(Qt.GlobalColor.transparent)
    painter = QPainter(pixmap)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
    pen = QPen(QColor(90, 90, 90))
    pen.setWidth(1)
    painter.setPen(pen)
    painter.setBrush(Qt.GlobalColor.transparent)
    painter.drawRect(2, 2, 10, 9)
    painter.drawLine(5, 2, 5, 0)
    painter.drawLine(9, 2, 9, 0)
    painter.drawLine(5, 0, 9, 0)
    painter.drawLine(5, 5, 9, 5)
    painter.drawLine(7, 5, 7, 9)
    painter.end()
    return QIcon(pixmap)


def _make_refresh_icon() -> QIcon:
    pixmap = QPixmap(14, 14)
    pixmap.fill(Qt.GlobalColor.transparent)
    painter = QPainter(pixmap)
    painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
    pen = QPen(QColor(90, 90, 90))
    pen.setWidth(1)
    painter.setPen(pen)
    painter.setBrush(Qt.GlobalColor.transparent)
    # Circular arrow icon.
    painter.drawArc(2, 2, 10, 10, 35 * 16, 300 * 16)
    painter.drawLine(10, 2, 12, 2)
    painter.drawLine(12, 2, 12, 4)
    painter.end()
    return QIcon(pixmap)


class _DataStatsWorkerSignals(QObject):
    completed = Signal(int, object)
    failed = Signal(int, str)


class _DataStatsWorker(QRunnable):
    def __init__(self, request_id: int, layer_kind: str, data: object):
        super().__init__()
        self.request_id = request_id
        self.layer_kind = layer_kind
        self.data = data
        self.signals = _DataStatsWorkerSignals()

    def run(self) -> None:
        try:
            if self.layer_kind == "labels":
                snapshot = np.array(self.data, copy=True)
                result = _compute_label_statistics(snapshot)
            else:
                image_payload = self.data
                snapshot = np.array(image_payload["data"], copy=True)
                result = _compute_image_statistics(
                    snapshot,
                    bool(image_payload["is_rgb"]),
                )
        except (
            AttributeError,
            IndexError,
            KeyError,
            TypeError,
            ValueError,
        ) as exc:  # pragma: no cover - defensive UI path
            self.signals.failed.emit(self.request_id, str(exc))
            return
        self.signals.completed.emit(self.request_id, (self.layer_kind, result))


class LayerStatisticsWidget(QWidget):
    def __init__(self, viewer: "napari.viewer.Viewer"):
        super().__init__()
        self._viewer = viewer
        self._current_layer: Layer | None = None
        self._fixed_layer: Layer | None = None
        self._thread_pool = QThreadPool(self)
        self._thread_pool.setMaxThreadCount(1)
        self._stats_request_id = 0
        self._worker_running = False
        self._recompute_requested = False
        self._class_color_overrides: dict[int, dict[int, QColor]] = {}
        self._fix_layer_checkbox = QCheckBox("Freeze selection")
        self._fix_layer_checkbox.setChecked(False)
        self._fix_layer_checkbox.setToolTip(
            "Lock this widget to the currently selected layer."
        )
        self._fix_layer_checkbox.toggled.connect(self._on_fix_layer_toggled)
        self._compute_data_checkbox = QCheckBox("Compute stats")
        self._compute_data_checkbox.setChecked(True)
        self._compute_data_checkbox.setToolTip(
            "Enable heavy image/labels statistics. "
            "When disabled, label class overview stays available."
        )
        self._compute_data_checkbox.toggled.connect(self._on_compute_data_toggled)
        self._refresh_button = QToolButton()
        self._refresh_button.setText("Refresh")
        self._refresh_button.setToolTip(
            "Refresh metadata, geometry, camera, and available statistics."
        )
        self._refresh_button.setIcon(_make_refresh_icon())
        self._refresh_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._refresh_button.clicked.connect(self._refresh_manually)
        self._copy_button = QToolButton()
        self._copy_button.setText("Copy stats")
        self._copy_button.setIcon(QIcon.fromTheme("edit-copy", _make_copy_icon()))
        self._copy_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._copy_button.clicked.connect(self._copy_statistics_to_clipboard)
        self._paste_camera_button = QToolButton()
        self._paste_camera_button.setText("Paste camera")
        self._paste_camera_button.setIcon(
            QIcon.fromTheme("edit-paste", _make_paste_icon())
        )
        self._paste_camera_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._paste_camera_button.clicked.connect(self._paste_camera_from_clipboard)
        self._copy_camera_button = QToolButton()
        self._copy_camera_button.setText("Copy camera")
        self._copy_camera_button.setIcon(
            QIcon.fromTheme("edit-copy", _make_copy_icon())
        )
        self._copy_camera_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._copy_camera_button.clicked.connect(self._copy_camera_to_clipboard)
        self._copy_colors_button = QToolButton()
        self._copy_colors_button.setText("Copy colors")
        self._copy_colors_button.setIcon(
            QIcon.fromTheme("edit-copy", _make_copy_icon())
        )
        self._copy_colors_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._copy_colors_button.clicked.connect(self._copy_colors_to_clipboard)
        self._paste_colors_button = QToolButton()
        self._paste_colors_button.setText("Paste colors")
        self._paste_colors_button.setIcon(
            QIcon.fromTheme("edit-paste", _make_paste_icon())
        )
        self._paste_colors_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self._paste_colors_button.clicked.connect(self._paste_colors_from_clipboard)
        self._status_indicator = QLabel()
        self._status_indicator.setFixedSize(14, 14)
        self._status_indicator.setToolTip("Idle")

        self._shape_value = QLabel("-")
        self._spacing_value = QLineEdit("-")
        self._origin_value = QLineEdit("-")
        self._direction_value = QLineEdit("-")
        self._metadata_name_value = QLabel("-")
        self._metadata_type_value = QLabel("-")
        self._metadata_dtype_value = QLabel("-")
        self._camera_ndisplay_value = QLineEdit("-")
        self._camera_displayed_axis_value = QLineEdit("-")
        self._camera_zoom_value = QLineEdit("-")
        self._camera_center_value = QLineEdit("-")
        self._camera_angle_value = QLineEdit("-")
        self._camera_canvas_size_value = QLineEdit("-")
        self._background_value = QLabel("-")
        self._class_count_value = QLabel("-")
        self._classes_present_value = QLabel("-")
        self._foreground_value = QLabel("-")

        for label in (
            self._shape_value,
            self._metadata_name_value,
            self._metadata_type_value,
            self._metadata_dtype_value,
            self._background_value,
            self._class_count_value,
            self._classes_present_value,
            self._foreground_value,
        ):
            _style_value_label(label)
        for input_widget in (
            self._spacing_value,
            self._origin_value,
            self._direction_value,
            self._camera_ndisplay_value,
            self._camera_displayed_axis_value,
            self._camera_zoom_value,
            self._camera_center_value,
            self._camera_angle_value,
            self._camera_canvas_size_value,
        ):
            _style_value_input(input_widget)
            input_widget.setReadOnly(True)

        self._spacing_value.returnPressed.connect(self._apply_spacing_from_input)
        self._origin_value.returnPressed.connect(self._apply_origin_from_input)
        self._direction_value.returnPressed.connect(self._apply_direction_from_input)
        self._camera_ndisplay_value.returnPressed.connect(
            self._apply_camera_ndisplay_from_input
        )
        self._camera_displayed_axis_value.returnPressed.connect(
            self._apply_camera_displayed_axis_from_input
        )
        self._camera_zoom_value.returnPressed.connect(
            self._apply_camera_zoom_from_input
        )
        self._camera_center_value.returnPressed.connect(
            self._apply_camera_center_from_input
        )
        self._camera_angle_value.returnPressed.connect(
            self._apply_camera_angle_from_input
        )
        self._camera_canvas_size_value.returnPressed.connect(
            self._apply_camera_canvas_size_from_input
        )

        self._classes_table = QTableWidget(0, 2)
        self._classes_table.setHorizontalHeaderLabels(["Class", "Voxels (% fg)"])
        self._classes_table.verticalHeader().setVisible(False)
        self._classes_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self._classes_table.setSelectionMode(QAbstractItemView.NoSelection)
        self._classes_table.setFocusPolicy(Qt.NoFocus)
        self._classes_table.setMouseTracking(True)
        self._classes_table.viewport().setMouseTracking(True)
        self._classes_table.viewport().installEventFilter(self)
        self._classes_table.horizontalHeader().setSectionResizeMode(
            0, QHeaderView.ResizeToContents
        )
        self._classes_table.horizontalHeader().setStretchLastSection(True)
        self._classes_table.cellClicked.connect(self._on_classes_table_cell_clicked)

        header_widget = QWidget()
        header_layout = QGridLayout()
        header_layout.setContentsMargins(0, 0, 0, 0)
        top_left = QHBoxLayout()
        top_left.setContentsMargins(0, 0, 0, 0)
        top_left.addWidget(self._status_indicator, 0, Qt.AlignCenter)
        top_left.addWidget(self._refresh_button)
        top_left.addStretch(1)
        header_layout.addLayout(top_left, 0, 0)
        header_layout.addWidget(self._copy_button, 0, 1, Qt.AlignLeft)
        header_layout.addWidget(self._fix_layer_checkbox, 1, 0, Qt.AlignLeft)
        header_layout.addWidget(self._compute_data_checkbox, 1, 1, Qt.AlignLeft)
        header_layout.setColumnStretch(0, 1)
        header_layout.setColumnStretch(1, 1)
        header_widget.setLayout(header_layout)

        self._metadata_group = QGroupBox("Metadata")
        metadata_layout = QFormLayout()
        metadata_layout.addRow("Name", self._metadata_name_value)
        metadata_layout.addRow("Type", self._metadata_type_value)
        metadata_layout.addRow("Dtype", self._metadata_dtype_value)
        self._metadata_group.setLayout(metadata_layout)

        self._geometry_group = QGroupBox("Geometry")
        self._geometry_group.setCheckable(True)
        self._geometry_group.setChecked(True)
        self._geometry_body = QWidget()
        geometry_values_layout = QFormLayout()
        geometry_values_layout.addRow("Array shape", self._shape_value)
        geometry_values_layout.addRow("Spacing", self._spacing_value)
        geometry_values_layout.addRow("Origin", self._origin_value)
        geometry_values_layout.addRow("Direction", self._direction_value)
        self._geometry_body.setLayout(geometry_values_layout)
        geometry_layout = QVBoxLayout()
        geometry_layout.addWidget(self._geometry_body)
        self._geometry_group.setLayout(geometry_layout)
        self._geometry_group.toggled.connect(self._on_geometry_group_toggled)
        self._on_geometry_group_toggled(True)

        self._camera_group = QGroupBox("Camera")
        self._camera_group.setCheckable(True)
        self._camera_group.setChecked(False)
        self._camera_body = QWidget()
        camera_values_layout = QFormLayout()
        camera_values_layout.addRow("NDisplay", self._camera_ndisplay_value)
        camera_values_layout.addRow("Displayed axis", self._camera_displayed_axis_value)
        camera_values_layout.addRow("Zoom", self._camera_zoom_value)
        camera_values_layout.addRow("Center", self._camera_center_value)
        camera_values_layout.addRow("Angle", self._camera_angle_value)
        camera_values_layout.addRow("Canvas (w, h)", self._camera_canvas_size_value)
        self._camera_body.setLayout(camera_values_layout)
        camera_layout = QVBoxLayout()
        camera_header = QHBoxLayout()
        camera_header.setContentsMargins(0, 0, 0, 0)
        camera_header.addWidget(self._copy_camera_button, 0, Qt.AlignLeft)
        camera_header.addWidget(self._paste_camera_button, 0, Qt.AlignLeft)
        camera_header.addStretch(1)
        camera_layout.addLayout(camera_header)
        camera_layout.addWidget(self._camera_body)
        self._camera_group.setLayout(camera_layout)
        self._camera_group.toggled.connect(self._on_camera_group_toggled)
        self._on_camera_group_toggled(False)

        self._image_group = QGroupBox("Image Statistics")
        self._image_stat_names = [
            "Range",
            "Mean / std",
            "Median",
            "P1 / P99",
            "Non-zero",
        ]
        self._image_table = QTableWidget(len(self._image_stat_names), 1)
        self._image_table.setHorizontalHeaderLabels(["Stats"])
        self._image_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self._image_table.setSelectionMode(QAbstractItemView.NoSelection)
        self._image_table.setFocusPolicy(Qt.NoFocus)
        self._image_table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
        self._image_table.verticalHeader().setVisible(False)
        self._image_table.horizontalHeader().setSectionResizeMode(
            QHeaderView.Interactive
        )
        self._image_table.horizontalHeader().setMinimumSectionSize(72)
        self._image_table.horizontalHeader().setDefaultSectionSize(96)
        self._image_table.horizontalHeader().setStretchLastSection(False)
        image_layout = QVBoxLayout()
        image_layout.addWidget(self._image_table)
        self._image_group.setLayout(image_layout)

        self._labels_group = QGroupBox("Labels Statistics")
        coverage_layout = QFormLayout()
        coverage_layout.addRow("Number Classes", self._class_count_value)
        coverage_layout.addRow("Classes present", self._classes_present_value)
        coverage_layout.addRow("Background\n(voxels, % image)", self._background_value)
        coverage_layout.addRow("Foreground\n(voxels, % image)", self._foreground_value)
        labels_layout = QVBoxLayout()
        labels_layout.addLayout(coverage_layout)
        labels_header = QHBoxLayout()
        labels_header.setContentsMargins(0, 0, 0, 0)
        labels_header.addWidget(self._copy_colors_button, 0, Qt.AlignLeft)
        labels_header.addWidget(self._paste_colors_button, 0, Qt.AlignLeft)
        labels_header.addStretch(1)
        labels_layout.addLayout(labels_header)
        labels_layout.addWidget(self._classes_table)
        self._labels_group.setLayout(labels_layout)

        root_layout = QVBoxLayout()
        top_layout = QFormLayout()
        top_layout.addRow(header_widget)
        root_layout.addLayout(top_layout)
        root_layout.addWidget(self._metadata_group)
        root_layout.addWidget(self._geometry_group)
        root_layout.addWidget(self._camera_group)
        root_layout.addWidget(self._image_group)
        root_layout.addWidget(self._labels_group)
        self.setLayout(root_layout)

        self._viewer.layers.events.inserted.connect(self._on_layers_changed)
        self._viewer.layers.events.removed.connect(self._on_layers_changed)
        self._viewer.layers.events.reordered.connect(self._on_layers_changed)
        selection_events = getattr(self._viewer.layers.selection, "events", None)
        if selection_events is not None and hasattr(selection_events, "changed"):
            selection_events.changed.connect(self._on_viewer_selection_changed)
        camera_events = getattr(getattr(self._viewer, "camera", None), "events", None)
        if camera_events is not None:
            for event_name in (
                "center",
                "zoom",
                "angles",
            ):
                emitter = getattr(camera_events, event_name, None)
                if emitter is not None:
                    emitter.connect(self._update_camera_statistics)
        dims_events = getattr(getattr(self._viewer, "dims", None), "events", None)
        if dims_events is not None:
            for event_name in ("ndisplay", "order"):
                emitter = getattr(dims_events, event_name, None)
                if emitter is not None:
                    emitter.connect(self._update_camera_statistics)
        canvas_events = getattr(self._viewer_canvas(), "events", None)
        if canvas_events is not None:
            resize_emitter = getattr(canvas_events, "resize", None)
            if resize_emitter is not None:
                resize_emitter.connect(self._update_camera_statistics)
        self._set_empty_state()
        self._sync_layer_from_viewer(force=True)

    def sizeHint(self) -> QSize:
        return QSize(400, 760)

    def minimumSizeHint(self) -> QSize:
        return QSize(300, 560)

    def _on_camera_group_toggled(self, expanded: bool) -> None:
        self._camera_body.setVisible(expanded)

    def _on_geometry_group_toggled(self, expanded: bool) -> None:
        self._geometry_body.setVisible(expanded)

    def _viewer_canvas(self):
        window = getattr(self._viewer, "window", None)
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", FutureWarning)
            qt_viewer = getattr(window, "_qt_viewer", None)
        if qt_viewer is None:
            qt_viewer = getattr(window, "qt_viewer", None)
        return getattr(qt_viewer, "canvas", None)

    def _sync_canvas_size_to_viewer(self) -> None:
        canvas = self._viewer_canvas()
        if canvas is None:
            return
        size = getattr(canvas, "size", None)
        with suppress(TypeError, ValueError):
            values = np.asarray(size, dtype=float).ravel()
            if values.size >= 2:
                height = int(round(values[0]))
                width = int(round(values[1]))
                if height > 0 and width > 0:
                    with warnings.catch_warnings():
                        warnings.simplefilter("ignore", FutureWarning)
                        setattr(self._viewer, "_canvas_size", (height, width))

    def _camera_canvas_size_text(self) -> str:
        self._sync_canvas_size_to_viewer()
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", FutureWarning)
            canvas_size_current = getattr(self._viewer, "_canvas_size", None)
        with suppress(TypeError, ValueError):
            values = np.asarray(canvas_size_current, dtype=float).ravel()
            if values.size >= 2:
                height = int(round(values[0]))
                width = int(round(values[1]))
                return _format_vector((width, height))
        return "-"

    def _refresh_manually(self) -> None:
        if self._fixed_layer is None:
            self._sync_layer_from_viewer(force=True)
        else:
            self._on_layer_changed(self._fixed_layer, force=True)

    def _on_fix_layer_toggled(self, checked: bool) -> None:
        if checked:
            self._fixed_layer = self._current_layer or self._supported_layer(
                self._active_viewer_layer()
            )
            self._on_layer_changed(self._fixed_layer)
        else:
            self._fixed_layer = None
            self._sync_layer_from_viewer()

    def _on_compute_data_toggled(self, checked: bool) -> None:
        if checked:
            self._update_data_statistics()
            return
        self._stats_request_id += 1
        self._recompute_requested = False
        self._show_data_statistics_disabled(self._current_layer)

    def _on_layers_changed(self, event=None) -> None:
        del event
        alive_layer_ids = {id(layer) for layer in self._viewer.layers}
        self._class_color_overrides = {
            layer_id: overrides
            for layer_id, overrides in self._class_color_overrides.items()
            if layer_id in alive_layer_ids
        }
        if (
            self._fixed_layer is not None
            and self._fixed_layer not in self._viewer.layers
        ):
            self._fixed_layer = None
            self._fix_layer_checkbox.blockSignals(True)
            self._fix_layer_checkbox.setChecked(False)
            self._fix_layer_checkbox.blockSignals(False)
        if self._fixed_layer is None:
            self._sync_layer_from_viewer(force=True)
        else:
            self._on_layer_changed(self._fixed_layer, force=True)

    def _on_viewer_selection_changed(self, event=None) -> None:
        del event
        if self._fixed_layer is not None:
            return
        self._sync_layer_from_viewer()

    def _sync_layer_from_viewer(self, force: bool = False) -> None:
        layer = self._supported_layer(self._active_viewer_layer())
        self._on_layer_changed(layer, force=force)

    def _active_viewer_layer(self) -> "Layer | None":
        selection = getattr(self._viewer.layers, "selection", None)
        if selection is None:
            return None
        return getattr(selection, "active", None)

    def _supported_layer(self, layer: "Layer | None") -> "Layer | None":
        return layer

    def _on_layer_changed(self, layer: "Layer | None", force: bool = False) -> None:
        if not force and layer is self._current_layer:
            return
        self._disconnect_layer_refresh_events()
        self._current_layer = layer
        self._update_metadata_fields(layer)
        self._connect_layer_refresh_events()
        self._update_statistics()

    def _update_metadata_fields(self, layer: "Layer | None") -> None:
        if layer is None:
            self._metadata_name_value.setText("-")
            self._metadata_type_value.setText("-")
            self._metadata_dtype_value.setText("-")
            return

        raw_name = str(getattr(layer, "name", "-"))
        self._metadata_name_value.setText(_wrap_continuous_text(raw_name))
        self._metadata_name_value.setToolTip(raw_name)
        self._metadata_type_value.setText(layer.__class__.__name__)
        self._metadata_dtype_value.setText(self._layer_dtype_text(layer))

    def _layer_dtype_text(self, layer: "Layer") -> str:
        dtype = getattr(layer, "dtype", None)
        if dtype is not None:
            return str(dtype)
        with suppress(AttributeError, IndexError, TypeError, ValueError):
            return str(_get_primary_array(layer).dtype)
        return "-"

    def _is_geometry_editable_layer(self, layer: "Layer | None") -> bool:
        return layer is not None

    def _set_geometry_inputs_editable(self, editable: bool) -> None:
        for input_widget in (
            self._spacing_value,
            self._origin_value,
            self._direction_value,
        ):
            input_widget.setReadOnly(not editable)
            input_widget.setToolTip(
                "Press Enter to apply."
                if editable
                else "Select a layer to edit geometry."
            )

    def _set_camera_inputs_editable(self, editable: bool) -> None:
        for input_widget in (
            self._camera_ndisplay_value,
            self._camera_displayed_axis_value,
            self._camera_zoom_value,
            self._camera_center_value,
            self._camera_angle_value,
            self._camera_canvas_size_value,
        ):
            input_widget.setReadOnly(not editable)
            input_widget.setToolTip(
                "Press Enter to apply." if editable else "Camera is unavailable."
            )

    def _layer_vector_size(self, layer: "Layer", attr_name: str) -> int:
        value = getattr(layer, attr_name, None)
        with suppress(TypeError, ValueError):
            size = int(np.asarray(value, dtype=float).size)
            if size > 0:
                return size
        return _layer_ndim(layer)

    def _apply_spacing_from_input(self) -> None:
        layer = self._current_layer
        if not self._is_geometry_editable_layer(layer):
            return
        assert layer is not None
        expected_size = self._layer_vector_size(layer, "scale")
        try:
            layer.scale = _parse_vector_text(self._spacing_value.text(), expected_size)
            self._update_geometry_statistics()
        except (SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid spacing: {exc}")

    def _apply_origin_from_input(self) -> None:
        layer = self._current_layer
        if not self._is_geometry_editable_layer(layer):
            return
        assert layer is not None
        expected_size = self._layer_vector_size(layer, "translate")
        try:
            layer.translate = _parse_vector_text(
                self._origin_value.text(), expected_size
            )
            self._update_geometry_statistics()
        except (SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid origin: {exc}")

    def _apply_direction_from_input(self) -> None:
        layer = self._current_layer
        if not self._is_geometry_editable_layer(layer):
            return
        assert layer is not None
        expected_size = _layer_ndim(layer)
        try:
            direction = _parse_matrix_text(self._direction_value.text(), expected_size)
            affine_matrix = np.eye(expected_size + 1, dtype=float)
            affine = getattr(layer, "affine", None)
            with suppress(TypeError, ValueError):
                current = np.asarray(
                    getattr(affine, "affine_matrix", None), dtype=float
                )
                if current.shape == (expected_size + 1, expected_size + 1):
                    affine_matrix = current.copy()
            affine_matrix[:-1, :-1] = direction
            layer.affine = affine_matrix
            self._update_geometry_statistics()
        except (AttributeError, SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid direction: {exc}")

    def _apply_camera_ndisplay_from_input(self) -> None:
        dims = getattr(self._viewer, "dims", None)
        if dims is None:
            return
        try:
            value = int(ast.literal_eval(self._camera_ndisplay_value.text().strip()))
            if value not in (2, 3):
                raise ValueError("expected 2 or 3")
            dims.ndisplay = value
            self._update_camera_statistics()
        except (SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid ndisplay: {exc}")

    def _apply_camera_displayed_axis_from_input(self) -> None:
        dims = getattr(self._viewer, "dims", None)
        if dims is None:
            return
        try:
            ndisplay = int(getattr(dims, "ndisplay", 2))
            ndim = int(getattr(dims, "ndim", 0))
            if ndim <= 0:
                raise ValueError("no dimensions available")
            displayed_axes = _parse_int_vector_text(
                self._camera_displayed_axis_value.text(), ndisplay
            )
            if len(set(displayed_axes)) != ndisplay:
                raise ValueError("axes must be unique")
            if any(axis < 0 or axis >= ndim for axis in displayed_axes):
                raise ValueError(f"axes must be in [0, {ndim - 1}]")
            current_order = tuple(
                int(axis) for axis in getattr(dims, "order", tuple(range(ndim)))
            )
            if len(current_order) != ndim:
                current_order = tuple(range(ndim))
            non_displayed = [
                axis for axis in current_order if axis not in displayed_axes
            ]
            dims.order = tuple(non_displayed + list(displayed_axes))
            self._update_camera_statistics()
        except (AttributeError, SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid displayed axis: {exc}")

    def _apply_camera_zoom_from_input(self) -> None:
        camera = getattr(self._viewer, "camera", None)
        if camera is None:
            return
        try:
            value = float(ast.literal_eval(self._camera_zoom_value.text().strip()))
            if value <= 0:
                raise ValueError("must be > 0")
            camera.zoom = value
            self._update_camera_statistics()
        except (AttributeError, SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid zoom: {exc}")

    def _apply_camera_center_from_input(self) -> None:
        camera = getattr(self._viewer, "camera", None)
        if camera is None:
            return
        try:
            camera.center = _parse_vector_text(self._camera_center_value.text(), 3)
            self._update_camera_statistics()
        except (AttributeError, SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid center: {exc}")

    def _apply_camera_angle_from_input(self) -> None:
        camera = getattr(self._viewer, "camera", None)
        if camera is None:
            return
        try:
            camera.angles = _parse_vector_text(self._camera_angle_value.text(), 3)
            self._update_camera_statistics()
        except (AttributeError, SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid angle: {exc}")

    def _apply_camera_canvas_size_from_input(self) -> None:
        if getattr(self._viewer, "window", None) is None:
            return
        try:
            width, height = _parse_int_vector_text(
                self._camera_canvas_size_value.text(), 2
            )
            if width <= 0 or height <= 0:
                raise ValueError("both width and height must be > 0")

            self._sync_canvas_size_to_viewer()
            with warnings.catch_warnings():
                warnings.simplefilter("ignore", FutureWarning)
                canvas_size_current = np.asarray(
                    getattr(self._viewer, "_canvas_size", None), dtype=float
                ).ravel()
            if canvas_size_current.size < 2:
                raise ValueError("current canvas size is unavailable")
            current_canvas_h = int(round(canvas_size_current[0]))
            current_canvas_w = int(round(canvas_size_current[1]))

            delta_w = int(width) - current_canvas_w
            delta_h = int(height) - current_canvas_h

            window_size_current = self._viewer.window.geometry()[2:]
            if len(window_size_current) < 2:
                raise ValueError("window resize is unavailable")
            current_window_w = int(window_size_current[0])
            current_window_h = int(window_size_current[1])
            target_window_w = max(1, current_window_w + delta_w)
            target_window_h = max(1, current_window_h + delta_h)
            window_size_new = (target_window_w, target_window_h)
            self._viewer.window.resize(*window_size_new)
            self._update_camera_statistics()
        except (AttributeError, SyntaxError, TypeError, ValueError) as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(f"Invalid canvas size: {exc}")

    def _connect_layer_refresh_events(self) -> None:
        layer = self._current_layer
        if layer is None:
            return
        for event_name in self._layer_refresh_event_names(layer):
            emitter = getattr(layer.events, event_name, None)
            if emitter is not None:
                emitter.connect(self._refresh_selected_layer_statistics)

    def _disconnect_layer_refresh_events(self) -> None:
        layer = self._current_layer
        if layer is None:
            return
        for event_name in self._layer_refresh_event_names(layer):
            emitter = getattr(layer.events, event_name, None)
            if emitter is None:
                continue
            with suppress(TypeError, ValueError, RuntimeError):
                emitter.disconnect(self._refresh_selected_layer_statistics)

    def _layer_refresh_event_names(self, layer: "Layer") -> tuple[str, ...]:
        try:
            from napari.layers import Labels

            if isinstance(layer, Labels):
                return ("paint", "fill", "polygon", "remove")
        except ImportError:
            pass
        return ()

    def _refresh_selected_layer_statistics(self, event=None) -> None:
        del event
        self._update_data_statistics()

    def _copy_statistics_to_clipboard(self) -> None:
        clipboard = QApplication.clipboard()
        clipboard.setText(self._build_statistics_summary())

    def _copy_camera_to_clipboard(self) -> None:
        clipboard = QApplication.clipboard()
        clipboard.setText(self._build_camera_summary())

    def _camera_summary_lines(self) -> list[str]:
        return [
            "Camera",
            f"NDisplay: {self._camera_ndisplay_value.text()}",
            f"Displayed axis: {self._camera_displayed_axis_value.text()}",
            f"Zoom: {self._camera_zoom_value.text()}",
            f"Center: {self._camera_center_value.text()}",
            f"Angle: {self._camera_angle_value.text()}",
            f"Canvas (w, h): {self._camera_canvas_size_value.text()}",
        ]

    def _build_camera_summary(self) -> str:
        return "\n".join(self._camera_summary_lines())

    def _labels_layer_for_colors(self):
        layer = self._current_layer
        if layer is None:
            return None
        try:
            from napari.layers import Labels

            if isinstance(layer, Labels):
                return layer
        except ImportError:
            return None
        return None

    def _build_colors_summary(self) -> str:
        layer = self._labels_layer_for_colors()
        if layer is None:
            return ""
        lines = ["Colors"]
        label_ids: list[int] = []
        with suppress(TypeError, ValueError):
            data = _get_primary_array(layer)
            unique_ids = np.unique(np.asarray(data).ravel()).tolist()
            label_ids = sorted(int(value) for value in unique_ids if int(value) != 0)
        for label_id in label_ids:
            color = self._get_display_label_qcolor(layer, label_id)
            if color is None:
                continue
            lines.append(
                f"{label_id}: ({color.red()}, {color.green()}, {color.blue()}, {color.alpha()})"
            )
        return "\n".join(lines)

    def _copy_colors_to_clipboard(self) -> None:
        clipboard = QApplication.clipboard()
        clipboard.setText(self._build_colors_summary())

    def _parse_qcolor_text(self, text: str) -> QColor:
        parsed = ast.literal_eval(text.strip())
        values = np.asarray(parsed, dtype=float).ravel()
        if values.size not in (3, 4):
            raise ValueError("expected an RGB or RGBA tuple")
        rounded = np.rint(values)
        if not np.allclose(values, rounded):
            raise ValueError("expected integer channel values")
        channels = [int(np.clip(value, 0, 255)) for value in rounded.tolist()]
        if len(channels) == 3:
            channels.append(255)
        return QColor(channels[0], channels[1], channels[2], channels[3])

    def _extract_color_settings_from_text(self, text: str) -> dict[int, QColor]:
        section_names = {
            "metadata",
            "geometry",
            "camera",
            "colors",
            "image statistics",
            "labels statistics",
        }
        values: dict[int, QColor] = {}
        in_colors_section = False
        for raw_line in text.splitlines():
            line = raw_line.strip()
            if not line:
                continue
            lowered = line.lower()
            if lowered == "colors":
                in_colors_section = True
                continue
            if lowered in section_names and lowered != "colors":
                if in_colors_section:
                    break
                continue
            if ":" not in line:
                if in_colors_section:
                    break
                continue
            key, raw_value = line.split(":", 1)
            key_text = key.strip().lower().replace("class", "").strip()
            with suppress(TypeError, ValueError):
                label_id = int(key_text)
                values[label_id] = self._parse_qcolor_text(raw_value.strip())
        return values

    def _paste_colors_from_clipboard(self) -> None:
        layer = self._labels_layer_for_colors()
        if layer is None:
            self._set_status("Failed")
            self._status_indicator.setToolTip("Select a Labels layer first.")
            return
        clipboard = QApplication.clipboard()
        text = clipboard.text()
        if not text or not text.strip():
            self._set_status("Failed")
            self._status_indicator.setToolTip("Clipboard is empty.")
            return

        values = self._extract_color_settings_from_text(text)
        if not values:
            self._set_status("Failed")
            self._status_indicator.setToolTip("No class colors found in clipboard.")
            return

        for label_id, color in values.items():
            try:
                self._apply_label_class_color(layer, label_id, color)
            except ValueError as exc:
                self._set_status("Failed")
                self._status_indicator.setToolTip(str(exc))
                return

        self._set_status("Up to date")
        self._status_indicator.setToolTip("Pasted class colors from clipboard.")

    def _extract_camera_settings_from_text(self, text: str) -> dict[str, str]:
        key_aliases = {
            "ndisplay": "ndisplay",
            "displayed axis": "displayed_axis",
            "zoom": "zoom",
            "center": "center",
            "angle": "angle",
            "angles": "angle",
            "canvas (w, h)": "canvas_size",
            "canvas": "canvas_size",
        }
        section_names = {
            "metadata",
            "geometry",
            "camera",
            "image statistics",
            "labels statistics",
        }
        values: dict[str, str] = {}
        in_camera_section = False

        for raw_line in text.splitlines():
            line = raw_line.strip()
            if not line:
                continue
            lowered = line.lower()
            if lowered == "camera":
                in_camera_section = True
                continue
            if lowered in section_names and lowered != "camera":
                if in_camera_section:
                    break
                continue
            if ":" not in line:
                if in_camera_section:
                    break
                continue
            key, raw_value = line.split(":", 1)
            canonical = key_aliases.get(key.strip().lower())
            if canonical is None:
                continue
            if in_camera_section or canonical in key_aliases.values():
                values[canonical] = raw_value.strip()

        return values

    def _paste_camera_from_clipboard(self) -> None:
        clipboard = QApplication.clipboard()
        text = clipboard.text()
        if not text or not text.strip():
            self._set_status("Failed")
            self._status_indicator.setToolTip("Clipboard is empty.")
            return

        values = self._extract_camera_settings_from_text(text)
        if not values:
            self._set_status("Failed")
            self._status_indicator.setToolTip("No camera settings found in clipboard.")
            return

        field_handlers = (
            (
                "ndisplay",
                self._camera_ndisplay_value,
                self._apply_camera_ndisplay_from_input,
            ),
            (
                "displayed_axis",
                self._camera_displayed_axis_value,
                self._apply_camera_displayed_axis_from_input,
            ),
            ("zoom", self._camera_zoom_value, self._apply_camera_zoom_from_input),
            ("center", self._camera_center_value, self._apply_camera_center_from_input),
            ("angle", self._camera_angle_value, self._apply_camera_angle_from_input),
            (
                "canvas_size",
                self._camera_canvas_size_value,
                self._apply_camera_canvas_size_from_input,
            ),
        )

        applied_any = False
        self._set_status("Idle")
        self._status_indicator.setToolTip("Idle")
        for key, widget, handler in field_handlers:
            value = values.get(key)
            if value is None:
                continue
            widget.setText(value)
            handler()
            if self._status_indicator.toolTip().startswith("Invalid "):
                return
            applied_any = True

        if not applied_any:
            self._set_status("Failed")
            self._status_indicator.setToolTip(
                "No supported camera settings found in clipboard."
            )
            return

        self._update_camera_statistics()
        self._set_status("Up to date")
        self._status_indicator.setToolTip("Pasted camera settings from clipboard.")

    def _build_statistics_summary(self) -> str:
        layer = self._current_layer
        if layer is None:
            return ""

        lines = [
            "Metadata",
            f"Name: {self._metadata_name_value.text()}",
            f"Type: {self._metadata_type_value.text()}",
            f"Dtype: {self._metadata_dtype_value.text()}",
            "",
            "Geometry",
        ]
        lines.append(f"Array shape: {self._shape_value.text()}")
        lines.append(f"Spacing: {self._spacing_value.text()}")
        lines.append(f"Origin: {self._origin_value.text()}")
        lines.append(f"Direction: {self._direction_value.text()}")

        from napari.layers import Image, Labels

        if isinstance(layer, Image):
            lines.append("")
            lines.append("Image Statistics")
            for column in range(1, self._image_table.columnCount()):
                header_item = self._image_table.horizontalHeaderItem(column)
                channel_name = (
                    header_item.text() if header_item is not None else f"C{column}"
                )
                channel_values = []
                for row in range(self._image_table.rowCount()):
                    stat_item = self._image_table.item(row, 0)
                    item = self._image_table.item(row, column)
                    if stat_item is None or item is None:
                        continue
                    channel_values.append(f"{stat_item.text()}: {item.text()}")
                lines.append(f"{channel_name}: " + ", ".join(channel_values))

        if isinstance(layer, Labels):
            lines.append("")
            lines.append("Labels Statistics")
            lines.append(f"Number Classes: {self._class_count_value.text()}")
            lines.append(f"Classes present: {self._classes_present_value.text()}")
            lines.append(f"Background: {self._background_value.text()}")
            lines.append(f"Foreground: {self._foreground_value.text()}")
            for row in range(self._classes_table.rowCount()):
                class_item = self._classes_table.item(row, 0)
                value_item = self._classes_table.item(row, 1)
                if class_item is None or value_item is None:
                    continue
                lines.append(f"{class_item.text()}: {value_item.text()}")

        return "\n".join(lines)

    def _set_class_rows(self, rows: list[tuple[str, str]]) -> None:
        self._classes_table.setRowCount(0)
        if not rows:
            self._classes_table.setRowCount(1)
            self._classes_table.setItem(0, 0, QTableWidgetItem("-"))
            self._classes_table.setItem(
                0, 1, QTableWidgetItem("No non-zero labels present.")
            )
            self._update_classes_table_height()
            return

        self._classes_table.setRowCount(len(rows))
        for row_index, (label_id, value_text) in enumerate(rows):
            label_item = QTableWidgetItem(label_id)
            self._apply_label_color_to_item(label_item, label_id)
            self._classes_table.setItem(row_index, 0, label_item)
            self._classes_table.setItem(row_index, 1, QTableWidgetItem(value_text))
        self._update_classes_table_height()

    def _apply_label_color_to_item(
        self, item: QTableWidgetItem, label_id_text: str
    ) -> None:
        layer = self._current_layer
        if layer is None:
            return

        try:
            from napari.layers import Labels

            if not isinstance(layer, Labels):
                return
        except ImportError:
            return

        try:
            label_id = int(label_id_text)
        except ValueError:
            return

        qcolor = self._get_display_label_qcolor(layer, label_id)
        if qcolor is None:
            return

        item.setForeground(QBrush())
        item.setData(Qt.ItemDataRole.DecorationRole, _make_color_icon(qcolor))

    def _get_display_label_qcolor(
        self, layer: "Labels", label_id: int
    ) -> QColor | None:
        overrides = self._class_color_overrides.get(id(layer), {})
        override = overrides.get(label_id)
        if override is not None:
            return override
        return self._get_label_qcolor(layer, label_id)

    def _get_label_qcolor(self, layer: "Labels", label_id: int) -> QColor | None:
        color = None

        colormap = getattr(layer, "colormap", None)
        color_dict = getattr(colormap, "color_dict", None)
        if isinstance(color_dict, Mapping):
            color = color_dict.get(label_id)

        if color is None:
            legacy_color_dict = getattr(layer, "color", None)
            if isinstance(legacy_color_dict, Mapping):
                color = legacy_color_dict.get(label_id)

        if color is None:
            get_color = getattr(layer, "get_color", None)
            if callable(get_color):
                try:
                    color = get_color(label_id)
                except TypeError:
                    try:
                        color = get_color(np.asarray([label_id]))
                        if np.asarray(color).ndim > 1:
                            color = np.asarray(color)[0]
                    except (AttributeError, IndexError, TypeError, ValueError):
                        color = None
                except (AttributeError, IndexError, ValueError):
                    color = None

        return _to_qcolor(color)

    def _update_classes_table_height(self) -> None:
        header_height = self._classes_table.horizontalHeader().height()
        row_height = sum(
            self._classes_table.rowHeight(row)
            for row in range(self._classes_table.rowCount())
        )
        frame = self._classes_table.frameWidth() * 2
        extra = self._classes_table.horizontalScrollBar().height()
        max_visible_rows = 8
        if self._classes_table.rowCount() > max_visible_rows:
            visible_row_height = sum(
                self._classes_table.rowHeight(row) for row in range(max_visible_rows)
            )
        else:
            visible_row_height = row_height
        target_height = header_height + visible_row_height + frame + extra
        self._classes_table.setMinimumHeight(target_height)

    def _apply_label_class_color(
        self, layer: "Labels", label_id: int, color: QColor
    ) -> None:
        if not isinstance(layer.colormap, CyclicLabelColormap):
            raise ValueError(
                "Color picking currently supports only CyclicLabelColormap layers."
            )
        try:
            colormap = CyclicLabelColormap(**layer.colormap.dict())
            color_target = np.array(
                [color.redF(), color.greenF(), color.blueF(), color.alphaF()],
                dtype=float,
            )
            index_source = (
                colormap._cmap_without_selection()
                if hasattr(colormap, "_cmap_without_selection")
                else colormap
            )
            color_index = int(
                np.asarray(
                    index_source._data_to_texture(
                        np.asarray([label_id], dtype=np.int64)
                    )
                ).ravel()[0]
            )
            colors = np.asarray(colormap.colors, dtype=float).copy()
            if not (0 <= color_index < len(colors)):
                raise ValueError("Class color index is out of bounds.")
            colors[color_index] = color_target
            colormap.colors = colors
            layer.colormap = colormap
        except (AttributeError, IndexError, TypeError, ValueError) as exc:
            raise ValueError(
                "Color picking currently supports only CyclicLabelColormap layers."
            ) from exc

        self._class_color_overrides.setdefault(id(layer), {})[label_id] = QColor(color)

        for row in range(self._classes_table.rowCount()):
            class_item = self._classes_table.item(row, 0)
            if class_item is None:
                continue
            with suppress(TypeError, ValueError):
                if int(class_item.text()) != label_id:
                    continue
                class_item.setForeground(QBrush())
                class_item.setData(
                    Qt.ItemDataRole.DecorationRole, _make_color_icon(color)
                )
                break

    def _on_classes_table_cell_clicked(self, row: int, column: int) -> None:
        if column != 0:
            return

        layer = self._current_layer
        if layer is None:
            return

        try:
            from napari.layers import Labels

            if not isinstance(layer, Labels):
                return
        except ImportError:
            return

        item = self._classes_table.item(row, 0)
        if item is None:
            return

        try:
            label_id = int(item.text())
        except ValueError:
            return

        initial = self._get_label_qcolor(layer, label_id)
        if initial is None:
            initial = QColor(255, 255, 255)
        selected = QColorDialog.getColor(
            initial,
            self,
            f"Class {label_id} color",
        )
        if not selected.isValid():
            return

        try:
            self._apply_label_class_color(layer, label_id, selected)
        except ValueError as exc:
            self._set_status("Failed")
            self._status_indicator.setToolTip(str(exc))
            QMessageBox.information(self, "Unsupported colormap", str(exc))
            return

    def _is_color_picker_cell(self, row: int, column: int) -> bool:
        if column != 0:
            return False

        layer = self._current_layer
        if layer is None:
            return False

        try:
            from napari.layers import Labels

            if not isinstance(layer, Labels):
                return False
        except ImportError:
            return False

        if not isinstance(layer.colormap, CyclicLabelColormap):
            return False

        item = self._classes_table.item(row, 0)
        if item is None:
            return False
        with suppress(TypeError, ValueError):
            int(item.text())
            return True
        return False

    def eventFilter(self, watched: QObject, event: QEvent) -> bool:
        if watched is self._classes_table.viewport():
            if event.type() == QEvent.Type.MouseMove:
                pos_getter = getattr(event, "pos", None)
                if callable(pos_getter):
                    index = self._classes_table.indexAt(pos_getter())
                    if index.isValid() and self._is_color_picker_cell(
                        index.row(), index.column()
                    ):
                        self._classes_table.viewport().setCursor(
                            Qt.CursorShape.PointingHandCursor
                        )
                    else:
                        self._classes_table.viewport().unsetCursor()
            elif event.type() == QEvent.Type.Leave:
                self._classes_table.viewport().unsetCursor()
        return super().eventFilter(watched, event)

    def _set_image_rows(self, rows: list[tuple[str, str, str, str, str, str]]) -> None:
        self._image_table.setRowCount(len(self._image_stat_names))
        for row_index, stat_name in enumerate(self._image_stat_names):
            self._image_table.setItem(row_index, 0, QTableWidgetItem(stat_name))
        if not rows:
            self._image_table.setColumnCount(2)
            self._image_table.setHorizontalHeaderLabels(["Stats", "-"])
            self._image_table.horizontalHeader().setSectionResizeMode(
                0, QHeaderView.ResizeToContents
            )
            self._image_table.horizontalHeader().setSectionResizeMode(
                1, QHeaderView.Stretch
            )
            placeholder_values = [
                "No image statistics",
                "-",
                "-",
                "-",
                "-",
            ]
            for row_index, value in enumerate(placeholder_values):
                self._image_table.setItem(row_index, 1, QTableWidgetItem(value))
            self._update_image_table_height()
            return

        self._image_table.setColumnCount(len(rows) + 1)
        self._image_table.setHorizontalHeaderLabels(
            ["Stats", *[row_values[0] for row_values in rows]]
        )
        self._image_table.horizontalHeader().setSectionResizeMode(
            0, QHeaderView.ResizeToContents
        )
        for column_index in range(1, self._image_table.columnCount()):
            self._image_table.horizontalHeader().setSectionResizeMode(
                column_index, QHeaderView.Stretch
            )
        for column_index, row_values in enumerate(rows, start=1):
            stat_values = row_values[1:]
            for row_index, value in enumerate(stat_values):
                self._image_table.setItem(
                    row_index, column_index, QTableWidgetItem(value)
                )
        self._update_image_table_height()

    def _set_image_status_message(self, message: str) -> None:
        self._image_table.setRowCount(1)
        self._image_table.setColumnCount(2)
        self._image_table.setHorizontalHeaderLabels(["Stats", "Value"])
        self._image_table.horizontalHeader().setSectionResizeMode(
            0, QHeaderView.ResizeToContents
        )
        self._image_table.horizontalHeader().setSectionResizeMode(
            1, QHeaderView.Stretch
        )
        self._image_table.setItem(0, 0, QTableWidgetItem("Status"))
        self._image_table.setItem(0, 1, QTableWidgetItem(message))
        self._update_image_table_height()

    def _update_image_table_height(self) -> None:
        header_height = self._image_table.horizontalHeader().height()
        row_height = sum(
            self._image_table.rowHeight(row)
            for row in range(self._image_table.rowCount())
        )
        frame = self._image_table.frameWidth() * 2
        extra = self._image_table.horizontalScrollBar().height()
        max_visible_rows = 6
        if self._image_table.rowCount() > max_visible_rows:
            visible_row_height = sum(
                self._image_table.rowHeight(row) for row in range(max_visible_rows)
            )
        else:
            visible_row_height = row_height
        target_height = header_height + visible_row_height + frame + extra
        self._image_table.setMinimumHeight(target_height)

    def _update_statistics(self, event=None) -> None:
        self._update_geometry_statistics(event)
        self._update_camera_statistics(event)
        self._update_data_statistics(event)

    def _update_camera_statistics(self, event=None) -> None:
        del event
        camera = getattr(self._viewer, "camera", None)
        dims = getattr(self._viewer, "dims", None)
        if camera is None or dims is None:
            self._set_camera_inputs_editable(False)
            self._camera_ndisplay_value.setText("-")
            self._camera_displayed_axis_value.setText("-")
            self._camera_zoom_value.setText("-")
            self._camera_center_value.setText("-")
            self._camera_angle_value.setText("-")
            self._camera_canvas_size_value.setText("-")
            return

        self._set_camera_inputs_editable(True)
        self._camera_ndisplay_value.setText(
            _format_scalar(getattr(dims, "ndisplay", "-"))
        )
        self._camera_displayed_axis_value.setText(
            _format_vector(getattr(dims, "displayed", "-"))
        )
        self._camera_center_value.setText(
            _format_vector(getattr(camera, "center", "-"))
        )
        self._camera_zoom_value.setText(_format_scalar(getattr(camera, "zoom", "-")))
        self._camera_angle_value.setText(_format_vector(getattr(camera, "angles", "-")))
        self._camera_canvas_size_value.setText(self._camera_canvas_size_text())

    def _update_geometry_statistics(self, event=None) -> None:
        del event
        layer = self._current_layer
        if layer is None:
            self._set_empty_state()
            return

        self._set_geometry_inputs_editable(self._is_geometry_editable_layer(layer))
        self._shape_value.setText(_layer_data_shape_text(layer))
        self._spacing_value.setText(_layer_vector_attr_text(layer, "scale"))
        self._origin_value.setText(_layer_vector_attr_text(layer, "translate"))
        self._direction_value.setText(_format_matrix(_get_direction_matrix(layer)))

    def _update_data_statistics(self, event=None) -> None:
        del event
        layer = self._current_layer
        if layer is None:
            self._set_empty_state()
            return
        if not self._compute_data_checkbox.isChecked():
            self._show_data_statistics_disabled(layer)
            return

        from napari.layers import Image, Labels

        if isinstance(layer, Image):
            self._image_group.setVisible(True)
            self._set_image_stats_placeholder()
            self._background_value.setText("-")
            self._class_count_value.setText("-")
            self._classes_present_value.setText("-")
            self._foreground_value.setText("-")
            self._labels_group.setVisible(False)
            self._set_class_rows([])
            self._queue_data_statistics(
                "image",
                {
                    "data": _get_primary_array(layer),
                    "is_rgb": bool(getattr(layer, "rgb", False)),
                },
            )
        elif isinstance(layer, Labels):
            self._image_group.setVisible(False)
            self._set_image_rows([])
            self._labels_group.setVisible(True)
            self._set_label_stats_placeholder()
            self._queue_data_statistics("labels", _get_primary_array(layer))
        else:
            self._stats_request_id += 1
            self._recompute_requested = False
            self._set_status("Idle")
            self._image_group.setVisible(False)
            self._set_image_rows([])
            self._background_value.setText("-")
            self._class_count_value.setText("-")
            self._classes_present_value.setText("-")
            self._foreground_value.setText("-")
            self._labels_group.setVisible(False)
            self._set_class_rows([])

    def _show_data_statistics_disabled(self, layer: "Layer | None") -> None:
        if layer is None:
            self._set_empty_state()
            return

        self._set_status("Idle")

        from napari.layers import Image, Labels

        if isinstance(layer, Image):
            self._image_group.setVisible(True)
            self._set_image_status_message("Disabled")
            self._background_value.setText("-")
            self._class_count_value.setText("-")
            self._classes_present_value.setText("-")
            self._foreground_value.setText("-")
            self._labels_group.setVisible(False)
            self._set_class_rows([])
            return

        if isinstance(layer, Labels):
            self._image_group.setVisible(False)
            self._set_image_rows([])
            self._labels_group.setVisible(True)
            self._background_value.setText("-")
            class_count_text, classes_present_text, class_rows = (
                _compute_label_class_overview(_get_primary_array(layer))
            )
            self._class_count_value.setText(class_count_text)
            self._classes_present_value.setText(classes_present_text)
            self._foreground_value.setText("-")
            self._set_class_rows(class_rows)
            return

        self._image_group.setVisible(False)
        self._set_image_rows([])
        self._background_value.setText("-")
        self._class_count_value.setText("-")
        self._classes_present_value.setText("-")
        self._foreground_value.setText("-")
        self._labels_group.setVisible(False)
        self._set_class_rows([])

    def _set_empty_state(self) -> None:
        self._stats_request_id += 1
        self._recompute_requested = False
        self._set_status("Idle")
        self._update_metadata_fields(None)
        self._update_camera_statistics()
        self._shape_value.setText("-")
        self._set_geometry_inputs_editable(False)
        self._spacing_value.setText("-")
        self._origin_value.setText("-")
        self._direction_value.setText("-")
        self._image_group.setVisible(False)
        self._set_image_rows([])
        self._background_value.setText("-")
        self._class_count_value.setText("-")
        self._classes_present_value.setText("-")
        self._foreground_value.setText("-")
        self._labels_group.setVisible(False)
        self._set_class_rows([])
        self._geometry_group.setVisible(True)
        self._camera_group.setVisible(True)

    def _queue_data_statistics(self, layer_kind: str, data: object) -> None:
        self._stats_request_id += 1
        request_id = self._stats_request_id
        self._set_status("Computing...")

        if self._worker_running:
            self._recompute_requested = True
            return

        self._start_data_statistics_worker(layer_kind, data, request_id)

    def _start_data_statistics_worker(
        self,
        layer_kind: str,
        data: object,
        request_id: int,
    ) -> None:
        self._worker_running = True
        worker = _DataStatsWorker(request_id, layer_kind, data)
        worker.signals.completed.connect(self._on_data_statistics_completed)
        worker.signals.failed.connect(self._on_data_statistics_failed)
        self._thread_pool.start(worker)

    def _on_data_statistics_completed(
        self,
        request_id: int,
        result: object,
    ) -> None:
        self._worker_running = False

        if request_id != self._stats_request_id:
            if self._recompute_requested:
                self._recompute_requested = False
                self._restart_data_statistics_if_needed()
            return

        layer_kind, payload = result
        if layer_kind == "labels":
            (
                background_text,
                class_count_text,
                classes_present_text,
                foreground_text,
                class_rows,
            ) = payload
            self._background_value.setText(background_text)
            self._class_count_value.setText(class_count_text)
            self._classes_present_value.setText(classes_present_text)
            self._foreground_value.setText(foreground_text)
            self._set_class_rows(class_rows)
        else:
            self._set_image_rows(payload)
        self._set_status("Up to date")

        if self._recompute_requested:
            self._recompute_requested = False
            self._restart_data_statistics_if_needed()

    def _on_data_statistics_failed(self, request_id: int, message: str) -> None:
        self._worker_running = False
        if request_id != self._stats_request_id:
            if self._recompute_requested:
                self._recompute_requested = False
                self._restart_data_statistics_if_needed()
            return
        self._set_status("Failed")
        from napari.layers import Image, Labels

        if isinstance(self._current_layer, Labels):
            self._set_class_rows([("!", message)])
        elif isinstance(self._current_layer, Image):
            self._set_image_rows([("!", message, "-", "-", "-", "-")])

    def _restart_data_statistics_if_needed(self) -> None:
        layer = self._current_layer
        if layer is None:
            self._set_status("Idle")
            return

        from napari.layers import Image, Labels

        if isinstance(layer, Labels):
            self._start_data_statistics_worker(
                "labels", _get_primary_array(layer), self._stats_request_id
            )
        elif isinstance(layer, Image):
            self._start_data_statistics_worker(
                "image",
                {
                    "data": _get_primary_array(layer),
                    "is_rgb": bool(getattr(layer, "rgb", False)),
                },
                self._stats_request_id,
            )

    def _set_status(self, text: str) -> None:
        color = "#9ca3af"
        if text == "Computing...":
            color = "#f59e0b"
        elif text == "Up to date":
            color = "#22c55e"
        elif text == "Failed":
            color = "#ef4444"

        self._status_indicator.setStyleSheet(
            "border-radius: 7px;"
            f"background-color: {color};"
            "border: 1px solid rgba(0, 0, 0, 0.25);"
        )
        self._status_indicator.setToolTip(text)

    def _set_image_stats_placeholder(self) -> None:
        self._set_image_rows([("...", "...", "...", "...", "...", "...", "...")])

    def _set_label_stats_placeholder(self) -> None:
        self._background_value.setText("...")
        self._class_count_value.setText("...")
        self._classes_present_value.setText("...")
        self._foreground_value.setText("...")
        self._set_class_rows([("...", "Computing...")])
